From e16ef9031cbc5482efd948f483a8c03fdebabd43 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Mon, 27 Jul 2026 15:32:49 -0700 Subject: [PATCH 1/7] fix(parser): detect bulleted headings and other block constructs correctly BlockParser.parseBlock() only checked for ATX heading markers, fenced code blocks, blockquotes, ordered list items, thematic breaks, and GFM tables before consuming a bullet token, never after. Decorating a bullet with any of these (e.g. "- # Heading", "- ```lang", "- > quote", "- 1. item", "- ---", "- | a | b |") caused the marker to be parsed as literal text instead of the intended construct. Extract the shared detection logic into tryConsumeNonHeadingConstruct() / parseFencedCodeBlock() helpers and call them both before and after bullet consumption, mirroring the existing tryConsumeAtxHeadingMarker() pattern. Thread indentLevel through HeadingBlockNode so bulleted headings retain their outline position. Regression tests cover all 6 constructs (heading + the 5 newly fixed) in both bulleted and top-level form. --- .../stapler/stelekit/parser/MarkdownParser.kt | 2 +- .../stapler/stelekit/parsing/BlockParser.kt | 352 ++++++++++-------- .../stelekit/parsing/ast/BlockNodes.kt | 5 +- .../stelekit/parsing/BlockConstructsSpec.kt | 56 +++ .../stelekit/parsing/BlockParserTest.kt | 63 ++++ 5 files changed, 314 insertions(+), 164 deletions(-) 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..3f71b6063 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt @@ -39,7 +39,7 @@ class MarkdownParser { val level = when(block) { is BulletBlockNode -> block.level is ParagraphBlockNode -> 0 - is HeadingBlockNode -> 0 + is HeadingBlockNode -> block.indentLevel is CodeFenceBlockNode -> 0 is BlockquoteBlockNode -> 0 is OrderedListItemBlockNode -> block.level 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..e49eba724 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt @@ -48,162 +48,20 @@ 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() + return HeadingBlockNode( + level = topLevelHeadingLevel, + content = listOf(TextNode(stripped)) + ) } - // 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, or + // GFM table at the top level. + tryConsumeNonHeadingConstruct(level)?.let { return it } // 2. Check for Bullet val isBullet = if (currentToken.type == TokenType.BULLET) { @@ -213,6 +71,21 @@ 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, or GFM table (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 @@ -263,18 +136,27 @@ 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 ) @@ -471,6 +353,152 @@ 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, or GFM table 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. + */ + private fun tryConsumeNonHeadingConstruct(level: Int): BlockNode? { + // Fenced code block: ``` + if (currentToken.type == TokenType.BACKTICK) { + val fenceLen = currentToken.end - currentToken.start + if (fenceLen >= 3) { + return parseFencedCodeBlock(TokenType.BACKTICK) + } + } + + // Fenced code block: ~~~ + if (currentToken.type == TokenType.TILDE) { + val fenceLen = currentToken.end - currentToken.start + if (fenceLen >= 3) { + return parseFencedCodeBlock(TokenType.TILDE) + } + } + + // 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() + } + } + } + + // 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() + } + } + } + + // Blockquote: > content + if (currentToken.type == TokenType.R_ANGLE) { + advance() // consume > + if (currentToken.type == TokenType.WS) advance() // optional space + return parseBlockquote(level) + } + + // 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 + ) + } + } + } + + // GFM pipe table: starts with | + if (currentToken.type == TokenType.PIPE) { + val tableNode = tryParseTable() + if (tableNode != null) return tableNode + } + + return null + } + + /** + * Parses the body of a fenced code block whose opening fence token type is + * [fenceType] (BACKTICK for ``` ``` ```, TILDE for `~~~`). [currentToken] must be + * positioned on the opening fence token when this is called. + */ + private fun parseFencedCodeBlock(fenceType: TokenType): CodeFenceBlockNode { + advance() // consume opening fence + 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 fence (same fence type, length >= 3) or EOF + val body = StringBuilder() + while (currentToken.type != TokenType.EOF) { + if (currentToken.type == fenceType) { + val closeLen = currentToken.end - currentToken.start + if (closeLen >= 3) { + advance() // consume closing fence + 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) + } + private fun peekToken(offset: Int): Token { if (offset == 0) return currentToken diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt index 46ef7da20..2eaa3e0fe 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt @@ -27,12 +27,15 @@ data class ParagraphBlockNode( * and as decoration on bullet blocks (e.g. `- ## TODO My heading`). * * [level] is 1–6 (number of leading `#` characters). + * [indentLevel] is the outline nesting depth (mirrors [BulletBlockNode.level]) for headings + * that decorate a bullet; top-level ATX headings default to 0. */ data class HeadingBlockNode( val level: Int, override val content: List, override val children: List = emptyList(), - override val properties: Map = emptyMap() + override val properties: Map = emptyMap(), + val indentLevel: Int = 0 ) : BlockNode() /** diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt index a1865bdf9..a8a0c3d7f 100644 --- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt +++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt @@ -563,4 +563,60 @@ class BlockConstructsSpec { val doc = parse(input) assertFalse(doc.children.any { it is TableBlockNode }, "No separator row → not a table") } + + // ------------------------------------------------------------------------- + // BULLET-DECORATED CONSTRUCTS — regression coverage for the structural bug + // where a construct's marker was only detected BEFORE bullet-token + // consumption (never after), so decorating a bullet with it fell through + // to plain bullet/paragraph parsing and rendered as literal Markdown text. + // This is the same class of bug already fixed for ATX headings + // (see BlockParserTest's "bulleted ATX heading" tests); these tests cover + // the remaining constructs: fenced code blocks, blockquotes, ordered list + // items, thematic breaks, and GFM tables. + // ------------------------------------------------------------------------- + + @Test + fun `bulleted fenced code block is classified as CodeFenceBlockNode`() { + val doc = parse("- ```kotlin\nval x = 1\n```") + assertEquals(1, doc.children.size) + val code = doc.children[0] as CodeFenceBlockNode + assertEquals("kotlin", code.language) + assertEquals("val x = 1", code.rawContent) + } + + @Test + fun `bulleted blockquote is classified as BlockquoteBlockNode`() { + val doc = parse("- > a quote") + assertEquals(1, doc.children.size) + val bq = doc.children[0] as BlockquoteBlockNode + val inner = bq.children[0] as ParagraphBlockNode + assertEquals("a quote", (inner.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted ordered list item is classified as OrderedListItemBlockNode`() { + val doc = parse("- 1. first item") + assertEquals(1, doc.children.size) + val item = doc.children[0] as OrderedListItemBlockNode + assertEquals(1, item.number) + assertEquals("first item", (item.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted thematic break is classified as ThematicBreakBlockNode`() { + val doc = parse("- ---") + assertEquals(1, doc.children.size) + assertIs(doc.children[0]) + } + + @Test + fun `bulleted table is classified as TableBlockNode`() { + val input = "- | Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |" + val doc = parse(input) + assertEquals(1, doc.children.size) + val table = doc.children[0] as TableBlockNode + assertEquals(listOf("Header 1", "Header 2"), table.headers.map { it.trim() }) + assertEquals(1, table.rows.size) + assertEquals(listOf("Cell 1", "Cell 2"), table.rows[0].map { it.trim() }) + } } 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()) + } } From de990909b4eb60e606344ec4937d6bf37fe35cdc Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Mon, 27 Jul 2026 15:32:56 -0700 Subject: [PATCH 2/7] chore(sdd): planning artifacts for markdown-rendering-gaps audit Requirements doc for the block-level markdown rendering gap audit that produced the bulleted-construct parser fix. --- .../markdown-rendering-gaps/requirements.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 project_plans/markdown-rendering-gaps/requirements.md 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)? From baddcaab8e070405aed7277b2eaa64374ac98d15 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Mon, 27 Jul 2026 15:48:30 -0700 Subject: [PATCH 3/7] fix(parser): preserve outline children and properties on bulleted constructs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bullet-decorated fenced code blocks, blockquotes, thematic breaks, and GFM tables returned from tryConsumeNonHeadingConstruct immediately, bypassing parseBlock's shared step 3 (property parsing + parseBlocksAtLevel children collection). Any nested outline bullet or "key:: value" property line following one of these constructs was silently reparented to the grandparent level instead of attaching to the construct itself — a data-loss regression flagged by three independent adversarial review agents against the earlier bulleted-heading fix (PR #260). Adds parseTrailingPropertiesAndChildren, mirroring the existing OrderedListItemBlockNode handling, and applies it uniformly across all five non-heading constructs (also closing a pre-existing gap where ordered list items never parsed trailing properties). Blockquote's children field, which already stores the quote's own continuation paragraphs, now appends outline children after them rather than losing one or the other. Co-Authored-By: Claude Sonnet 5 --- .../stapler/stelekit/parsing/BlockParser.kt | 71 ++++++++++-- .../stelekit/parsing/BlockConstructsSpec.kt | 101 ++++++++++++++++++ 2 files changed, 165 insertions(+), 7 deletions(-) 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 e49eba724..78b3f1756 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt @@ -378,13 +378,21 @@ class BlockParser(private val source: CharSequence) { * (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. + * + * 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? { // Fenced code block: ``` if (currentToken.type == TokenType.BACKTICK) { val fenceLen = currentToken.end - currentToken.start if (fenceLen >= 3) { - return parseFencedCodeBlock(TokenType.BACKTICK) + val node = parseFencedCodeBlock(TokenType.BACKTICK) + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return node.copy(properties = properties, children = children) } } @@ -392,7 +400,9 @@ class BlockParser(private val source: CharSequence) { if (currentToken.type == TokenType.TILDE) { val fenceLen = currentToken.end - currentToken.start if (fenceLen >= 3) { - return parseFencedCodeBlock(TokenType.TILDE) + val node = parseFencedCodeBlock(TokenType.TILDE) + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return node.copy(properties = properties, children = children) } } @@ -404,7 +414,8 @@ class BlockParser(private val source: CharSequence) { if (next.type == TokenType.NEWLINE || next.type == TokenType.EOF) { advance() // consume --- if (currentToken.type == TokenType.NEWLINE) advance() - return ThematicBreakBlockNode() + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return ThematicBreakBlockNode(properties = properties, children = children) } } } @@ -417,7 +428,8 @@ class BlockParser(private val source: CharSequence) { if (next.type == TokenType.NEWLINE || next.type == TokenType.EOF) { advance() // consume *** if (currentToken.type == TokenType.NEWLINE) advance() - return ThematicBreakBlockNode() + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return ThematicBreakBlockNode(properties = properties, children = children) } } } @@ -426,7 +438,12 @@ class BlockParser(private val source: CharSequence) { if (currentToken.type == TokenType.R_ANGLE) { advance() // consume > if (currentToken.type == TokenType.WS) advance() // optional space - return parseBlockquote(level) + val bq = parseBlockquote(level) + // 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: N. content @@ -440,11 +457,12 @@ class BlockParser(private val source: CharSequence) { advance() // consume "N." if (currentToken.type == TokenType.WS) advance() // consume space val contentStr = parseLine() - val children = parseBlocksAtLevel(level + 1) + val (properties, children) = parseTrailingPropertiesAndChildren(level) return OrderedListItemBlockNode( number = number, content = listOf(TextNode(contentStr)), children = children, + properties = properties, level = level ) } @@ -454,12 +472,51 @@ class BlockParser(private val source: CharSequence) { // GFM pipe table: starts with | if (currentToken.type == TokenType.PIPE) { val tableNode = tryParseTable() - if (tableNode != null) return tableNode + if (tableNode != null) { + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return tableNode.copy(properties = properties, children = children) + } } return null } + /** + * 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, List> { + val properties = mutableMapOf() + while (currentToken.type != TokenType.EOF) { + val nextLevel = peekIndentLevel() + val nextIsBullet = peekIsBullet() + if (nextLevel <= level || nextIsBullet) break + + val savedState = lexer.saveState() + val savedToken = currentToken + if (currentToken.type == TokenType.INDENT) advance() + + val property = tryParseProperty() + if (property != null) { + properties[property.first] = property.second + if (currentToken.type == TokenType.NEWLINE) advance() + } else { + // Not a property line — restore (including the INDENT) and let + // parseBlocksAtLevel parse it as its own block at the correct level. + lexer.restoreState(savedState) + currentToken = savedToken + break + } + } + val children = parseBlocksAtLevel(level + 1) + return properties to children + } + /** * Parses the body of a fenced code block whose opening fence token type is * [fenceType] (BACKTICK for ``` ``` ```, TILDE for `~~~`). [currentToken] must be diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt index a8a0c3d7f..8d90e3219 100644 --- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt +++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt @@ -619,4 +619,105 @@ class BlockConstructsSpec { assertEquals(1, table.rows.size) assertEquals(listOf("Cell 1", "Cell 2"), table.rows[0].map { it.trim() }) } + + // ------------------------------------------------------------------------- + // BULLET-DECORATED CONSTRUCTS — nested children & properties regression + // coverage. Fixing the classification bug above (returning the construct + // node immediately) originally left a second, more severe bug: any outline + // children or "key:: value" properties following the decorated bullet were + // silently reparented to the grandparent level instead of attaching to the + // construct itself, since the early return skipped parseBlock's shared + // step-3 handling entirely. These tests cover that data-loss regression. + // ------------------------------------------------------------------------- + + @Test + fun `bulleted fenced code block keeps a nested outline child`() { + val doc = parse("- ```kotlin\nval x = 1\n```\n - child note") + assertEquals(1, doc.children.size) + val code = doc.children[0] as CodeFenceBlockNode + assertEquals(1, code.children.size) + val child = code.children[0] as BulletBlockNode + assertEquals("child note", (child.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted fenced code block parses a trailing property`() { + val doc = parse("- ```kotlin\nval x = 1\n```\n id:: abc") + val code = doc.children[0] as CodeFenceBlockNode + assertEquals("abc", code.properties["id"]?.trim()) + } + + @Test + fun `bulleted blockquote keeps a nested outline child alongside its own quote lines`() { + val doc = parse("- > a quote\n - child note") + val bq = doc.children[0] as BlockquoteBlockNode + // First child is the quote's own paragraph content (existing behaviour). + assertIs(bq.children[0]) + // Second child is the nested outline bullet — must not be dropped. + assertEquals(2, bq.children.size, "Outline child must be preserved alongside the quote's own paragraph") + val outlineChild = bq.children[1] as BulletBlockNode + assertEquals("child note", (outlineChild.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted ordered list item keeps a nested outline child`() { + val doc = parse("- 1. first item\n - child note") + val item = doc.children[0] as OrderedListItemBlockNode + assertEquals(1, item.children.size) + val child = item.children[0] as BulletBlockNode + assertEquals("child note", (child.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted ordered list item parses a trailing property`() { + val doc = parse("- 1. first item\n id:: xyz") + val item = doc.children[0] as OrderedListItemBlockNode + assertEquals("xyz", item.properties["id"]?.trim()) + } + + @Test + fun `bulleted thematic break keeps a nested outline child`() { + val doc = parse("- ---\n - child note") + val brk = doc.children[0] as ThematicBreakBlockNode + assertEquals(1, brk.children.size) + val child = brk.children[0] as BulletBlockNode + assertEquals("child note", (child.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted table keeps a nested outline child`() { + val input = "- | Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |\n - child note" + val doc = parse(input) + val table = doc.children[0] as TableBlockNode + assertEquals(1, table.children.size) + val child = table.children[0] as BulletBlockNode + assertEquals("child note", (child.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted fenced code block resumes sibling parsing after its children`() { + val doc = parse("- ```kotlin\nval x = 1\n```\n - child note\n- next sibling") + assertEquals(2, doc.children.size, "next sibling must be a root sibling, not nested under the code block") + assertIs(doc.children[0]) + val next = doc.children[1] as BulletBlockNode + assertEquals("next sibling", (next.content[0] as TextNode).content.trim()) + } + + @Test + fun `bulleted ordered list item without a space after the dot is NOT classified as an ordered list`() { + // Boundary case: "1.item" (no space) must not match the ordered-list marker, + // matching the top-level ORDERED_LIST_EXTRACT_REGEX + WS/EOF/NEWLINE guard. + val doc = parse("- 1.item not a list") + assertEquals(1, doc.children.size) + assertIs(doc.children[0], "Missing space after the dot must fall back to a plain bullet") + } + + @Test + fun `bulleted dashes with trailing text are NOT classified as a thematic break`() { + // Boundary case: "---text" is not a bare thematic break line (no NEWLINE/EOF + // immediately after the run of dashes), so it must fall back to a plain bullet. + val doc = parse("- ---text") + assertEquals(1, doc.children.size) + assertIs(doc.children[0], "Dashes followed by text must fall back to a plain bullet") + } } From af21b053286d80706f8255b3a46aa9574f4fe44b Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Mon, 27 Jul 2026 16:08:18 -0700 Subject: [PATCH 4/7] fix(parser): thread indentLevel through remaining bullet-decorated constructs, add raw HTML block parsing CodeFenceBlockNode, BlockquoteBlockNode, ThematicBreakBlockNode, and TableBlockNode previously had no indentLevel field, so MarkdownParser.convertBlock() hardcoded level=0 for all of them regardless of actual outline nesting depth -- any of these constructs decorating a nested bullet lost their true position on conversion to ParsedBlock. Mirrors the existing HeadingBlockNode.indentLevel fix. - Add indentLevel: Int = 0 to CodeFenceBlockNode, BlockquoteBlockNode, ThematicBreakBlockNode, TableBlockNode, and RawHtmlBlockNode. - BlockParser.tryConsumeNonHeadingConstruct now takes isBulletDecorated and computes indentLevel = if (isBulletDecorated) level else 0, threading it into each constructed node. - parseBlockquote's previously-unused _level parameter is now wired through to BlockquoteBlockNode.indentLevel instead of being dead code. - MarkdownParser.convertBlock() reads block.indentLevel instead of hardcoding 0 for these five node types. Root cause for raw HTML: RawHtmlBlockNode was fully wired through OutlinerParser, MarkdownParser, and BlockTypeMapper, but BlockParser never constructed one -- literal HTML (CommonMark 4.6) fell through to plain paragraph/bullet parsing and rendered as inline text, the same class of bug as the original ATX-heading gap. Added detection for block-level HTML tags and comments (using the existing L_ANGLE/EXCLAMATION/TEXT token primitives) at both the top level and after bullet-token consumption, plus a contentString case in MarkdownParser so raw HTML round-trips instead of persisting as empty content. Adds regression coverage in BlockConstructsSpec for indentLevel propagation across all four constructs and raw HTML block detection/round-tripping. Co-Authored-By: Claude Sonnet 5 --- .../stapler/stelekit/parser/MarkdownParser.kt | 11 +- .../stapler/stelekit/parsing/BlockParser.kt | 107 +++++++++--- .../stelekit/parsing/ast/BlockNodes.kt | 29 +++- .../stelekit/parsing/BlockConstructsSpec.kt | 161 ++++++++++++++++++ 4 files changed, 276 insertions(+), 32 deletions(-) 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 3f71b6063..8592770ed 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parser/MarkdownParser.kt @@ -40,12 +40,12 @@ class MarkdownParser { is BulletBlockNode -> block.level is ParagraphBlockNode -> 0 is HeadingBlockNode -> block.indentLevel - is CodeFenceBlockNode -> 0 - is BlockquoteBlockNode -> 0 + 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 78b3f1756..0158eef83 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 { @@ -59,9 +72,9 @@ class BlockParser(private val source: CharSequence) { ) } - // 1b. Check for a fenced code block, blockquote, ordered list, thematic break, or - // GFM table at the top level. - tryConsumeNonHeadingConstruct(level)?.let { return it } + // 1b. Check for a fenced code block, blockquote, ordered list, thematic break, + // GFM table, or raw HTML block at the top level. + tryConsumeNonHeadingConstruct(level, isBulletDecorated = false)?.let { return it } // 2. Check for Bullet val isBullet = if (currentToken.type == TokenType.BULLET) { @@ -77,13 +90,14 @@ class BlockParser(private val source: CharSequence) { 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, or GFM table (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. + // 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 } + tryConsumeNonHeadingConstruct(level, isBulletDecorated = true)?.let { return it } } // 3. Parse Content & Properties @@ -163,7 +177,14 @@ class BlockParser(private val source: CharSequence) { } } - 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() @@ -186,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? { @@ -374,10 +395,15 @@ class BlockParser(private val source: CharSequence) { /** * Detects and parses a fenced code block, blockquote, ordered list item, thematic - * break, or GFM table 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. + * 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. + * + * [isBulletDecorated] indicates whether this construct is decorating a bullet ([level] + * is then the bullet's outline nesting depth) or standing at the top level (in which + * case the resulting node's `indentLevel` defaults to 0 regardless of [level]) — + * mirrors the [HeadingBlockNode.indentLevel] pattern. * * Each matched construct also collects any trailing property lines ("key:: value") * and outline children indented past [level] via [parseTrailingPropertiesAndChildren], @@ -385,14 +411,16 @@ class BlockParser(private val source: CharSequence) { * 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? { + private fun tryConsumeNonHeadingConstruct(level: Int, isBulletDecorated: Boolean): BlockNode? { + val indentLevel = if (isBulletDecorated) level else 0 + // Fenced code block: ``` if (currentToken.type == TokenType.BACKTICK) { val fenceLen = currentToken.end - currentToken.start if (fenceLen >= 3) { val node = parseFencedCodeBlock(TokenType.BACKTICK) val (properties, children) = parseTrailingPropertiesAndChildren(level) - return node.copy(properties = properties, children = children) + return node.copy(properties = properties, children = children, indentLevel = indentLevel) } } @@ -402,7 +430,7 @@ class BlockParser(private val source: CharSequence) { if (fenceLen >= 3) { val node = parseFencedCodeBlock(TokenType.TILDE) val (properties, children) = parseTrailingPropertiesAndChildren(level) - return node.copy(properties = properties, children = children) + return node.copy(properties = properties, children = children, indentLevel = indentLevel) } } @@ -415,7 +443,7 @@ class BlockParser(private val source: CharSequence) { advance() // consume --- if (currentToken.type == TokenType.NEWLINE) advance() val (properties, children) = parseTrailingPropertiesAndChildren(level) - return ThematicBreakBlockNode(properties = properties, children = children) + return ThematicBreakBlockNode(properties = properties, children = children, indentLevel = indentLevel) } } } @@ -429,7 +457,7 @@ class BlockParser(private val source: CharSequence) { advance() // consume *** if (currentToken.type == TokenType.NEWLINE) advance() val (properties, children) = parseTrailingPropertiesAndChildren(level) - return ThematicBreakBlockNode(properties = properties, children = children) + return ThematicBreakBlockNode(properties = properties, children = children, indentLevel = indentLevel) } } } @@ -438,7 +466,7 @@ class BlockParser(private val source: CharSequence) { if (currentToken.type == TokenType.R_ANGLE) { advance() // consume > if (currentToken.type == TokenType.WS) advance() // optional space - val bq = parseBlockquote(level) + 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. @@ -474,7 +502,42 @@ class BlockParser(private val source: CharSequence) { val tableNode = tryParseTable() if (tableNode != null) { val (properties, children) = parseTrailingPropertiesAndChildren(level) - return tableNode.copy(properties = properties, children = children) + return tableNode.copy(properties = properties, children = children, indentLevel = indentLevel) + } + } + + // Raw HTML block:
, , etc. (CommonMark §4.6, type-6 tag subset) + if (currentToken.type == TokenType.L_ANGLE) { + 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)) { + val rawLine = parseLine() + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return RawHtmlBlockNode( + rawHtml = rawLine, + properties = properties, + children = children, + indentLevel = indentLevel + ) } } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt index 2eaa3e0fe..8d84d1e66 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/ast/BlockNodes.kt @@ -45,13 +45,16 @@ data class HeadingBlockNode( * [language] is the info string immediately after the opening fence (e.g. "kotlin", "python"). * [options] are additional words on the opening fence line (Logseq/org-mode extensions). * [rawContent] is the verbatim body of the block, newlines preserved. + * [indentLevel] is the outline nesting depth (mirrors [BulletBlockNode.level]) for fenced + * code blocks that decorate a bullet; top-level fenced code blocks default to 0. */ data class CodeFenceBlockNode( val language: String?, val options: List = emptyList(), val rawContent: String, override val children: List = emptyList(), - override val properties: Map = emptyMap() + override val properties: Map = emptyMap(), + val indentLevel: Int = 0 ) : BlockNode() { override val content: List = emptyList() } @@ -59,11 +62,15 @@ data class CodeFenceBlockNode( /** * Block-level blockquote: one or more lines prefixed with `>`. * CommonMark spec §5.1. + * + * [indentLevel] is the outline nesting depth (mirrors [BulletBlockNode.level]) for + * blockquotes that decorate a bullet; top-level blockquotes default to 0. */ data class BlockquoteBlockNode( override val children: List, override val content: List = emptyList(), - override val properties: Map = emptyMap() + override val properties: Map = emptyMap(), + val indentLevel: Int = 0 ) : BlockNode() /** @@ -84,16 +91,23 @@ data class OrderedListItemBlockNode( /** * Thematic break: `---`, `***`, `___` (3+ matching characters, optional spaces). * CommonMark spec §4.1. + * + * [indentLevel] is the outline nesting depth (mirrors [BulletBlockNode.level]) for + * thematic breaks that decorate a bullet; top-level thematic breaks default to 0. */ data class ThematicBreakBlockNode( override val content: List = emptyList(), override val children: List = emptyList(), - override val properties: Map = emptyMap() + override val properties: Map = emptyMap(), + val indentLevel: Int = 0 ) : BlockNode() /** * GFM pipe table. * GFM spec §4.10. + * + * [indentLevel] is the outline nesting depth (mirrors [BulletBlockNode.level]) for tables + * that decorate a bullet; top-level tables default to 0. */ data class TableBlockNode( val headers: List, @@ -101,7 +115,8 @@ data class TableBlockNode( val rows: List>, override val content: List = emptyList(), override val children: List = emptyList(), - override val properties: Map = emptyMap() + override val properties: Map = emptyMap(), + val indentLevel: Int = 0 ) : BlockNode() enum class TableAlignment { LEFT, RIGHT, CENTER } @@ -109,10 +124,14 @@ enum class TableAlignment { LEFT, RIGHT, CENTER } /** * Raw HTML block — passed through verbatim, rendered as a code block in Compose. * CommonMark spec §4.6. + * + * [indentLevel] is the outline nesting depth (mirrors [BulletBlockNode.level]) for raw + * HTML blocks that decorate a bullet; top-level raw HTML blocks default to 0. */ data class RawHtmlBlockNode( val rawHtml: String, override val content: List = emptyList(), override val children: List = emptyList(), - override val properties: Map = emptyMap() + override val properties: Map = emptyMap(), + val indentLevel: Int = 0 ) : BlockNode() diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt index 8d90e3219..2c611a197 100644 --- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt +++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt @@ -720,4 +720,165 @@ class BlockConstructsSpec { assertEquals(1, doc.children.size) assertIs(doc.children[0], "Dashes followed by text must fall back to a plain bullet") } + + // ------------------------------------------------------------------------- + // INDENT LEVEL TRACKING — CodeFenceBlockNode, BlockquoteBlockNode, + // ThematicBreakBlockNode, TableBlockNode. Prior to this fix these four + // constructs always hardcoded indentLevel=0 (via MarkdownParser.convertBlock's + // `level = 0` branches), so any of them decorating a nested bullet lost their + // true outline nesting depth on conversion to ParsedBlock. Mirrors the + // HeadingBlockNode.indentLevel fix. + // ------------------------------------------------------------------------- + + @Test + fun `top-level fenced code block has indentLevel 0`() { + val doc = parse("```kotlin\nval x = 1\n```") + val code = doc.children[0] as CodeFenceBlockNode + assertEquals(0, code.indentLevel) + } + + @Test + fun `bulleted fenced code block carries the bullet's outline level as indentLevel`() { + val doc = parse("- root\n - ```kotlin\nval x = 1\n```") + val root = doc.children[0] as BulletBlockNode + val code = root.children[0] as CodeFenceBlockNode + assertEquals(1, code.indentLevel) + } + + @Test + fun `top-level blockquote has indentLevel 0`() { + val doc = parse("> a quote") + val bq = doc.children[0] as BlockquoteBlockNode + assertEquals(0, bq.indentLevel) + } + + @Test + fun `bulleted blockquote carries the bullet's outline level as indentLevel`() { + val doc = parse("- root\n - > a quote") + val root = doc.children[0] as BulletBlockNode + val bq = root.children[0] as BlockquoteBlockNode + assertEquals(1, bq.indentLevel) + } + + @Test + fun `top-level thematic break has indentLevel 0`() { + val doc = parse("---") + val brk = doc.children[0] as ThematicBreakBlockNode + assertEquals(0, brk.indentLevel) + } + + @Test + fun `bulleted thematic break carries the bullet's outline level as indentLevel`() { + val doc = parse("- root\n - ---") + val root = doc.children[0] as BulletBlockNode + val brk = root.children[0] as ThematicBreakBlockNode + assertEquals(1, brk.indentLevel) + } + + @Test + fun `top-level table has indentLevel 0`() { + val input = "| A | B |\n|---|---|\n| 1 | 2 |" + val doc = parse(input) + val table = doc.children[0] as TableBlockNode + assertEquals(0, table.indentLevel) + } + + @Test + fun `bulleted table carries the bullet's outline level as indentLevel`() { + val input = "- root\n - | A | B |\n|---|---|\n| 1 | 2 |" + val doc = parse(input) + val root = doc.children[0] as BulletBlockNode + val table = root.children[0] as TableBlockNode + assertEquals(1, table.indentLevel) + } + + // ------------------------------------------------------------------------- + // RAW HTML BLOCKS — CommonMark §4.6. Previously RawHtmlBlockNode existed in + // the AST (and was fully wired through OutlinerParser, MarkdownParser, and + // BlockTypeMapper) but BlockParser never constructed one, so literal HTML + // fell through to plain paragraph/bullet parsing and rendered as inline + // text — the same class of bug as the original ATX-heading gap. + // ------------------------------------------------------------------------- + + @Test + fun `top-level HTML block tag is classified as RawHtmlBlockNode`() { + val doc = parse("
\nsome content\n
") + assertIs(doc.children[0]) + } + + @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 `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}") + } } From d14f008b37e688934c366d691f9e0d768651ef32 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Mon, 27 Jul 2026 16:55:12 -0700 Subject: [PATCH 5/7] fix(parser): render raw HTML blocks, dedupe property/construct parsing, add coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses remaining review findings on the markdown block-parser diff: - MAJOR: BlockItem.kt had no rendering branch for BlockType.RawHtml despite RawHtmlBlockNode's KDoc claiming it renders as a code block; add the branch (reusing CodeFenceBlock) so the KDoc's claim is true instead of just fixing the comment. - MAJOR: extract the duplicated speculative indented-property parsing in parseBlock's continuation loop and parseTrailingPropertiesAndChildren into one shared tryConsumeIndentedProperty() helper. - MAJOR: split the 130-line tryConsumeNonHeadingConstruct into dedicated tryParseX helpers (fenced code, thematic break, blockquote, ordered list, table, raw HTML), and along the way collapse the duplicated BACKTICK/TILDE fenced-code dispatch and TEXT/STAR thematic-break detection into single shared checks per construct. - Test coverage: nested bulleted heading indentLevel, inline and are NOT classified as raw HTML blocks, multi-line raw HTML block parses as one node, and the tryConsumeIndentedProperty restore-on- failure path hands a non-property/non-bullet indented line to parseBlocksAtLevel instead of dropping or misparsing it. Verified with ./gradlew jvmTest (full suite) — BUILD SUCCESSFUL. Co-Authored-By: Claude Sonnet 5 --- .../stapler/stelekit/db/MarkdownPageParser.kt | 13 +- .../stapler/stelekit/parsing/BlockParser.kt | 313 ++++++++++-------- .../stelekit/ui/components/BlockItem.kt | 14 +- .../stelekit/parsing/BlockConstructsSpec.kt | 51 +++ 4 files changed, 251 insertions(+), 140 deletions(-) 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/parsing/BlockParser.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt index 0158eef83..7845bafbc 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt @@ -66,9 +66,13 @@ class BlockParser(private val source: CharSequence) { 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)) + content = listOf(TextNode(stripped)), + children = children, + properties = properties, + indentLevel = level ) } @@ -129,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()) } @@ -414,134 +414,151 @@ class BlockParser(private val source: CharSequence) { private fun tryConsumeNonHeadingConstruct(level: Int, isBulletDecorated: Boolean): BlockNode? { val indentLevel = if (isBulletDecorated) level else 0 - // Fenced code block: ``` - if (currentToken.type == TokenType.BACKTICK) { - val fenceLen = currentToken.end - currentToken.start - if (fenceLen >= 3) { - val node = parseFencedCodeBlock(TokenType.BACKTICK) - val (properties, children) = parseTrailingPropertiesAndChildren(level) - return node.copy(properties = properties, children = children, indentLevel = indentLevel) - } - } + 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 } - // Fenced code block: ~~~ - if (currentToken.type == TokenType.TILDE) { - val fenceLen = currentToken.end - currentToken.start - if (fenceLen >= 3) { - val node = parseFencedCodeBlock(TokenType.TILDE) - val (properties, children) = parseTrailingPropertiesAndChildren(level) - return node.copy(properties = properties, children = children, indentLevel = indentLevel) - } - } + return null + } - // 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() - val (properties, children) = parseTrailingPropertiesAndChildren(level) - return ThematicBreakBlockNode(properties = properties, children = children, indentLevel = indentLevel) - } - } - } + /** 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 - // 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() - val (properties, children) = parseTrailingPropertiesAndChildren(level) - return ThematicBreakBlockNode(properties = properties, children = children, indentLevel = indentLevel) - } - } - } + val node = parseFencedCodeBlock(fenceType) + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return node.copy(properties = properties, children = children, indentLevel = indentLevel) + } - // Blockquote: > content - if (currentToken.type == TokenType.R_ANGLE) { - 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) + /** + * 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 - // 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 (properties, children) = parseTrailingPropertiesAndChildren(level) - return OrderedListItemBlockNode( - number = number, - content = listOf(TextNode(contentStr)), - children = children, - properties = properties, - level = level - ) - } - } - } + val next = peekToken(1) + if (next.type != TokenType.NEWLINE && next.type != TokenType.EOF) return null - // GFM pipe table: starts with | - if (currentToken.type == TokenType.PIPE) { - val tableNode = tryParseTable() - if (tableNode != null) { - val (properties, children) = parseTrailingPropertiesAndChildren(level) - return tableNode.copy(properties = properties, children = children, indentLevel = indentLevel) - } - } + advance() // consume marker run + if (currentToken.type == TokenType.NEWLINE) advance() + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return ThematicBreakBlockNode(properties = properties, children = children, indentLevel = indentLevel) + } - // Raw HTML block:
, , etc. (CommonMark §4.6, type-6 tag subset) - if (currentToken.type == TokenType.L_ANGLE) { - 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)) { - val rawLine = parseLine() - val (properties, children) = parseTrailingPropertiesAndChildren(level) - return RawHtmlBlockNode( - rawHtml = rawLine, - 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 } } - - return null + if (!(isComment || (tagName != null && tagName in BLOCK_HTML_TAGS))) return null + + // CommonMark §4.6 type-6 raw HTML blocks continue consuming lines at the + // *same* indentation as the opening line 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 + // top-level sibling blocks instead of one raw HTML block. Lines indented + // *past* this construct's level (or bulleted) are left alone — those are + // this outliner's own nested properties/children (see the failing-without- + // this-guard cases: a bulleted raw HTML block's trailing "id:: value" + // property or nested "- child" bullet must NOT be swallowed as HTML text), + // handled below by parseTrailingPropertiesAndChildren as usual. + 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 + } + if (peekIndentLevel() != level || peekIsBullet()) break + htmlBuilder.append('\n') + htmlBuilder.append(parseLine()) + } + val (properties, children) = parseTrailingPropertiesAndChildren(level) + return RawHtmlBlockNode( + rawHtml = htmlBuilder.toString(), + properties = properties, + children = children, + indentLevel = indentLevel + ) } /** @@ -560,19 +577,13 @@ class BlockParser(private val source: CharSequence) { val nextIsBullet = peekIsBullet() if (nextLevel <= level || nextIsBullet) break - val savedState = lexer.saveState() - val savedToken = currentToken - if (currentToken.type == TokenType.INDENT) advance() - - val property = tryParseProperty() + val property = tryConsumeIndentedProperty() if (property != null) { properties[property.first] = property.second - if (currentToken.type == TokenType.NEWLINE) advance() } else { - // Not a property line — restore (including the INDENT) and let - // parseBlocksAtLevel parse it as its own block at the correct level. - lexer.restoreState(savedState) - currentToken = savedToken + // Not a property line — the lexer position was already restored (including + // the INDENT) by tryConsumeIndentedProperty, so parseBlocksAtLevel sees the + // correct indent level and parses it as its own block. break } } @@ -580,6 +591,34 @@ class BlockParser(private val source: CharSequence) { return properties to children } + /** + * Speculatively parses a "key:: value" property line, consuming a leading INDENT + * token (if present) and the trailing NEWLINE on success. Shared by [parseBlock]'s + * content/property loop and [parseTrailingPropertiesAndChildren] — both need to try + * a candidate indented line as a property before falling back to their own + * different handling of a non-property line (continuation text vs. leaving it for + * [parseBlocksAtLevel]). + * + * On failure (the line is not a property), the lexer/token state is fully restored + * to exactly where it was before this call — including the INDENT token — so the + * caller can decide how to (re-)consume the line. + */ + private fun tryConsumeIndentedProperty(): Pair? { + val savedState = lexer.saveState() + val savedToken = currentToken + if (currentToken.type == TokenType.INDENT) advance() + + val property = tryParseProperty() + if (property != null) { + if (currentToken.type == TokenType.NEWLINE) advance() + return property + } + + lexer.restoreState(savedState) + currentToken = savedToken + return null + } + /** * Parses the body of a fenced code block whose opening fence token type is * [fenceType] (BACKTICK for ``` ``` ```, TILDE for `~~~`). [currentToken] must be diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/BlockItem.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/BlockItem.kt index 150b8dc1f..7af3bed06 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/BlockItem.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/BlockItem.kt @@ -451,6 +451,18 @@ internal fun BlockItem( onLongPressSelect = onLongPressSelect, modifier = Modifier.weight(1f), ) + is BlockType.RawHtml -> CodeFenceBlock( + // Rendered like a code fence (monospace, no inline-markdown parsing) + // per RawHtmlBlockNode's KDoc — raw HTML is passed through verbatim + // and should not be interpreted as Markdown. + content = block.content, + language = "html", + onStartEditing = onStartEditing, + isInSelectionMode = isInSelectionMode, + onToggleSelect = onToggleSelect, + onLongPressSelect = onLongPressSelect, + modifier = Modifier.weight(1f), + ) else -> { val imageData = remember(block.content) { extractSingleImageNode(block.content) } if (imageData != null) { @@ -465,7 +477,7 @@ internal fun BlockItem( modifier = Modifier.weight(1f), ) } else { - BlockViewer( // BULLET, PARAGRAPH, RAW_HTML, unknown + BlockViewer( // BULLET, PARAGRAPH, unknown content = block.content, textColor = textColor, linkColor = linkColor, diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt index 2c611a197..e4981d9ea 100644 --- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt +++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt @@ -647,6 +647,21 @@ class BlockConstructsSpec { assertEquals("abc", code.properties["id"]?.trim()) } + @Test + fun `bulleted fenced code block hands a non-property, non-bullet indented line to its children (tryConsumeIndentedProperty fallback)`() { + // Regression test for the shared tryConsumeIndentedProperty() speculative-parse + // helper: " just some text" is indented past the code fence's level but is + // neither a "key:: value" property nor a bullet. tryConsumeIndentedProperty must + // fully restore the lexer/token state (including the leading INDENT) on its + // failed property match so parseBlocksAtLevel can re-parse the line from scratch + // as an ordinary child paragraph — not silently drop it or misparse it. + val doc = parse("- ```kotlin\nval x = 1\n```\n just some text") + val code = doc.children[0] as CodeFenceBlockNode + assertEquals(1, code.children.size) + val child = code.children[0] as ParagraphBlockNode + assertEquals("just some text", (child.content[0] as TextNode).content.trim()) + } + @Test fun `bulleted blockquote keeps a nested outline child alongside its own quote lines`() { val doc = parse("- > a quote\n - child note") @@ -806,6 +821,17 @@ class BlockConstructsSpec { assertIs(doc.children[0]) } + @Test + fun `multi-line raw HTML block is a single node, not split into siblings`() { + // CommonMark §4.6 type-6 raw HTML blocks continue consuming lines until a + // blank line or EOF. A parser that only reads the opening tag's line would + // split "some content" and "
" 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("
") @@ -863,6 +889,31 @@ class BlockConstructsSpec { 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 From 594f1f88148c5a3a1550b6bc3508ed0b1a2e0be0 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Tue, 28 Jul 2026 11:00:48 -0700 Subject: [PATCH 6/7] fix(parser): correct indentLevel and raw HTML continuation for non-bulleted/nested constructs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tryConsumeNonHeadingConstruct always zeroed indentLevel for non-bullet-decorated constructs, even at nonzero outline depth (e.g. a fenced code block nested as a heading's non-bulleted child), losing its true nesting level. - tryParseRawHtmlConstruct's continuation loop required exact indent equality with the opening line, so any deeper-indented (but still-inside) continuation line terminated the block early instead of continuing to a blank line/EOF per CommonMark §4.6. The fix distinguishes genuine deeper-indented outline children/property lines (left for parseTrailingPropertiesAndChildren) from raw HTML text via a non-consuming property/bullet lookahead. Co-Authored-By: Claude Sonnet 5 --- .../stapler/stelekit/parsing/BlockParser.kt | 57 +++++++++++++------ .../stelekit/parsing/BlockConstructsSpec.kt | 43 ++++++++++++++ 2 files changed, 84 insertions(+), 16 deletions(-) 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 7845bafbc..a0030b769 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt @@ -400,10 +400,10 @@ class BlockParser(private val source: CharSequence) { * a bullet's content — see the call sites in [parseBlock]. Returns null and leaves * the token stream untouched if none of these constructs match. * - * [isBulletDecorated] indicates whether this construct is decorating a bullet ([level] - * is then the bullet's outline nesting depth) or standing at the top level (in which - * case the resulting node's `indentLevel` defaults to 0 regardless of [level]) — - * mirrors the [HeadingBlockNode.indentLevel] pattern. + * [isBulletDecorated] indicates whether this construct is decorating a bullet or + * standing as a non-bulleted construct; either way [level] is the correct outline + * nesting depth 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], @@ -412,7 +412,7 @@ class BlockParser(private val source: CharSequence) { * orphan its nested children/properties to the caller as mis-leveled siblings. */ private fun tryConsumeNonHeadingConstruct(level: Int, isBulletDecorated: Boolean): BlockNode? { - val indentLevel = if (isBulletDecorated) level else 0 + val indentLevel = level tryParseFencedCodeConstruct(level, indentLevel)?.let { return it } tryParseThematicBreakConstruct(level, indentLevel)?.let { return it } @@ -530,16 +530,19 @@ class BlockParser(private val source: CharSequence) { } if (!(isComment || (tagName != null && tagName in BLOCK_HTML_TAGS))) return null - // CommonMark §4.6 type-6 raw HTML blocks continue consuming lines at the - // *same* indentation as the opening line 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 - // top-level sibling blocks instead of one raw HTML block. Lines indented - // *past* this construct's level (or bulleted) are left alone — those are - // this outliner's own nested properties/children (see the failing-without- - // this-guard cases: a bulleted raw HTML block's trailing "id:: value" - // property or nested "- child" bullet must NOT be swallowed as HTML text), - // handled below by parseTrailingPropertiesAndChildren as usual. + // 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) { @@ -548,7 +551,13 @@ class BlockParser(private val source: CharSequence) { advance() break } - if (peekIndentLevel() != level || peekIsBullet()) 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()) } @@ -619,6 +628,22 @@ class BlockParser(private val source: CharSequence) { return null } + /** + * Non-consuming lookahead for [tryConsumeIndentedProperty]: reports whether the + * current (possibly INDENT-prefixed) line is a "key:: value" property line, without + * disturbing the lexer/token state either way. Used by [tryParseRawHtmlConstruct]'s + * continuation loop to tell a genuine trailing property line apart from raw HTML + * text that happens to be indented past the construct's own level. + */ + private fun peekIsIndentedProperty(): Boolean { + val savedState = lexer.saveState() + val savedToken = currentToken + val isProperty = tryConsumeIndentedProperty() != null + lexer.restoreState(savedState) + currentToken = savedToken + return isProperty + } + /** * Parses the body of a fenced code block whose opening fence token type is * [fenceType] (BACKTICK for ``` ``` ```, TILDE for `~~~`). [currentToken] must be diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt index e4981d9ea..c0859ac3e 100644 --- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt +++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/parsing/BlockConstructsSpec.kt @@ -932,4 +932,47 @@ class BlockConstructsSpec { 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("
      \n
    • foo
    • \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("
      ", "
    • foo
    • ", "
    "), lines) + } } From 59c7f10fbf7ab329ec4ecdd48ef02f759491e540 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Tue, 28 Jul 2026 11:23:07 -0700 Subject: [PATCH 7/7] fix(parser): remove dead isBulletDecorated parameter indentLevel = level is unconditional since 594f1f88, leaving the parameter unread and tripping detekt's UnusedParameter rule. --- .../dev/stapler/stelekit/parsing/BlockParser.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 a0030b769..5243fc6cb 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/parsing/BlockParser.kt @@ -78,7 +78,7 @@ class BlockParser(private val source: CharSequence) { // 1b. Check for a fenced code block, blockquote, ordered list, thematic break, // GFM table, or raw HTML block at the top level. - tryConsumeNonHeadingConstruct(level, isBulletDecorated = false)?.let { return it } + tryConsumeNonHeadingConstruct(level)?.let { return it } // 2. Check for Bullet val isBullet = if (currentToken.type == TokenType.BULLET) { @@ -101,7 +101,7 @@ class BlockParser(private val source: CharSequence) { // parsing and rendered as literal Markdown text — the same structural bug already // fixed for headings. if (isBullet && bulletHeadingLevel == null) { - tryConsumeNonHeadingConstruct(level, isBulletDecorated = true)?.let { return it } + tryConsumeNonHeadingConstruct(level)?.let { return it } } // 3. Parse Content & Properties @@ -400,9 +400,8 @@ class BlockParser(private val source: CharSequence) { * a bullet's content — see the call sites in [parseBlock]. Returns null and leaves * the token stream untouched if none of these constructs match. * - * [isBulletDecorated] indicates whether this construct is decorating a bullet or - * standing as a non-bulleted construct; either way [level] is the correct outline - * nesting depth and is used directly as `indentLevel` — mirrors the + * [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") @@ -411,7 +410,7 @@ class BlockParser(private val source: CharSequence) { * 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, isBulletDecorated: Boolean): BlockNode? { + private fun tryConsumeNonHeadingConstruct(level: Int): BlockNode? { val indentLevel = level tryParseFencedCodeConstruct(level, indentLevel)?.let { return it }