moonmd markdown

the guide

A document, whole and in pieces.

Install the package, read one document, look at what came out, then read one that is still arriving. Every output below is what the library printed for the code above it.

§01

Install

shell
moon add moonbitstack/moonmd

The import alias is a convention, not a requirement. parse takes a StringView, so a String is handed over with [:].

moonbit
import {
  "moonbitstack/moonmd" @moonmd,
}

§02

A document

One call reads the text and returns a Document. It holds the blocks and the flavor it was read with; nothing is rendered until asked.

moonbit
let document = @moonmd.parse("# Notes\n\nA *small* page.\n"[:])
println(document.html(safe=false))
output
<h1>Notes</h1>
<p>A <em>small</em> page.</p>

A document that is empty, or holds only blank lines, has no blocks at all. A last line without a line ending is still a line.

§03

The tree

Blocks hold blocks; a paragraph, a heading and a table cell hold inlines. There are nine block kinds and ten inline kinds, and walk visits each node once, containers before their contents.

moonbit
let document = @moonmd.parse("# Notes\n\n- one\n- two\n"[:])
@moonmd.walk(document, fn(node) {
  match node {
    @moonmd.Block(@moonmd.Heading(heading)) =>
      println("heading, level \{heading.level}")
    @moonmd.Block(@moonmd.List(list)) =>
      println("list of \{list.items.length()}")
    @moonmd.Inline(@moonmd.Text(text)) => println("text: \{text}")
    _ => ()
  }
})
output
heading, level 1
text: Notes
list of 2
text: one
text: two

A list item carries a task marker of its own, so a checkbox is data rather than a string that has to be recognised again later.

§04

Where a block came from

A block knows the lines it was read from, counted from one and in the order of blocks(). That is what cutting a document by heading needs, and what points a reader back at the text behind a node.

moonbit
let document = @moonmd.parse("# Notes\n\ntwo\nthree\n"[:])
for span in document.lines() {
  println("lines \{span.first}-\{span.last}")
}
output
lines 1-1
lines 3-4

Blank lines between blocks belong to neither. A container reports the lines of everything inside it, a fenced block keeps its blank lines and its closing fence, and a table keeps its delimiter row. A document assembled with of_blocks was not read from text, so it reports no lines.

§05

HTML

html takes one flag. With the default safe=true, raw HTML is escaped and a destination that would run script keeps its text and loses its link.

moonbit
let page = @moonmd.parse("<b>bold</b> [x](javascript:alert(1))\n"[:])
println(page.html())
println(page.html(safe=false))
output
<p>&lt;b&gt;bold&lt;/b&gt; x</p>
<p><b>bold</b> <a href="javascript:alert(1)">x</a></p>

The flag is a rendering choice, not a parsing one: the tree is the same either way, so a viewer can offer both without reading the document twice. xhtml is the other one, on by default: it closes the elements that carry nothing, and xhtml=false ends them at the angle bracket, the way a document written to HTML5 does.

safe=false reproduces the HTML the CommonMark examples expect, character for character. Keep it for text you control.

§06

Markdown

The same tree writes itself back out. The writer escapes what would otherwise start a block, and grows a fence past any run of its own characters inside the code, so the result reads back to the same HTML.

moonbit
let document = @moonmd.parse("# Notes\n\nA *small* page.\n"[:])
println(document.markdown())
output
# Notes

A *small* page.

The output is not the input: a table written with uneven pipes comes back aligned, an indented code block becomes a fence. What survives is the document, not its whitespace.

§07

Footnotes

gfm also reads footnotes: a [^label] in the text is a reference, and a line that opens [^label]: is its definition. The reference becomes a numbered marker; the definitions gather at the end as a notes list, each with a link back to where it was cited.

moonbit
let document = @moonmd.parse("A claim[^note]\n\n[^note]: the support\n"[:])
println(document.html(safe=false))
output
<p>A claim<sup class="footnote-ref"><a href="#fn-note" id="fnref-note" data-footnote-ref>1</a></sup></p>
<section class="footnotes" data-footnotes>
<ol>
<li id="fn-note">
<p>the support <a href="#fnref-note" class="footnote-backref" data-footnote-backref data-footnote-backref-idx="1" aria-label="Back to reference 1">↩</a></p>
</li>
</ol>
</section>

The number follows the first reference, not the definition, so a document can define its notes in any order. markdown writes both the reference and the definition back out, and a reference with no definition stays the text it was written as.

§08

Streaming

feed takes the next chunk and returns the blocks that no later text can change. finish returns what is left. A chunk may end anywhere, including in the middle of a line.

moonbit
let stream = @moonmd.Stream::new()
println(stream.feed("# Notes\n\nA *sm"[:]).length())
println(stream.feed("all* page.\n\n"[:]).length())
println(stream.finish().length())
output
1
1
0

The heading settles when the blank line after it arrives; the paragraph settles when its own blank line does. Nothing is handed out twice and nothing is taken back.

defer_links is on by default: a block that mentions a link label waits for finish, because a definition written later still applies to it. Pass defer_links=false to Stream::new to get such blocks earlier, and accept that a forward reference then stays text.

§09

Flavors

gfm is the default: CommonMark plus tables, strikethrough, bare links, task list markers, footnotes and the tag filter. commonmark turns all six off, and a Flavor literal turns them off one at a time.

moonbit
let plain = @moonmd.parse("~~gone~~\n"[:], flavor=@moonmd.commonmark)
println(plain.html(safe=false))
output
<p>~~gone~~</p>

A flavor is a plain record, so a caller can take gfm and change one field without copying the rest by hand.

One field is not an extension. smart writes the text the way a typesetter would: quotes curl towards what they wrap, two hyphens become a dash and three dots an ellipsis. It is off in both presets, and a code span, an escaped character, an entity and a destination keep what they were written with.

moonbit
let typed : @moonmd.Flavor = { ..@moonmd.gfm, smart: true }
let page = @moonmd.parse("\"quoted\" -- and...\n"[:], flavor=typed)
println(page.html(safe=false))
output
<p>“quoted” – and…</p>

A second field reads the line ending itself: breaks makes a newline inside a paragraph a hard break, as a message box does, instead of the soft break the specification gives it.

>