@ende/markdown
ende add jsr:@ende/markdownVersion 2026.10.18 on JSR.
A CommonMark parser and HTML renderer, with optional GitHub Flavored Markdown, and the features a documentation site needs:
parseturns Markdown into a syntax tree shaped like mdast, and gives every heading a unique, GitHub-compatibleid(slugis the default algorithm; thesluggeroption replaces it).renderturns 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).toclists the headings for a table of contents.visitwalks the tree, for changes between parsing and rendering.- With the
gfmoption: tables, task lists, strikethrough, extended autolinks, footnotes, and GitHub's alerts (> [!NOTE]) asadmonitionnodes.
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 alinknode with the definition'surlandtitle, and an image reference becomes animagenode. The tree has nodefinition,linkReferenceorimageReferencenodes. - Nodes have no
positioninformation. - Headings have an
id, and GitHub alerts areadmonitionnodes. - A
codenode'svaluehas no final line ending, so a fenced code block whose only content is one blank line has an emptyvalue, 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,tableRowandtableCellnodes, with the column alignments intable.align. - Task list items set
listItem.checkedtotrueorfalse(it'snullfor other items). - Strikethrough with one or two tildes (
~a~,~~a~~) becomesdelete. - Extended autolinks:
www.addresses,http://,https://andftp://URLs, and email addresses becomelinknodes without the<and>. - Footnotes:
[^label]: textbecomes afootnoteDefinition, which stays where it was written, and[^label]becomes afootnoteReference.
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
@ende/markdown/parse@ende/markdown/render@ende/markdown/slug@ende/markdown/toc@ende/markdown/types— Types for the Markdown syntax tree. The node names and fields follow mdast, including its GitHub Flavored Markdown nodes (tables, `delete` and footnotes), with these differences: link reference definitions are resolved into the Link and Image nodes that use them (so there are no `definition`, `linkReference` or `imageReference` nodes), nodes have no `position`, a Heading has an `id`, and GitHub alerts are Admonition nodes.@ende/markdown/visit
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`.