| name | add-block-syntax |
| description | This skill provides the complete workflow for adding a new block-level Markdown syntax to the KMP Markdown library. It covers AST node definition, BlockStarter implementation, Flavour registration, block renderer creation, BlockRenderer dispatcher update, unit tests, preview examples, and coverage doc update. This skill should be used when the user wants to add support for a new block-level element such as a new type of code block, container, or leaf block to the parser and renderer. |
Add Block-Level Syntax
This skill implements the end-to-end workflow for adding a new block-level Markdown syntax feature across all modules: parser → renderer → preview → tests → docs.
Workflow
Execute steps 1–8 in order. Each step specifies the exact file path and the pattern to follow.
Step 1: Define AST Node
File: markdown-parser/src/commonMain/kotlin/com/hrm/markdown/parser/ast/BlockNodes.kt
Determine whether the new block is a container (can hold child nodes) or a leaf (holds literal text only).
Leaf node pattern (e.g., code block, math block):
class MyBlock(
override var literal: String = "",
var myCustomField: String = "",
) : LeafNode() {
override fun <R> accept(visitor: NodeVisitor<R>): R = visitor.visitMyBlock(this)
}
Container node pattern (e.g., admonition, custom container):
class MyContainer(
var type: String = "",
var title: String = "",
) : ContainerNode() {
override fun <R> accept(visitor: NodeVisitor<R>): R = visitor.visitMyContainer(this)
}
Also add the visitMyBlock/visitMyContainer method to NodeVisitor interface and DefaultNodeVisitor in markdown-parser/src/commonMain/kotlin/com/hrm/markdown/parser/ast/NodeVisitor.kt.
Step 2: Implement BlockStarter
File: Create markdown-parser/src/commonMain/kotlin/com/hrm/markdown/parser/block/starters/MyBlockStarter.kt
Follow this pattern:
package com.hrm.markdown.parser.block.starters
import com.hrm.markdown.parser.LineRange
import com.hrm.markdown.parser.ast.MyBlock
import com.hrm.markdown.parser.block.OpenBlock
import com.hrm.markdown.parser.core.LineCursor
internal class MyBlockStarter : BlockStarter {
override val priority: Int = 350
override val canInterruptParagraph: Boolean = true
override fun tryStart(cursor: LineCursor, lineIdx: Int, tip: OpenBlock): OpenBlock? {
val indent = cursor.advanceSpaces(3)
val block = MyBlock(literal = "...")
block.lineRange = LineRange(lineIdx, lineIdx + 1)
val ob = OpenBlock(block, lastLineIndex = lineIdx)
ob.starterTag = this::class.simpleName
return ob
}
}
Key points:
cursor.advanceSpaces(3) consumes up to 3 leading spaces (CommonMark indentation rule)
cursor.peek() / cursor.peek(N) for lookahead without consuming
cursor.advance() consumes one character
cursor.rest() returns remaining line content
- Return
null on no match — the registry auto-restores cursor via snapshot/restore
- For multi-line blocks (e.g., fenced), set
contentStartLine in OpenBlock and accumulate lines via ob.contentLines
Step 3: (Optional) Implement PostProcessor
File: Create markdown-parser/src/commonMain/kotlin/com/hrm/markdown/parser/block/postprocessors/MyPostProcessor.kt
Only needed if the block requires AST transformations after initial parsing (e.g., converting a generic block into a specialized type, extracting metadata).
package com.hrm.markdown.parser.block.postprocessors
import com.hrm.markdown.parser.ast.*
class MyPostProcessor : PostProcessor {
override val priority: Int = 350
override fun process(document: Document) {
for (child in document.children) {
processRecursive(child)
}
}
private fun processRecursive(node: Node) {
when (node) {
is MyBlock -> { }
is ContainerNode -> node.children.forEach { processRecursive(it) }
else -> {}
}
}
}
Step 4: Register in Flavour
File: markdown-parser/src/commonMain/kotlin/com/hrm/markdown/parser/flavour/ExtendedFlavour.kt
Add the new starter to blockStarters list, maintaining priority order:
override val blockStarters: List<BlockStarter> = listOf(
MyBlockStarter(),
)
If a PostProcessor was created, add it to postProcessors list similarly.
Decide which Flavour to register in:
CommonMarkFlavour — only for spec-mandated CommonMark blocks
GFMFlavour — for GFM extensions (tables, etc.)
ExtendedFlavour — for all other extensions (most common)
Step 5: Create Block Renderer
File: Create markdown-renderer/src/commonMain/kotlin/com/hrm/markdown/renderer/block/MyBlockRenderer.kt
package com.hrm.markdown.renderer.block
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.hrm.markdown.parser.ast.MyBlock
import com.hrm.markdown.renderer.LocalMarkdownTheme
@Composable
internal fun MyBlockRenderer(
node: MyBlock,
modifier: Modifier = Modifier,
) {
val theme = LocalMarkdownTheme.current
}
Access theme properties via LocalMarkdownTheme.current. If the new block needs custom theme properties, add them to MarkdownTheme data class in markdown-renderer/src/commonMain/kotlin/com/hrm/markdown/renderer/MarkdownTheme.kt.
Step 6: Register in BlockRenderer Dispatcher
File: markdown-renderer/src/commonMain/kotlin/com/hrm/markdown/renderer/block/BlockRenderer.kt
Add a new branch to the when (node) expression:
is MyBlock -> MyBlockRenderer(node, modifier)
Step 7: Write Unit Tests
File: Create markdown-parser/src/commonTest/kotlin/com/hrm/markdown/parser/MyBlockTest.kt
package com.hrm.markdown.parser
import com.hrm.markdown.parser.ast.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class MyBlockTest {
private val parser = MarkdownParser()
@Test
fun should_parse_basic_my_block() {
val doc = parser.parse("<<< my block content >>>")
val node = doc.children.first()
assertIs<MyBlock>(node)
assertEquals("my block content", node.literal)
}
@Test
fun should_handle_empty_my_block() {
val doc = parser.parse("<<<>>>")
val node = doc.children.first()
assertIs<MyBlock>(node)
assertEquals("", node.literal)
}
@Test
fun should_not_parse_invalid_my_block() {
val doc = parser.parse("<< not enough brackets")
val node = doc.children.first()
assertIs<Paragraph>(node)
}
}
Required coverage: happy path, edge cases (empty, special chars, nesting), error handling (invalid input degrades to paragraph).
Run tests: ./gradlew :markdown-parser:jvmTest
Step 8: Add Preview and Update Docs
Preview: Add preview items in markdown-preview/src/commonMain/kotlin/com/hrm/markdown/preview/. Either add to an existing *Preview.kt or create a new file with List<PreviewGroup> and register in previewCategories in MarkdownPreview.kt.
Coverage doc: Update markdown-parser/PARSER_COVERAGE_ANALYSIS.md — mark the new syntax as ✅ supported, update coverage percentages.
Verification
Run full test suite and confirm BUILD SUCCESSFUL:
./gradlew jvmTest
Handling Multi-line (Fenced) Blocks
For blocks spanning multiple lines (like fenced code blocks or math blocks), the BlockParser uses continuation logic:
- In
tryStart(): Create OpenBlock with contentStartLine set, return the open block
- The
BlockParser will call continueBlock() for subsequent lines — for fenced blocks, check if the current line is a closing fence
- Lines between open and close are accumulated in
ob.contentLines
- On
finalizeBlock(), join content lines into node.literal
Refer to FencedCodeBlockStarter.kt and the continueBlock()/finalizeBlock() logic in BlockParser.kt for the exact pattern.