kotmark

A Markdown engine
Kotlin Multiplatform

CommonMark parser & renderer for Kotlin

Markdown,
with the marks
left in.

A CommonMark parser and renderer written in Kotlin. Keep the syntax, inspect the tree, and add only the extensions your application needs.

[1] The core stays CommonMark-shaped; extensions are opt-in.

input.mdsource → tree → html
SOURCE · CommonMark
## Field notes

A tree you can **walk**.
PARSED · node outline
Document
├─ Heading(level = 2)
│  └─ Text("Field notes")
└─ Paragraph
   ├─ Text("A tree you can ")
   ├─ StrongEmphasis
   │  └─ Text("walk")
   └─ Text(".")
Kotlin MultiplatformCommonMark 0.31.2Explicit extensionsHTML · Markdown · Plain text

Getting started

Parse Markdown.
Render your content.

Add Kotmark to your Kotlin project.

For Kotlin Multiplatform, put the dependency in commonMain.dependencies. For a JVM project, use the module’s dependencies block. JVM applications require Java 17 or newer.

GRADLE · KOTLIN DSL
implementation("io.heapy.kotmark:kotmark:0.1.0")

Parse, then render

The default parser and HTML renderer are plain Kotlin factories. Start with CommonMark; add an extension to both sides only when its syntax and output are wanted.

Parse Markdown to HTML
import io.heapy.kotmark.parser.parser
import io.heapy.kotmark.renderer.html.htmlRenderer

fun renderMarkdown(markdown: String): String {
    val document = parser().parse(markdown)
    return htmlRenderer().render(document)
}

Specification support

CommonMark core.
Optional extensions.

Kotmark implements CommonMark 0.31.2. The specification tests cover the complete current example set and the corpus bundled with commonmark-java.

655/655CommonMark spec
version 0.31.2
652/652commonmark-java
bundled spec corpus

GFM tables, strikethrough, task lists, and alerts are available as extensions. Kotmark supports selected GFM features; it does not claim full GFM conformance. Bare URL detection follows autolink-java rules, with documented differences from GFM.

Extension index / opt-in syntax

Keep the core lean.
Pull in what you use.

Add the extension module you need and register it with the parser and each renderer you use. The autolink module also provides a standalone URL and email extractor.

ModuleAddsDialect note
autolinkBare URL and email linksUses upstream autolink-java rules
emojiUnicode emoji from shortcodesGitHub gemoji aliases and custom mappings
footnotesFootnote references and definitionsOpt-in syntax
gfm-alertsGitHub-style alert blocksSupported alert fixtures; not full GFM
gfm-strikethroughStruck-through textConfigurable delimiter rules
gfm-tablesPipe tablesSupported GFM table grammar
heading-anchorStable heading IDsCustomizable ID generation
highlight==highlighted text==Own inline extension syntax
image-attributesExtra image attributesParser and HTML renderer hooks
insInserted textOpt-in inline syntax
mathInline and fenced formula sourceOpaque content; no TeX engine
task-list-itemsCheckbox list itemsOpt-in list rendering
yaml-front-matterYAML document metadataSeparate front-matter node

Every module has its own page with syntax, options, and a compiled example: start from the extension index. Registration is covered in the configuration guide.

Extension workshop / custom blocks

Give a block its
own shape.

A custom block has two jobs: recognize its source lines and put a node in the tree. The example uses :::memo through :::, stores the body as literal text, and escapes it on HTML output.

  1. ModelSubclass CustomBlock; expose the content your renderers need.
  2. ParseUse AbstractBlockParser and a factory to recognize the opener, continue, and finish at the closer.
  3. RegisterAttach the factory in Parser.ParserExtension.
  4. RenderRegister a node renderer; send untrusted text through HtmlWriter.text.
Open the complete block extension example
import io.heapy.kotmark.Extension
import io.heapy.kotmark.extensions
import io.heapy.kotmark.node.CustomBlock
import io.heapy.kotmark.parser.Parser
import io.heapy.kotmark.parser.SourceLine
import io.heapy.kotmark.parser.block.AbstractBlockParser
import io.heapy.kotmark.parser.block.AbstractBlockParserFactory
import io.heapy.kotmark.parser.block.BlockContinue
import io.heapy.kotmark.parser.block.BlockStart
import io.heapy.kotmark.parser.block.MatchedBlockParser
import io.heapy.kotmark.parser.block.ParserState
import io.heapy.kotmark.parser.parser
import io.heapy.kotmark.renderer.html.HtmlRenderer
import io.heapy.kotmark.renderer.html.HtmlRenderer.HtmlRendererExtension
import io.heapy.kotmark.renderer.html.htmlRenderer
import io.heapy.kotmark.renderer.html.nodeRenderer

class MemoBlock : CustomBlock() {
    var literal: String = ""
}

private class MemoBlockParser : AbstractBlockParser() {
    override val block = MemoBlock()
    private val content = StringBuilder()
    private var openingLine = true

    override fun tryContinue(parserState: ParserState): BlockContinue {
        val line = parserState.line.content
            .subSequence(parserState.nextNonSpaceIndex, parserState.line.content.length)
            .toString()
            .trimEnd(' ', '\t')
        return if (line == ":::") {
            BlockContinue.Finished
        } else {
            BlockContinue.AtIndex(parserState.nextNonSpaceIndex)
        }
    }

    override fun addLine(line: SourceLine) {
        if (openingLine) {
            openingLine = false
        } else {
            content.append(line.content).append('\n')
        }
    }

    override fun closeBlock() {
        block.literal = content.toString()
    }
}

private class MemoBlockParserFactory : AbstractBlockParserFactory() {
    override fun tryStart(
        state: ParserState,
        matchedBlockParser: MatchedBlockParser,
    ): BlockStart? {
        val line = state.line.content
            .subSequence(state.nextNonSpaceIndex, state.line.content.length)
            .toString()
        if (state.indent >= 4 || line != ":::memo") return null
        return BlockStart.of(MemoBlockParser()).atIndex(state.line.content.length)
    }
}

class MemoExtension private constructor() : Parser.ParserExtension, HtmlRendererExtension {
    override fun extend(parserConfiguration: Parser.Configuration) {
        parserConfiguration.customBlockParserFactory(MemoBlockParserFactory())
    }

    override fun extend(rendererConfiguration: HtmlRenderer.Configuration) {
        rendererConfiguration.nodeRenderer<MemoBlock> { memo ->
            writer.line()
            writer.tag("aside", mapOf("class" to "memo"))
            writer.text(memo.literal)
            writer.tag("/aside")
            writer.line()
        }
    }

    companion object {
        fun create(): Extension = MemoExtension()
    }
}

fun renderMemo(markdown: String): String {
    val extensions = extensions { add(MemoExtension.create()) }
    val document = parser { extensions(extensions) }.parse(markdown)
    return htmlRenderer { extensions(extensions) }.render(document)
}

[5] This is an intentionally small teaching grammar. Production extensions should define container behavior, indentation, unterminated input, and precedence explicitly.

Extension workshop / custom renderers

Replace one mark.
Keep the rest.

Register a typed handler with nodeRenderer<Heading>. The heading is already typed, and the context is the receiver. Delegate children with renderChildren so emphasis and other inline nodes still use their normal renderers.

A typed handler for headings
import io.heapy.kotmark.node.Heading
import io.heapy.kotmark.renderer.html.HtmlRenderer
import io.heapy.kotmark.renderer.html.htmlRenderer
import io.heapy.kotmark.renderer.html.nodeRenderer
import io.heapy.kotmark.renderer.html.renderChildren

fun chapterRenderer(): HtmlRenderer = htmlRenderer {
    nodeRenderer<Heading> { heading ->
        val tag = "h${heading.level}"
        writer.line()
        writer.tag(tag, extendAttributes(heading, tag, mapOf("class" to "chapter-title")))
        renderChildren(heading)
        writer.tag("/$tag")
        writer.line()
    }
}

WHAT THIS PRESERVES

  • Core rendering for inline children
  • Attribute-provider changes
  • Escaping in delegated text nodes

Factories are consulted in registration order. A custom renderer registered before another renderer for the same node type takes precedence.

For data attributes on core-rendered nodes, prefer an AttributeProvider over replacing the renderer.

Under the paper / parse to output

One tree.
Several ways out.

The parser builds a document tree; renderers walk that same tree into HTML, Markdown, or plain text. Extensions join at the parse or render boundary, not by rewriting a string after the fact.

Kotmark parsing and rendering pathMarkdown source passes through block and inline parsing into a node tree, then the tree can be rendered as HTML, Markdown, or text. Markdownsource linesParserblocks + inlinesNode treedocument structureHTMLMarkdownPlain text block parser factoriesextension nodes fit here
Figure A · Custom syntax becomes structure before any renderer sees it.

Supported platforms

Kotlin across
more than the JVM.

JVM + AndroidJava 17+ on the JVM

JS + WasmJavaScript, Wasm-JS, and Wasm-WASI

Desktop NativeLinux, macOS, and Windows

Apple platformsiOS, tvOS, and watchOS

Exact targets and their runtime verification are listed in the platform matrix.

Guides & API

Build on the basics.

A

Configuration

Configure parsers, renderers, source spans, and extensions.

Read the guide
B

URL resolution

Resolve relative links and images against a base URL or your application’s routes.

Read the guide
C

kotlinx.html renderer

Render documents straight into the kotlinx.html DSL or a DOM consumer, with an adapter for each extension.

Read the guide

Migration / Java lineage, Kotlin API

Familiar grammar.
Different surface.

Moving from commonmark-java or an earlier builder-based API? Kotmark uses Kotlin configuration factories and the io.heapy.kotmark packages. Existing integrations need source changes and recompilation.

COMMONMARK-JAVA STYLEParser.builder().build()
KOTMARK DSLparser { includeSourceSpans = … }
  • Configuration uses top-level Kotlin factories such as parser { } and htmlRenderer { }.
  • Extensions are registered through typed configuration hooks; add them to the parser and the renderers you use.
  • Block continuation uses explicit outcomes: AtIndex, AtColumn, Finished, or null.
  • Review source and binary changes against the migration notes before porting extensions.

Migration guide → · Block continuation → · Upstream record →