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