markdown worked out once at build: one parse, anchors, measured pictures, tags as data
npx shadcn add @ag/markdown-prepareAlready installed? Preview an update
npx shadcn add @ag/markdown-prepare --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-17
- New: markdown worked out once at build.
prepareMarkdown(source, { measure, tags })parses with markdown's own syntax and returns the treeMarkdowndraws, with its headings (one anchor each, the table of contents and the page agreeing), its words as plain text, its pictures measured by yourmeasure, and the tags you allow (tags: ["Bars"]) read as data: a prop is a literal, never code, and a tag not allowed throws. The tree is JSON, so a build keeps it on disk.highlightTree(tree)colours every code block ahead with Shiki. In place of an MDX runtime, for text whose tags are components with data.
one parse, the picture measured, the code coloured ahead, the tags drawn by the site's own components
the headings for a table of contents, the words for a description, the pictures and the tags used
- headings
- #the-slow-road (h2), #what-we-packed (h2)
- text
- The slow road We left before the sun was up. One lens, no gimbal, and a rule: stop when the light is good. The valley, ten minutes after we stopped. What we packed const bag = ["one lens", "two batteries", "no gimbal"] Nothing else fit, and nothing else was needed.
- images
- /glass-valley.jpg 1200×900
- tags
- {"Stat":1,"Note":1}
the demo above, verbatim
import { join } from "node:path"
import sharp from "sharp"
import { Sample } from "@/app/samples"
import {
highlightTree,
prepareMarkdown,
} from "@/registry/base-nova/lib/markdown-prepare"
import { Markdown } from "@/registry/base-nova/ui/markdown"
// #region text
/** A post as a site keeps it: markdown, a picture beside it, two tags of its own. */
const POST = `## The slow road
We left before the sun was up. One lens, no gimbal, and a rule: stop when the light is good.

*The valley, ten minutes after we stopped.*
<Stat label="kilometres" value={212} />
## What we packed
\`\`\`ts
const bag = ["one lens", "two batteries", "no gimbal"]
\`\`\`
<Note tone="quiet">
Nothing else fit, and nothing else was needed.
</Note>
`
// #endregion
// #region elements
/** The site's own components, named by the text's tags. */
function Stat({ label, value }: { label: string; value: number }) {
return (
<p className="font-mono text-sm">
<span className="text-2xl text-foreground">{value}</span> {label}
</p>
)
}
function Note({ children }: { children: React.ReactNode }) {
return (
<aside className="rounded-xl bg-foreground/4 px-4 py-1 text-sm">
{children}
</aside>
)
}
// #endregion
export default async function Demo() {
// #region prepare
// The host holds the files, so it answers their sizes: measured here, once.
const meta = await sharp(
join(process.cwd(), "public", "glass-valley.jpg"),
).metadata()
const sizes: Record<string, { width: number; height: number }> = {
"/glass-valley.jpg": { width: meta.width ?? 0, height: meta.height ?? 0 },
}
const prepared = prepareMarkdown(POST, {
measure: (src) => sizes[src] ?? null,
tags: ["Stat", "Note"],
})
const tree = await highlightTree(prepared.tree)
// #endregion
return (
<div className="space-y-12">
<Sample
name="page"
title="prepared, then drawn"
description="one parse, the picture measured, the code coloured ahead, the tags drawn by the site's own components"
with="text elements prepare"
>
<Markdown tree={tree} elements={{ Stat, Note }} className="max-w-xl" />
</Sample>
<Sample
name="facts"
title="what the page around reads"
description="the headings for a table of contents, the words for a description, the pictures and the tags used"
with="text prepare"
>
<dl className="grid max-w-xl grid-cols-[auto_1fr] gap-x-6 gap-y-3 font-mono text-xs">
<dt className="text-muted-foreground">headings</dt>
<dd>
{prepared.headings.map((h) => `#${h.id} (h${h.depth})`).join(", ")}
</dd>
<dt className="text-muted-foreground">text</dt>
<dd className="line-clamp-3">{prepared.text}</dd>
<dt className="text-muted-foreground">images</dt>
<dd>
{prepared.images
.map((i) => `${i.src} ${i.width}×${i.height}`)
.join(", ")}
</dd>
<dt className="text-muted-foreground">tags</dt>
<dd>{JSON.stringify(prepared.tags)}</dd>
</dl>
</Sample>
</div>
)
}the source, every file an install writes
// MARKDOWN, PREPARED: what a page of markdown needs worked out once, where the files
// are (a build, a script), so the page only draws. `prepareMarkdown` parses the text
// with markdown-core's own syntax and returns the tree `Markdown` draws (`tree`), with
// what a page around it reads: its headings as a table of contents, its words as plain
// text (a description, a preview), its pictures measured. `highlightTree` then colours
// its code blocks with Shiki, so no highlighter loads in the browser.
//
// FACTS COME FROM WHOEVER HOLDS THEM. A picture's size is in its file, and only the
// host knows where its files are: it passes `measure`, which answers a local picture's
// size or null for one that is not its own (another site's), and THROWS for one of its
// own that is missing, the broken post a build should refuse. The highlighter and its
// themes are the host's too (`highlight`); ui's code themes are the default.
//
// TAGS ARE DATA. A text that names components (`<Bars unit="s" data={[…]} />`) says
// which with `tags`; a tag not among them throws, and so does a prop that is not a
// literal (a string, a number, true, false, null, an array or an object of them): a
// prop is read here, never run. The props land on the node (`data.props`) as JSON, so
// a prepared tree can be kept on disk and drawn later. With tags, raw HTML and `<url>`
// autolinks are not markdown (as in MDX): a `<` opens a tag.
//
// Every node keeps its source position. Framework-free: no react, no DOM.
import type { Expression, Program } from "acorn"
import * as acorn from "acorn"
import type * as M from "mdast"
import { fromMarkdown } from "mdast-util-from-markdown"
import {
type MdxJsxFlowElement,
type MdxJsxTextElement,
mdxJsxFromMarkdown,
} from "mdast-util-mdx-jsx"
import { mdxJsx } from "micromark-extension-mdx-jsx"
import { CODE_THEMES } from "@/registry/base-nova/lib/code-tokens"
import {
anchors,
plainOf,
SYNTAX,
} from "@/registry/base-nova/lib/markdown-core"
/** A heading, as a table of contents lists it: its depth, its words, its anchor. */
export type Heading = { depth: number; text: string; id: string }
/** A picture's size in pixels. */
export type Size = { width: number; height: number }
export type PreparedMarkdown = {
/** The tree `Markdown` draws (`<Markdown tree={…} />`): headings carry their
* anchor (`data.id`), measured pictures their size (`data.width`, `data.height`),
* tags their props (`data.props`). */
tree: M.Root
/** Every heading, in order. */
headings: Heading[]
/** The words, markup gone: a description, a preview, a search. */
text: string
/** The pictures it shows that were measured, in order. */
images: (Size & { src: string })[]
/** How many times each tag is used, by name. */
tags: Record<string, number>
}
export type PrepareOptions = {
/** A picture's size by its source: null for one that is not the host's (it is
* drawn unmeasured); throws for one of the host's that is missing. */
measure?: (src: string) => Size | null
/** The components the text may name as tags. None: the text is plain markdown. */
tags?: readonly string[]
}
/** A node prepare annotates: its `data` carries what was worked out. */
type Annotated = { data?: Record<string, unknown> }
/** The text, prepared: parsed once, its headings anchored, its pictures measured,
* its tags' props read. */
export function prepareMarkdown(
source: string,
{ measure, tags }: PrepareOptions = {},
): PreparedMarkdown {
const allowed = new Set(tags ?? [])
const tree = fromMarkdown(source, {
extensions: [
...SYNTAX.extensions,
...(tags
? [
mdxJsx({ acorn, addResult: true }),
// A `<` opens a tag: raw HTML and `<url>` would read the same text
// another way.
{ disable: { null: ["htmlFlow", "htmlText", "autolink"] } },
]
: []),
],
mdastExtensions: [
...SYNTAX.mdastExtensions,
...(tags ? [mdxJsxFromMarkdown()] : []),
],
})
const anchor = anchors()
const headings: Heading[] = []
const images: PreparedMarkdown["images"] = []
const used: Record<string, number> = {}
const walk = (node: M.Nodes) => {
if (node.type === "heading") {
const text = plainOf(node)
const id = anchor(text)
headings.push({ depth: node.depth, text, id })
annotate(node, { id })
}
if (node.type === "image" && measure) {
const size = measure(node.url)
if (size) {
images.push({ src: node.url, ...size })
annotate(node, { ...size })
}
}
if (
node.type === "mdxJsxFlowElement" ||
node.type === "mdxJsxTextElement"
) {
const name = node.name
if (!name || !allowed.has(name))
throw new Error(
`markdown-prepare: <${name ?? ""}> is not a tag this text may use (${[...allowed].join(", ") || "none"})`,
)
used[name] = (used[name] ?? 0) + 1
annotate(node, { props: propsOf(node) })
}
if ("children" in node) for (const child of node.children) walk(child)
}
walk(tree)
return { tree, headings, text: plainOf(tree).trim(), images, tags: used }
}
function annotate(node: M.Nodes | MdxJsxFlowElement, data: object) {
const n = node as Annotated
n.data = { ...n.data, ...data }
}
/** A tag's props, read as data: a bare name is `true`, a quoted value its string,
* `{…}` a literal. Anything else throws, naming the tag and the prop. */
function propsOf(
node: MdxJsxFlowElement | MdxJsxTextElement,
): Record<string, unknown> {
const props: Record<string, unknown> = {}
for (const attribute of node.attributes) {
if (attribute.type === "mdxJsxExpressionAttribute")
throw new Error(
`markdown-prepare: <${node.name} {${attribute.value}}>: a spread is code, a prop is data`,
)
const { name, value } = attribute
if (value === null || value === undefined) props[name] = true
else if (typeof value === "string") props[name] = value
else {
// The tree builder types it as ESTree's Program; acorn made it, by acorn's.
const program = value.data?.estree as unknown as Program | undefined
const statement = program?.body[0]
if (statement?.type !== "ExpressionStatement")
throw new Error(
`markdown-prepare: <${node.name} ${name}={${value.value}}> holds no value`,
)
props[name] = literalOf(statement.expression, `<${node.name} ${name}>`)
// Read: its syntax tree goes, so the prepared tree stays plain JSON (the
// value's source text stays, `value.value`).
delete value.data
}
}
return props
}
/** A JavaScript literal's value, never run: a string, a number (signed), true, false,
* null, a template with no holes, an array or an object of them. */
function literalOf(node: Expression, where: string): unknown {
switch (node.type) {
case "Literal":
if ("regex" in node || "bigint" in node)
throw new Error(
`markdown-prepare: ${where} holds a ${node.raw}, not data`,
)
return node.value
case "UnaryExpression":
if (
(node.operator === "-" || node.operator === "+") &&
node.argument.type === "Literal" &&
typeof node.argument.value === "number"
)
return node.operator === "-"
? -node.argument.value
: node.argument.value
break
case "TemplateLiteral":
if (node.expressions.length === 0)
return node.quasis.map((q) => q.value.cooked).join("")
break
case "ArrayExpression":
return node.elements.map((element) => {
if (!element || element.type === "SpreadElement")
throw new Error(`markdown-prepare: ${where} has a hole or a spread`)
return literalOf(element, where)
})
case "ObjectExpression":
return Object.fromEntries(
node.properties.map((property) => {
if (
property.type !== "Property" ||
property.computed ||
property.kind !== "init" ||
property.method
)
throw new Error(
`markdown-prepare: ${where} has a key that is not a plain name`,
)
const key =
property.key.type === "Identifier"
? property.key.name
: property.key.type === "Literal"
? String(property.key.value)
: null
if (key === null)
throw new Error(`markdown-prepare: ${where} has a computed key`)
return [key, literalOf(property.value as Expression, where)]
}),
)
}
throw new Error(
`markdown-prepare: ${where} holds ${node.type}, which is code: a prop is a literal`,
)
}
/** Code as HTML, coloured: Shiki's markup of `text` in `lang`, or null where the
* language is not one it knows (the block is drawn plain). */
export type Highlight = (text: string, lang: string) => Promise<string | null>
/** Shiki with ui's code themes (code-tokens), both schemes in one markup: what the
* browser's highlighter (markdown-code) draws, done ahead. */
export const highlightCode: Highlight = async (text, lang) => {
const { bundledLanguages, codeToHtml } = await import("shiki")
if (lang === "text" || !(lang in bundledLanguages)) return null
return codeToHtml(text, { lang, themes: CODE_THEMES, defaultColor: false })
}
/** The tree with every code block coloured ahead (`data.html`), by `highlight`. A new
* tree; the given one is untouched. */
export async function highlightTree(
tree: M.Root,
highlight: Highlight = highlightCode,
): Promise<M.Root> {
const copy = structuredClone(tree)
const blocks: M.Code[] = []
const walk = (node: M.Nodes) => {
if (node.type === "code") blocks.push(node)
if ("children" in node) for (const child of node.children) walk(child)
}
walk(copy)
await Promise.all(
blocks.map(async (block) => {
const html = await highlight(block.value, block.lang ?? "text")
if (html) annotate(block, { html })
}),
)
return copy
}