dom-md
v0.1.0
Published
Render DOM into readable Markdown.
Readme
dom-md
dom-md renders the DOM already on screen as readable Markdown.
import { renderMarkdown } from 'dom-md';
const markdown = renderMarkdown();The DOM remains the source of truth. Start with semantic HTML and accurate ARIA,
then add data-md or plugins where the visual UI needs a clearer text form.
Render a page or region
With no root, renderMarkdown() uses the first data-md-root, then main,
then document.body.
renderMarkdown();
renderMarkdown(document.querySelector('[data-request-panel]'));
const parsed = new DOMParser().parseFromString(html, 'text/html');
renderMarkdown(parsed);Use a renderer when several call sites share configuration:
import { createMarkdownRenderer } from 'dom-md';
import { budget, frontmatter } from 'dom-md/plugins';
const renderer = createMarkdownRenderer({
plugins: [
budget({ maxChars: 20_000 }),
frontmatter(() => ({ title: document.title, url: location.href })),
],
});
const tree = renderer.toTree(document.body);
const markdown = renderer.stringify(tree);
// Pure tree serialization:
renderer.stringify(tree);
// Full render, including terminal postprocess plugins:
renderer.render(document.body);The tree is a small Markdown tree that is compatible with the MDAST shapes
dom-md emits. stringify(tree) stays pure; render(root) also applies
terminal postprocess plugins.
DOM attributes
Exclude interface chrome:
<button data-md-ignore aria-label="Close"><CloseIcon /></button>Replace a region with authored Markdown:
<section data-md={'## Consumption\n\n- Compute: 42 GB-hours'}>
<UsageChart />
</section>data-md is trusted as raw Markdown and emitted as-is. Put a children token on
its own line to splice live descendants between raw Markdown chunks:
<section data-md={'## Request\n\n{{children}}'}>
<p>Status: 200</p>
</section>Use data-md-children-token to choose another token, data-md-max-chars for a
local cap, and data-md-root to select the default capture root.
Default behavior
The defaults cover semantic text, headings, lists, links, code, native and ARIA tables, live form values, common ARIA widget state, buttons, images, labeled SVGs, canvases, hidden content, and ignored content. Password values are marked as omitted and hidden inputs are skipped.
Text content gets contextual minimal escaping: characters that would change
Markdown structure (backticks, brackets, asterisks, emphasis-forming
underscores, and paragraph-leading heading, list, or blockquote markers) are
escaped, while ordinary prose such as snake_case stays untouched. Markdown
provided through data-md or rawMarkdown nodes is never escaped.
Plugins
Callers pass one plugins list. A plugin is made of steps, and each step runs
in one phase:
type MarkdownPlugin =
| AnnotateElementPlugin
| RenderElementPlugin
| TransformPlugin
| PostprocessPlugin;Array order matters inside a phase. A user plugin with the same phase and id
replaces that default. The default renderElement IDs are data-md,
visuals, tables, aria-widgets, forms, and buttons; max-chars runs
as a default annotate/transform pair. To disable a default, replace it with a
step that always passes:
const markdown = renderMarkdown(root, {
plugins: [{ id: 'tables', phase: 'renderElement', render: () => undefined }],
});Most code should author plugins with createPlugin().
Render an element
Render element plugins return one or more Markdown tree nodes, null to omit,
or undefined to continue to the next renderer/default.
import { createPlugin } from 'dom-md/plugins';
import { paragraph, text } from 'dom-md/builders';
const compactButtons = createPlugin('buttons', ({ renderElement }) => [
renderElement((element) => {
if (element.tagName !== 'BUTTON') return undefined;
const label =
element.getAttribute('aria-label') ??
element.textContent?.trim() ??
'Unlabeled action';
if (label === 'Copy') return null;
return paragraph([text(`Action: ${label}`)]);
}),
]);The context provides:
children(parent?)— extracted child nodesinline(parent?)— extract child content as inline nodes for a paragraph or headingextract(element)— extract another element through the full pipelinerenderDefault()— render the current element through later plugins and built-ins
Most render plugins use inline(), children(), or extract().
renderDefault() is available for rare cases that intentionally build on the
default output for the same element.
Annotate an element
Use annotateElement() when a DOM attribute should become typed metadata for a
later transform, without changing how the element renders.
Annotations are merged onto the Markdown nodes emitted for that element. Read
the metadata later from node.data. For wrapper elements, the emitted nodes may
come from descendants.
import { createPlugin } from 'dom-md/plugins';
import { visit } from 'dom-md/tree';
type ViewportData = {
viewport?: {
visible: boolean;
};
};
const viewport = createPlugin<ViewportData>('viewport', ({ annotateElement, transform }) => [
annotateElement((element) => {
if (!element.hasAttribute('data-offscreen')) return undefined;
return { viewport: { visible: false } };
}),
transform((tree) => {
visit(tree, (node) => {
if (node.data?.viewport?.visible === false && node.type === 'paragraph') {
node.children = [text('Offscreen content omitted')];
}
});
}),
]);Transform the tree
Transforms receive the Markdown tree Root. They may mutate it and return
nothing, or return a replacement root. context.stringify(node) uses the
renderer's canonical Markdown serializer, which makes measurements reliable.
import { heading, text } from 'dom-md/builders';
import { createPlugin } from 'dom-md/plugins';
function generatedTitle(title: string) {
return createPlugin('generated-title', ({ transform }) => [
transform((tree) => {
tree.children.unshift(heading(1, [text(title)]));
}),
]);
}Built-in plugins include budget(), outline(), frontmatter(), and
redactSecrets(). Frontmatter inserts a YAML node; it is not a string
postprocessor. Secret redaction combines a render step, a tree transform, and a
final string postprocess pass — entirely on the public plugin API, so your app
could ship the same plugin in userland.
redactSecrets() is opt-in and redacts common token formats plus credentialed
URL passwords before Markdown leaves a capture surface:
import { redactSecrets } from 'dom-md/plugins';
const markdown = renderMarkdown(document.body, {
plugins: [
redactSecrets({
patterns: [/tenant_secret_[a-z0-9]+/g],
replacement: (value) => `[REDACTED:${value.length}]`,
}),
],
});Add data-md-redact when markup knows a field is sensitive even if it does not
match a built-in pattern:
<span data-md-redact>internal-only-value</span>Use visit() from dom-md/tree when a transform needs to inspect every node:
import { visit } from 'dom-md/tree';
import { createPlugin } from 'dom-md/plugins';
const markFields = createPlugin('mark-fields', ({ transform }) => [
transform((tree) => {
visit(tree, (node) => {
if (node.data?.domMd?.kind === 'field') {
node.data.app = { important: true };
}
});
}),
]);Postprocess the final string
Postprocess plugins run only during render(), after the Markdown tree has
already been serialized. Use them for terminal string concerns such as wrapping,
transport framing, or final redaction:
import { createPlugin } from 'dom-md/plugins';
const xmlEnvelope = createPlugin('xml-envelope', ({ postprocess }) => [
postprocess((markdown) =>
`<page-markdown>\n${markdown}\n</page-markdown>`),
]);Tree interoperability
The public tree is MDAST-compatible, with a small rawMarkdown extension for
trusted authored Markdown and optional provenance under node.data.domMd:
type DomMdMetadata = {
authored?: boolean;
chunk?: boolean;
kind?: 'action' | 'field' | 'override' | 'visual';
label?: string;
sourceId?: string;
};This works directly with unist-util-visit and with Remark plugins that operate
on the node shapes dom-md emits. dom-md itself does not host a unified runtime
because its primary job is DOM interpretation, not Markdown parsing.
For example, a Remark plugin can be adapted into the same plugin list without making unified part of the renderer runtime. Install the Remark/unified packages in your app if you want this adapter:
import remarkToc from 'remark-toc';
import { unified } from 'unified';
import type { MdastRoot } from 'dom-md';
import { createPlugin } from 'dom-md/plugins';
const tocProcessor = unified().use(remarkToc);
const toc = createPlugin('remark-toc', ({ transform }) => [
transform((tree) =>
tocProcessor.runSync(tree as never) as MdastRoot),
]);The renderer validates supported Markdown tree content models after extraction, after
each transform, and before public stringification. A malformed plugin throws a
DomMarkdownError with the phase and plugin id. If a plugin needs to emit
Markdown syntax outside dom-md's tree model, use a rawMarkdown node.
Next.js
dom-md/next provides createMarkdownRoute() for a same-origin catch-all
Route Handler. The adapter forwards cookie and authorization headers only to
the application origin, rejects cross-origin redirects, bounds response size
and fetch time, marks output noindex, and defaults to private, no-store.
import { renderMarkdown } from 'dom-md';
import { createMarkdownRoute } from 'dom-md/next';
import { DOMParser } from 'linkedom';
export const GET = createMarkdownRoute(
({ document }) => renderMarkdown(document),
{ parser: new DOMParser() },
);Entry points
| Entry point | Purpose |
| --- | --- |
| dom-md | Rendering, Markdown tree access, and plugin contracts |
| dom-md/plugins | Built-in plugins and the plugin authoring API |
| dom-md/builders | Small Markdown tree builders |
| dom-md/tree | Markdown tree traversal helpers |
| dom-md/md | String helpers for authoring data-md values |
| dom-md/devtools | Inspection, provenance, audit, and browser panel |
| dom-md/next | Next.js route adapter |
API
renderMarkdown(root?, options?): string;
toMarkdownTree(root?, options?): MarkdownTree;
stringifyMarkdown(tree, options?): string;
createMarkdownRenderer(options?): {
render(root?): string;
toTree(root?): MarkdownTree;
stringify(tree): string;
};