diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MarkdownPageParser.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MarkdownPageParser.kt
index bde7b688f..2f1a46602 100644
--- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MarkdownPageParser.kt
+++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MarkdownPageParser.kt
@@ -155,7 +155,14 @@ object MarkdownPageParser {
parentUuid = parentUuid?.let { BlockUuid(it) },
leftUuid = previousSiblingUuid?.let { BlockUuid(it) },
content = parsedBlock.content,
- level = baseLevel,
+ // parsedBlock.level is the outline nesting depth computed from the source
+ // Markdown's own indentation (bullet/heading indent, etc. — see
+ // MarkdownParser.convertBlock / BlockNode.indentLevel). It agrees with
+ // baseLevel (the tree-recursion depth) for well-formed, contiguously
+ // indented documents, but baseLevel alone discards the indentLevel this
+ // parser computes for headings/code-fences/blockquotes/etc., so it must be
+ // read here for that value to ever reach storage.
+ level = parsedBlock.level,
position = positionKey,
createdAt = now,
updatedAt = now,
@@ -216,7 +223,9 @@ object MarkdownPageParser {
pageUuid = pageUuid,
parentUuid = parentUuid?.let { BlockUuid(it) },
content = parsedBlock.content,
- level = baseLevel,
+ // See processParsedBlocks for why parsedBlock.level (not baseLevel) is
+ // the value that must reach the persisted Block.level.
+ level = parsedBlock.level,
position = stubPositionKey,
createdAt = now,
updatedAt = now,
diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt
index 135732763..8592770ed 100644
--- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt
+++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt
@@ -39,13 +39,13 @@ class MarkdownParser {
val level = when(block) {
is BulletBlockNode -> block.level
is ParagraphBlockNode -> 0
- is HeadingBlockNode -> 0
- is CodeFenceBlockNode -> 0
- is BlockquoteBlockNode -> 0
+ is HeadingBlockNode -> block.indentLevel
+ is CodeFenceBlockNode -> block.indentLevel
+ is BlockquoteBlockNode -> block.indentLevel
is OrderedListItemBlockNode -> block.level
- is ThematicBreakBlockNode -> 0
- is TableBlockNode -> 0
- is RawHtmlBlockNode -> 0
+ is ThematicBreakBlockNode -> block.indentLevel
+ is TableBlockNode -> block.indentLevel
+ is RawHtmlBlockNode -> block.indentLevel
}
val blockType = when (block) {
@@ -85,6 +85,7 @@ class MarkdownParser {
is BlockquoteBlockNode -> {
block.children.map { child -> "> ${reconstructContent(child.content)}" }.joinToString("\n")
}
+ is RawHtmlBlockNode -> block.rawHtml
else -> reconstructContent(block.content)
}
diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt
index 130dfc1b3..5243fc6cb 100644
--- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt
+++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt
@@ -11,6 +11,19 @@ class BlockParser(private val source: CharSequence) {
private val THEMATIC_BREAK_REGEX = Regex("---+|___+")
private val TABLE_SEPARATOR_REGEX = Regex("-+")
private val ORDERED_LIST_EXTRACT_REGEX = Regex("^(\\d+)\\.$")
+
+ // Block-level HTML tag names (CommonMark §4.6 type-6 tag list, trimmed to the
+ // subset relevant for a Markdown outliner). Case-insensitive.
+ private val BLOCK_HTML_TAGS = setOf(
+ "address", "article", "aside", "base", "basefont", "blockquote", "body",
+ "caption", "center", "col", "colgroup", "dd", "details", "dialog", "dir",
+ "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form",
+ "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header",
+ "hr", "html", "iframe", "legend", "li", "link", "main", "menu", "menuitem",
+ "nav", "noframes", "ol", "optgroup", "option", "p", "param", "section",
+ "summary", "table", "tbody", "td", "tfoot", "th", "thead", "title", "tr",
+ "track", "ul", "script", "style", "pre", "textarea"
+ )
}
fun parse(): DocumentNode {
@@ -48,162 +61,24 @@ class BlockParser(private val source: CharSequence) {
}
// 1a. Check for ATX heading: # / ## / ### etc at line start
- if (currentToken.type == TokenType.HASH) {
- val hashLen = currentToken.end - currentToken.start
- val headingLevel = hashLen.coerceIn(1, 6)
- // Valid heading: hash run followed by WS, NEWLINE, or EOF
- // Also reject if hashLen > 6 (e.g. ####### is not a heading)
- if (hashLen <= 6) {
- val next = peekToken(1)
- if (next.type == TokenType.WS || next.type == TokenType.NEWLINE || next.type == TokenType.EOF) {
- advance() // consume HASH run
- if (currentToken.type == TokenType.WS) advance() // consume space
- val contentStr = parseLine()
- // Strip optional trailing # sequence and whitespace
- val stripped = contentStr.trimEnd('#').trimEnd()
- return HeadingBlockNode(
- level = headingLevel,
- content = listOf(TextNode(stripped))
- )
- }
- }
- }
-
- // 1b. Check for fenced code block: ``` or ~~~
- if (currentToken.type == TokenType.BACKTICK) {
- val fenceLen = currentToken.end - currentToken.start
- if (fenceLen >= 3) {
- advance() // consume opening ```
- // Optional language identifier on same line
- val language = if (currentToken.type == TokenType.TEXT) {
- val lang = currentToken.text(source).toString().trim()
- parseLine() // consume rest of opening line (including lang token)
- lang
- } else {
- parseLine() // consume newline
- null
- }
- // Collect body until matching ``` (same fence length) or EOF
- val body = StringBuilder()
- while (currentToken.type != TokenType.EOF) {
- if (currentToken.type == TokenType.BACKTICK) {
- val closeLen = currentToken.end - currentToken.start
- if (closeLen >= 3) {
- advance() // consume closing ```
- if (currentToken.type == TokenType.NEWLINE) advance()
- break
- }
- }
- if (currentToken.type == TokenType.NEWLINE) {
- body.append('\n')
- advance()
- } else {
- body.append(currentToken.text(source))
- advance()
- }
- }
- // Trim trailing newline from body
- val rawContent = body.toString().trimEnd('\n')
- return CodeFenceBlockNode(language = language, rawContent = rawContent)
- }
- }
-
- // 1c. Check for tilde fenced code block: ~~~
- if (currentToken.type == TokenType.TILDE) {
- val fenceLen = currentToken.end - currentToken.start
- if (fenceLen >= 3) {
- advance() // consume opening ~~~
- val language = if (currentToken.type == TokenType.TEXT) {
- val lang = currentToken.text(source).toString().trim()
- parseLine()
- lang
- } else {
- parseLine()
- null
- }
- val body = StringBuilder()
- while (currentToken.type != TokenType.EOF) {
- if (currentToken.type == TokenType.TILDE) {
- val closeLen = currentToken.end - currentToken.start
- if (closeLen >= 3) {
- advance()
- if (currentToken.type == TokenType.NEWLINE) advance()
- break
- }
- }
- if (currentToken.type == TokenType.NEWLINE) {
- body.append('\n')
- advance()
- } else {
- body.append(currentToken.text(source))
- advance()
- }
- }
- val rawContent = body.toString().trimEnd('\n')
- return CodeFenceBlockNode(language = language, rawContent = rawContent)
- }
- }
-
- // 1d. Check for thematic break from TEXT token: --- or ___
- if (currentToken.type == TokenType.TEXT) {
- val text = currentToken.text(source).toString()
- if (text.matches(THEMATIC_BREAK_REGEX)) {
- val next = peekToken(1)
- if (next.type == TokenType.NEWLINE || next.type == TokenType.EOF) {
- advance() // consume ---
- if (currentToken.type == TokenType.NEWLINE) advance()
- return ThematicBreakBlockNode()
- }
- }
- }
-
- // 1e. Check for thematic break from STAR token: ***
- if (currentToken.type == TokenType.STAR) {
- val runLen = currentToken.end - currentToken.start
- if (runLen >= 3) {
- val next = peekToken(1)
- if (next.type == TokenType.NEWLINE || next.type == TokenType.EOF) {
- advance() // consume ***
- if (currentToken.type == TokenType.NEWLINE) advance()
- return ThematicBreakBlockNode()
- }
- }
- }
-
- // 1f. Check for blockquote: > content
- if (currentToken.type == TokenType.R_ANGLE) {
- advance() // consume >
- if (currentToken.type == TokenType.WS) advance() // optional space
- return parseBlockquote(level)
- }
-
- // 1g. Check for ordered list: N. content
- if (currentToken.type == TokenType.TEXT) {
- val txt = currentToken.text(source).toString()
- val numDotMatch = ORDERED_LIST_EXTRACT_REGEX.find(txt)
- if (numDotMatch != null) {
- val peekNext = peekToken(1)
- if (peekNext.type == TokenType.WS || peekNext.type == TokenType.EOF || peekNext.type == TokenType.NEWLINE) {
- val number = numDotMatch.groupValues[1].toInt()
- advance() // consume "N."
- if (currentToken.type == TokenType.WS) advance() // consume space
- val contentStr = parseLine()
- val children = parseBlocksAtLevel(level + 1)
- return OrderedListItemBlockNode(
- number = number,
- content = listOf(TextNode(contentStr)),
- children = children,
- level = level
- )
- }
- }
+ val topLevelHeadingLevel = tryConsumeAtxHeadingMarker()
+ if (topLevelHeadingLevel != null) {
+ val contentStr = parseLine()
+ // Strip optional trailing # sequence and whitespace
+ val stripped = contentStr.trimEnd('#').trimEnd()
+ val (properties, children) = parseTrailingPropertiesAndChildren(level)
+ return HeadingBlockNode(
+ level = topLevelHeadingLevel,
+ content = listOf(TextNode(stripped)),
+ children = children,
+ properties = properties,
+ indentLevel = level
+ )
}
- // 1h. Check for GFM pipe table: starts with |
- if (currentToken.type == TokenType.PIPE) {
- val tableNode = tryParseTable()
- if (tableNode != null) return tableNode
- }
+ // 1b. Check for a fenced code block, blockquote, ordered list, thematic break,
+ // GFM table, or raw HTML block at the top level.
+ tryConsumeNonHeadingConstruct(level)?.let { return it }
// 2. Check for Bullet
val isBullet = if (currentToken.type == TokenType.BULLET) {
@@ -213,6 +88,22 @@ class BlockParser(private val source: CharSequence) {
false
}
+ // 2a. A bullet's content may itself be an ATX heading (e.g. "- # Core Definition"),
+ // which is how Logseq decorates outline items as headings. Detect it here so the
+ // bullet's outline structure (level/children) is preserved alongside heading styling.
+ val bulletHeadingLevel = if (isBullet) tryConsumeAtxHeadingMarker() else null
+
+ // 2b. A bullet's content may likewise be a fenced code block, blockquote, ordered
+ // list item, thematic break, GFM table, or raw HTML block (e.g. "- ```kotlin",
+ // "- > quote", "- 1. item", "- ---", "- | a | b |", "-
"). These constructs
+ // were previously only detected before bullet-token consumption (see 1b above), so
+ // decorating a bullet with any of them fell through to plain bullet/paragraph
+ // parsing and rendered as literal Markdown text — the same structural bug already
+ // fixed for headings.
+ if (isBullet && bulletHeadingLevel == null) {
+ tryConsumeNonHeadingConstruct(level)?.let { return it }
+ }
+
// 3. Parse Content & Properties
// A block consists of:
// - First line text
@@ -242,17 +133,13 @@ class BlockParser(private val source: CharSequence) {
}
// It is indented and NOT a bullet -> Content or Property
- // Consume the indent
- if (currentToken.type == TokenType.INDENT) advance()
-
- // Check for Property (key:: value)
- val property = tryParseProperty()
+ val property = tryConsumeIndentedProperty()
if (property != null) {
properties[property.first] = property.second
- // Consume newline after property
- if (currentToken.type == TokenType.NEWLINE) advance()
} else {
- // Continuation text
+ // Not a property — consume the indent and treat the rest of the line as
+ // continuation text.
+ if (currentToken.type == TokenType.INDENT) advance()
if (contentBuilder.isNotEmpty()) contentBuilder.append("\n")
contentBuilder.append(parseLine())
}
@@ -263,25 +150,41 @@ class BlockParser(private val source: CharSequence) {
// We already verified above that if we hit a bullet > level, it's a child.
val children = parseBlocksAtLevel(level + 1)
- val inlineContent = listOf(TextNode(contentBuilder.toString())) // Placeholder
-
- return if (isBullet) {
- BulletBlockNode(
- content = inlineContent,
+ return when {
+ bulletHeadingLevel != null -> {
+ // Strip optional trailing # sequence and whitespace, mirroring the
+ // top-level ATX heading handling above.
+ val stripped = contentBuilder.toString().trimEnd('#').trimEnd()
+ HeadingBlockNode(
+ level = bulletHeadingLevel,
+ content = listOf(TextNode(stripped)),
+ children = children,
+ properties = properties,
+ indentLevel = level
+ )
+ }
+ isBullet -> BulletBlockNode(
+ content = listOf(TextNode(contentBuilder.toString())),
children = children,
properties = properties,
level = level
)
- } else {
- ParagraphBlockNode(
- content = inlineContent,
+ else -> ParagraphBlockNode(
+ content = listOf(TextNode(contentBuilder.toString())),
children = children,
properties = properties
)
}
}
- private fun parseBlockquote(_level: Int): BlockquoteBlockNode {
+ /**
+ * Parses a blockquote's own lines (the `> `-prefixed content, plus any `>`-prefixed
+ * continuation lines). [indentLevel] is the outline nesting depth of the bullet this
+ * blockquote decorates (0 for a top-level, non-bulleted blockquote) and is attached
+ * verbatim to the returned node so [MarkdownParser.convertBlock] can recover the
+ * blockquote's outline position — mirroring [HeadingBlockNode.indentLevel].
+ */
+ private fun parseBlockquote(indentLevel: Int): BlockquoteBlockNode {
val innerBlocks = mutableListOf
()
// Parse first line content
val line = parseLine()
@@ -304,7 +207,7 @@ class BlockParser(private val source: CharSequence) {
}
} else break
}
- return BlockquoteBlockNode(children = innerBlocks)
+ return BlockquoteBlockNode(children = innerBlocks, indentLevel = indentLevel)
}
private fun tryParseTable(): TableBlockNode? {
@@ -471,6 +374,314 @@ class BlockParser(private val source: CharSequence) {
return false
}
+ /**
+ * If [currentToken] starts a valid ATX heading marker (a run of 1–6 `#` followed by
+ * whitespace, a newline, or EOF), consumes the `#` run and any single following space
+ * and returns the heading level (1–6). Otherwise leaves the token stream untouched and
+ * returns null.
+ */
+ private fun tryConsumeAtxHeadingMarker(): Int? {
+ if (currentToken.type != TokenType.HASH) return null
+ val hashLen = currentToken.end - currentToken.start
+ if (hashLen > 6) return null
+ val next = peekToken(1)
+ if (next.type != TokenType.WS && next.type != TokenType.NEWLINE && next.type != TokenType.EOF) return null
+
+ val headingLevel = hashLen.coerceIn(1, 6)
+ advance() // consume HASH run
+ if (currentToken.type == TokenType.WS) advance() // consume space
+ return headingLevel
+ }
+
+ /**
+ * Detects and parses a fenced code block, blockquote, ordered list item, thematic
+ * break, GFM table, or raw HTML block starting at [currentToken]. Used both at the
+ * top level and (after bullet-token consumption) for the same constructs decorating
+ * a bullet's content — see the call sites in [parseBlock]. Returns null and leaves
+ * the token stream untouched if none of these constructs match.
+ *
+ * [level] is the correct outline nesting depth whether this construct decorates a
+ * bullet or stands unbulleted, and is used directly as `indentLevel` — mirrors the
+ * [HeadingBlockNode.indentLevel] pattern.
+ *
+ * Each matched construct also collects any trailing property lines ("key:: value")
+ * and outline children indented past [level] via [parseTrailingPropertiesAndChildren],
+ * mirroring how [parseBlock]'s shared step 3 handles headings and plain bullets. Without
+ * this, a bullet decorated with one of these constructs would return immediately and
+ * orphan its nested children/properties to the caller as mis-leveled siblings.
+ */
+ private fun tryConsumeNonHeadingConstruct(level: Int): BlockNode? {
+ val indentLevel = level
+
+ tryParseFencedCodeConstruct(level, indentLevel)?.let { return it }
+ tryParseThematicBreakConstruct(level, indentLevel)?.let { return it }
+ tryParseBlockquoteConstruct(level, indentLevel)?.let { return it }
+ tryParseOrderedListItemConstruct(level)?.let { return it }
+ tryParseTableConstruct(level, indentLevel)?.let { return it }
+ tryParseRawHtmlConstruct(level, indentLevel)?.let { return it }
+
+ return null
+ }
+
+ /** Fenced code block: ``` or ~~~ (both fence characters share identical dispatch logic). */
+ private fun tryParseFencedCodeConstruct(level: Int, indentLevel: Int): CodeFenceBlockNode? {
+ val fenceType = currentToken.type
+ if (fenceType != TokenType.BACKTICK && fenceType != TokenType.TILDE) return null
+ val fenceLen = currentToken.end - currentToken.start
+ if (fenceLen < 3) return null
+
+ val node = parseFencedCodeBlock(fenceType)
+ val (properties, children) = parseTrailingPropertiesAndChildren(level)
+ return node.copy(properties = properties, children = children, indentLevel = indentLevel)
+ }
+
+ /**
+ * Thematic break: `---`/`___` (lexed as TEXT) or `***` (lexed as a STAR run) — both
+ * forms share identical marker-consumption and trailing-properties/children logic
+ * once the marker itself is recognized.
+ */
+ private fun tryParseThematicBreakConstruct(level: Int, indentLevel: Int): ThematicBreakBlockNode? {
+ val isThematicBreakMarker = when (currentToken.type) {
+ TokenType.TEXT -> currentToken.text(source).toString().matches(THEMATIC_BREAK_REGEX)
+ TokenType.STAR -> (currentToken.end - currentToken.start) >= 3
+ else -> false
+ }
+ if (!isThematicBreakMarker) return null
+
+ val next = peekToken(1)
+ if (next.type != TokenType.NEWLINE && next.type != TokenType.EOF) return null
+
+ advance() // consume marker run
+ if (currentToken.type == TokenType.NEWLINE) advance()
+ val (properties, children) = parseTrailingPropertiesAndChildren(level)
+ return ThematicBreakBlockNode(properties = properties, children = children, indentLevel = indentLevel)
+ }
+
+ /** Blockquote: `> content`. */
+ private fun tryParseBlockquoteConstruct(level: Int, indentLevel: Int): BlockquoteBlockNode? {
+ if (currentToken.type != TokenType.R_ANGLE) return null
+
+ advance() // consume >
+ if (currentToken.type == TokenType.WS) advance() // optional space
+ val bq = parseBlockquote(indentLevel)
+ // BlockquoteBlockNode.children already holds the quote's own continuation
+ // paragraphs (see parseBlockquote); append outline children after them so
+ // neither the quote's internal structure nor its nested outline items are lost.
+ val (properties, outlineChildren) = parseTrailingPropertiesAndChildren(level)
+ return bq.copy(properties = properties, children = bq.children + outlineChildren)
+ }
+
+ /** Ordered list item: `N. content`. */
+ private fun tryParseOrderedListItemConstruct(level: Int): OrderedListItemBlockNode? {
+ if (currentToken.type != TokenType.TEXT) return null
+ val txt = currentToken.text(source).toString()
+ val numDotMatch = ORDERED_LIST_EXTRACT_REGEX.find(txt) ?: return null
+
+ val peekNext = peekToken(1)
+ if (peekNext.type != TokenType.WS && peekNext.type != TokenType.EOF && peekNext.type != TokenType.NEWLINE) return null
+
+ val number = numDotMatch.groupValues[1].toInt()
+ advance() // consume "N."
+ if (currentToken.type == TokenType.WS) advance() // consume space
+ val contentStr = parseLine()
+ val (properties, children) = parseTrailingPropertiesAndChildren(level)
+ return OrderedListItemBlockNode(
+ number = number,
+ content = listOf(TextNode(contentStr)),
+ children = children,
+ properties = properties,
+ level = level
+ )
+ }
+
+ /** GFM pipe table: starts with `|`. */
+ private fun tryParseTableConstruct(level: Int, indentLevel: Int): TableBlockNode? {
+ if (currentToken.type != TokenType.PIPE) return null
+ val tableNode = tryParseTable() ?: return null
+ val (properties, children) = parseTrailingPropertiesAndChildren(level)
+ return tableNode.copy(properties = properties, children = children, indentLevel = indentLevel)
+ }
+
+ /** Raw HTML block: ``, ``, etc. (CommonMark §4.6, type-6 tag subset). */
+ private fun tryParseRawHtmlConstruct(level: Int, indentLevel: Int): RawHtmlBlockNode? {
+ if (currentToken.type != TokenType.L_ANGLE) return null
+
+ val isComment = run {
+ val excl = peekToken(1)
+ val body = peekToken(2)
+ excl.type == TokenType.EXCLAMATION &&
+ body.type == TokenType.TEXT &&
+ body.text(source).startsWith("--")
+ }
+ val tagName = run {
+ // A closing tag ("
") lexes as a single TEXT token "/div" because '/'
+ // is not a special character — strip the leading slash before extracting
+ // the tag name so both opening and closing tags are recognized.
+ val nameToken = peekToken(1)
+ if (nameToken.type == TokenType.TEXT) {
+ nameToken.text(source).toString()
+ .removePrefix("/")
+ .takeWhile { it.isLetterOrDigit() }
+ .lowercase()
+ } else {
+ null
+ }
+ }
+ if (!(isComment || (tagName != null && tagName in BLOCK_HTML_TAGS))) return null
+
+ // CommonMark §4.6 type-6 raw HTML blocks continue consuming lines indented at
+ // or deeper than the opening line's level until a blank line or EOF — a single
+ // parseLine() call previously captured only the opening tag's line, splitting
+ // multi-line HTML (e.g. "\ncontent\n
") into separate sibling blocks
+ // instead of one raw HTML block, and requiring exact-indent continuation lines
+ // broke on any deeper-indented (but still-inside) content like " ...". The
+ // block ends when a line dedents shallower than this construct's level, or
+ // dedents back to exactly this level as a new sibling bullet (so a following
+ // "- next item" at the same depth is NOT swallowed as HTML text). A line indented
+ // *deeper* than this level that is itself a bullet (nested outline child) or a
+ // "key:: value" property line is likewise left alone — those belong to this
+ // outliner's own nested properties/children, handled below by
+ // parseTrailingPropertiesAndChildren, not to the raw HTML text.
+ val htmlBuilder = StringBuilder(parseLine())
+ while (currentToken.type != TokenType.EOF) {
+ if (currentToken.type == TokenType.NEWLINE) {
+ // Blank line reached — terminates the raw HTML block. Consume it so
+ // it doesn't leak into the next construct's parsing.
+ advance()
+ break
+ }
+ val nextIndent = peekIndentLevel()
+ if (nextIndent < level) break
+ if (nextIndent == level) {
+ if (peekIsBullet()) break
+ } else if (peekIsBullet() || peekIsIndentedProperty()) {
+ break
+ }
+ htmlBuilder.append('\n')
+ htmlBuilder.append(parseLine())
+ }
+ val (properties, children) = parseTrailingPropertiesAndChildren(level)
+ return RawHtmlBlockNode(
+ rawHtml = htmlBuilder.toString(),
+ properties = properties,
+ children = children,
+ indentLevel = indentLevel
+ )
+ }
+
+ /**
+ * Collects property lines ("key:: value") and outline children immediately following
+ * a just-parsed non-heading construct (fenced code, blockquote, thematic break,
+ * ordered list item, or table), mirroring [parseBlock]'s shared step 3 handling for
+ * headings and plain bullets. A candidate property line is speculatively consumed past
+ * its leading INDENT; if it does not turn out to be a property, the lexer position is
+ * restored (INDENT included) so [parseBlocksAtLevel] sees the correct indent level and
+ * parses it as its own block instead.
+ */
+ private fun parseTrailingPropertiesAndChildren(level: Int): Pair " off into their own sibling blocks.
+ val doc = parse("\nsome content\n
")
+ assertEquals(1, doc.children.size, "Multi-line raw HTML must parse as one block, got: ${doc.children}")
+ val html = doc.children[0] as RawHtmlBlockNode
+ assertEquals("\nsome content\n
", html.rawHtml.trim())
+ }
+
+ @Test
+ fun `raw HTML block captures the opening tag line verbatim`() {
+ val doc = parse("")
+ val html = doc.children[0] as RawHtmlBlockNode
+ assertEquals("
", html.rawHtml.trim())
+ }
+
+ @Test
+ fun `HTML comment is classified as RawHtmlBlockNode`() {
+ val doc = parse("")
+ assertIs(doc.children[0])
+ }
+
+ @Test
+ fun `closing HTML tag alone is classified as RawHtmlBlockNode`() {
+ val doc = parse("
")
+ assertIs
(doc.children[0])
+ }
+
+ @Test
+ fun `bulleted HTML block tag is classified as RawHtmlBlockNode`() {
+ val doc = parse("- inline html
")
+ assertEquals(1, doc.children.size)
+ assertIs(doc.children[0])
+ }
+
+ @Test
+ fun `bulleted raw HTML block carries the bullet's outline level as indentLevel`() {
+ val doc = parse("- root\n - nested html
")
+ val root = doc.children[0] as BulletBlockNode
+ val html = root.children[0] as RawHtmlBlockNode
+ assertEquals(1, html.indentLevel)
+ }
+
+ @Test
+ fun `top-level raw HTML block has indentLevel 0`() {
+ val doc = parse("top level
")
+ val html = doc.children[0] as RawHtmlBlockNode
+ assertEquals(0, html.indentLevel)
+ }
+
+ @Test
+ fun `bulleted raw HTML block keeps a nested outline child`() {
+ val doc = parse("- html
\n - child note")
+ val html = doc.children[0] as RawHtmlBlockNode
+ assertEquals(1, html.children.size)
+ val child = html.children[0] as BulletBlockNode
+ assertEquals("child note", (child.content[0] as TextNode).content.trim())
+ }
+
+ @Test
+ fun `bulleted raw HTML block parses a trailing property`() {
+ val doc = parse("- html
\n id:: abc")
+ val html = doc.children[0] as RawHtmlBlockNode
+ assertEquals("abc", html.properties["id"]?.trim())
+ }
+
+ @Test
+ fun `nested bulleted heading carries the bullet's outline level as indentLevel`() {
+ val doc = parse("- parent\n - # Nested Heading")
+ val root = doc.children[0] as BulletBlockNode
+ val heading = root.children[0] as HeadingBlockNode
+ assertEquals(1, heading.indentLevel)
+ }
+
+ @Test
+ fun `inline-only span tag is NOT classified as raw HTML (falls back to paragraph)`() {
+ // is an inline HTML element, not one of the CommonMark §4.6 type-6
+ // block-level tags, so it must not be picked up by the BLOCK_HTML_TAGS check.
+ val doc = parse("inline html")
+ assertIs(doc.children[0], "Inline must fall back to a paragraph, not RawHtmlBlockNode")
+ }
+
+ @Test
+ fun `DOCTYPE declaration is NOT classified as raw HTML (falls back to paragraph)`() {
+ // is a type-7 HTML construct in CommonMark, not one of the
+ // recognized block tag names or an HTML comment, so BLOCK_HTML_TAGS must not
+ // match it.
+ val doc = parse("")
+ assertIs(doc.children[0], " must fall back to a paragraph, not RawHtmlBlockNode")
+ }
+
+ @Test
+ fun `unrecognized angle-bracket text is NOT classified as raw HTML (falls back to paragraph)`() {
+ // Boundary case: "<3 not html" starts with '<' but the following token is not a
+ // recognized block-level tag name (nor an HTML comment), so it must fall through
+ // to plain paragraph parsing rather than being misclassified as RawHtmlBlockNode.
+ val doc = parse("<3 not html")
+ assertIs(doc.children[0], "Unrecognized angle-bracket text must fall back to a paragraph")
+ }
+
+ @Test
+ fun `MarkdownParser round-trips RawHtmlBlockNode content and indentLevel`() {
+ val page = dev.stapler.stelekit.parser.MarkdownParser().parsePage("- root\n - hi
")
+ val root = page.blocks[0]
+ val html = root.children[0]
+ assertEquals(dev.stapler.stelekit.model.BlockType.RawHtml, html.blockType)
+ assertEquals(1, html.level)
+ assertTrue(html.content.contains("hi
"), "Raw HTML content must round-trip, got: ${html.content}")
+ }
+
+ // -------------------------------------------------------------------------
+ // REGRESSION — PR #260 adversarial review findings.
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun `non-bulleted fenced code block nested under a heading carries the true outline depth as indentLevel`() {
+ // Regression for tryConsumeNonHeadingConstruct hardcoding indentLevel=0 for any
+ // non-bullet-decorated construct, even when it is an outline child parsed via
+ // parseBlocksAtLevel(level + 1) at a nonzero depth. `level` is always the correct
+ // outline nesting depth (see the ATX heading path in parseBlock, which uses
+ // `indentLevel = level` unconditionally) — it must not be zeroed just because the
+ // construct itself isn't bullet-decorated.
+ val doc = parse("# Heading\n ```kotlin\nval x = 1\n```")
+ val heading = doc.children[0] as HeadingBlockNode
+ val code = heading.children[0] as CodeFenceBlockNode
+ assertEquals(1, code.indentLevel, "Non-bulleted child construct must carry its true outline depth, not 0")
+ }
+
+ @Test
+ fun `raw HTML block continuation consumes lines indented deeper than the opening tag, not just equal`() {
+ // Regression: tryParseRawHtmlConstruct's continuation loop previously required
+ // peekIndentLevel() == level exactly, so any continuation line indented *deeper*
+ // than the construct's own level (e.g. naturally-indented nested markup, or a
+ // bulleted HTML block's content indented one level past the bullet) incorrectly
+ // terminated the block early instead of continuing to a blank line/EOF per
+ // CommonMark §4.6.
+ val doc = parse("- \n content\n
")
+ assertEquals(1, doc.children.size, "Must parse as a single block, got: ${doc.children}")
+ val html = doc.children[0] as RawHtmlBlockNode
+ val lines = html.rawHtml.trim().lines().map { it.trim() }
+ assertEquals(listOf("", "content", "
"), lines)
+ assertEquals(0, html.children.size, "No spurious child blocks should be produced")
+ }
+
+ @Test
+ fun `non-bulleted top-level raw HTML block consumes all indented continuation lines`() {
+ val doc = parse("")
+ assertEquals(1, doc.children.size, "Must parse as a single block, got: ${doc.children}")
+ val html = doc.children[0] as RawHtmlBlockNode
+ val lines = html.rawHtml.trim().lines().map { it.trim() }
+ assertEquals(listOf(""), lines)
+ }
}
diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockParserTest.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockParserTest.kt
index 736b6dc4a..055d8a818 100644
--- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockParserTest.kt
+++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockParserTest.kt
@@ -1,6 +1,7 @@
package dev.stapler.stelekit.parsing
import dev.stapler.stelekit.parsing.ast.BulletBlockNode
+import dev.stapler.stelekit.parsing.ast.HeadingBlockNode
import dev.stapler.stelekit.parsing.ast.TextNode
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -78,4 +79,66 @@ class BlockParserTest {
assertEquals(1, block2.properties.size)
assertEquals("value", block2.properties["prop"]?.trim())
}
+
+ @Test
+ fun `test top-level ATX heading`() {
+ val input = "# Core Definition"
+
+ val parser = BlockParser(input)
+ val doc = parser.parse()
+
+ assertEquals(1, doc.children.size)
+ val heading = doc.children[0] as HeadingBlockNode
+ assertEquals(1, heading.level)
+ assertEquals(0, heading.indentLevel)
+ assertEquals("Core Definition", (heading.content[0] as TextNode).content.trim())
+ }
+
+ @Test
+ fun `test bulleted ATX heading is classified as heading`() {
+ // Logseq decorates outline bullet items as headings this way (e.g. "- # Core Definition").
+ val input = "- # Core Definition"
+
+ val parser = BlockParser(input)
+ val doc = parser.parse()
+
+ assertEquals(1, doc.children.size)
+ val heading = doc.children[0] as HeadingBlockNode
+ assertEquals(1, heading.level)
+ assertEquals("Core Definition", (heading.content[0] as TextNode).content.trim())
+ }
+
+ @Test
+ fun `test nested bulleted heading preserves outline structure`() {
+ val input = """
+- ## Parent Heading
+ - Child bullet
+ """.trimIndent()
+
+ val parser = BlockParser(input)
+ val doc = parser.parse()
+
+ assertEquals(1, doc.children.size)
+ val heading = doc.children[0] as HeadingBlockNode
+ assertEquals(2, heading.level)
+ assertEquals(0, heading.indentLevel)
+ assertEquals("Parent Heading", (heading.content[0] as TextNode).content.trim())
+ assertEquals(1, heading.children.size, "Heading bullet should retain its child")
+
+ val child = heading.children[0] as BulletBlockNode
+ assertEquals("Child bullet", (child.content[0] as TextNode).content.trim())
+ }
+
+ @Test
+ fun `test bulleted tag is not misdetected as heading`() {
+ // "#tag" has no whitespace after the hash run, so it must NOT be treated as a heading.
+ val input = "- #tag some content"
+
+ val parser = BlockParser(input)
+ val doc = parser.parse()
+
+ assertEquals(1, doc.children.size)
+ val bullet = doc.children[0] as BulletBlockNode
+ assertEquals("#tag some content", (bullet.content[0] as TextNode).content.trim())
+ }
}
diff --git a/project_plans/markdown-rendering-gaps/requirements.md b/project_plans/markdown-rendering-gaps/requirements.md
new file mode 100644
index 000000000..327ebd587
--- /dev/null
+++ b/project_plans/markdown-rendering-gaps/requirements.md
@@ -0,0 +1,100 @@
+# Requirements: Markdown Rendering Gaps Audit
+
+**Date**: 2026-07-27
+**Type**: Bug fix / audit of existing feature (block-level Markdown rendering)
+
+## Problem Statement
+
+A user reported (via screenshot) that a page in SteleKit rendered several lines with
+literal `#` characters instead of styled headings. This session already root-caused
+and fixed one instance of the bug: ATX headings (`# Foo`) written as the content of an
+outline bullet (`- # Foo`) rendered with a literal `#` because
+`BlockParser.parseBlock()` only checked for the ATX heading marker *before*
+bullet-token consumption, never after. That fix (adding `tryConsumeAtxHeadingMarker()`,
+checking it post-bullet-consumption, threading `indentLevel` through
+`HeadingBlockNode`) is implemented and test-covered in the working tree, but not yet
+committed/PR'd.
+
+The user's original ask was to fix "all of these markdown rendering gaps" (plural).
+It is not yet confirmed whether the same structural bug (a block-level construct's
+detection logic running only before bullet-token consumption, or a correctly-detected
+`BlockType` never getting a Compose UI renderer) affects other constructs: fenced code
+blocks, blockquotes, ordered lists, thematic breaks, tables when they decorate a
+bullet's content, and/or whether `BlockItem.kt`'s dispatch has a live gap between what
+`BlockType` variants the parser emits and what has a dedicated Composable.
+
+## Users / Consumers
+
+End users (human note-takers) viewing Markdown-formatted outline pages in the SteleKit
+desktop/Android/iOS/Web app. No downstream systems are affected — this is purely a
+rendering-correctness bug in the read-mode (non-editing) block view.
+
+## Success Metrics
+
+- Every block-level construct that Logseq/CommonMark allows as bullet-decorated
+ content is detected correctly by `BlockParser` regardless of whether it decorates a
+ bullet or stands alone at top level.
+- Every `BlockType` variant the parser can emit has a corresponding dedicated
+ Composable in the `BlockItem.kt` dispatch (no unexpected fallback to generic
+ bullet/paragraph text rendering for a correctly-classified block).
+- Regression tests exist for each gap found and fixed, and `./gradlew jvmTest` /
+ targeted `--tests` runs pass with visible green output.
+- If no further gaps exist beyond the already-fixed heading bug, that is reported
+ clearly with the verification evidence (audit trail of checks performed), not
+ papered over with speculative changes.
+
+## Constraints
+
+- Do not redo or duplicate the already-completed ATX heading fix (already in the
+ working tree, uncommitted).
+- Follow existing code conventions in `parsing/BlockParser.kt`,
+ `parsing/ast/BlockNodes.kt`, `parser/MarkdownParser.kt`,
+ `model/ParsedModels.kt` (`BlockType`), and `ui/components/BlockItem.kt` and its
+ sibling block Composables.
+- Kotlin Multiplatform: any UI fix must work across Desktop/Android/iOS/Web targets
+ (no platform-specific branches).
+- No completion claims without running `./gradlew jvmTest` (or narrower
+ `--tests` filters) and `./gradlew ciCheck` before shipping, per repo CLAUDE.md
+ engineering-discipline rules.
+- Git hygiene: never `git add -A` / `git add .`; stage only touched files; PR opened
+ as a draft by default.
+
+## Scope
+
+### In Scope
+- Audit `BlockParser.kt` for the same "marker check only fires before bullet-token
+ consumption" structural bug across: fenced code blocks (```` ``` ````), blockquotes
+ (`>`), ordered list items (`1.`), thematic breaks (`---`/`***`/`___`), and tables
+ (`| a | b |`) when used as bullet-decorated content.
+- Audit `model/ParsedModels.kt` (`BlockType`) and `ui/components/BlockItem.kt` dispatch
+ for parity — does every `BlockType` variant the parser can emit route to a real
+ Composable, or does any correctly-classified type fall through to a generic
+ bullet/paragraph renderer?
+- Fix any gaps found, following the same pattern as the heading fix (parser-level
+ structural fix + threading any needed metadata through the AST/model + regression
+ tests).
+- Ship a PR (draft) covering only the newly-found-and-fixed gaps, on top of the
+ current working tree state (including the already-fixed heading bug, since it is
+ uncommitted).
+
+### Out of Scope
+- Re-implementing or re-verifying the already-fixed ATX heading bug.
+- New Markdown syntax not already supported by the parser (e.g. block embeds,
+ transclusion — see prior `project_plans/render-all-markdown/` project for that
+ separate, larger effort).
+- Editor/edit-mode rendering (`BlockEditor.kt`) — this audit is scoped to read-mode
+ (view) rendering only, matching the shape of the original bug report.
+- Inline-level markdown (bold/italic/code spans/wikilinks) — the reported bug and the
+ known fix are both block-level; inline rendering is a separate, already-mature code
+ path (`InlineParser.kt` / `MarkdownEngine.kt`) unless investigation surfaces a
+ directly analogous inline bug.
+
+## Open Questions
+
+- Does `project_plans/render-all-markdown/` (an earlier, broader planning effort for
+ block-renderer coverage, with ADRs already drafted) fully describe the current state
+ of `BlockItem.kt`'s dispatch, or has the code diverged since those ADRs were
+ written? Needs verification against current source, not assumed from the ADRs.
+- Are there other reported/observed instances of literal Markdown syntax leaking
+ through in the original screenshot beyond headings that haven't been described in
+ text (the screenshot itself is not available in this text-only session)?