fix(parser): detect bulleted headings and other block constructs correctly - #260
Merged
Conversation
…ectly 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.
Requirements doc for the block-level markdown rendering gap audit that produced the bulleted-construct parser fix.
…structs 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 <noreply@anthropic.com>
…nstructs, 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 <noreply@anthropic.com>
…g, add coverage 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 <span> and <!DOCTYPE html> 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 <noreply@anthropic.com>
tstapler
marked this pull request as ready for review
July 28, 2026 17:38
Contributor
JVM Load Benchmark (Desktop)Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Flamegraphs (this PR)**Allocation** — object allocation pressure (JDBC/SQLite churn)Alloc flamegraph not available CPU — method-level hotspots by on-CPU time CPU flamegraph not available Top allocation hotspots (this PR)`35.5%` byte[]_[k] `7.4%` java.lang.String_[k] `6.8%` java.util.LinkedHashMap$Entry_[k] `6.4%` int[]_[k] `3.7%` java.lang.Object[]_[k]Top CPU hotspots (this PR)`97%` /usr/lib/x86_64-linux-gnu/libc.so.6 `0.9%` clock_nanosleep `0.6%` /tmp/sqlite-3.51.3.0-4c4ce51d-7a9c-4c34-922e-4662832313dd-libsqlitejdbc.so `0.4%` fsync `0.2%` __libc_pwrite |
Contributor
Android Load BenchmarkInstrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph. Comparing Graph Load
Interactive Write Latency (during Phase 3)
SAF I/O Overhead (ContentProvider vs direct File read)Measures Binder IPC cost added by ContentResolver per readFile() call.
|
…lleted/nested constructs - 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 <noreply@anthropic.com>
indentLevel = level is unconditional since 594f1f8, leaving the parameter unread and tripping detekt's UnusedParameter rule.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
BlockParser.parseBlock()into sharedtryConsumeNonHeadingConstruct()/parseFencedCodeBlock()helpers, and calls them both before and after bullet-token consumption (mirroring the existingtryConsumeAtxHeadingMarker()pattern used for headings).indentLevelthroughHeadingBlockNode, and now also throughCodeFenceBlockNode,BlockquoteBlockNode,ThematicBreakBlockNode, andTableBlockNode, so every bullet-decorated construct retains its true outline nesting depth end-to-end.key:: valueproperties, reparenting them to the grandparent level instead. Independently flagged by three parallel adversarial review agents run against this diff.MarkdownParser.convertBlock()no longer hardcodeslevel = 0forCodeFenceBlockNode/BlockquoteBlockNode/ThematicBreakBlockNode/TableBlockNode, readingblock.indentLevelinstead; (2)parseBlockquote's previously-unused_levelparameter is now wired through toBlockquoteBlockNode.indentLevelinstead of being dead code; (3) raw HTML blocks (CommonMark §4.6) — previously an entirely unimplementedBlockType/AST variant that was never constructed by the parser and fell through to plain paragraph/bullet text — are now detected and parsed, both top-level and bullet-decorated.Context
A user reported (via screenshot) that a SteleKit page rendered several lines with literal
#characters instead of styled headings. Root cause:BlockParser.parseBlock()checked for the ATX heading marker only before bullet-token consumption, never after, so- # Foofell through to plain-text bullet parsing. That fix was already implemented; this PR extends the audit (project_plans/markdown-rendering-gaps/requirements.md) to the remaining block constructs that shared the same structural gap, and fixes each one found.ui/components/BlockItem.kt's dispatch andMarkdownParser.convertBlock's AST→BlockTypemapping were both audited and confirmed to already have full parity — dispatch is an exhaustive, compiler-enforcedwhenover the sealedBlockType/BlockNodehierarchies, so no gap exists there.After the initial classification fix landed, three parallel adversarial review agents (correctness, domain-semantics, test-quality) independently converged on the same BLOCKER:
tryConsumeNonHeadingConstruct()returned each matched construct node immediately, bypassingparseBlock's shared step 3 (property parsing +parseBlocksAtLevel(level + 1)children collection). SinceParsedBlock.children— not.level— is what actually drives DB parent/child structure (confirmed viaMarkdownPageParser.processParsedBlocks), this meant a nested bullet under e.g.- ```kotlin ... ```was silently reparented to the grandparent, and any:: propertyline following such a construct was mis-parsed as literal paragraph text. This is fixed by a newparseTrailingPropertiesAndChildren()helper, applied uniformly to all five non-heading constructs (also closing a pre-existing gap where ordered list items never parsed trailing properties).A subsequent review of that fix flagged 4 remaining gaps as MINOR/out-of-scope at the time, tracked and now closed in this PR:
CodeFenceBlockNode,BlockquoteBlockNode,ThematicBreakBlockNode, andTableBlockNodehad no field to carry outline nesting depth, soMarkdownParser.convertBlock()hardcodedlevel = 0for all four regardless of where they actually sat in the outline.MarkdownParser.ktis fixed by reading the newindentLevelfield instead.parseBlockquote's_levelparameter was accepted but never used (dead parameter) — now wired through toBlockquoteBlockNode.indentLevel.RawHtmlBlockNodewas fully wired throughOutlinerParser,MarkdownParser, andBlockTypeMapper, butBlockParsernever constructed one — literal HTML in a page (CommonMark §4.6) fell through entirely to plain paragraph/bullet parsing, the same bug class as the original ATX-heading gap. Root cause fixed by adding detection for block-level HTML tags and comments using the existingL_ANGLE/EXCLAMATION/TEXTtoken primitives, at both the top level and after bullet-token consumption, plus acontentStringcase inMarkdownParserso raw HTML round-trips instead of persisting as empty content.Changes
parsing/BlockParser.kt: Extracted fenced-code/blockquote/ordered-list/thematic-break/table detection intotryConsumeNonHeadingConstruct()andparseFencedCodeBlock(); invoked from both the pre-bullet (top-level) and post-bullet-consumption call sites. AddedparseTrailingPropertiesAndChildren(), called after each of the five constructs, to collect trailing properties and nested outline children (mirroring the existingOrderedListItemBlockNode/ heading handling).BlockquoteBlockNode'schildrenfield — already used to store the quote's own continuation paragraphs — now has outline children appended after them rather than losing one or the other.tryConsumeNonHeadingConstruct()now takesisBulletDecoratedand computesindentLevel = if (isBulletDecorated) level else 0, threading it intoCodeFenceBlockNode/BlockquoteBlockNode/ThematicBreakBlockNode/TableBlockNode.parseBlockquote'sindentLevelparameter (renamed from the unused_level) is passed straight intoBlockquoteBlockNode.indentLevel. Added raw-HTML-block detection (block-level tag list + HTML comment lookahead viapeekToken) that constructsRawHtmlBlockNode, reusingparseLine()for verbatim capture andparseTrailingPropertiesAndChildren()for properties/children, consistent with the other five constructs.parsing/ast/BlockNodes.kt: AddedindentLeveltoHeadingBlockNode,CodeFenceBlockNode,BlockquoteBlockNode,ThematicBreakBlockNode,TableBlockNode, andRawHtmlBlockNode.parser/MarkdownParser.kt: Usesblock.indentLevelwhen convertingHeadingBlockNode/CodeFenceBlockNode/BlockquoteBlockNode/ThematicBreakBlockNode/TableBlockNode/RawHtmlBlockNodetoParsedBlock.level. Added acontentStringcase forRawHtmlBlockNode(block.rawHtml) so raw HTML content round-trips instead of serializing as an empty string.BlockParserTest.kt(4 heading regression tests);BlockConstructsSpec.kt— 5 classification tests for bulleted constructs, 12 tests covering nested children/trailing properties/sibling resumption/boundary cases for the bulleted forms, 8 new tests coveringindentLevelpropagation (top-level vs. bulleted) for the four newly-tracked constructs, and 11 new tests covering raw HTML block detection, verbatim capture, comments, closing-tag-only forms, bullet decoration,indentLevel, nested children, trailing properties, a false-positive boundary case, and an end-to-endMarkdownParser.parsePageround-trip.Impact
BlockParser), read-mode rendering. No changes to inline parsing, the editor, or UI dispatch.indentLevel: Int = 0); no fields removed.parseBlocksAtLevelrecursion, not a new pass over the document. Raw HTML detection only activates onL_ANGLE, a single extra branch check on an already-consumed token stream.Testing
./gradlew jvmTest— full suite,BUILD SUCCESSFUL, zero failures (confirmed viagrep -c "FAILED"returning 0), includes all new regression tests./gradlew jvmTest --tests "dev.stapler.stelekit.parsing.BlockConstructsSpec"— targeted run, all new and existing tests pass (one pre-existing@Ignored test correctly skipped)./gradlew detekt --no-configuration-cache—BUILD SUCCESSFUL, no findings./gradlew ciCheck— not run as-is in this worktree:androidApp:compileDebugJavaWithJavacrequiresANDROID_HOME/local.properties(not configured here), anddetektalone hits an unrelated Gradle configuration-cache task-graph validation issue (generateDemoFileSystemoutput used without a declared dependency) that reproduces with configuration cache enabled but not with--no-configuration-cache— worked around accordingly. Neither issue is caused by this diff.Reviewer Notes
parseTrailingPropertiesAndChildren()inBlockParser.kt— it speculatively consumes a leadingINDENTtoken to trytryParseProperty(), then restores the pre-INDENT lexer state viasaveState()/restoreState()if the line isn't a property, soparseBlocksAtLevelsees the correct indent level for non-property continuation content. Also confirmBlockquoteBlockNode.children's dual use (quote continuation paragraphs + appended outline children) reads clearly at the call site. For the raw HTML addition: closing tags (</div>) lex as a singleTEXTtoken"/div"rather than a separate/token, since/is not a special character inLexer.isSpecial()— tag-name extraction strips the leading/before matching againstBLOCK_HTML_TAGS.parseLine()), matching this codebase's existing simplified precedent forThematicBreakBlockNode/OrderedListItemBlockNode, rather than attempting full CommonMark multi-line blank-line-terminated semantics, which don't map naturally onto Logseq's outliner model.🤖 Generated with Claude Code