Sign in

    moonbase-mdc

    Markdown with YAML frontmatter and traversal APIs for Moonbase

    markdown
    frontmatter
    mdc
    Download zip
    Author
    Version
    0.0.1
    License
    Apache-2.0
    Last updated
    5 hours ago
    Downloads
    3

    #MDC

    moonrockz/moonbase-mdc parses Markdown with optional YAML frontmatter. It uses cmark 0.4.12 for Markdown and yaml 0.0.7 for metadata.

    This module handles document syntax. A later OKF module can interpret metadata and Markdown nodes without adding OKF rules to the MDC parser.

    #Parsing

    Import moonrockz/moonbase-mdc as @mdc in your moon.pkg.

    let doc = @mdc.Document::Document("---\ntitle: Goal\n---\n# Goal\n")
    // doc.frontmatter() contains the raw YAML and a parsed Yaml mapping.
    // doc.body() is exactly the Markdown after the closing delimiter line.

    Document is opaque and immutable. Its constructor derives the frontmatter, Markdown tree and source positions from the input. parse(source, ...) is an alias for the constructor. Accessors return immutable values. to_cmark() exports a detached Markdown tree for cmark folders, mappers and renderers; changing that tree cannot change the document or its traversal views.

    Frontmatter is opaque. Create one from delimiter-free YAML text with Frontmatter::Frontmatter(raw). The constructor validates the payload and derives the mapping from it. raw() returns the exact original text, and value() returns a deep copy of the YAML mapping. Mutating that copy leaves the frontmatter unchanged. The document parser uses this same constructor.

    parse raises ParseError for an unterminated header, invalid YAML or a payload that is not a mapping. InvalidFrontmatter contains a message and one-based line and column numbers in the original input. Frontmatter::Frontmatter reports positions relative to its payload. An empty or comment-only header is an empty mapping.

    Frontmatter must start on the first line with ---. Either --- or ... closes it. Delimiters accept trailing spaces and tabs, but no leading indentation. The parser accepts a leading BOM and LF, CRLF or CR line endings. Later --- lines belong to Markdown. A first-line --- always opens frontmatter, so it requires a closing delimiter even when the intended content is a thematic break.

    strict, layout, locs and file pass through to cmark. Defaults are strict=true, layout=true, locs=true and file="". Use strict=false for cmark extensions such as tables, task lists, strikethrough and footnotes.

    Cmark locations refer to doc.body(). Add doc.body_offset() to string offsets and doc.body_line() - 1 to line numbers to recover original source positions. String offsets use UTF-16 code units, as MoonBit strings do. The BOM contributes one code unit to the offset when present. Cmark layout nodes, including blank lines, remain in the AST.

    #Traversal

    All traversal APIs visit the document, frontmatter when present, then Markdown blocks and inlines in depth-first document order. Frontmatter is a leaf with a parsed YAML mapping. Markdown nodes expose opaque BlockView and InlineView values. Their kind() methods return algebraic data types containing immutable inspection data, such as heading levels and text. location() returns the source location. Link definitions and code blocks are leaves; link and image text, table cells and footnote definition bodies are children. Resolved references are not followed a second time.

    Use doc.root() to obtain a traversal root. Node::children() returns a ReadOnlyArray[Node] of immutable views. Use walk with enter and optional leave callbacks. Enter returns Continue, SkipChildren or Stop. Skipped nodes still receive leave callbacks. Stop ends the walk immediately, without pending leave callbacks. Traversal uses an explicit stack.

    Implement Visitor and call doc.accept(visitor) for named callbacks: visit_document, visit_frontmatter, visit_block, visit_inline and leave. Block and inline callbacks receive immutable views, so callbacks cannot change the document or the nodes that other callbacks observe. Default enter callbacks return Continue; default leave does nothing.

    Use doc.fold(initial, Folder::Folder(callback)) to accumulate a result. The callback receives the accumulator and a Node, and returns FoldAction::Continue(acc), SkipChildren(acc) or Stop(acc).

    let headings = doc.fold(0, @mdc.Folder::Folder((count, node) => {
    match node {
    Block(view) => match view.kind() {
    Heading(_) => Continue(count + 1)
    _ => Continue(count)
    }
    _ => Continue(count)
    }
    }))

    To use cmark's existing APIs directly, pass doc.to_cmark() to a cmark folder, mapper or renderer. These APIs operate on the Markdown body alone. Block and inline views also offer to_cmark() for exporting a detached subtree, including its layout and metadata. Exports copy mutable containers throughout the tree; use kind(), location() and children() for inspection without subtree copies.

    #Cursor navigation

    doc.cursor(), node.cursor() and Cursor::Cursor(node) create immutable positions at a navigation root. node() gives the focused node. first_child(), last_child(), parent(), next_sibling() and previous_sibling() return a new Cursor in Some, or None at a boundary. The original position remains a bookmark. children() returns a read-only collection of child positions.

    depth() counts edges from the navigation root. path() returns an opaque NodePath with read-only child indices(). Its display form is $[0][1], with $ denoting the root. Paths follow MDC traversal children, including blank lines and other cmark layout nodes. Frontmatter, when present, is the document's first child. node.at(path) resolves a path relative to that node and returns None if it does not exist there.

    Use walk_cursor(root, enter, leave=...) for cursor callbacks, or implement CursorVisitor and call doc.accept_cursor(visitor). Its visit_document, visit_frontmatter, visit_block, visit_inline and leave methods receive a Cursor. The enter methods return the same traversal controls as Visitor. Saved positions and navigation within callbacks cannot move the outer traversal.

    Folder::with_cursor(callback) passes (accumulator, Cursor) to a fold callback:

    let deepest = doc.fold(0, @mdc.Folder::with_cursor((deepest, cursor) => {
    Continue(if cursor.depth() > deepest { cursor.depth() } else { deepest })
    }))

    Node::fold starts a new navigation root at that node. A saved cursor's walk, accept and fold methods traverse its subtree while retaining its original ancestors, depth and path. Cursor::accept takes a CursorVisitor. The existing node-only visitor, folder and walk APIs remain available.

    #Bottom-up reduce

    doc.reduce(callback) and reduce(root, callback) compute one result per node after its children. The callback receives (Node, ReadOnlyArray[A]), with one result per immediate child in children() order. Leaves receive an empty collection. The final answer is the root's result:

    let nodes = doc.reduce((_, children : ReadOnlyArray[Int]) => {
    1 + children.fold(init=0, (total, count) => total + count)
    })

    doc.reduce_cursor and reduce_cursor(root, callback) pass a Cursor instead of a node. cursor.reduce(callback) retains that position's original navigation context while reducing its subtree. A reduction visits every node exactly once in postorder and uses an explicit stack. Fold threads one accumulator in preorder and supports pruning and stopping; reduce combines the completed results of each node's own children.

    Child result collections are immutable. Values supplied by callbacks belong to the caller and are not deep-copied. Use immutable result types when sharing them between reduction results.

    #Trait implementations

    Consumers can implement these open traits on their own types:

    TraitRequired methodEntry point
    Foldenter(Self, Node) -> FoldAction[Self]fold_with(root, initial) or doc.fold_with(initial)
    CursorFoldenter(Self, Cursor) -> FoldAction[Self]fold_cursor_with(root, initial) or doc.fold_cursor_with(initial)
    Reducerreduce(Node, ReadOnlyArray[Self]) -> Selfreduce_with(root) or doc.reduce_with()
    CursorReducerreduce(Cursor, ReadOnlyArray[Self]) -> Selfreduce_cursor_with(root) or doc.reduce_cursor_with()

    Fold implementations return the next accumulator with traversal control. Reducer implementations construct a result from the node and its completed child results. The reducer method has no receiver; a result annotation selects the implementation. Neither operation requires mutable fields or shared state.

    The traits are not generic, but their implementations and the traversal functions can be. A generic wrapper can preserve a domain result while adding node counts:

    struct Tracked[A] {
    value : A
    count : Int
    }

    impl[A : @mdc.Reducer] @mdc.Reducer for Tracked[A] with fn reduce(node, children) {
    {
    value: @mdc.Reducer::reduce(node, children.map(child => child.value)),
    count: 1 + children.fold(init=0, (sum, child) => sum + child.count),
    }
    }

    If a consumer's Intent type implements Reducer, then let result : Tracked[Intent] = doc.reduce_with() computes both the domain result and its node count. Generic fold wrappers work the same way, forwarding each FoldAction while returning a new wrapper value. The trait laws exercise generic wrappers for all four traits through the public API.

    Node offers the same trait entry points as Document, starting a new root. cursor.fold_with(initial) takes a CursorFold and cursor.reduce_with() uses a CursorReducer; both retain the saved position's original ancestors and paths. Folder::from_trait() and Folder::from_cursor_trait() adapt trait implementations to the existing fold(initial, folder) API. Closure-based APIs remain available when a separate type would add no useful contract.

    #Tests

    The package contains focused unit and regression tests. Public API acceptance scenarios live in tests/mdc-acceptance/, and property laws in tests/mdc-laws/. See the test guide for the laws and commands. mise run test runs all of them on their supported targets.

    CursorFold

    pub(open) trait CursorFold {
    fn enter(Self, Cursor) -> FoldAction[Self]
    }

    An accumulator with immutable cursor context. Saved cursor positions and navigation have the same semantics as Folder::with_cursor.

    CursorReducer

    pub(open) trait CursorReducer {
    fn reduce(Cursor, ReadOnlyArray[Self]) -> Self
    }

    A result type whose reduction can inspect immutable navigation context.

    CursorVisitor

    pub(open) trait CursorVisitor {
    fn visit_document(Self, Cursor) -> Control = _
    fn visit_frontmatter(Self, Cursor) -> Control = _
    fn visit_block(Self, Cursor) -> Control = _
    fn visit_inline(Self, Cursor) -> Control = _
    fn leave(Self, Cursor) -> Unit = _
    }

    A visitor with immutable navigation positions. Default enter callbacks continue; default leave does nothing. Nodes are available through node().

    Fold

    pub(open) trait Fold {
    fn enter(Self, Node) -> FoldAction[Self]
    }

    Implement on an accumulator type. Each call returns the next accumulator and traversal control, just like a Folder callback. No state is copied by the engine; immutable implementations return a new value for each update.

    Reducer

    pub(open) trait Reducer {
    fn reduce(Node, ReadOnlyArray[Self]) -> Self
    }

    Implement on the result type of a bottom-up reduction. Child results arrive in children() order; leaves receive an empty read-only collection.

    Visitor

    pub(open) trait Visitor {
    fn visit_document(Self, Document) -> Control = _
    fn visit_frontmatter(Self, Frontmatter) -> Control = _
    fn visit_block(Self, BlockView) -> Control = _
    fn visit_inline(Self, InlineView) -> Control = _
    fn leave(Self, Node) -> Unit = _
    }

    A visitor overrides only the callbacks it needs. Enter callbacks control traversal; leave follows the same rules as walk.

    ParseError

    pub(all) suberror ParseError {
    UnterminatedFrontmatter
    InvalidFrontmatter(line~ : Int, column~ : Int, message~ : String)
    } derive(
    Debug
    )

    BlockKind

    pub enum BlockKind {
    BlankLine
    BlockQuote
    Blocks
    CodeBlock
    Heading(level~ : Int)
    HtmlBlock
    LinkRefDefinition
    List
    Paragraph
    ThematicBreak
    ExtMathBlock
    ExtTable
    ExtFootnoteDefinition
    } derive(Eq,
    Debug
    )

    Block alternatives contain only immutable inspection data.

    BlockKind::equal

    fn BlockKind::equal(BlockKind, BlockKind) -> Bool

    BlockKind::not_equal

    fn BlockKind::not_equal(x : BlockKind, y : BlockKind) -> Bool

    BlockView

    type BlockView derive(
    Debug
    )

    An immutable view into a document's Markdown block. Only the parser and traversal create views; no mutable cmark value can be supplied by callers.

    BlockView::kind

    fn BlockView::kind(self : BlockView) -> BlockKind

    BlockView::to_cmark

    Export an owned cmark subtree. Mutating it cannot change the view or document. Use kind() and location() for inspection without copying the subtree.

    Control

    pub(all) enum Control {
    Continue
    SkipChildren
    Stop
    } derive(Eq,
    Debug
    )

    Control::equal

    fn Control::equal(Control, Control) -> Bool

    Control::not_equal

    fn Control::not_equal(x : Control, y : Control) -> Bool

    Control::to_repr

    Cursor

    type Cursor

    An immutable navigation position. Moves return new positions, or None at a boundary. Keeping a position saves it; no copy or restore is needed. Parent navigation never goes above the node supplied to the constructor.

    Cursor::Cursor

    fn Cursor::Cursor(root : Node) -> Cursor

    Cursor::accept

    fn[V : CursorVisitor] Cursor::accept(self : Cursor, visitor : V) -> Unit

    Visit this subtree with its original navigation context.

    Cursor::children

    fn Cursor::children(self : Cursor) -> ReadOnlyArray[Cursor]

    Cursor::depth

    fn Cursor::depth(self : Cursor) -> Int

    Cursor::first_child

    fn Cursor::first_child(self : Cursor) -> Cursor?

    Cursor::fold

    fn[A] Cursor::fold(self : Cursor, init : A, folder : Folder[A]) -> A

    Fold this subtree while retaining navigation context from its original root.

    Cursor::fold_with

    fn[A : CursorFold] Cursor::fold_with(self : Cursor, init : A) -> A

    Fold a saved position's subtree, retaining its original navigation context.

    Cursor::last_child

    fn Cursor::last_child(self : Cursor) -> Cursor?

    Cursor::next_sibling

    fn Cursor::next_sibling(self : Cursor) -> Cursor?

    Cursor::node

    fn Cursor::node(self : Cursor) -> Node

    Cursor::parent

    fn Cursor::parent(self : Cursor) -> Cursor?

    Cursor::path

    fn Cursor::path(self : Cursor) -> NodePath

    Construct the path on demand, without copying ancestors during navigation.

    Cursor::previous_sibling

    fn Cursor::previous_sibling(self : Cursor) -> Cursor?

    Cursor::reduce

    fn[A] Cursor::reduce(self : Cursor, f : (Cursor, ReadOnlyArray[A]) -> A) -> A

    Reduce this subtree while retaining its original ancestors and paths.

    Cursor::reduce_with

    fn[A : CursorReducer] Cursor::reduce_with(self : Cursor) -> A

    Reduce a saved position's subtree with its original ancestors and paths.

    Cursor::to_repr

    Cursor::walk

    fn Cursor::walk(self : Cursor, enter : (Cursor) -> Control, leave? : (Cursor) -> Unit) -> Unit

    Walk this position's subtree, retaining its original ancestors and paths.

    Document

    An immutable MDC document. Cmark locations are relative to body(). Add body_offset() to cmark string offsets and body_line() - 1 to line numbers to locate Markdown in the original input. Offsets count UTF-16 code units.

    Document::Document

    fn Document::Document(source : String, strict? : Bool, layout? : Bool, locs? : Bool, file? : String) -> Document raise ParseError

    Parse CommonMark with an optional leading YAML mapping between --- lines. ... also closes frontmatter. A UTF-8 BOM decoded as U+FEFF is accepted. An opener must be the first line, with no leading whitespace. An empty payload is an empty mapping. Malformed YAML and non-mappings are errors. Cmark's strict option defaults to true. Set it to false for extensions.

    Document::accept

    fn[V : Visitor] Document::accept(self : Document, visitor : V) -> Unit

    Document::accept_cursor

    fn[V : CursorVisitor] Document::accept_cursor(self : Document, visitor : V) -> Unit

    Document::body

    fn Document::body(self : Document) -> String

    Document::body_line

    fn Document::body_line(self : Document) -> Int

    Document::body_offset

    fn Document::body_offset(self : Document) -> Int

    Document::cursor

    fn Document::cursor(self : Document) -> Cursor

    Document::fold

    fn[A] Document::fold(self : Document, init : A, folder : Folder[A]) -> A

    Document::fold_cursor_with

    fn[A : CursorFold] Document::fold_cursor_with(self : Document, init : A) -> A

    Document::fold_with

    fn[A : Fold] Document::fold_with(self : Document, init : A) -> A

    Document::frontmatter

    fn Document::frontmatter(self : Document) -> Frontmatter?

    Document::reduce

    fn[A] Document::reduce(self : Document, f : (Node, ReadOnlyArray[A]) -> A) -> A

    Document::reduce_cursor

    fn[A] Document::reduce_cursor(self : Document, f : (Cursor, ReadOnlyArray[A]) -> A) -> A

    Document::reduce_cursor_with

    fn[A : CursorReducer] Document::reduce_cursor_with(self : Document) -> A

    Document::reduce_with

    fn[A : Reducer] Document::reduce_with(self : Document) -> A

    Document::root

    fn Document::root(self : Document) -> Node

    Document::to_cmark

    An owned cmark copy for rendering or transformation. Its mutable maps and sequences share no storage with the document or its traversal views.

    Document::to_repr

    FoldAction

    pub(all) enum FoldAction[A] {
    Continue(A)
    SkipChildren(A)
    Stop(A)
    } derive(Eq,
    Debug
    )

    FoldAction::equal

    fn[A : Eq] FoldAction::equal(FoldAction[A], FoldAction[A]) -> Bool

    FoldAction::not_equal

    fn[A : Eq] FoldAction::not_equal(x : FoldAction[A], y : FoldAction[A]) -> Bool

    Folder

    type Folder[A]

    A fold callback threads an accumulator through nodes in pre-order.

    Folder::Folder

    fn[A] Folder::Folder(enter : (A, Node) -> FoldAction[A]) -> Folder[A]

    Folder::from_cursor_trait

    fn[A : CursorFold] Folder::from_cursor_trait() -> Folder[A]

    Folder::from_trait

    fn[A : Fold] Folder::from_trait() -> Folder[A]

    Adapt a user-implemented fold to the closure-based folder API.

    Folder::with_cursor

    fn[A] Folder::with_cursor(enter : (A, Cursor) -> FoldAction[A]) -> Folder[A]

    A folder with navigation context, using the same accumulator and control semantics as the node-only constructor.

    Frontmatter

    type Frontmatter derive(
    Debug
    )

    A YAML payload and its parsed mapping. Construct with Frontmatter::Frontmatter. The representation is hidden so text and parsed YAML always agree.

    Frontmatter::Frontmatter

    fn Frontmatter::Frontmatter(raw : String) -> Frontmatter raise ParseError

    Validate a YAML payload without delimiter lines and preserve its exact text. Empty and comment-only payloads become empty mappings. Reject invalid YAML, multiple documents and non-mappings. Error positions are one-based and relative to this payload; parse translates them to document positions.

    Frontmatter::raw

    fn Frontmatter::raw(self : Frontmatter) -> String

    The original YAML payload, excluding delimiter lines.

    Frontmatter::value

    A deep copy of the parsed YAML mapping. Changes to returned maps and arrays cannot change this frontmatter or make its stored YAML disagree with raw.

    InlineKind

    pub enum InlineKind {
    Autolink
    Break
    CodeSpan(code~ : String)
    Emphasis
    Image
    Inlines
    Link
    RawHtml
    StrongEmphasis
    Text(text~ : String)
    ExtStrikethrough
    ExtMathSpan(tex~ : String)
    } derive(Eq,
    Debug
    )

    InlineKind::equal

    fn InlineKind::equal(InlineKind, InlineKind) -> Bool

    InlineKind::not_equal

    fn InlineKind::not_equal(x : InlineKind, y : InlineKind) -> Bool

    InlineView

    type InlineView derive(
    Debug
    )

    InlineView::kind

    fn InlineView::kind(self : InlineView) -> InlineKind

    InlineView::to_cmark

    Export an owned cmark subtree. Use kind() for inspection without copying.

    Node

    pub(all) enum Node {
    Document(Document)
    Frontmatter(Frontmatter)
    Block(BlockView)
    Inline(InlineView)
    } derive(
    Debug
    )

    Immutable nodes visited in document order. Obtain a root from doc.root().

    Node::accept_cursor

    fn[V : CursorVisitor] Node::accept_cursor(self : Node, visitor : V) -> Unit

    Node::at

    fn Node::at(self : Node, path : NodePath) -> Node?

    Resolve a path relative to this node. A path from another tree may not exist.

    Node::children

    fn Node::children(self : Node) -> ReadOnlyArray[Node]

    Direct children, in source order. Frontmatter is a leaf containing YAML.

    Node::cursor

    fn Node::cursor(self : Node) -> Cursor

    Node::fold

    fn[A] Node::fold(self : Node, init : A, folder : Folder[A]) -> A

    Fold a subtree. Cursor paths start at this node.

    Node::fold_cursor_with

    fn[A : CursorFold] Node::fold_cursor_with(self : Node, init : A) -> A

    Node::fold_with

    fn[A : Fold] Node::fold_with(self : Node, init : A) -> A

    Fold with a user-implemented accumulator, starting a new navigation root.

    Node::reduce_cursor_with

    fn[A : CursorReducer] Node::reduce_cursor_with(self : Node) -> A

    Node::reduce_with

    fn[A : Reducer] Node::reduce_with(self : Node) -> A

    Node::to_repr

    NodePath

    type NodePath derive(Eq,
    Debug
    )

    A path of child indices, relative to the cursor's starting node. Paths describe MDC traversal children, including layout nodes.
    impl Show for NodePath

    NodePath::equal

    fn NodePath::equal(NodePath, NodePath) -> Bool

    NodePath::indices

    fn NodePath::indices(self : NodePath) -> ReadOnlyArray[Int]

    NodePath::not_equal

    fn NodePath::not_equal(x : NodePath, y : NodePath) -> Bool

    NodePath::output

    fn NodePath::output(self : NodePath, logger : &Logger) -> Unit

    NodePath::to_repr

    NodePath::to_string

    fn NodePath::to_string(self : NodePath) -> String

    fold_cursor_with

    fn[A : CursorFold] fold_cursor_with(root : Node, init : A) -> A

    fold_with

    fn[A : Fold] fold_with(root : Node, init : A) -> A

    The concrete state may itself be generic, with a conditional Fold impl.

    parse

    fn parse(source : String, strict? : Bool, layout? : Bool, locs? : Bool, file? : String) -> Document raise ParseError

    Parse an immutable document. Equivalent to Document::Document.

    reduce

    fn[A] reduce(root : Node, f : (Node, ReadOnlyArray[A]) -> A) -> A

    Bottom-up reduction: f receives a node and its immediate children's results in children() order. Leaves receive an empty collection. The root's result is returned. Every node is reduced exactly once, using an explicit stack.

    reduce_cursor

    fn[A] reduce_cursor(root : Node, f : (Cursor, ReadOnlyArray[A]) -> A) -> A

    Bottom-up reduction with immutable navigation context. Child result collections are immutable; their values belong to the caller and are not deep-copied. Cursor paths start at root.

    reduce_cursor_with

    fn[A : CursorReducer] reduce_cursor_with(root : Node) -> A

    reduce_with

    fn[A : Reducer] reduce_with(root : Node) -> A

    Reduce with the implementation on the requested result type. Supply a result type annotation when it cannot be inferred, for example let n : Count = ....

    walk

    fn walk(root : Node, enter : (Node) -> Control, leave? : (Node) -> Unit) -> Unit

    Walk depth-first, calling enter before children and leave after them. Skipped nodes still receive leave. Stop returns immediately without pending leave calls. An explicit stack avoids recursive traversal.

    walk_cursor

    fn walk_cursor(root : Node, enter : (Cursor) -> Control, leave? : (Cursor) -> Unit) -> Unit

    Walk with immutable cursor positions. Navigation in either callback cannot change this walk. Positions retain their ancestors and may be saved safely.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io