sdxc

Type to search, or start from one of these:

@sdxc/markdown

GitHub Flavored Markdown: parse to a typed AST, transform it, write it back

npm add @sdxc/markdown
pnpm add @sdxc/markdown
yarn add @sdxc/markdown
bun add @sdxc/markdown
Depends on
@standard-schema/specremix
Used by
uptime, blog, books

GitHub Flavored Markdown: parse to a typed AST, transform it, write it back.

The dialect is the one every author already knows — everything in CommonMark, plus GitHub Flavored Markdown's tables, task lists, strikethrough and literal autolinks, plus GitHub's alerts and footnotes.

Every node is a plain JSON-serializable object with a type discriminator, its own fields and a position, so a parsed document can be cached, sent in a payload, diffed in a test, and narrowed by the compiler at every hop.

Installation

npm add @sdxc/markdown

Every entry point reports its outcome as a Result from @sdxc/result, which is where isFailure and unwrap come from; the frontmatter block is read by @sdxc/yaml and validated against any Standard Schema validator, and @sdxc/markdown/remix renders into the UI nodes of remix.

Usage

Parse A Document

import { Markdown } from "@sdxc/markdown";
import { isFailure } from "@sdxc/result";

let result = Markdown.parse("# Hello\n\nSome **markdown**.");
if (isFailure(result)) throw result.error;

let { document } = result.data;

document.children[0]; // { type: "heading", level: 1, children: [...], ... }

Validate The Frontmatter

A schema turns the block into typed data, and a document missing a required field fails with the line to open:

import { Markdown } from "@sdxc/markdown";
import { isFailure } from "@sdxc/result";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

let Frontmatter = s.object({
	title: s.string(),
	description: s.string(),
	publishedAt: coerce.date(),
});

let result = Markdown.parse(source, { frontmatter: Frontmatter });
if (isFailure(result)) throw result.error;

let { frontmatter, document } = result.data;

frontmatter.publishedAt; // Date

Markdown.frontmatter takes the same options object and stops after the block, so an index over a hundred documents reads a hundred titles without parsing a hundred bodies.

Render It

Static HTML, for a response that carries markup:

import { toHTML } from "@sdxc/markdown/html";

let body = toHTML(document);

Or UI nodes, for a view that owns the markup around them:

import type { Markdown } from "@sdxc/markdown";
import type { Handle } from "remix/ui";

import { toRemix } from "@sdxc/markdown/remix";

export default function DocView({ props }: Handle<{ title: string; document: Markdown.Document }>) {
	return () => (
		<article>
			<h1>{props.title}</h1>
			{toRemix(props.document)}
		</article>
	);
}

Extract Plain Text

import { toPlainText } from "@sdxc/markdown/plain";

let excerpt = toPlainText(document);
let indexed = toPlainText(document, { code: true });

It takes a node rather than a source string, so a caller that has already parsed does not parse twice, and a caller that walked the tree first measures what it actually renders.

Write A Document Back

import { Markdown } from "@sdxc/markdown";
import { isFailure, unwrap } from "@sdxc/result";

let { frontmatter, document } = unwrap(Markdown.parse(source, { frontmatter: Frontmatter }));

let written = Markdown.stringify(document, {
	frontmatter: { ...frontmatter, updatedAt: new Date() },
});
if (isFailure(written)) throw written.error;

Omitting frontmatter writes the body alone.

API

@sdxc/markdown

Markdown

A class with a private constructor and only static methods, merged with a namespace of the same name. new Markdown() is a compile error, so the name is a namespace the compiler enforces: Markdown.parse() is the method and Markdown.Document is the type it returns.

Markdown.parse(source, options?)

Reads the frontmatter block and the body in one traversal, answering with { frontmatter, document } or a MarkdownParseError carrying the position it stopped at. options.frontmatter is the schema to validate the block against, and options.tags names the tags the document may use.

Markdown.frontmatter(source, options?)

Reads the frontmatter block and stops, leaving the body unparsed. What it yields depends on whether the file opens with a block and whether the options carry a schema:

BlockSchemafrontmatter is
presentyesthe schema's output, or a failure carrying its issues and the position
presentnothe YAML value as read, typed unknown
absentyesthe schema run against {}, so a required field fails at line 1
absentno{}

A block the YAML parser rejects is a failure, not an empty object: the error carries the YAML error as its cause and the line it sits on.

Frontmatter is a set of named fields, so a delimited block holding anything else — a scalar, a sequence, nothing at all — counts as absent, and its lines belong to the body. That is what lets a document open on two thematic breaks.

Markdown.stringify(document, options?)

Writes a document back as markdown, answering with the source or a MarkdownStringifyError for a frontmatter value YAML cannot write. options.frontmatter is written as a YAML block ahead of the body.

The serializer normalizes rather than reproducing the source. What holds is idempotency: parsing the output and serializing again yields the same string.

Markdown.walk(node, visitor)

Rewrites a tree through a visitor, returning a new node of the same type and sharing every subtree no handler touched. The result is a Result<N, MarkdownWalkError>, or a promise of one when a handler is asynchronous. See Pattern: Transform A Document.

namespace Markdown

The AST. Markdown.Node is Document | Block | Inline; document is the root and belongs to neither column, so a Block[] can never hold a nested document.

BlocksInline
heading paragraph code list listItem blockquote alert table tableRow tableCell thematicBreak html footnoteDefinition tagtext emphasis strong strikethrough inlineCode link image softBreak hardBreak inlineHtml footnoteReference variable tag

Every type names exactly one interface, so Extract<Markdown.Node, { type: K }> always narrows to one shape. The one name in both columns is tag: a tag is block-level or inline depending on where it is written, and its children say which.

Every node carries a position — 1-based line and column, 0-based offset, all into the source as written, frontmatter included. Every block carries attributes, because an annotation may sit above any block.

Two constructs exist in the source and not in the tree. A link reference definition is consumed at parse time and every reference to it becomes a link. Footnotes keep both halves, because a renderer draws them in two places.

Markdown.Options, Markdown.StringifyOptions, Markdown.TagDefinition and Markdown.Visitor live here too, beside the node interfaces.

MarkdownParseError, MarkdownStringifyError, MarkdownWalkError

MarkdownParseError and MarkdownWalkError carry the position they failed at; MarkdownParseError also carries the issues a frontmatter or attribute schema reported, and MarkdownWalkError the thrown value as its cause. MarkdownStringifyError reports a frontmatter value YAML cannot write, which belongs to no place in the document.

@sdxc/markdown/plain

toPlainText(node: Markdown.Node, options?: PlainTextOptions): string

Reads the prose out of any node, blocks separated by a blank line. Taking any node is what makes it usable inside a visitor.

  • options.code: Include the body of code blocks; defaults to false. Inline code is always kept, since it is part of the sentence around it.

  • options.images: Include image alternative text; defaults to false.

@sdxc/markdown/html

toHTML(node: Markdown.Node, options?: HTMLOptions): string

Renders any node as static HTML, with no wrapper element of its own, so a caller can render a fragment of a document. options.tags carries markup for a tag keyed by tag name; a tag with no renderer contributes its children alone.

Elements are plain and semantic, and a md- class appears only where HTML has no element that says what the node is:

ClassOn
md-alert md-alert-<kind>the <aside> an alert renders as, with data-kind
md-codea code block's <pre>, beside language-<name>
md-tablea table
md-align-left/center/righta cell in a column the delimiter row aligned
md-task, md-task-boxa task list item and its checkbox
md-footnotes, md-footnote-list, md-footnote-ref, md-footnote-backthe trailing footnote section and its links
md-variablean unresolved {% $name %} hole

An annotation's id and class are written onto the element, and any other attribute becomes a data- attribute, so {% #install .lead %} styles and links the way it reads.

Raw HTML renders as escaped text, so nothing the renderer did not vet becomes markup. A code block a visitor painted first renders one <span class="token …"> per run.

HTMLTagRenderer

The shape of one entry in options.tags: it takes the tag's name, its attributes and its children already rendered, and returns the markup to stand in its place.

@sdxc/markdown/remix

toRemix(node: Markdown.Node, options?: RemixOptions): RemixNode

Renders any node into Remix UI output, so a caller can render a fragment of a document. options.components is keyed by tag name, or by a node type to take that node over wherever it sits.

It returns nodes from data and holds no props or reactive state, so calling it inside a render closure is not a component called as a function.

Raw HTML renders as escaped text, so a stray <div> shows as written rather than becoming an element. An author who wants an element registers a tag for it.

MarkdownComponent

The shape of one entry in options.components: a component whose props are the node's own content fields and the attributes an annotation wrote, flattened into one bag, plus children.

Pattern: Transform A Document

Markdown.walk is the only transform mechanism. A visitor is a plain object with one optional handler per node type, keyed by the type's name, so the handler's argument is typed with no narrowing to write. A handler receives the node and its parent, and a node whose type has no handler is passed through untouched.

What a handler returns decides what happens to that node:

ReturnsEffect
a nodereplaces the node
an array of nodessplices them in its place
nullremoves the node
nothingleaves the node alone, children still visited
a promise of any of the abovemakes the whole walk asynchronous

The walk is top-down, and no handler runs on a replacement node itself — only on its children — so a code handler returning a code terminates rather than looping. A replacement stands in the slot the original held, so a block yields blocks and an inline yields inline. The node the walk started from is handed back whole: replacing it with a node of its own category is allowed, and removing or splicing it is a failure.

let absolute = Markdown.walk(document, {
	link(node) {
		if (!node.href.startsWith("/")) return;
		return { ...node, href: new URL(node.href, origin).href };
	},
	image(node) {
		return { ...node, src: new URL(node.src, origin).href };
	},
});

A handler that throws lands on the failure branch with the node's position attached, rather than escaping as an exception. The cause is always the original value, so a caller that throws on an unfamiliar cause and handles a familiar one keeps both.

Being an object makes a visitor a value: it can be named, exported from another package, and merged by spread, so a consumer runs one pass instead of three. Syntax highlighting ships that way, as the highlight visitor in @sdxc/highlight:

import { highlight } from "@sdxc/highlight/markdown";

let result = Markdown.walk(document, { ...highlight, ...anchors });

An exported visitor is declared with satisfies Markdown.Visitor, never with a type annotation: an annotation widens every handler's return type, and the walk reads those types to decide whether it is asynchronous.

A visitor may attach a field of its own to a node by declaring it through the namespace the class merges with:

declare module "@sdxc/markdown" {
	namespace Markdown {
		interface Code {
			tokens?: Token[];
		}
	}
}

Markdown.stringify writes only the fields the parser produces, so anything a visitor attaches is derived data that survives a round trip through the document and not through the text.

Pattern: Read The Tree Without Transforming It

A handler that returns nothing changes nothing, which makes Markdown.walk a traversal as well.

import { Markdown } from "@sdxc/markdown";
import { toPlainText } from "@sdxc/markdown/plain";

let toc: Array<{ level: number; text: string; id: string }> = [];

Markdown.walk(document, {
	heading(node) {
		if (node.level > 3) return;
		toc.push({
			level: node.level,
			text: toPlainText(node),
			id: String(node.attributes.id ?? slugify(toPlainText(node))),
		});
	},
});

Pattern: Annotations And Tags

An annotation attaches attributes to a block that already exists. It is {% key="value" %}, it supports #id and .class shorthands, and it goes on the same line for blocks with a single-line opener — a heading, a fence — and on its own line above for the rest. A blank line between the annotation and its block is allowed. Two annotations above one block merge, the lower one winning a repeated key.

## Getting started {% #getting-started .lead %}

```typescript
let client = connect(url);
```

{% .wide %}

| Plan    | Seats |
| ------- | ----- |
| Starter | 5     |

A tag creates a node that has no markdown syntax, and is written as an element. Only registered names become tags; an unregistered <div> stays raw HTML. Registering a tag declares its attribute schema, which is what makes a bad attribute a parse error with a line number:

let options = {
	frontmatter: Frontmatter,
	tags: {
		callout: { attributes: s.object({ type: s.enum_(["info", "warning", "danger"]) }) },
		kbd: { content: "inline" },
		video: { content: "none", attributes: s.object({ src: s.string() }) },
	},
} satisfies Markdown.Options;

content says what the tag contains, which is the one question the parser asks before reading what follows the opening tag:

contentThe tag's childrenExample
"blocks" (default)parsed as block markdown — paragraphs, lists, tags<callout>
"inline"parsed as inline markdown only<kbd>
"none"none; the tag is written self-closing<video />
<callout type="warning">
Deleting a workspace also deletes its **history**, permanently.
</callout>

Press <kbd>Cmd</kbd> then <kbd>K</kbd> to open search.

<video src="/demo/tour.mp4" />

A tag's children are markdown all the way down, so **history** arrives as a strong node. Supply the components where the document is rendered:

toRemix(document, { components: { callout: Callout, kbd: Kbd, video: Video } });

A tag with no component renders its children and nothing else, so a missing component drops the chrome and keeps the content.

Pattern: Variables Resolved Per Render

{% $name %} in text parses to a variable node carrying that name. Nothing is substituted at parse time, so one parsed document serves every render, and resolution is a Markdown.walk the caller writes.

let variables: Record<string, string | number> = { product: "Acme", plan: team.plan };

let result = Markdown.walk(cachedDocument, {
	variable(node) {
		let value = variables[node.name];
		if (value === undefined) throw new Error(`Unresolved variable ${node.name}`);
		return { type: "text", value: String(value), position: node.position };
	},
});

What a variable means is the consumer's: documentation can fail loudly on an unresolved name, a marketing page can render the literal, and a preview can show the name itself. Throwing from the handler turns it into the failure branch with the node's position attached; returning nothing leaves the hole in place.

Because variables survive parsing, Markdown.stringify round-trips them, so the markdown a client fetches is the template rather than one tenant's copy.

Pattern: GitHub Alerts

> [!NOTE]
> Parsing happens once, and rendering happens per request.

They parse to an alert node carrying kind, so a renderer draws them without inspecting the first line of a block quote, and a custom component can take them over:

toRemix(document, { components: { alert: Alert } });

Pattern: Lint Content In CI

Parsing returns positions, so a check reads like a test:

let problems: string[] = [];

Markdown.walk(document, {
	code(node) {
		if (node.language === undefined) {
			problems.push(`${file}:${node.position.start.line} code block without a language`);
		}
	},
});