Skip to content

Latest commit

 

History

264 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@mizchi/markdown

CST-based incremental Markdown parser for JavaScript/MoonBit.

A cross-platform (JS/WASM/native) Markdown compiler optimized for real-time editing with incremental parsing.

Features

  • Fast: Edit-position based incremental updates inspired by CRDTs Go Brrr
  • CommonMark compliant: passes all 652 examples of the CommonMark 0.31.2 spec
  • Source-oriented CST: Retains node spans so byte-preserving tools can edit the original source
  • Incremental parsing: Re-parses only changed blocks (up to 42x faster)
  • GFM: GitHub Flavored Markdown support (tables, task lists, strikethrough)
  • Cross-platform: Works on JS, WASM-GC, and native targets
  • HTML rendering: Built-in HTML renderer with remark-html compatible output
  • mdast compatible: AST follows mdast specification

JavaScript API

npm install @mizchi/markdown

Usage

import { parse, toHtml, toMarkdown } from "@mizchi/markdown";

// Parse to AST
const ast = parse("# Hello\n\n**Bold** text");
console.log(ast.children[0].type); // "heading"

// Convert to HTML
const html = toHtml("See https://example.com/docs\n");
// => '<p>See <a href="https://example.com/docs">https://example.com/docs</a></p>\n'

// Disable bare URL links when you need plain text output
const plain = toHtml("See https://example.com/docs\n", { autolink: false });
// => "<p>See https://example.com/docs</p>\n"

// Match CommonMark 0.31.2 rendering by disabling GFM renderer extensions
const commonmark = toHtml("<script>raw</script>\n\nhttps://example.com\n", {
  autolink: false,
  tagfilter: false,
});
// => "<script>raw</script>\n<p>https://example.com</p>\n"

// Normalize markdown
const normalized = toMarkdown("# Hello\n\n\n\nWorld");
// => "# Hello\n\nWorld\n"

WebAssembly API

The same one-shot API is available from a Wasm GC build using JS String Builtins. JavaScript strings cross the Wasm boundary directly, without a UTF-8 linear-memory copy. parse() also builds ordinary JavaScript objects through externref imports, avoiding an AST JSON stringify/parse round trip. The module initializes its Wasm instance once and then exposes synchronous functions:

import { parse, toHtml, toMarkdown } from "@mizchi/markdown/wasm";

const ast = parse("# Hello");
const html = toHtml("See https://example.com/docs\n");
const markdown = toMarkdown("# Hello\n\n\nWorld");

This subpath requires an ESM runtime with Wasm GC, JS String Builtins, and top-level await support. The handle-based incremental API remains available from the default @mizchi/markdown entry point.

Cloudflare Workers

The default @mizchi/markdown entry point cannot currently run inside a Cloudflare Worker. It imports the SIMD module through WebAssembly ESM Integration named exports, while Wrangler exposes imported .wasm files as a default-exported WebAssembly.Module. Supporting workerd therefore requires a workerd-specific bridge that instantiates that module, selected through a workerd conditional export. See Cloudflare's documentation for Wasm module bundling and conditional exports.

The repository's current playground deployment is unaffected because its Worker only serves prebuilt static assets; it does not import the parser into the workerd runtime.

Optional WikiLinks

WikiLinks are disabled by default to keep CommonMark-compatible behavior. Pass { wikilinks: true } to parse [[target]] and [[target|label]].

import { parse, toHtml } from "@mizchi/markdown";

const ast = parse("[[MoonBit#syntax|MoonBit syntax]]", { wikilinks: true });
// ast.children[0].children[0].type === "wikiLink"

const html = toHtml("[[MoonBit|MoonBit notes]]", { wikilinks: true });
// => '<p><a href="MoonBit">MoonBit notes</a></p>\n'

Extended syntax

The parser also exposes semantic nodes for GitHub alerts, footnotes, display math, container/text directives, definition lists, and block attributes:

> [!WARNING]
> Check this first.[^details]

$$
E = mc^2
$$

:::note Optional title
Markdown stays available inside the container.
:::

Use :badge[stable]{.green level=high}.

Term
: Definition

# Attributed heading
{#intro .wide}

[^details]: Footnote content.

Display math is represented as a math block with a raw value. The default HTML output is an escaped <pre> fallback, so JavaScript consumers can instead send the AST value to KaTeX or another math renderer.

Incremental Parsing

For real-time editing scenarios:

import { createDocument, insertEdit } from "@mizchi/markdown";

// Create document handle
const doc = createDocument("# Hello");

// Access AST, HTML, or Markdown
console.log(doc.ast);        // Parsed AST
console.log(doc.toHtml());   // "<h1>Hello</h1>\n"
console.log(doc.toMarkdown()); // "# Hello\n"

// Incremental update (faster than full re-parse)
const edit = insertEdit(7, 6); // Insert 6 chars at position 7
const newDoc = doc.update("# Hello World", edit);

// Free resources when done
doc.dispose();
newDoc.dispose();

TypeScript Support

Full TypeScript definitions are included:

import { parse, Document, Block, Inline } from "@mizchi/markdown";

const ast: Document = parse("# Hello");
const heading = ast.children[0] as HeadingBlock;
console.log(heading.level); // 1

MoonBit API

Installation

moon add mizchi/markdown

Usage

// Parse markdown
let result = @markdown.parse("# Hello\n\nWorld")
let doc = result.document

// Serialize back to canonical, normalized Markdown
let output = @markdown.serialize(doc)

// Render to HTML
let html = @markdown.render_html(doc)

// Or use convenience function
let html = @markdown.md_to_html("# Hello\n\nWorld")
let linked = @markdown.md_to_html("See https://example.com/docs\n")
let plain = @markdown.md_to_html("See https://example.com/docs\n", autolink=false)

// Disable the GFM tagfilter when matching plain CommonMark raw-HTML output
let commonmark_html = @markdown.md_to_html("<script>raw</script>\n", tagfilter=false)

// Enable the WikiLink extension explicitly
let wiki_html = @markdown.md_to_html("[[MoonBit|MoonBit notes]]", wikilinks=true)

Display-math renderer

The MoonBit HTML renderer has an explicit display-math boundary. The callback receives the raw block contents and returns trusted HTML, making KaTeX or a different backend replaceable without coupling it to the parser:

let document = @markdown.parse("$$\nx^2\n$$\n").document
let options = @markdown.RenderOptions::default().with_math_block_renderer(
  fn(source) { render_with_katex(source) },
)
let html = @markdown.render_html_with_options(document, options)

Without a callback, math is safely HTML-escaped in <pre class="math math-display"><code>…</code></pre>.

Native CLI

Build the native command with just build-native. The executable is produced from src/cmd/mmmd-native and can be installed or renamed as mmmd:

_build/native/release/build/cmd/mmmd-native/mmmd-native.exe --format html < document.md
_build/native/release/build/cmd/mmmd-native/mmmd-native.exe --format tui < document.md

The native TUI format emits normalized Markdown. Mermaid remains an ordinary fenced code block; diagram rendering is available in the JavaScript/Wasm CLI.

Incremental Parsing

// Initial parse
let result = @markdown.parse(source)
let doc = result.document

// Create edit info
let edit = @markdown.EditInfo::replace(
  change_start,    // Start position
  old_length,      // Length of replaced text
  new_length       // Length of new text
)

// Incremental update (reuses unchanged blocks)
let inc_result = @markdown.parse_incremental(doc, old_source, new_source, edit)
let new_doc = inc_result.document

Typed MDX Declarations

mizchi/markdown/x/mdx extracts MDX JSX components and validates their attributes against a closed, typed schema. It is intended for document metadata and domain declarations, rather than evaluating arbitrary MDX code.

Expressions use a deterministic literal-only subset: strings, booleans, and arrays of strings. For example, requires={["auth.mfa"]} is valid, while requires={loadRequirements()} is rejected.

import {
  "mizchi/markdown",
  "mizchi/markdown/x/mdx" @mdx,
}

let schema = @mdx.MdxSchema::closed([
  @mdx.ComponentSchema::new(
    "Fold",
    [
      @mdx.PropSchema::required("id", @mdx.MdxValueType::Text),
      @mdx.PropSchema::required(
        "kind",
        @mdx.MdxValueType::OneOf(["concept", "procedure"]),
      ),
      @mdx.PropSchema::optional("requires", @mdx.MdxValueType::TextList),
    ],
  ),
])

let source = #|<Fold id="auth.mfa" kind="procedure" requires={["auth.login"]} />
let checked = @mdx.type_check_mdx(@markdown.parse(source).document, schema)
let is_valid = checked.is_valid()

Playground

pnpm install
moon build --target js
pnpm exec vite

Frontend Editor Package

@mizchi/markdown/editor exports the Luna-based markdown editor without bundling syntax highlighters into the initial module. Code block highlighters are loaded on demand through dynamic imports under @mizchi/markdown/highlight.

The editor uses @luna_ui/luna as a JSX runtime and signal library; it is declared as an optional peer dependency — install it alongside @mizchi/markdown only if you use the editor entry. See frontend/editor/README.md for the editor-specific docs.

pnpm add @mizchi/markdown @luna_ui/luna
import { SyntaxHighlightEditor } from "@mizchi/markdown/editor";
import "@mizchi/markdown/editor/style.css";

<SyntaxHighlightEditor
  value={() => markdown}
  onChange={(next) => setMarkdown(next)}
/>;

You can also preload or call a language highlighter explicitly:

import { loadHighlighter } from "@mizchi/markdown/highlight";

const highlightMoonBit = await loadHighlighter("moonbit");
const html = highlightMoonBit?.("fn main { println(\"hi\") }");

The currently split lazy highlighter entries are typescript, moonbit, json, html, css, bash, and rust.

Performance

Document Full Parse Incremental Speedup
10 paragraphs 68.89µs 7.36µs 9.4x
50 paragraphs 327.99µs 8.67µs 37.8x
100 paragraphs 651.14µs 15.25µs 42.7x

Native CPU profiling

moon run --profile can sample the same approximately 1 MiB corpus used by the competitor benchmark. On macOS it records an Instruments Time Profiler trace; on Linux Moon uses perf.

just profile-native parse 100
just profile-native render 200
just profile-native parse-render 100

parse rebuilds the document each iteration, render repeatedly renders one pre-parsed document, and parse-render measures the public combined path. Moon prints a demangled hot-function summary and writes profile.json plus the full trace below _build/native/release/profile/cmd/profile/.

The same corpus can be profiled on the JavaScript backend with Node's V8 CPU profiler. The resulting *.cpuprofile file can be opened in Chrome DevTools:

just profile-js parse 50

Profiles are written below _build/js/release/profile/cmd/profile/.

JavaScript Wasm SIMD via ESM Integration

On the JavaScript target, ASCII inline-text runs of at least 64 UTF-16 code units use a 396-byte Wasm SIMD scanner. The default npm entry point statically imports js/inline_marker_simd.wasm through WebAssembly ESM Integration; there is no inline byte array or manual WebAssembly.instantiate step. TextEncoder writes directly into reusable Wasm memory, while a non-ASCII prefix falls back to the UTF-16 scalar scanner so source offsets remain exact.

The WAT source and generated .wasm artifact can be rebuilt and checked separately. just bench-js preloads the same ESM bridge for MoonBit's direct JS benchmark runner:

just inline-wasm-build
just inline-wasm-check
just bench-js

Documentation

See docs/markdown.md for detailed architecture and design.

CommonMark Compatibility

md_to_html and the public JavaScript toHtml() API render all 652 examples of the CommonMark 0.31.2 spec byte for byte. Use { autolink: false, tagfilter: false } with toHtml() to select the CommonMark-faithful renderer configuration. scripts/gen-spec-tests.js checks the MoonBit API on every test run, while the JavaScript regression suite locks the 11 examples affected by these two GFM renderer extensions.

Bare-URL autolinking, tables, strikethrough, task lists and footnotes are GFM extensions on top of CommonMark. Tagfilter is also a GFM extension and escapes raw script, style, and textarea tags when enabled.

Markdown output (md_to_markdown) is normalized rather than byte-preserving. It uses stable markers and block spacing, and emits parsed link reference definitions in a canonical trailing section. Use node spans and the original source when an editor needs byte-preserving updates.

Credits

  • Fonts: PlemolJP (SIL Open Font License 1.1) — bundled in playground/public/fonts/

License

MIT

About

Markdown Parser for incremental update

Resources

Contributing

Stars

102 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages