@ende/markdown

ende add jsr:@ende/markdown

Version 2026.10.18 on JSR.

A CommonMark parser and HTML renderer, with optional GitHub Flavored Markdown, and the features a documentation site needs:

  • parse turns Markdown into a syntax tree shaped like mdast, and gives every heading a unique, GitHub-compatible id (slug is the default algorithm; the slugger option replaces it).
  • render turns that tree into HTML, with options to rewrite link and image URLs (rewriteLink), highlight code blocks (highlight), allow, escape or strip raw HTML (html), and leave out heading ids (headingIds).
  • toc lists the headings for a table of contents.
  • visit walks the tree, for changes between parsing and rendering.
  • With the gfm option: tables, task lists, strikethrough, extended autolinks, footnotes, and GitHub's alerts (> [!NOTE]) as admonition nodes.

The parser is a port of commonmark.js, the CommonMark reference implementation, and passes every example in the CommonMark Spec 0.31.2.

import { parse, render } from "@ende/markdown";
import { assertEquals } from "@ende/assert";

const ast = parse("Hello, *Ende*!");
assertEquals(render(ast), "<p>Hello, <em>Ende</em>!</p>\n");

Building a documentation page

With @ende/front-matter for the front matter:

import { extract } from "@ende/front-matter/yaml";
import { parse, render, toc } from "@ende/markdown";
import { assertEquals } from "@ende/assert";

const source = "---\ntitle: Guide\n---\n# Guide\n\n> [!NOTE]\n> See [setup](setup.md).\n\n## Install\n\n~~~sh\nende install\n~~~\n";
// A real highlighter would add markup to the escaped code.
const highlight = (code: string, lang: string | null) => lang === "sh" ? `<span class="sh">${code}</span>` : undefined;

const { attrs, body } = extract<{ title: string }>(source);
const ast = parse(body, { gfm: true });
const html = render(ast, { highlight, rewriteLink: href => href.replace(/\.md$/, "/") });
const outline = toc(ast, { maxDepth: 3 });

assertEquals(attrs.title, "Guide");
assertEquals(outline, [
  { depth: 1, text: "Guide", slug: "guide" },
  { depth: 2, text: "Install", slug: "install" }
]);
assertEquals(
  html,
  `<h1 id="guide">Guide</h1>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title">Note</p>
<p>See <a href="setup/">setup</a>.</p>
</div>
<h2 id="install">Install</h2>
<pre><code class="language-sh"><span class="sh">ende install</span></code></pre>
`
);

Heading ids

Heading ids are the one way the default output differs from the HTML of the CommonMark and GFM specs: render() writes <h2 id="install"> where the specs have <h2>. Pass headingIds: false for spec output. The default slug follows GitHub (github-slugger), so #install links work both on GitHub and on a site built with this package, and repeated headings get -1, -2 and so on. render() never writes an id twice: when a footnote's id (fn-1, fnref-1) is already a heading's, the footnote's id gets a suffix instead.

Differences from mdast

  • Link reference definitions are resolved at parse time. A reference link such as [text][label] becomes a link node with the definition's url and title, and an image reference becomes an image node. The tree has no definition, linkReference or imageReference nodes.
  • Nodes have no position information.
  • Headings have an id, and GitHub alerts are admonition nodes.
  • A code node's value has no final line ending, so a fenced code block whose only content is one blank line has an empty value, like an empty block.

Raw HTML is a html node both as a block and inline; it's an HTML block when its parent is a block container (root, blockquote, listItem or footnoteDefinition) and inline HTML when its parent holds phrasing content.

import { parse } from "@ende/markdown";
import { assertEquals } from "@ende/assert";

const ast = parse("See [the docs].\n\n[the docs]: https://docs.ende.sh");
assertEquals(ast.children.length, 1);
assertEquals(ast.children[0], {
  type: "paragraph",
  children: [
    { type: "text", value: "See " },
    { type: "link", url: "https://docs.ende.sh", title: null, children: [{ type: "text", value: "the docs" }] },
    { type: "text", value: "." }
  ]
});

GitHub Flavored Markdown

The gfm option enables the extensions of the GitHub Flavored Markdown Spec, and footnotes:

  • Tables become table, tableRow and tableCell nodes, with the column alignments in table.align.
  • Task list items set listItem.checked to true or false (it's null for other items).
  • Strikethrough with one or two tildes (~a~, ~~a~~) becomes delete.
  • Extended autolinks: www. addresses, http://, https:// and ftp:// URLs, and email addresses become link nodes without the < and >.
  • Footnotes: [^label]: text becomes a footnoteDefinition, which stays where it was written, and [^label] becomes a footnoteReference.

Parsing and rendering match cmark-gfm, the implementation behind GitHub, and the extension examples of the GFM Spec and cmark-gfm's extension tests pass. render() writes footnotes the way GitHub does, with numbered references and a footnotes section at the end. Without gfm, parsing is pure CommonMark.

import { parse, render } from "@ende/markdown";
import { assertEquals } from "@ende/assert";

const markdown = "| Feature | Done |\n| :-- | :-: |\n| Tables | ~~no~~ yes |\n";
assertEquals(
  render(parse(markdown, { gfm: true })),
  `<table>
<thead>
<tr>
<th align="left">Feature</th>
<th align="center">Done</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">Tables</td>
<td align="center"><del>no</del> yes</td>
</tr>
</tbody>
</table>
`
);

GitHub's alerts are recognized as GitHub recognizes them: a block quote at the top level of the document (not inside a list, another block quote or a footnote) whose first line is just [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] or [!CAUTION], in any case, with content after it. One extension: text after the marker on its line, such as > [!TIP] Want to type-check only?, is the alert's title, rendered in place of "Tip". GitHub shows such a block quote as a plain block quote.

The GFM Disallowed Raw HTML extension (tagfilter), which escapes tags such as <script> and <iframe>, isn't included: by default raw HTML renders as written, with or without gfm. render()'s html option escapes or strips all raw HTML instead. It doesn't sanitize URLs (such as javascript: links), so sanitize the HTML of untrusted Markdown.

Modules

Exports

  • Admonition — A GitHub alert, such as `> [!NOTE]`. Only parsed with the `gfm` option, from a block quote at the top level of the document whose first line is just the marker (`[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]`, in any case) and that has content after it. The marker line isn't part of the children.
  • AlignType — The alignment of a table column: `:--` is `"left"`, `--:` is `"right"`, `:-:` is `"center"`, and `---` is `null`.
  • BlockContent — Nodes that can appear in a block container (Root, Blockquote, ListItem or FootnoteDefinition).
  • Blockquote — A block quote.
  • Break — A hard line break: two or more spaces, or a backslash, before a newline.
  • Code — A fenced or indented code block.
  • DefinitionContent — Definitions, which can appear in a block container next to BlockContent. Link reference definitions are resolved while parsing, so the only definition in the tree is FootnoteDefinition.
  • Delete — Strikethrough text, such as `~~this~~`. Only parsed with the `gfm` option.
  • Emphasis — Emphasis, such as `*this*`.
  • FootnoteDefinition — A footnote definition, such as `[^1]: The note.`. Only parsed with the `gfm` option. The definition stays where it was written; `render()` moves the definitions that are referenced into a footnotes section at the end.
  • FootnoteReference — A reference to a footnote, such as `[^1]`. Only parsed with the `gfm` option, and only when a FootnoteDefinition with the same identifier exists; otherwise the text stays as it is.
  • Heading — An ATX (`# Title`) or setext (`Title` underlined with `=` or `-`) heading.
  • Html — Raw HTML. As a child of a block container it's an HTML block; inside a paragraph or other phrasing content it's inline HTML.
  • Image — An image. Like Link, reference images are resolved.
  • InlineCode — A code span, such as `` `code` ``.
  • Link — A link. Inline links, autolinks, and reference links all become this node; a reference link carries the URL and title of its definition.
  • List — A bullet or ordered list.
  • ListItem — A list item.
  • Literal — Nodes that have a string `value`.
  • Nodes — Any node, as a union discriminated by `type`.
  • Paragraph — A paragraph.
  • Parent — Nodes that have children.
  • parse — Parses CommonMark into an mdast-shaped syntax tree. With the `gfm` option, it also parses the GitHub Flavored Markdown extensions: tables, task list items, strikethrough, extended autolinks, and footnotes, and GitHub's alerts (`> [!NOTE]`), which become `admonition` nodes.
  • ParseOptions — Options for `parse()`.
  • PhrasingContent — Nodes that can appear in paragraphs, headings and other inline content.
  • render — Renders a syntax tree from `parse()` to HTML. The output matches the CommonMark reference implementation, commonmark.js, and for the GitHub Flavored Markdown nodes, cmark-gfm, with one difference by default: headings have an `id` attribute (their Heading.id). The `headingIds: false` option turns that off.
  • RenderOptions — Options for `render()`.
  • Root — The root of a document.
  • slug — Turns text into a heading id the way GitHub does (with the algorithm of github-slugger): it lowercases the text, removes everything except letters, marks, numbers, connector punctuation such as `_`, hyphens and spaces, and turns each space into a hyphen. Runs of spaces and hyphens aren't collapsed, and the result isn't trimmed, so ids match GitHub's anchors.
  • Strong — Strong emphasis, such as `**this**`.
  • Table — A table. Only parsed with the `gfm` option. The first row is the header row, and every row has one cell per column.
  • TableCell — A cell of a TableRow.
  • TableRow — A row of a Table.
  • Text — Plain text.
  • ThematicBreak — A thematic break, such as `***` or `---`.
  • toc — Lists the headings of a document for a table of contents: each heading's level, plain text and id, in document order. The list is flat; use `depth` to indent or nest it. Headings inside block quotes, lists and admonitions count too.
  • TocEntry — A heading in the list `toc()` returns.
  • TocOptions — Options for `toc()`.
  • visit — Calls `fn` for every node of the given type in a tree, in pre-order (a parent before its children, children in document order), with the node's index in its parent's `children` and the parent. For the node `visit()` starts from, the index and parent are `null`.