🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

dom-md

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

dom-md - npm Package Compare versions

Comparing version
0.0.1
to
0.1.0
+103
dist/authoring-1IXjCaLp.js
//#region src/plugins/authoring.ts
/**
* Create a composed plugin from one or more typed plugin steps.
*
* The returned value is a plain array of plugins, so it can be placed directly
* inside `plugins: [...]` without spreading. Step ids are prefixed with the
* plugin id, e.g. `createPlugin('callouts', ({ renderElement }) => [
* renderElement('aside', fn),
* ])` creates the plugin id `callouts:aside`.
*/
function createPlugin(id, define) {
return prefixSteps(id, define(createPluginAuthoringApi()));
}
function prefixSteps(id, steps) {
const prefix = id.trim();
if (!prefix) throw new Error("Markdown plugins must have a non-empty id.");
if (steps.length === 1) {
const [step] = steps;
if (step && !step.id?.trim()) return [{
...step,
id: prefix
}];
}
const counts = {
annotateElement: 0,
postprocess: 0,
renderElement: 0,
transform: 0
};
return steps.map((step) => {
const stepId = step.id?.trim() || `${step.phase}:${counts[step.phase]++}`;
return {
...step,
id: `${prefix}:${stepId}`
};
});
}
function createPluginAuthoringApi() {
return {
annotateElement,
renderElement,
transform,
postprocess,
data: withCustomData
};
}
function annotateElement(idOrAnnotate, maybeAnnotate) {
return typeof idOrAnnotate === "string" ? {
id: idOrAnnotate,
phase: "annotateElement",
annotate: maybeAnnotate
} : {
phase: "annotateElement",
annotate: idOrAnnotate
};
}
function renderElement(idOrRender, maybeRender) {
return typeof idOrRender === "string" ? {
id: idOrRender,
phase: "renderElement",
render: maybeRender
} : {
phase: "renderElement",
render: idOrRender
};
}
function transform(idOrTransform, maybeTransform) {
return typeof idOrTransform === "string" ? {
id: idOrTransform,
phase: "transform",
transform: maybeTransform
} : {
phase: "transform",
transform: idOrTransform
};
}
function postprocess(idOrPostprocess, maybePostprocess) {
return typeof idOrPostprocess === "string" ? {
id: idOrPostprocess,
phase: "postprocess",
postprocess: maybePostprocess
} : {
phase: "postprocess",
postprocess: idOrPostprocess
};
}
function withCustomData(value, metadata) {
return Array.isArray(value) ? value.map((node) => mergeCustomData(node, metadata)) : mergeCustomData(value, metadata);
}
function mergeCustomData(node, metadata) {
if ("domMd" in metadata) throw new Error("Plugin data cannot write reserved node.data.domMd metadata.");
return {
...node,
data: {
...node.data,
...metadata
}
};
}
//#endregion
export { createPlugin as t };
//# sourceMappingURL=authoring-1IXjCaLp.js.map
{"version":3,"file":"authoring-1IXjCaLp.js","names":[],"sources":["../src/plugins/authoring.ts"],"sourcesContent":["import type {\n AnnotateElementContext,\n AnnotateElementPlugin,\n ElementContent,\n ElementAnnotation,\n ElementOutput,\n MarkdownNode,\n MarkdownPlugin,\n MarkdownTransformContext,\n MarkdownTree,\n PostprocessContext,\n PostprocessPlugin,\n RenderElementContext,\n RenderElementPlugin,\n TransformPlugin,\n} from '../core/types';\n\ntype CustomData = Record<string, unknown>;\ntype AnonymousAnnotateElementPlugin<Data extends object = CustomData> =\n Omit<AnnotateElementPlugin<Data>, 'id'> & { id?: string };\ntype AnonymousRenderElementPlugin<Data extends object = CustomData> =\n Omit<RenderElementPlugin<Data>, 'id'> & { id?: string };\ntype AnonymousTransformPlugin<Data extends object = CustomData> =\n Omit<TransformPlugin<Data>, 'id'> & { id?: string };\ntype AnonymousPostprocessPlugin<Data extends object = CustomData> =\n Omit<PostprocessPlugin<Data>, 'id'> & { id?: string };\ntype PluginStep<Data extends object = CustomData> =\n | AnonymousAnnotateElementPlugin<Data>\n | AnonymousPostprocessPlugin<Data>\n | AnonymousRenderElementPlugin<Data>\n | AnonymousTransformPlugin<Data>\n | MarkdownPlugin<Data>;\n\ntype AnnotateElementHelper<Data extends object> = {\n (\n annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,\n ): AnonymousAnnotateElementPlugin<Data>;\n (\n id: string,\n annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,\n ): AnnotateElementPlugin<Data>;\n};\n\ntype RenderElementHelper<Data extends object> = {\n (\n render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,\n ): AnonymousRenderElementPlugin<Data>;\n (\n id: string,\n render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,\n ): RenderElementPlugin<Data>;\n};\n\ntype TransformHelper<Data extends object> = {\n (\n transform: (\n tree: MarkdownTree<Data>,\n context: MarkdownTransformContext<Data>,\n ) => MarkdownTree<Data> | void,\n ): AnonymousTransformPlugin<Data>;\n (\n id: string,\n transform: (\n tree: MarkdownTree<Data>,\n context: MarkdownTransformContext<Data>,\n ) => MarkdownTree<Data> | void,\n ): TransformPlugin<Data>;\n};\n\ntype PostprocessHelper<Data extends object> = {\n (\n postprocess: (markdown: string, context: PostprocessContext<Data>) => string,\n ): AnonymousPostprocessPlugin<Data>;\n (\n id: string,\n postprocess: (markdown: string, context: PostprocessContext<Data>) => string,\n ): PostprocessPlugin<Data>;\n};\n\ntype DataHelper<Data extends object> = {\n <Node extends MarkdownNode<Data>>(node: Node, metadata: Data & { domMd?: never }): Node;\n <Node extends ElementContent<Data>>(nodes: readonly Node[], metadata: Data & { domMd?: never }): Node[];\n};\n\nexport type PluginAuthoringApi<Data extends object = CustomData> = {\n /** Create a DOM annotation step typed with this plugin's custom metadata. */\n annotateElement: AnnotateElementHelper<Data>;\n /** Create a DOM rendering step typed with this plugin's custom metadata. */\n renderElement: RenderElementHelper<Data>;\n /** Create a transform plugin step typed with this plugin's custom metadata. */\n transform: TransformHelper<Data>;\n /** Create a postprocess plugin step typed with this plugin's custom metadata. */\n postprocess: PostprocessHelper<Data>;\n /** Attach custom plugin metadata to one node or an array of nodes. */\n data: DataHelper<Data>;\n};\n\n/**\n * Create a composed plugin from one or more typed plugin steps.\n *\n * The returned value is a plain array of plugins, so it can be placed directly\n * inside `plugins: [...]` without spreading. Step ids are prefixed with the\n * plugin id, e.g. `createPlugin('callouts', ({ renderElement }) => [\n * renderElement('aside', fn),\n * ])` creates the plugin id `callouts:aside`.\n */\nexport function createPlugin<Data extends object = CustomData>(\n id: string,\n define: (api: PluginAuthoringApi<Data>) => readonly PluginStep<Data>[],\n): MarkdownPlugin<Data>[] {\n return prefixSteps(id, define(createPluginAuthoringApi<Data>()));\n}\n\nfunction prefixSteps<Data extends object>(\n id: string,\n steps: readonly PluginStep<Data>[],\n): MarkdownPlugin<Data>[] {\n const prefix = id.trim();\n if (!prefix) throw new Error('Markdown plugins must have a non-empty id.');\n if (steps.length === 1) {\n const [step] = steps;\n if (step && !step.id?.trim()) return [{ ...step, id: prefix } as MarkdownPlugin<Data>];\n }\n\n const counts: Record<MarkdownPlugin<Data>['phase'], number> = {\n annotateElement: 0,\n postprocess: 0,\n renderElement: 0,\n transform: 0,\n };\n\n return steps.map((step) => {\n const stepId = step.id?.trim() || `${step.phase}:${counts[step.phase]++}`;\n return { ...step, id: `${prefix}:${stepId}` } as MarkdownPlugin<Data>;\n });\n}\n\nfunction createPluginAuthoringApi<Data extends object>(): PluginAuthoringApi<Data> {\n return {\n annotateElement: annotateElement as AnnotateElementHelper<Data>,\n renderElement: renderElement as RenderElementHelper<Data>,\n transform: transform as TransformHelper<Data>,\n postprocess: postprocess as PostprocessHelper<Data>,\n data: withCustomData as DataHelper<Data>,\n };\n}\n\n/**\n * Create an element annotation step.\n *\n * Annotation steps run for every non-skipped element and add metadata to the\n * nodes produced from that element. They do not decide how the element renders.\n */\nfunction annotateElement<Data extends object = CustomData>(\n annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,\n): AnonymousAnnotateElementPlugin<Data>;\nfunction annotateElement<Data extends object = CustomData>(\n id: string,\n annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,\n): AnnotateElementPlugin<Data>;\nfunction annotateElement<Data extends object = CustomData>(\n idOrAnnotate: string | ((element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>),\n maybeAnnotate?: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,\n): AnonymousAnnotateElementPlugin<Data> | AnnotateElementPlugin<Data> {\n return typeof idOrAnnotate === 'string'\n ? { id: idOrAnnotate, phase: 'annotateElement', annotate: maybeAnnotate! }\n : { phase: 'annotateElement', annotate: idOrAnnotate };\n}\n\n/**\n * Create an element rendering step.\n *\n * Render steps run during DOM extraction. Return nodes to claim an element,\n * `null` to omit it, or `undefined` to let the next plugin/default extractor\n * handle it.\n */\nfunction renderElement<Data extends object = CustomData>(\n render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,\n): AnonymousRenderElementPlugin<Data>;\nfunction renderElement<Data extends object = CustomData>(\n id: string,\n render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,\n): RenderElementPlugin<Data>;\nfunction renderElement<Data extends object = CustomData>(\n idOrRender: string | ((element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>),\n maybeRender?: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,\n): AnonymousRenderElementPlugin<Data> | RenderElementPlugin<Data> {\n return typeof idOrRender === 'string'\n ? { id: idOrRender, phase: 'renderElement', render: maybeRender! }\n : { phase: 'renderElement', render: idOrRender };\n}\n\n/**\n * Create a transform plugin step.\n *\n * Transform plugins run after DOM extraction and receive the complete Markdown\n * tree. Mutate the tree in place and return nothing, or return a replacement\n * tree.\n */\nfunction transform<Data extends object = CustomData>(\n transform: (\n tree: MarkdownTree<Data>,\n context: MarkdownTransformContext<Data>,\n ) => MarkdownTree<Data> | void,\n): AnonymousTransformPlugin<Data>;\nfunction transform<Data extends object = CustomData>(\n id: string,\n transform: (\n tree: MarkdownTree<Data>,\n context: MarkdownTransformContext<Data>,\n ) => MarkdownTree<Data> | void,\n): TransformPlugin<Data>;\nfunction transform<Data extends object = CustomData>(\n idOrTransform: string | ((\n tree: MarkdownTree<Data>,\n context: MarkdownTransformContext<Data>,\n ) => MarkdownTree<Data> | void),\n maybeTransform?: (\n tree: MarkdownTree<Data>,\n context: MarkdownTransformContext<Data>,\n ) => MarkdownTree<Data> | void,\n): AnonymousTransformPlugin<Data> | TransformPlugin<Data> {\n return typeof idOrTransform === 'string'\n ? { id: idOrTransform, phase: 'transform', transform: maybeTransform! }\n : { phase: 'transform', transform: idOrTransform };\n}\n\n/**\n * Create a postprocess plugin step.\n *\n * Postprocess plugins run only in `render()` after the tree has already been\n * serialized. Use them for terminal string concerns such as wrapping, final\n * redaction, or transport-specific framing.\n */\nfunction postprocess<Data extends object = CustomData>(\n postprocess: (markdown: string, context: PostprocessContext<Data>) => string,\n): AnonymousPostprocessPlugin<Data>;\nfunction postprocess<Data extends object = CustomData>(\n id: string,\n postprocess: (markdown: string, context: PostprocessContext<Data>) => string,\n): PostprocessPlugin<Data>;\nfunction postprocess<Data extends object = CustomData>(\n idOrPostprocess: string | ((markdown: string, context: PostprocessContext<Data>) => string),\n maybePostprocess?: (markdown: string, context: PostprocessContext<Data>) => string,\n): AnonymousPostprocessPlugin<Data> | PostprocessPlugin<Data> {\n return typeof idOrPostprocess === 'string'\n ? { id: idOrPostprocess, phase: 'postprocess', postprocess: maybePostprocess! }\n : { phase: 'postprocess', postprocess: idOrPostprocess };\n}\n\nfunction withCustomData<Data extends object, Node extends MarkdownNode<Data>>(\n node: Node,\n metadata: Data,\n): Node;\nfunction withCustomData<Data extends object, Node extends ElementContent<Data>>(\n nodes: readonly Node[],\n metadata: Data,\n): Node[];\nfunction withCustomData<Data extends object, Node extends MarkdownNode<Data> | ElementContent<Data>>(\n value: Node | readonly Node[],\n metadata: Data,\n): Node | Node[] {\n return Array.isArray(value)\n ? value.map((node) => mergeCustomData(node as MarkdownNode<Data>, metadata)) as Node[]\n : mergeCustomData(value as MarkdownNode<Data>, metadata) as Node;\n}\n\nfunction mergeCustomData<Data extends object, Node extends MarkdownNode<Data>>(\n node: Node,\n metadata: Data,\n): Node {\n if ('domMd' in metadata) {\n throw new Error('Plugin data cannot write reserved node.data.domMd metadata.');\n }\n return {\n ...node,\n data: {\n ...node.data,\n ...metadata,\n },\n } as Node;\n}\n"],"mappings":";;;;;;;;;;AA0GA,SAAgB,aACd,IACA,QACwB;CACxB,OAAO,YAAY,IAAI,OAAO,yBAA+B,CAAC,CAAC;AACjE;AAEA,SAAS,YACP,IACA,OACwB;CACxB,MAAM,SAAS,GAAG,KAAK;CACvB,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,4CAA4C;CACzE,IAAI,MAAM,WAAW,GAAG;EACtB,MAAM,CAAC,QAAQ;EACf,IAAI,QAAQ,CAAC,KAAK,IAAI,KAAK,GAAG,OAAO,CAAC;GAAE,GAAG;GAAM,IAAI;EAAO,CAAyB;CACvF;CAEA,MAAM,SAAwD;EAC5D,iBAAiB;EACjB,aAAa;EACb,eAAe;EACf,WAAW;CACb;CAEA,OAAO,MAAM,KAAK,SAAS;EACzB,MAAM,SAAS,KAAK,IAAI,KAAK,KAAK,GAAG,KAAK,MAAM,GAAG,OAAO,KAAK,MAAM;EACrE,OAAO;GAAE,GAAG;GAAM,IAAI,GAAG,OAAO,GAAG;EAAS;CAC9C,CAAC;AACH;AAEA,SAAS,2BAA0E;CACjF,OAAO;EACY;EACF;EACJ;EACE;EACb,MAAM;CACR;AACF;AAeA,SAAS,gBACP,cACA,eACoE;CACpE,OAAO,OAAO,iBAAiB,WAC3B;EAAE,IAAI;EAAc,OAAO;EAAmB,UAAU;CAAe,IACvE;EAAE,OAAO;EAAmB,UAAU;CAAa;AACzD;AAgBA,SAAS,cACP,YACA,aACgE;CAChE,OAAO,OAAO,eAAe,WACzB;EAAE,IAAI;EAAY,OAAO;EAAiB,QAAQ;CAAa,IAC/D;EAAE,OAAO;EAAiB,QAAQ;CAAW;AACnD;AAsBA,SAAS,UACP,eAIA,gBAIwD;CACxD,OAAO,OAAO,kBAAkB,WAC5B;EAAE,IAAI;EAAe,OAAO;EAAa,WAAW;CAAgB,IACpE;EAAE,OAAO;EAAa,WAAW;CAAc;AACrD;AAgBA,SAAS,YACP,iBACA,kBAC4D;CAC5D,OAAO,OAAO,oBAAoB,WAC9B;EAAE,IAAI;EAAiB,OAAO;EAAe,aAAa;CAAkB,IAC5E;EAAE,OAAO;EAAe,aAAa;CAAgB;AAC3D;AAUA,SAAS,eACP,OACA,UACe;CACf,OAAO,MAAM,QAAQ,KAAK,IACtB,MAAM,KAAK,SAAS,gBAAgB,MAA4B,QAAQ,CAAC,IACzE,gBAAgB,OAA6B,QAAQ;AAC3D;AAEA,SAAS,gBACP,MACA,UACM;CACN,IAAI,WAAW,UACb,MAAM,IAAI,MAAM,6DAA6D;CAE/E,OAAO;EACL,GAAG;EACH,MAAM;GACJ,GAAG,KAAK;GACR,GAAG;EACL;CACF;AACF"}
import { a as Link, c as Root, d as Text, i as InlineContent, l as RootContent, n as Emphasis, r as Heading, s as Paragraph, t as DomMdMetadata, u as Strong } from "./mdast-CsrWVudR.js";
//#region src/builders.d.ts
type HeadingDepth = 1 | 2 | 3 | 4 | 5 | 6;
declare function root(children: RootContent[]): Root;
declare function paragraph(children: InlineContent[], metadata?: DomMdMetadata): Paragraph;
declare function heading(depth: HeadingDepth, children: InlineContent[], metadata?: DomMdMetadata): Heading;
declare function text(value: string, metadata?: DomMdMetadata): Text;
declare function strong(children: InlineContent[], metadata?: DomMdMetadata): Strong;
declare function emphasis(children: InlineContent[], metadata?: DomMdMetadata): Emphasis;
declare function link(url: string, children: InlineContent[], metadata?: DomMdMetadata): Link;
//#endregion
export { emphasis, heading, link, paragraph, root, strong, text };
//# sourceMappingURL=builders.d.ts.map
//#region src/builders.ts
function root(children) {
return {
type: "root",
children
};
}
function paragraph(children, metadata) {
return withMetadata({
type: "paragraph",
children
}, metadata);
}
function heading(depth, children, metadata) {
return withMetadata({
type: "heading",
depth,
children
}, metadata);
}
function text(value, metadata) {
return withMetadata({
type: "text",
value
}, metadata);
}
function strong(children, metadata) {
return withMetadata({
type: "strong",
children
}, metadata);
}
function emphasis(children, metadata) {
return withMetadata({
type: "emphasis",
children
}, metadata);
}
function link(url, children, metadata) {
return withMetadata({
type: "link",
url,
children
}, metadata);
}
function withMetadata(node, metadata) {
return metadata ? {
...node,
data: { domMd: metadata }
} : node;
}
//#endregion
export { emphasis, heading, link, paragraph, root, strong, text };
//# sourceMappingURL=builders.js.map
{"version":3,"file":"builders.js","names":[],"sources":["../src/builders.ts"],"sourcesContent":["import type {\n Emphasis,\n Heading,\n InlineContent,\n Link,\n Paragraph,\n Root,\n RootContent,\n Strong,\n Text,\n} from './core/mdast';\nimport type { DomMdMetadata } from './core/types';\n\ntype HeadingDepth = 1 | 2 | 3 | 4 | 5 | 6;\n\nexport function root(children: RootContent[]): Root {\n return { type: 'root', children };\n}\n\nexport function paragraph(\n children: InlineContent[],\n metadata?: DomMdMetadata,\n): Paragraph {\n return withMetadata({ type: 'paragraph', children }, metadata);\n}\n\nexport function heading(\n depth: HeadingDepth,\n children: InlineContent[],\n metadata?: DomMdMetadata,\n): Heading {\n return withMetadata({ type: 'heading', depth, children }, metadata);\n}\n\nexport function text(value: string, metadata?: DomMdMetadata): Text {\n return withMetadata({ type: 'text', value }, metadata);\n}\n\nexport function strong(\n children: InlineContent[],\n metadata?: DomMdMetadata,\n): Strong {\n return withMetadata({ type: 'strong', children }, metadata);\n}\n\nexport function emphasis(\n children: InlineContent[],\n metadata?: DomMdMetadata,\n): Emphasis {\n return withMetadata({ type: 'emphasis', children }, metadata);\n}\n\nexport function link(\n url: string,\n children: InlineContent[],\n metadata?: DomMdMetadata,\n): Link {\n return withMetadata({ type: 'link', url, children }, metadata);\n}\n\nfunction withMetadata<T>(\n node: T,\n metadata?: DomMdMetadata,\n): T {\n return metadata\n ? {\n ...(node as object),\n data: {\n domMd: metadata,\n },\n } as T\n : node;\n}\n"],"mappings":";AAeA,SAAgB,KAAK,UAA+B;CAClD,OAAO;EAAE,MAAM;EAAQ;CAAS;AAClC;AAEA,SAAgB,UACd,UACA,UACW;CACX,OAAO,aAAa;EAAE,MAAM;EAAa;CAAS,GAAG,QAAQ;AAC/D;AAEA,SAAgB,QACd,OACA,UACA,UACS;CACT,OAAO,aAAa;EAAE,MAAM;EAAW;EAAO;CAAS,GAAG,QAAQ;AACpE;AAEA,SAAgB,KAAK,OAAe,UAAgC;CAClE,OAAO,aAAa;EAAE,MAAM;EAAQ;CAAM,GAAG,QAAQ;AACvD;AAEA,SAAgB,OACd,UACA,UACQ;CACR,OAAO,aAAa;EAAE,MAAM;EAAU;CAAS,GAAG,QAAQ;AAC5D;AAEA,SAAgB,SACd,UACA,UACU;CACV,OAAO,aAAa;EAAE,MAAM;EAAY;CAAS,GAAG,QAAQ;AAC9D;AAEA,SAAgB,KACd,KACA,UACA,UACM;CACN,OAAO,aAAa;EAAE,MAAM;EAAQ;EAAK;CAAS,GAAG,QAAQ;AAC/D;AAEA,SAAS,aACP,MACA,UACG;CACH,OAAO,WACH;EACE,GAAI;EACJ,MAAM,EACJ,OAAO,SACT;CACF,IACA;AACN"}
import { c as Root } from "./mdast-CsrWVudR.js";
import { v as RenderMarkdownOptions } from "./types-AEJT1bfE.js";
//#region src/devtools/inspect.d.ts
type MarkdownInspection = {
markdown: string;
domMarkdown: string;
tree: Root;
contributors: MarkdownContributor[];
elements: ElementActivity;
timeline: PluginTimelineEntry[];
};
type MarkdownContributor = {
source: 'data-md' | 'fallback' | 'plugin';
pluginId?: string;
label?: string;
selector: string;
element: Element;
chars: number;
share: number;
excerpt: string;
};
type ElementRecord = {
selector: string;
element: Element;
};
type ElementActivity = {
authored: ElementRecord[];
claimed: (ElementRecord & {
pluginId: string;
})[];
omitted: (ElementRecord & {
reason: 'empty' | 'excluded-tag' | 'hidden' | 'ignored' | 'plugin-omitted';
})[];
fallbackCount: number;
};
type PluginTimelineEntry = {
phase: 'postprocess' | 'transform';
pluginId: string;
chars: {
before: number;
after: number;
};
output: string;
};
declare function inspectMarkdown(root?: ParentNode | null, options?: RenderMarkdownOptions): MarkdownInspection;
//#endregion
//#region src/devtools/audit.d.ts
type MarkdownAudit = {
markdown: string;
domMarkdown: string;
contributors: MarkdownAuditContributor[];
elements: MarkdownAuditElementActivity;
timeline: PluginTimelineEntry[];
};
type MarkdownAuditContributor = {
source: 'data-md' | 'fallback' | 'plugin';
pluginId?: string;
label?: string;
selector: string;
chars: number;
share: number;
excerpt: string;
};
type MarkdownAuditElementActivity = {
authored: {
selector: string;
}[];
claimed: {
selector: string;
pluginId: string;
}[];
omitted: {
selector: string;
reason: 'empty' | 'excluded-tag' | 'hidden' | 'ignored' | 'plugin-omitted';
}[];
fallbackCount: number;
};
type MarkdownAuditConfig = RenderMarkdownOptions & {
root?: ParentNode | null;
};
type MarkdownAuditHandle = {
inspect(): MarkdownAudit;
unmount(): void;
};
type MarkdownAuditWindow = Window & {
__DOM_MD_AUDIT__?: () => MarkdownAudit;
};
/**
* Expose a browser-serializable inspection function for development audits.
* Pass a getter when the root or plugin configuration changes at runtime.
*/
declare function mountMarkdownAudit(config?: MarkdownAuditConfig | (() => MarkdownAuditConfig)): MarkdownAuditHandle;
declare function createMarkdownAudit(root?: ParentNode | null, options?: RenderMarkdownOptions): MarkdownAudit;
//#endregion
//#region src/devtools/panel.d.ts
type MarkdownDevtoolsConfig = RenderMarkdownOptions & {
root?: ParentNode | null;
};
type MarkdownDevtoolsHandle = {
/** Update the config and, if the panel is open, re-inspect. */refresh(config?: MarkdownDevtoolsConfig): void;
unmount(): void;
};
/**
* Mount a floating dom-md inspector on the current page. The widget lives
* in a shadow root, is excluded from rendering via data-md-ignore, and only
* runs the pipeline while the panel is open.
*/
declare function mountMarkdownDevtools(config?: MarkdownDevtoolsConfig): MarkdownDevtoolsHandle;
//#endregion
export { type ElementActivity, type ElementRecord, type MarkdownAudit, type MarkdownAuditConfig, type MarkdownAuditContributor, type MarkdownAuditElementActivity, type MarkdownAuditHandle, type MarkdownAuditWindow, type MarkdownContributor, type MarkdownDevtoolsConfig, type MarkdownDevtoolsHandle, type MarkdownInspection, type PluginTimelineEntry, createMarkdownAudit, inspectMarkdown, mountMarkdownAudit, mountMarkdownDevtools };
//# sourceMappingURL=devtools.d.ts.map
import { i as collapseWhitespace } from "./utils-CVpYLNBk.js";
import { n as runMarkdown, s as stringifyMarkdownNode } from "./serialize-CVJfCHsE.js";
import { a as MARKDOWN_IGNORE_ATTR } from "./types-ue0OR47d.js";
import { visit } from "./tree.js";
//#region src/devtools/inspect.ts
function inspectMarkdown(root, options = {}) {
const provenance = /* @__PURE__ */ new Map();
const elements = {
authored: [],
claimed: [],
omitted: [],
fallbackCount: 0
};
const timeline = [];
let contributors = [];
let domMarkdown = "";
const { markdown, tree } = runMarkdown(root, options, {
elementResolved({ element, nodes, resolution }) {
if (!element.isConnected) return;
recordElementActivity(elements, element, nodes, resolution);
for (const node of nodes) {
const sourceId = node.data?.domMd?.sourceId;
if (sourceId && !provenance.has(sourceId)) provenance.set(sourceId, {
element,
resolution
});
}
},
treeBuilt({ tree, markdown }) {
domMarkdown = markdown;
contributors = collectContributors(tree, provenance, markdown);
},
transformApplied({ pluginId, before, after }) {
timeline.push({
phase: "transform",
pluginId,
chars: {
before: before.length,
after: after.length
},
output: after
});
},
postprocessApplied({ pluginId, before, after }) {
timeline.push({
phase: "postprocess",
pluginId,
chars: {
before: before.length,
after: after.length
},
output: after
});
}
});
return {
markdown,
domMarkdown,
tree,
contributors,
elements,
timeline
};
}
function recordElementActivity(activity, element, nodes, resolution) {
const selector = cssPath(element);
if (resolution.kind === "skipped") activity.omitted.push({
selector,
element,
reason: resolution.reason
});
else if (resolution.kind === "fallback") if (nodes.length) activity.fallbackCount += 1;
else activity.omitted.push({
selector,
element,
reason: "empty"
});
else if (!nodes.length) activity.omitted.push({
selector,
element,
reason: "plugin-omitted"
});
else if (isDataMdResolution(resolution)) activity.authored.push({
selector,
element
});
else activity.claimed.push({
selector,
element,
pluginId: resolution.pluginId
});
}
function collectContributors(tree, provenance, markdown) {
const result = [];
const seen = /* @__PURE__ */ new Set();
visit(tree, (node) => {
const sourceId = node.data?.domMd?.sourceId;
if (!sourceId || seen.has(sourceId)) return;
const record = provenance.get(sourceId);
if (!record || record.resolution.kind === "skipped") return;
const rendered = stringifyMarkdownNode(node);
if (!rendered) return;
seen.add(sourceId);
const resolution = record.resolution;
const source = contributorSource(resolution);
result.push({
source,
...source === "plugin" && resolution.kind === "plugin" ? { pluginId: resolution.pluginId } : {},
label: node.data?.domMd?.label,
selector: cssPath(record.element),
element: record.element,
chars: rendered.length,
share: rendered.length / Math.max(1, markdown.length),
excerpt: excerpt(rendered)
});
});
return result;
}
function contributorSource(resolution) {
return isDataMdResolution(resolution) ? "data-md" : resolution.kind;
}
function isDataMdResolution(resolution) {
return resolution.kind === "plugin" && resolution.pluginId === "data-md";
}
function excerpt(markdown, maxChars = 80) {
const value = collapseWhitespace(markdown);
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1).trimEnd()}…`;
}
function cssPath(element, maxDepth = 5) {
const parts = [];
let current = element;
while (current && parts.length < maxDepth) {
const id = current.getAttribute("id");
if (id) {
parts.unshift(`#${id}`);
break;
}
const tag = current.tagName.toLowerCase();
if (tag === "body" || tag === "html") break;
const parent = current.parentElement;
const siblings = parent ? Array.from(parent.children).filter((child) => child.tagName === current?.tagName) : [];
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag);
current = parent;
}
return parts.join(" > ");
}
//#endregion
//#region src/devtools/audit.ts
/**
* Expose a browser-serializable inspection function for development audits.
* Pass a getter when the root or plugin configuration changes at runtime.
*/
function mountMarkdownAudit(config = {}) {
if (typeof window === "undefined") return {
inspect: () => createMarkdownAudit(),
unmount() {}
};
const auditWindow = window;
const previous = auditWindow.__DOM_MD_AUDIT__;
const inspect = () => {
const { root, ...options } = typeof config === "function" ? config() : config;
return createMarkdownAudit(root, options);
};
auditWindow.__DOM_MD_AUDIT__ = inspect;
return {
inspect,
unmount() {
if (auditWindow.__DOM_MD_AUDIT__ !== inspect) return;
if (previous) auditWindow.__DOM_MD_AUDIT__ = previous;
else delete auditWindow.__DOM_MD_AUDIT__;
}
};
}
function createMarkdownAudit(root, options = {}) {
return toMarkdownAudit(inspectMarkdown(root, options));
}
function toMarkdownAudit(inspection) {
return {
markdown: inspection.markdown,
domMarkdown: inspection.domMarkdown,
timeline: inspection.timeline,
contributors: inspection.contributors.map(({ element, ...entry }) => entry),
elements: {
authored: inspection.elements.authored.map(({ selector }) => ({ selector })),
claimed: inspection.elements.claimed.map(({ element, ...entry }) => entry),
omitted: inspection.elements.omitted.map(({ element, ...entry }) => entry),
fallbackCount: inspection.elements.fallbackCount
}
};
}
//#endregion
//#region src/devtools/panel.ts
const MIN_WIDTH = 480;
const MIN_HEIGHT = 260;
/**
* Mount a floating dom-md inspector on the current page. The widget lives
* in a shadow root, is excluded from rendering via data-md-ignore, and only
* runs the pipeline while the panel is open.
*/
function mountMarkdownDevtools(config = {}) {
if (typeof document === "undefined") return {
refresh() {},
unmount() {}
};
let currentConfig = config;
let inspection = null;
let error = null;
let open = false;
let width = 680;
let height = 420;
let highlighted = null;
const host = document.createElement("div");
host.setAttribute(MARKDOWN_IGNORE_ATTR, "");
const shadow = host.attachShadow({ mode: "open" });
shadow.appendChild(createStyles());
const toggle = el("button", "toggle", "MD");
toggle.setAttribute("aria-label", "Toggle dom-md devtools");
const panel = el("div", "panel");
panel.hidden = true;
shadow.append(toggle, panel);
document.body.appendChild(host);
const applySize = () => {
panel.style.width = `${width}px`;
panel.style.height = `${height}px`;
};
applySize();
const resizeHandle = el("div", "resize");
resizeHandle.addEventListener("pointerdown", (event) => {
event.preventDefault();
resizeHandle.setPointerCapture(event.pointerId);
const start = {
x: event.clientX,
y: event.clientY,
width,
height
};
const onMove = (move) => {
width = Math.max(MIN_WIDTH, start.width + start.x - move.clientX);
height = Math.max(MIN_HEIGHT, start.height + start.y - move.clientY);
applySize();
};
const onUp = () => {
resizeHandle.removeEventListener("pointermove", onMove);
resizeHandle.removeEventListener("pointerup", onUp);
};
resizeHandle.addEventListener("pointermove", onMove);
resizeHandle.addEventListener("pointerup", onUp);
});
const clearHighlight = () => {
if (!highlighted) return;
highlighted.element.style.outline = highlighted.outline;
highlighted = null;
};
const highlight = (element) => {
clearHighlight();
if (!(element instanceof HTMLElement) || !element.isConnected) return;
highlighted = {
element,
outline: element.style.outline
};
element.style.outline = "2px solid #ff0080";
};
const inspect = () => {
try {
inspection = inspectMarkdown(currentConfig.root, { plugins: currentConfig.plugins });
error = null;
} catch (cause) {
inspection = null;
error = cause instanceof Error ? cause.message : String(cause);
}
};
const render = () => {
clearHighlight();
panel.replaceChildren();
if (!open) return;
panel.append(resizeHandle, renderHeader());
if (error) {
panel.appendChild(el("div", "error", `Inspection failed: ${error}`));
return;
}
if (!inspection) return;
const body = el("div", "body");
const main = el("div", "main");
main.appendChild(el("pre", "markdown", inspection.markdown || "No Markdown output"));
const side = el("div", "side");
renderPipeline(side, inspection);
renderSources(side, inspection);
body.append(main, side);
panel.appendChild(body);
};
const renderHeader = () => {
const header = el("div", "header");
header.appendChild(el("span", "title", "dom-md"));
if (inspection) header.appendChild(el("span", "meta", `${formatCount(inspection.markdown.length)} chars`));
header.appendChild(el("span", "spacer"));
if (inspection) {
const copyButton = el("button", "copy", "Copy");
copyButton.addEventListener("click", async () => {
if (!inspection) return;
try {
await navigator.clipboard.writeText(inspection.markdown);
copyButton.textContent = "Copied";
copyButton.classList.add("copied");
setTimeout(() => {
copyButton.textContent = "Copy";
copyButton.classList.remove("copied");
}, 1500);
} catch {
copyButton.textContent = "Failed";
}
});
header.appendChild(copyButton);
}
const refreshButton = el("button", "icon", "↻");
refreshButton.setAttribute("aria-label", "Re-render");
refreshButton.addEventListener("click", () => {
inspect();
render();
});
const closeButton = el("button", "icon", "×");
closeButton.setAttribute("aria-label", "Close");
closeButton.addEventListener("click", () => {
open = false;
panel.hidden = true;
render();
});
header.append(refreshButton, closeButton);
return header;
};
const renderPipeline = (side, current) => {
side.appendChild(el("div", "sectionTitle", `Pipeline (${current.timeline.length})`));
if (current.timeline.length === 0) {
side.appendChild(el("div", "empty", "No plugins ran."));
return;
}
for (const entry of current.timeline) {
const row = el("div", "row");
row.append(el("span", `badge ${entry.phase}`, entry.phase), el("span", "rowTitle", entry.pluginId), el("span", "spacer"), el("span", "meta", formatDelta(entry.chars.before, entry.chars.after)));
row.title = `${formatCount(entry.chars.before)} → ${formatCount(entry.chars.after)} chars`;
side.appendChild(row);
}
};
const renderSources = (side, current) => {
const { contributors, elements } = current;
side.appendChild(el("div", "sectionTitle", `Sources (${contributors.length})`));
if (contributors.length === 0) side.appendChild(el("div", "empty", "No tracked source regions."));
for (const contributor of [...contributors].sort((a, b) => b.chars - a.chars)) {
const row = el("div", "row hoverable");
row.append(el("span", `badge ${contributor.source === "data-md" ? "authored" : contributor.source}`, contributor.source === "data-md" ? "data-md" : contributor.source), el("span", "rowTitle", contributor.label ?? contributor.selector), el("span", "spacer"), el("span", "meta", formatShare(contributor.share)));
row.title = `${formatCount(contributor.chars)} chars · ${contributor.selector}`;
row.addEventListener("mouseenter", () => highlight(contributor.element));
row.addEventListener("mouseleave", clearHighlight);
side.appendChild(row);
}
const ownOmissions = elements.omitted.filter(({ element }) => element !== host);
side.appendChild(el("div", "summary", `${elements.authored.length} authored · ${elements.claimed.length} claimed · ${elements.fallbackCount} fallback · ${ownOmissions.length} omitted`));
};
toggle.addEventListener("click", () => {
open = !open;
panel.hidden = !open;
if (open) inspect();
render();
});
return {
refresh(nextConfig) {
if (nextConfig) currentConfig = {
...currentConfig,
...nextConfig
};
if (open) {
inspect();
render();
}
},
unmount() {
clearHighlight();
host.remove();
}
};
}
function formatCount(count) {
return count.toLocaleString("en-US");
}
function formatShare(share) {
const percent = Math.round(share * 100);
return percent < 1 ? "<1%" : `${percent}%`;
}
function formatDelta(before, after) {
if (before === after) return "±0";
const change = Math.round((after - before) / Math.max(1, before) * 100);
return `${change > 0 ? "+" : ""}${change}%`;
}
function el(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== void 0) element.textContent = text;
return element;
}
function createStyles() {
const style = document.createElement("style");
style.textContent = `
:host {
all: initial;
position: fixed;
bottom: 16px;
right: 16px;
z-index: 2147483646;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
color: #ededed;
}
* { box-sizing: border-box; }
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
.toggle {
display: block;
margin-left: auto;
padding: 6px 12px;
border-radius: 999px;
background: #0a0a0a;
border: 1px solid #333;
font-weight: 600;
letter-spacing: 0.04em;
}
.toggle:hover { border-color: #666; }
.panel {
position: absolute;
bottom: 40px;
right: 0;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 72px);
display: flex;
flex-direction: column;
background: #0a0a0a;
border: 1px solid #2e2e2e;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.panel[hidden] { display: none; }
.resize {
position: absolute;
top: 0;
left: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
z-index: 1;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #1f1f1f;
flex-shrink: 0;
}
.title { font-weight: 600; }
.meta { color: #888; white-space: nowrap; }
.spacer { flex: 1; }
.icon { padding: 0 4px; color: #888; font-size: 14px; }
.icon:hover { color: #ededed; }
.copy {
padding: 2px 10px;
border: 1px solid #2e2e2e;
border-radius: 6px;
color: #a1a1a1;
font-size: 11px;
}
.copy:hover { color: #ededed; border-color: #454545; }
.copy.copied { color: #62c073; border-color: #16271c; }
.body { display: flex; flex: 1; min-height: 0; }
.main { flex: 1; min-width: 0; overflow: auto; padding: 10px 12px; }
.markdown {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
}
.side {
width: 240px;
flex-shrink: 0;
overflow: auto;
padding: 10px 12px;
border-left: 1px solid #1f1f1f;
}
.sectionTitle {
color: #666;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 6px;
}
.sectionTitle + .row { margin-top: 0; }
.row + .sectionTitle { margin-top: 16px; }
.summary + .sectionTitle, .empty + .sectionTitle { margin-top: 16px; }
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 3px 6px;
margin: 0 -6px;
border-radius: 6px;
}
.row.hoverable:hover { background: #1a1a1a; }
.rowTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 999px;
background: #1f1f1f;
color: #999;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge.authored { background: #10233d; color: #52a8ff; }
.badge.plugin { background: #16271c; color: #62c073; }
.summary { margin-top: 8px; color: #666; font-size: 11px; }
.empty { color: #666; padding: 2px 0 4px; }
.error { padding: 12px; color: #ff6166; }
`;
return style;
}
//#endregion
export { createMarkdownAudit, inspectMarkdown, mountMarkdownAudit, mountMarkdownDevtools };
//# sourceMappingURL=devtools.js.map
{"version":3,"file":"devtools.js","names":[],"sources":["../src/devtools/inspect.ts","../src/devtools/audit.ts","../src/devtools/panel.ts"],"sourcesContent":["import type { Root } from '../core/mdast';\nimport { stringifyMarkdownNode } from '../core/markdown';\nimport { runMarkdown } from '../core/serialize';\nimport type {\n ElementContent,\n ElementResolution,\n RenderMarkdownOptions,\n RenderObserver,\n} from '../core/types';\nimport { DATA_MD_PLUGIN_ID } from '../plugins/defaults/ids';\nimport { collapseWhitespace } from '../core/utils';\nimport { visit } from '../tree';\n\nexport type MarkdownInspection = {\n markdown: string;\n domMarkdown: string;\n tree: Root;\n contributors: MarkdownContributor[];\n elements: ElementActivity;\n timeline: PluginTimelineEntry[];\n};\n\nexport type MarkdownContributor = {\n source: 'data-md' | 'fallback' | 'plugin';\n pluginId?: string;\n label?: string;\n selector: string;\n element: Element;\n chars: number;\n share: number;\n excerpt: string;\n};\n\nexport type ElementRecord = { selector: string; element: Element };\nexport type ElementActivity = {\n authored: ElementRecord[];\n claimed: (ElementRecord & { pluginId: string })[];\n omitted: (ElementRecord & {\n reason: 'empty' | 'excluded-tag' | 'hidden' | 'ignored' | 'plugin-omitted';\n })[];\n fallbackCount: number;\n};\n\nexport type PluginTimelineEntry = {\n phase: 'postprocess' | 'transform';\n pluginId: string;\n chars: { before: number; after: number };\n output: string;\n};\n\nexport function inspectMarkdown(\n root?: ParentNode | null,\n options: RenderMarkdownOptions = {},\n): MarkdownInspection {\n const provenance = new Map<\n string,\n { element: Element; resolution: ElementResolution }\n >();\n const elements: ElementActivity = {\n authored: [], claimed: [], omitted: [], fallbackCount: 0,\n };\n const timeline: PluginTimelineEntry[] = [];\n let contributors: MarkdownContributor[] = [];\n let domMarkdown = '';\n\n const observer: RenderObserver = {\n elementResolved({ element, nodes, resolution }) {\n if (!element.isConnected) return;\n recordElementActivity(elements, element, nodes, resolution);\n for (const node of nodes) {\n const sourceId = node.data?.domMd?.sourceId;\n if (sourceId && !provenance.has(sourceId)) {\n provenance.set(sourceId, { element, resolution });\n }\n }\n },\n treeBuilt({ tree, markdown }) {\n domMarkdown = markdown;\n contributors = collectContributors(tree, provenance, markdown);\n },\n transformApplied({ pluginId, before, after }) {\n timeline.push({\n phase: 'transform',\n pluginId,\n chars: { before: before.length, after: after.length },\n output: after,\n });\n },\n postprocessApplied({ pluginId, before, after }) {\n timeline.push({\n phase: 'postprocess',\n pluginId,\n chars: { before: before.length, after: after.length },\n output: after,\n });\n },\n };\n\n const { markdown, tree } = runMarkdown(root, options, observer);\n return { markdown, domMarkdown, tree, contributors, elements, timeline };\n}\n\nfunction recordElementActivity(\n activity: ElementActivity,\n element: Element,\n nodes: ElementContent[],\n resolution: ElementResolution,\n): void {\n const selector = cssPath(element);\n if (resolution.kind === 'skipped') {\n activity.omitted.push({ selector, element, reason: resolution.reason });\n } else if (resolution.kind === 'fallback') {\n if (nodes.length) activity.fallbackCount += 1;\n else activity.omitted.push({ selector, element, reason: 'empty' });\n } else if (!nodes.length) {\n activity.omitted.push({ selector, element, reason: 'plugin-omitted' });\n } else if (isDataMdResolution(resolution)) {\n activity.authored.push({ selector, element });\n } else {\n activity.claimed.push({ selector, element, pluginId: resolution.pluginId });\n }\n}\n\nfunction collectContributors(\n tree: Root,\n provenance: Map<string, { element: Element; resolution: ElementResolution }>,\n markdown: string,\n): MarkdownContributor[] {\n const result: MarkdownContributor[] = [];\n const seen = new Set<string>();\n visit(tree, (node) => {\n const sourceId = node.data?.domMd?.sourceId;\n if (!sourceId || seen.has(sourceId)) return;\n const record = provenance.get(sourceId);\n if (!record || record.resolution.kind === 'skipped') return;\n const rendered = stringifyMarkdownNode(node);\n if (!rendered) return;\n seen.add(sourceId);\n const resolution = record.resolution;\n const source = contributorSource(resolution);\n result.push({\n source,\n ...(source === 'plugin' && resolution.kind === 'plugin' ? { pluginId: resolution.pluginId } : {}),\n label: node.data?.domMd?.label,\n selector: cssPath(record.element),\n element: record.element,\n chars: rendered.length,\n share: rendered.length / Math.max(1, markdown.length),\n excerpt: excerpt(rendered),\n });\n });\n return result;\n}\n\nfunction contributorSource(\n resolution: Exclude<ElementResolution, { kind: 'skipped' }>,\n): MarkdownContributor['source'] {\n return isDataMdResolution(resolution) ? 'data-md' : resolution.kind;\n}\n\nfunction isDataMdResolution(\n resolution: ElementResolution,\n): resolution is { kind: 'plugin'; pluginId: typeof DATA_MD_PLUGIN_ID } {\n return resolution.kind === 'plugin' && resolution.pluginId === DATA_MD_PLUGIN_ID;\n}\n\nfunction excerpt(markdown: string, maxChars = 80): string {\n const value = collapseWhitespace(markdown);\n return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1).trimEnd()}…`;\n}\n\nfunction cssPath(element: Element, maxDepth = 5): string {\n const parts: string[] = [];\n let current: Element | null = element;\n while (current && parts.length < maxDepth) {\n const id = current.getAttribute('id');\n if (id) { parts.unshift(`#${id}`); break; }\n const tag = current.tagName.toLowerCase();\n if (tag === 'body' || tag === 'html') break;\n const parent: Element | null = current.parentElement;\n const siblings = parent\n ? Array.from(parent.children).filter((child) => child.tagName === current?.tagName)\n : [];\n parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag);\n current = parent;\n }\n return parts.join(' > ');\n}\n","import type { RenderMarkdownOptions } from '../core/types';\nimport {\n inspectMarkdown,\n type MarkdownInspection,\n type PluginTimelineEntry,\n} from './inspect';\n\nexport type MarkdownAudit = {\n markdown: string;\n domMarkdown: string;\n contributors: MarkdownAuditContributor[];\n elements: MarkdownAuditElementActivity;\n timeline: PluginTimelineEntry[];\n};\n\nexport type MarkdownAuditContributor = {\n source: 'data-md' | 'fallback' | 'plugin';\n pluginId?: string;\n label?: string;\n selector: string;\n chars: number;\n share: number;\n excerpt: string;\n};\n\nexport type MarkdownAuditElementActivity = {\n authored: { selector: string }[];\n claimed: { selector: string; pluginId: string }[];\n omitted: {\n selector: string;\n reason: 'empty' | 'excluded-tag' | 'hidden' | 'ignored' | 'plugin-omitted';\n }[];\n fallbackCount: number;\n};\n\nexport type MarkdownAuditConfig = RenderMarkdownOptions & {\n root?: ParentNode | null;\n};\n\nexport type MarkdownAuditHandle = {\n inspect(): MarkdownAudit;\n unmount(): void;\n};\n\nexport type MarkdownAuditWindow = Window & {\n __DOM_MD_AUDIT__?: () => MarkdownAudit;\n};\n\n/**\n * Expose a browser-serializable inspection function for development audits.\n * Pass a getter when the root or plugin configuration changes at runtime.\n */\nexport function mountMarkdownAudit(\n config:\n | MarkdownAuditConfig\n | (() => MarkdownAuditConfig) = {},\n): MarkdownAuditHandle {\n if (typeof window === 'undefined') {\n return {\n inspect: () => createMarkdownAudit(),\n unmount() {},\n };\n }\n\n const auditWindow = window as MarkdownAuditWindow;\n const previous = auditWindow.__DOM_MD_AUDIT__;\n const inspect = () => {\n const current = typeof config === 'function' ? config() : config;\n const { root, ...options } = current;\n return createMarkdownAudit(root, options);\n };\n\n auditWindow.__DOM_MD_AUDIT__ = inspect;\n\n return {\n inspect,\n unmount() {\n if (auditWindow.__DOM_MD_AUDIT__ !== inspect) return;\n if (previous) {\n auditWindow.__DOM_MD_AUDIT__ = previous;\n } else {\n delete auditWindow.__DOM_MD_AUDIT__;\n }\n },\n };\n}\n\nexport function createMarkdownAudit(\n root?: ParentNode | null,\n options: RenderMarkdownOptions = {},\n): MarkdownAudit {\n return toMarkdownAudit(inspectMarkdown(root, options));\n}\n\nfunction toMarkdownAudit(inspection: MarkdownInspection): MarkdownAudit {\n return {\n markdown: inspection.markdown,\n domMarkdown: inspection.domMarkdown,\n timeline: inspection.timeline,\n contributors: inspection.contributors.map(({ element, ...entry }) => entry),\n elements: {\n authored: inspection.elements.authored.map(({ selector }) => ({\n selector,\n })),\n claimed: inspection.elements.claimed.map(({ element, ...entry }) => entry),\n omitted: inspection.elements.omitted.map(({ element, ...entry }) => entry),\n fallbackCount: inspection.elements.fallbackCount,\n },\n };\n}\n","import { inspectMarkdown, type MarkdownInspection } from './inspect';\nimport {\n MARKDOWN_IGNORE_ATTR,\n type RenderMarkdownOptions,\n} from '../core/types';\n\nexport type MarkdownDevtoolsConfig = RenderMarkdownOptions & {\n root?: ParentNode | null;\n};\n\nexport type MarkdownDevtoolsHandle = {\n /** Update the config and, if the panel is open, re-inspect. */\n refresh(config?: MarkdownDevtoolsConfig): void;\n unmount(): void;\n};\n\nconst MIN_WIDTH = 480;\nconst MIN_HEIGHT = 260;\n\n/**\n * Mount a floating dom-md inspector on the current page. The widget lives\n * in a shadow root, is excluded from rendering via data-md-ignore, and only\n * runs the pipeline while the panel is open.\n */\nexport function mountMarkdownDevtools(\n config: MarkdownDevtoolsConfig = {},\n): MarkdownDevtoolsHandle {\n if (typeof document === 'undefined') {\n return { refresh() {}, unmount() {} };\n }\n\n let currentConfig = config;\n let inspection: MarkdownInspection | null = null;\n let error: string | null = null;\n let open = false;\n let width = 680;\n let height = 420;\n let highlighted: { element: HTMLElement; outline: string } | null = null;\n\n const host = document.createElement('div');\n host.setAttribute(MARKDOWN_IGNORE_ATTR, '');\n const shadow = host.attachShadow({ mode: 'open' });\n shadow.appendChild(createStyles());\n\n const toggle = el('button', 'toggle', 'MD');\n toggle.setAttribute('aria-label', 'Toggle dom-md devtools');\n const panel = el('div', 'panel');\n panel.hidden = true;\n shadow.append(toggle, panel);\n document.body.appendChild(host);\n\n const applySize = () => {\n panel.style.width = `${width}px`;\n panel.style.height = `${height}px`;\n };\n applySize();\n\n // The panel is anchored bottom-right, so it resizes from its top-left corner.\n const resizeHandle = el('div', 'resize');\n resizeHandle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n resizeHandle.setPointerCapture(event.pointerId);\n const start = { x: event.clientX, y: event.clientY, width, height };\n const onMove = (move: PointerEvent) => {\n width = Math.max(MIN_WIDTH, start.width + start.x - move.clientX);\n height = Math.max(MIN_HEIGHT, start.height + start.y - move.clientY);\n applySize();\n };\n const onUp = () => {\n resizeHandle.removeEventListener('pointermove', onMove);\n resizeHandle.removeEventListener('pointerup', onUp);\n };\n resizeHandle.addEventListener('pointermove', onMove);\n resizeHandle.addEventListener('pointerup', onUp);\n });\n\n const clearHighlight = () => {\n if (!highlighted) return;\n highlighted.element.style.outline = highlighted.outline;\n highlighted = null;\n };\n\n const highlight = (element: Element) => {\n clearHighlight();\n if (!(element instanceof HTMLElement) || !element.isConnected) return;\n highlighted = { element, outline: element.style.outline };\n element.style.outline = '2px solid #ff0080';\n };\n\n const inspect = () => {\n try {\n inspection = inspectMarkdown(currentConfig.root, {\n plugins: currentConfig.plugins,\n });\n error = null;\n } catch (cause) {\n inspection = null;\n error = cause instanceof Error ? cause.message : String(cause);\n }\n };\n\n const render = () => {\n clearHighlight();\n panel.replaceChildren();\n if (!open) return;\n\n panel.append(resizeHandle, renderHeader());\n if (error) {\n panel.appendChild(el('div', 'error', `Inspection failed: ${error}`));\n return;\n }\n if (!inspection) return;\n\n const body = el('div', 'body');\n const main = el('div', 'main');\n main.appendChild(\n el('pre', 'markdown', inspection.markdown || 'No Markdown output'),\n );\n const side = el('div', 'side');\n renderPipeline(side, inspection);\n renderSources(side, inspection);\n body.append(main, side);\n panel.appendChild(body);\n };\n\n const renderHeader = () => {\n const header = el('div', 'header');\n header.appendChild(el('span', 'title', 'dom-md'));\n if (inspection) {\n header.appendChild(\n el('span', 'meta', `${formatCount(inspection.markdown.length)} chars`),\n );\n }\n header.appendChild(el('span', 'spacer'));\n\n if (inspection) {\n const copyButton = el('button', 'copy', 'Copy');\n copyButton.addEventListener('click', async () => {\n if (!inspection) return;\n try {\n await navigator.clipboard.writeText(inspection.markdown);\n copyButton.textContent = 'Copied';\n copyButton.classList.add('copied');\n setTimeout(() => {\n copyButton.textContent = 'Copy';\n copyButton.classList.remove('copied');\n }, 1500);\n } catch {\n copyButton.textContent = 'Failed';\n }\n });\n header.appendChild(copyButton);\n }\n\n const refreshButton = el('button', 'icon', '↻');\n refreshButton.setAttribute('aria-label', 'Re-render');\n refreshButton.addEventListener('click', () => {\n inspect();\n render();\n });\n\n const closeButton = el('button', 'icon', '×');\n closeButton.setAttribute('aria-label', 'Close');\n closeButton.addEventListener('click', () => {\n open = false;\n panel.hidden = true;\n render();\n });\n\n header.append(refreshButton, closeButton);\n return header;\n };\n\n const renderPipeline = (side: HTMLElement, current: MarkdownInspection) => {\n side.appendChild(\n el('div', 'sectionTitle', `Pipeline (${current.timeline.length})`),\n );\n if (current.timeline.length === 0) {\n side.appendChild(el('div', 'empty', 'No plugins ran.'));\n return;\n }\n for (const entry of current.timeline) {\n const row = el('div', 'row');\n row.append(\n el('span', `badge ${entry.phase}`, entry.phase),\n el('span', 'rowTitle', entry.pluginId),\n el('span', 'spacer'),\n el('span', 'meta', formatDelta(entry.chars.before, entry.chars.after)),\n );\n row.title = `${formatCount(entry.chars.before)} → ${formatCount(entry.chars.after)} chars`;\n side.appendChild(row);\n }\n };\n\n const renderSources = (side: HTMLElement, current: MarkdownInspection) => {\n const { contributors, elements } = current;\n side.appendChild(\n el('div', 'sectionTitle', `Sources (${contributors.length})`),\n );\n if (contributors.length === 0) {\n side.appendChild(el('div', 'empty', 'No tracked source regions.'));\n }\n\n for (const contributor of [...contributors].sort(\n (a, b) => b.chars - a.chars,\n )) {\n const row = el('div', 'row hoverable');\n row.append(\n el(\n 'span',\n `badge ${contributor.source === 'data-md' ? 'authored' : contributor.source}`,\n contributor.source === 'data-md' ? 'data-md' : contributor.source,\n ),\n el('span', 'rowTitle', contributor.label ?? contributor.selector),\n el('span', 'spacer'),\n el('span', 'meta', formatShare(contributor.share)),\n );\n row.title = `${formatCount(contributor.chars)} chars · ${contributor.selector}`;\n row.addEventListener('mouseenter', () => highlight(contributor.element));\n row.addEventListener('mouseleave', clearHighlight);\n side.appendChild(row);\n }\n\n const ownOmissions = elements.omitted.filter(\n ({ element }) => element !== host,\n );\n side.appendChild(\n el(\n 'div',\n 'summary',\n `${elements.authored.length} authored · ${elements.claimed.length} claimed · ` +\n `${elements.fallbackCount} fallback · ${ownOmissions.length} omitted`,\n ),\n );\n };\n\n toggle.addEventListener('click', () => {\n open = !open;\n panel.hidden = !open;\n if (open) inspect();\n render();\n });\n\n return {\n refresh(nextConfig) {\n if (nextConfig) currentConfig = { ...currentConfig, ...nextConfig };\n if (open) {\n inspect();\n render();\n }\n },\n unmount() {\n clearHighlight();\n host.remove();\n },\n };\n}\n\nfunction formatCount(count: number): string {\n return count.toLocaleString('en-US');\n}\n\nfunction formatShare(share: number): string {\n const percent = Math.round(share * 100);\n return percent < 1 ? '<1%' : `${percent}%`;\n}\n\nfunction formatDelta(before: number, after: number): string {\n if (before === after) return '±0';\n const change = Math.round(((after - before) / Math.max(1, before)) * 100);\n return `${change > 0 ? '+' : ''}${change}%`;\n}\n\nfunction el(tag: string, className?: string, text?: string): HTMLElement {\n const element = document.createElement(tag);\n if (className) element.className = className;\n if (text !== undefined) element.textContent = text;\n return element;\n}\n\nfunction createStyles(): HTMLStyleElement {\n const style = document.createElement('style');\n style.textContent = `\n :host {\n all: initial;\n position: fixed;\n bottom: 16px;\n right: 16px;\n z-index: 2147483646;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n line-height: 1.5;\n color: #ededed;\n }\n * { box-sizing: border-box; }\n button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }\n .toggle {\n display: block;\n margin-left: auto;\n padding: 6px 12px;\n border-radius: 999px;\n background: #0a0a0a;\n border: 1px solid #333;\n font-weight: 600;\n letter-spacing: 0.04em;\n }\n .toggle:hover { border-color: #666; }\n .panel {\n position: absolute;\n bottom: 40px;\n right: 0;\n max-width: calc(100vw - 32px);\n max-height: calc(100vh - 72px);\n display: flex;\n flex-direction: column;\n background: #0a0a0a;\n border: 1px solid #2e2e2e;\n border-radius: 12px;\n box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);\n overflow: hidden;\n }\n .panel[hidden] { display: none; }\n .resize {\n position: absolute;\n top: 0;\n left: 0;\n width: 16px;\n height: 16px;\n cursor: nwse-resize;\n touch-action: none;\n z-index: 1;\n }\n .header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n border-bottom: 1px solid #1f1f1f;\n flex-shrink: 0;\n }\n .title { font-weight: 600; }\n .meta { color: #888; white-space: nowrap; }\n .spacer { flex: 1; }\n .icon { padding: 0 4px; color: #888; font-size: 14px; }\n .icon:hover { color: #ededed; }\n .copy {\n padding: 2px 10px;\n border: 1px solid #2e2e2e;\n border-radius: 6px;\n color: #a1a1a1;\n font-size: 11px;\n }\n .copy:hover { color: #ededed; border-color: #454545; }\n .copy.copied { color: #62c073; border-color: #16271c; }\n .body { display: flex; flex: 1; min-height: 0; }\n .main { flex: 1; min-width: 0; overflow: auto; padding: 10px 12px; }\n .markdown {\n margin: 0;\n white-space: pre-wrap;\n word-break: break-word;\n font-size: 12px;\n }\n .side {\n width: 240px;\n flex-shrink: 0;\n overflow: auto;\n padding: 10px 12px;\n border-left: 1px solid #1f1f1f;\n }\n .sectionTitle {\n color: #666;\n font-size: 10px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n margin: 0 0 6px;\n }\n .sectionTitle + .row { margin-top: 0; }\n .row + .sectionTitle { margin-top: 16px; }\n .summary + .sectionTitle, .empty + .sectionTitle { margin-top: 16px; }\n .row {\n display: flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n padding: 3px 6px;\n margin: 0 -6px;\n border-radius: 6px;\n }\n .row.hoverable:hover { background: #1a1a1a; }\n .rowTitle {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .badge {\n flex-shrink: 0;\n padding: 1px 6px;\n border-radius: 999px;\n background: #1f1f1f;\n color: #999;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n .badge.authored { background: #10233d; color: #52a8ff; }\n .badge.plugin { background: #16271c; color: #62c073; }\n .summary { margin-top: 8px; color: #666; font-size: 11px; }\n .empty { color: #666; padding: 2px 0 4px; }\n .error { padding: 12px; color: #ff6166; }\n `;\n return style;\n}\n"],"mappings":"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"}
import { c as Root, i as InlineContent, l as RootContent, o as Nodes, t as DomMdMetadata } from "./mdast-CsrWVudR.js";
import { _ as RenderElementPlugin, a as ElementAnnotation, b as TransformPlugin, c as MarkdownNode, d as MarkdownRenderer, f as MarkdownTransformContext, g as RenderElementContext, h as PostprocessPlugin, i as DomMdKind, l as MarkdownPlugin, m as PostprocessContext, n as AnnotateElementPlugin, o as ElementContent, p as MarkdownTree, r as DomMarkdownError, s as ElementOutput, t as AnnotateElementContext, u as MarkdownPluginInput, v as RenderMarkdownOptions, y as RenderObserver } from "./types-AEJT1bfE.js";
//#region src/core/create-renderer.d.ts
declare function createMarkdownRenderer(options?: RenderMarkdownOptions, observer?: RenderObserver): MarkdownRenderer;
//#endregion
//#region src/core/serialize.d.ts
declare function renderMarkdown(root?: ParentNode | null, options?: RenderMarkdownOptions): string;
declare function toMarkdownTree(root?: ParentNode | null, options?: RenderMarkdownOptions): MarkdownTree;
declare function stringifyMarkdown(tree: MarkdownTree, options?: RenderMarkdownOptions): string;
//#endregion
export { type AnnotateElementContext, type AnnotateElementPlugin, DomMarkdownError, type DomMdKind, type DomMdMetadata, type ElementAnnotation, type ElementContent, type ElementOutput, type InlineContent, type MarkdownNode, type MarkdownPlugin, type MarkdownPluginInput, type MarkdownRenderer, type MarkdownTransformContext, type MarkdownTree, type Nodes as MdastNode, type Root as MdastRoot, type PostprocessContext, type PostprocessPlugin, type RenderElementContext, type RenderElementPlugin, type RenderMarkdownOptions, type RootContent, type TransformPlugin, createMarkdownRenderer, renderMarkdown, stringifyMarkdown, toMarkdownTree };
//# sourceMappingURL=index.d.ts.map
import { a as createMarkdownRenderer, i as toMarkdownTree, r as stringifyMarkdown, t as renderMarkdown } from "./serialize-CVJfCHsE.js";
import { n as DomMarkdownError } from "./types-ue0OR47d.js";
export { DomMarkdownError, createMarkdownRenderer, renderMarkdown, stringifyMarkdown, toMarkdownTree };
//#region src/md.d.ts
declare const md: {
join(parts: Array<string | null | undefined | false>): string;
heading(text: string, level?: number): string;
code(value: string): string;
codeBlock(value: string, language?: string): string;
list(items: string[], ordered?: boolean): string;
link(text: string, href: string): string;
table({
columns,
rows
}: {
columns: string[];
rows: string[][];
}): string;
children(token?: string): string;
};
type MarkdownHelpers = typeof md;
//#endregion
export { MarkdownHelpers, md };
//# sourceMappingURL=md.d.ts.map
import { c as escapeTableCell, l as inlineCode, r as codeBlock, s as escapeMarkdownText } from "./utils-CVpYLNBk.js";
import { t as DEFAULT_CHILDREN_TOKEN } from "./types-ue0OR47d.js";
//#region src/md.ts
const md = {
join(parts) {
return parts.filter(Boolean).join("\n\n");
},
heading(text, level = 1) {
return `${"#".repeat(Math.max(1, Math.min(6, level)))} ${text}`;
},
code(value) {
return inlineCode(value);
},
codeBlock(value, language = "") {
return codeBlock(value, language);
},
list(items, ordered = false) {
return items.map((item, index) => `${ordered ? `${index + 1}.` : "-"} ${item}`).join("\n");
},
link(text, href) {
return `[${escapeMarkdownText(text)}](${href})`;
},
table({ columns, rows }) {
return [
`| ${columns.map(escapeTableCell).join(" | ")} |`,
`| ${columns.map(() => "---").join(" | ")} |`,
...rows.map((row) => `| ${row.map(escapeTableCell).join(" | ")} |`)
].join("\n");
},
children(token = DEFAULT_CHILDREN_TOKEN) {
return token;
}
};
//#endregion
export { md };
//# sourceMappingURL=md.js.map
{"version":3,"file":"md.js","names":["formatInlineCode","formatCodeBlock"],"sources":["../src/md.ts"],"sourcesContent":["import { DEFAULT_CHILDREN_TOKEN } from './core/types';\nimport {\n codeBlock as formatCodeBlock,\n escapeMarkdownText,\n escapeTableCell,\n inlineCode as formatInlineCode,\n} from './core/utils';\n\nexport const md = {\n join(parts: Array<string | null | undefined | false>): string {\n return parts.filter(Boolean).join('\\n\\n');\n },\n\n heading(text: string, level = 1): string {\n return `${'#'.repeat(Math.max(1, Math.min(6, level)))} ${text}`;\n },\n\n code(value: string): string {\n return formatInlineCode(value);\n },\n\n codeBlock(value: string, language = ''): string {\n return formatCodeBlock(value, language);\n },\n\n list(items: string[], ordered = false): string {\n return items\n .map((item, index) => `${ordered ? `${index + 1}.` : '-'} ${item}`)\n .join('\\n');\n },\n\n link(text: string, href: string): string {\n return `[${escapeMarkdownText(text)}](${href})`;\n },\n\n table({ columns, rows }: { columns: string[]; rows: string[][] }): string {\n return [\n `| ${columns.map(escapeTableCell).join(' | ')} |`,\n `| ${columns.map(() => '---').join(' | ')} |`,\n ...rows.map((row) => `| ${row.map(escapeTableCell).join(' | ')} |`),\n ].join('\\n');\n },\n\n children(token = DEFAULT_CHILDREN_TOKEN): string {\n return token;\n },\n};\n\nexport type MarkdownHelpers = typeof md;\n"],"mappings":";;;AAQA,MAAa,KAAK;CAChB,KAAK,OAAyD;EAC5D,OAAO,MAAM,OAAO,OAAO,CAAC,CAAC,KAAK,MAAM;CAC1C;CAEA,QAAQ,MAAc,QAAQ,GAAW;EACvC,OAAO,GAAG,IAAI,OAAO,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,KAAK,CAAC,CAAC,EAAE,GAAG;CAC3D;CAEA,KAAK,OAAuB;EAC1B,OAAOA,WAAiB,KAAK;CAC/B;CAEA,UAAU,OAAe,WAAW,IAAY;EAC9C,OAAOC,UAAgB,OAAO,QAAQ;CACxC;CAEA,KAAK,OAAiB,UAAU,OAAe;EAC7C,OAAO,MACJ,KAAK,MAAM,UAAU,GAAG,UAAU,GAAG,QAAQ,EAAE,KAAK,IAAI,GAAG,MAAM,CAAC,CAClE,KAAK,IAAI;CACd;CAEA,KAAK,MAAc,MAAsB;EACvC,OAAO,IAAI,mBAAmB,IAAI,EAAE,IAAI,KAAK;CAC/C;CAEA,MAAM,EAAE,SAAS,QAAyD;EACxE,OAAO;GACL,KAAK,QAAQ,IAAI,eAAe,CAAC,CAAC,KAAK,KAAK,EAAE;GAC9C,KAAK,QAAQ,UAAU,KAAK,CAAC,CAAC,KAAK,KAAK,EAAE;GAC1C,GAAG,KAAK,KAAK,QAAQ,KAAK,IAAI,IAAI,eAAe,CAAC,CAAC,KAAK,KAAK,EAAE,GAAG;EACpE,CAAC,CAAC,KAAK,IAAI;CACb;CAEA,SAAS,QAAQ,wBAAgC;EAC/C,OAAO;CACT;AACF"}
//#region src/core/mdast.d.ts
type DomMdMetadata = {
authored?: boolean;
chunk?: boolean;
kind?: 'action' | 'field' | 'override' | 'visual';
label?: string;
sourceId?: string;
};
type Data<CustomData extends object = Record<string, unknown>> = {
domMd?: DomMdMetadata;
[key: string]: unknown;
} & CustomData;
type Root<CustomData extends object = Record<string, unknown>> = {
type: 'root';
children: RootContent<CustomData>[];
data?: Data<CustomData>;
};
type Paragraph<CustomData extends object = Record<string, unknown>> = {
type: 'paragraph';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
type Heading<CustomData extends object = Record<string, unknown>> = {
type: 'heading';
depth: 1 | 2 | 3 | 4 | 5 | 6;
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
type Text<CustomData extends object = Record<string, unknown>> = {
type: 'text';
value: string;
data?: Data<CustomData>;
};
type Emphasis<CustomData extends object = Record<string, unknown>> = {
type: 'emphasis';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
type Strong<CustomData extends object = Record<string, unknown>> = {
type: 'strong';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
type Link<CustomData extends object = Record<string, unknown>> = {
type: 'link';
url: string;
title?: string | null;
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
type Image<CustomData extends object = Record<string, unknown>> = {
type: 'image';
url: string;
alt?: string | null;
title?: string | null;
data?: Data<CustomData>;
};
type InlineCode<CustomData extends object = Record<string, unknown>> = {
type: 'inlineCode';
value: string;
data?: Data<CustomData>;
};
type Break<CustomData extends object = Record<string, unknown>> = {
type: 'break';
data?: Data<CustomData>;
};
type Code<CustomData extends object = Record<string, unknown>> = {
type: 'code';
value: string;
lang?: string | null;
meta?: string | null;
data?: Data<CustomData>;
};
type Blockquote<CustomData extends object = Record<string, unknown>> = {
type: 'blockquote';
children: BlockContent<CustomData>[];
data?: Data<CustomData>;
};
type List<CustomData extends object = Record<string, unknown>> = {
type: 'list';
ordered?: boolean | null;
start?: number | null;
spread?: boolean;
children: ListItem<CustomData>[];
data?: Data<CustomData>;
};
type ListItem<CustomData extends object = Record<string, unknown>> = {
type: 'listItem';
spread?: boolean;
checked?: boolean | null;
children: BlockContent<CustomData>[];
data?: Data<CustomData>;
};
type Table<CustomData extends object = Record<string, unknown>> = {
type: 'table';
children: TableRow<CustomData>[];
data?: Data<CustomData>;
};
type TableRow<CustomData extends object = Record<string, unknown>> = {
type: 'tableRow';
children: TableCell<CustomData>[];
data?: Data<CustomData>;
};
type TableCell<CustomData extends object = Record<string, unknown>> = {
type: 'tableCell';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
type ThematicBreak<CustomData extends object = Record<string, unknown>> = {
type: 'thematicBreak';
data?: Data<CustomData>;
};
type Yaml<CustomData extends object = Record<string, unknown>> = {
type: 'yaml';
value: string;
data?: Data<CustomData>;
};
type RawMarkdown<CustomData extends object = Record<string, unknown>> = {
type: 'rawMarkdown';
value: string;
data?: Data<CustomData>;
};
type InlineContent<CustomData extends object = Record<string, unknown>> = Break<CustomData> | Emphasis<CustomData> | Image<CustomData> | InlineCode<CustomData> | Link<CustomData> | Strong<CustomData> | Text<CustomData>;
type BlockContent<CustomData extends object = Record<string, unknown>> = Blockquote<CustomData> | Code<CustomData> | Heading<CustomData> | List<CustomData> | Paragraph<CustomData> | RawMarkdown<CustomData> | Table<CustomData> | ThematicBreak<CustomData> | Yaml<CustomData>;
type RootContent<CustomData extends object = Record<string, unknown>> = BlockContent<CustomData>;
type Nodes<CustomData extends object = Record<string, unknown>> = Root<CustomData> | RootContent<CustomData> | InlineContent<CustomData> | ListItem<CustomData> | TableRow<CustomData> | TableCell<CustomData>;
//#endregion
export { Link as a, Root as c, Text as d, InlineContent as i, RootContent as l, Emphasis as n, Nodes as o, Heading as r, Paragraph as s, DomMdMetadata as t, Strong as u };
//# sourceMappingURL=mdast-CsrWVudR.d.ts.map
//#region src/next.d.ts
type MarkdownRouteParams = {
path?: string[];
};
type MarkdownRouteHandlerContext = {
params: MarkdownRouteParams | Promise<MarkdownRouteParams>;
};
type MarkdownRouteRenderer = (context: {
document: Document;
pageUrl: URL;
}) => Promise<Response | string> | Response | string;
type MarkdownRouteOptions = {
parser: {
parseFromString(html: string, mimeType: 'text/html'): unknown;
};
fetch?: typeof fetch;
maxHtmlBytes?: number;
maxRedirects?: number;
timeoutMs?: number;
};
declare function createMarkdownRoute(render: MarkdownRouteRenderer, options: MarkdownRouteOptions): (request: Request, context: MarkdownRouteHandlerContext) => Promise<Response>;
//#endregion
export { createMarkdownRoute };
//# sourceMappingURL=next.d.ts.map
//#region src/next.ts
const DEFAULT_MAX_HTML_BYTES = 2e6;
const DEFAULT_MAX_REDIRECTS = 5;
const DEFAULT_TIMEOUT_MS = 1e4;
function createMarkdownRoute(render, options) {
const fetchPage = options.fetch ?? globalThis.fetch;
const parser = options.parser;
const maxHtmlBytes = options.maxHtmlBytes ?? DEFAULT_MAX_HTML_BYTES;
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!fetchPage) throw new Error("createMarkdownRoute requires a global fetch implementation.");
return async (request, routeContext) => {
if (request.method !== "GET") return markdownError("Method not allowed.", 405, { Allow: "GET" });
const pageUrl = pageUrlFromRequest(request, (await routeContext.params).path ?? []);
let pageResponse;
let html;
try {
pageResponse = await fetchSameOriginPage(pageUrl, request, {
fetchPage,
maxRedirects,
timeoutMs
});
html = await readHtml(pageResponse, maxHtmlBytes);
} catch (error) {
if (error instanceof MarkdownRouteError) return markdownError(error.message, error.status);
return markdownError("Could not load the page HTML.", 502);
}
return markdownResponse(await render({
document: parser.parseFromString(html, "text/html"),
pageUrl
}), pageResponse.status, pageUrl);
};
}
function pageUrlFromRequest(request, path) {
const requestUrl = new URL(request.url);
const pageUrl = new URL(requestUrl);
pageUrl.pathname = path.length === 0 ? "/" : `/${path.map((segment) => encodeURIComponent(segment)).join("/")}`;
return pageUrl;
}
async function fetchSameOriginPage(initialUrl, request, options) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
let currentUrl = initialUrl;
try {
for (let redirectCount = 0;; redirectCount += 1) {
const response = await options.fetchPage(currentUrl, {
cache: "no-store",
headers: pageRequestHeaders(request),
redirect: "manual",
signal: controller.signal
});
if (!isRedirect(response.status)) return response;
if (redirectCount >= options.maxRedirects) throw new MarkdownRouteError("Too many redirects.", 502);
const location = response.headers.get("location");
if (!location) throw new MarkdownRouteError("The page returned a redirect without a location.", 502);
const nextUrl = new URL(location, currentUrl);
if (nextUrl.origin !== initialUrl.origin) throw new MarkdownRouteError("The page redirected outside the application origin.", 502);
currentUrl = nextUrl;
}
} catch (error) {
if (error instanceof MarkdownRouteError) throw error;
if (controller.signal.aborted) throw new MarkdownRouteError("The page render timed out.", 504);
throw error;
} finally {
clearTimeout(timeout);
}
}
function pageRequestHeaders(request) {
const headers = new Headers({ Accept: "text/html" });
for (const name of [
"accept-language",
"authorization",
"cookie",
"user-agent"
]) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
return headers;
}
async function readHtml(response, maxHtmlBytes) {
const contentType = response.headers.get("content-type")?.toLowerCase();
if (contentType && !contentType.includes("text/html") && !contentType.includes("application/xhtml+xml")) throw new MarkdownRouteError(`Expected an HTML response, received ${contentType}.`, 502);
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxHtmlBytes) throw new MarkdownRouteError("The page HTML is too large to render.", 413);
const bytes = await response.arrayBuffer();
if (bytes.byteLength > maxHtmlBytes) throw new MarkdownRouteError("The page HTML is too large to render.", 413);
return new TextDecoder().decode(bytes);
}
function markdownResponse(result, pageStatus, pageUrl) {
const response = typeof result === "string" ? new Response(result, {
status: pageStatus,
headers: { "content-type": "text/markdown; charset=utf-8" }
}) : new Response(result.body, {
headers: result.headers,
status: result.status,
statusText: result.statusText
});
if (!response.headers.has("content-type")) response.headers.set("content-type", "text/markdown; charset=utf-8");
response.headers.set("content-location", pageUrl.href);
response.headers.set("x-robots-tag", "noindex");
appendVary(response.headers, "Accept");
if (!response.headers.has("cache-control")) response.headers.set("cache-control", "private, no-store");
return response;
}
function appendVary(headers, value) {
const existing = headers.get("vary");
const values = new Set(existing?.split(",").map((entry) => entry.trim()).filter(Boolean));
values.add(value);
headers.set("vary", Array.from(values).join(", "));
}
function isRedirect(status) {
return status >= 300 && status < 400;
}
function markdownError(message, status, headers) {
return new Response(`# Markdown unavailable\n\n${message}\n`, {
status,
headers: {
"cache-control": "private, no-store",
"content-type": "text/markdown; charset=utf-8",
vary: "Accept",
"x-robots-tag": "noindex",
...headers
}
});
}
var MarkdownRouteError = class extends Error {
status;
constructor(message, status) {
super(message);
this.name = "MarkdownRouteError";
this.status = status;
}
};
//#endregion
export { createMarkdownRoute };
//# sourceMappingURL=next.js.map
{"version":3,"file":"next.js","names":[],"sources":["../src/next.ts"],"sourcesContent":["const DEFAULT_MAX_HTML_BYTES = 2_000_000;\nconst DEFAULT_MAX_REDIRECTS = 5;\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\ntype MarkdownRouteParams = {\n path?: string[];\n};\n\ntype MarkdownRouteHandlerContext = {\n params: MarkdownRouteParams | Promise<MarkdownRouteParams>;\n};\n\ntype MarkdownRouteRenderer = (context: {\n document: Document;\n pageUrl: URL;\n}) => Promise<Response | string> | Response | string;\n\ntype MarkdownRouteOptions = {\n parser: {\n parseFromString(html: string, mimeType: 'text/html'): unknown;\n };\n fetch?: typeof fetch;\n maxHtmlBytes?: number;\n maxRedirects?: number;\n timeoutMs?: number;\n};\n\nexport function createMarkdownRoute(\n render: MarkdownRouteRenderer,\n options: MarkdownRouteOptions,\n): (\n request: Request,\n context: MarkdownRouteHandlerContext,\n) => Promise<Response> {\n const fetchPage = options.fetch ?? globalThis.fetch;\n const parser = options.parser;\n const maxHtmlBytes = options.maxHtmlBytes ?? DEFAULT_MAX_HTML_BYTES;\n const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n if (!fetchPage) {\n throw new Error('createMarkdownRoute requires a global fetch implementation.');\n }\n\n return async (request, routeContext) => {\n if (request.method !== 'GET') {\n return markdownError('Method not allowed.', 405, {\n Allow: 'GET',\n });\n }\n\n const params = await routeContext.params;\n const pageUrl = pageUrlFromRequest(request, params.path ?? []);\n let pageResponse: Response;\n let html: string;\n\n try {\n pageResponse = await fetchSameOriginPage(pageUrl, request, {\n fetchPage,\n maxRedirects,\n timeoutMs,\n });\n html = await readHtml(pageResponse, maxHtmlBytes);\n } catch (error) {\n if (error instanceof MarkdownRouteError) {\n return markdownError(error.message, error.status);\n }\n return markdownError('Could not load the page HTML.', 502);\n }\n\n const document = parser.parseFromString(html, 'text/html') as Document;\n const result = await render({ document, pageUrl });\n\n return markdownResponse(result, pageResponse.status, pageUrl);\n };\n}\n\nfunction pageUrlFromRequest(request: Request, path: string[]): URL {\n const requestUrl = new URL(request.url);\n const pageUrl = new URL(requestUrl);\n pageUrl.pathname =\n path.length === 0\n ? '/'\n : `/${path.map((segment) => encodeURIComponent(segment)).join('/')}`;\n return pageUrl;\n}\n\nasync function fetchSameOriginPage(\n initialUrl: URL,\n request: Request,\n options: {\n fetchPage: typeof fetch;\n maxRedirects: number;\n timeoutMs: number;\n },\n): Promise<Response> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), options.timeoutMs);\n let currentUrl = initialUrl;\n\n try {\n for (let redirectCount = 0; ; redirectCount += 1) {\n const response = await options.fetchPage(currentUrl, {\n cache: 'no-store',\n headers: pageRequestHeaders(request),\n redirect: 'manual',\n signal: controller.signal,\n });\n\n if (!isRedirect(response.status)) return response;\n if (redirectCount >= options.maxRedirects) {\n throw new MarkdownRouteError('Too many redirects.', 502);\n }\n\n const location = response.headers.get('location');\n if (!location) {\n throw new MarkdownRouteError(\n 'The page returned a redirect without a location.',\n 502,\n );\n }\n\n const nextUrl = new URL(location, currentUrl);\n if (nextUrl.origin !== initialUrl.origin) {\n throw new MarkdownRouteError(\n 'The page redirected outside the application origin.',\n 502,\n );\n }\n\n currentUrl = nextUrl;\n }\n } catch (error) {\n if (error instanceof MarkdownRouteError) throw error;\n if (controller.signal.aborted) {\n throw new MarkdownRouteError('The page render timed out.', 504);\n }\n throw error;\n } finally {\n clearTimeout(timeout);\n }\n}\n\nfunction pageRequestHeaders(request: Request): Headers {\n const headers = new Headers({\n Accept: 'text/html',\n });\n\n for (const name of [\n 'accept-language',\n 'authorization',\n 'cookie',\n 'user-agent',\n ]) {\n const value = request.headers.get(name);\n if (value) headers.set(name, value);\n }\n\n return headers;\n}\n\nasync function readHtml(\n response: Response,\n maxHtmlBytes: number,\n): Promise<string> {\n const contentType = response.headers.get('content-type')?.toLowerCase();\n if (\n contentType &&\n !contentType.includes('text/html') &&\n !contentType.includes('application/xhtml+xml')\n ) {\n throw new MarkdownRouteError(\n `Expected an HTML response, received ${contentType}.`,\n 502,\n );\n }\n\n const declaredLength = Number(response.headers.get('content-length'));\n if (Number.isFinite(declaredLength) && declaredLength > maxHtmlBytes) {\n throw new MarkdownRouteError('The page HTML is too large to render.', 413);\n }\n\n const bytes = await response.arrayBuffer();\n if (bytes.byteLength > maxHtmlBytes) {\n throw new MarkdownRouteError('The page HTML is too large to render.', 413);\n }\n\n return new TextDecoder().decode(bytes);\n}\n\nfunction markdownResponse(\n result: Response | string,\n pageStatus: number,\n pageUrl: URL,\n): Response {\n const response =\n typeof result === 'string'\n ? new Response(result, {\n status: pageStatus,\n headers: {\n 'content-type': 'text/markdown; charset=utf-8',\n },\n })\n : new Response(result.body, {\n headers: result.headers,\n status: result.status,\n statusText: result.statusText,\n });\n\n if (!response.headers.has('content-type')) {\n response.headers.set('content-type', 'text/markdown; charset=utf-8');\n }\n response.headers.set('content-location', pageUrl.href);\n response.headers.set('x-robots-tag', 'noindex');\n appendVary(response.headers, 'Accept');\n\n if (!response.headers.has('cache-control')) {\n response.headers.set('cache-control', 'private, no-store');\n }\n\n return response;\n}\n\nfunction appendVary(headers: Headers, value: string): void {\n const existing = headers.get('vary');\n const values = new Set(\n existing\n ?.split(',')\n .map((entry) => entry.trim())\n .filter(Boolean),\n );\n values.add(value);\n headers.set('vary', Array.from(values).join(', '));\n}\n\nfunction isRedirect(status: number): boolean {\n return status >= 300 && status < 400;\n}\n\nfunction markdownError(\n message: string,\n status: number,\n headers?: HeadersInit,\n): Response {\n return new Response(`# Markdown unavailable\\n\\n${message}\\n`, {\n status,\n headers: {\n 'cache-control': 'private, no-store',\n 'content-type': 'text/markdown; charset=utf-8',\n vary: 'Accept',\n 'x-robots-tag': 'noindex',\n ...headers,\n },\n });\n}\n\nclass MarkdownRouteError extends Error {\n readonly status: number;\n\n constructor(message: string, status: number) {\n super(message);\n this.name = 'MarkdownRouteError';\n this.status = status;\n }\n}\n"],"mappings":"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"}
import { _ as RenderElementPlugin, a as ElementAnnotation, b as TransformPlugin, c as MarkdownNode, f as MarkdownTransformContext, g as RenderElementContext, h as PostprocessPlugin, l as MarkdownPlugin, m as PostprocessContext, n as AnnotateElementPlugin, o as ElementContent, p as MarkdownTree, s as ElementOutput, t as AnnotateElementContext, u as MarkdownPluginInput } from "../types-AEJT1bfE.js";
//#region src/plugins/budget.d.ts
type BudgetOptions = {
/**
* Maximum number of Markdown characters to keep after compaction.
*
* The budget plugin first tries gentle summaries and only falls back to more
* aggressive truncation when the rendered Markdown is still too long.
*/
maxChars: number;
};
/**
* Add a transform plugin that keeps the final Markdown under a character budget.
*
* This is useful when passing page snapshots to tools or models with a known
* context limit. The plugin preserves headings and representative structure
* before truncating individual blocks.
*/
declare function budget(options: BudgetOptions): TransformPlugin;
//#endregion
//#region src/plugins/authoring.d.ts
type CustomData = Record<string, unknown>;
type AnonymousAnnotateElementPlugin<Data extends object = CustomData> = Omit<AnnotateElementPlugin<Data>, 'id'> & {
id?: string;
};
type AnonymousRenderElementPlugin<Data extends object = CustomData> = Omit<RenderElementPlugin<Data>, 'id'> & {
id?: string;
};
type AnonymousTransformPlugin<Data extends object = CustomData> = Omit<TransformPlugin<Data>, 'id'> & {
id?: string;
};
type AnonymousPostprocessPlugin<Data extends object = CustomData> = Omit<PostprocessPlugin<Data>, 'id'> & {
id?: string;
};
type PluginStep<Data extends object = CustomData> = AnonymousAnnotateElementPlugin<Data> | AnonymousPostprocessPlugin<Data> | AnonymousRenderElementPlugin<Data> | AnonymousTransformPlugin<Data> | MarkdownPlugin<Data>;
type AnnotateElementHelper<Data extends object> = {
(annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>): AnonymousAnnotateElementPlugin<Data>;
(id: string, annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>): AnnotateElementPlugin<Data>;
};
type RenderElementHelper<Data extends object> = {
(render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>): AnonymousRenderElementPlugin<Data>;
(id: string, render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>): RenderElementPlugin<Data>;
};
type TransformHelper<Data extends object> = {
(transform: (tree: MarkdownTree<Data>, context: MarkdownTransformContext<Data>) => MarkdownTree<Data> | void): AnonymousTransformPlugin<Data>;
(id: string, transform: (tree: MarkdownTree<Data>, context: MarkdownTransformContext<Data>) => MarkdownTree<Data> | void): TransformPlugin<Data>;
};
type PostprocessHelper<Data extends object> = {
(postprocess: (markdown: string, context: PostprocessContext<Data>) => string): AnonymousPostprocessPlugin<Data>;
(id: string, postprocess: (markdown: string, context: PostprocessContext<Data>) => string): PostprocessPlugin<Data>;
};
type DataHelper<Data extends object> = {
<Node extends MarkdownNode<Data>>(node: Node, metadata: Data & {
domMd?: never;
}): Node;
<Node extends ElementContent<Data>>(nodes: readonly Node[], metadata: Data & {
domMd?: never;
}): Node[];
};
type PluginAuthoringApi<Data extends object = CustomData> = {
/** Create a DOM annotation step typed with this plugin's custom metadata. */annotateElement: AnnotateElementHelper<Data>; /** Create a DOM rendering step typed with this plugin's custom metadata. */
renderElement: RenderElementHelper<Data>; /** Create a transform plugin step typed with this plugin's custom metadata. */
transform: TransformHelper<Data>; /** Create a postprocess plugin step typed with this plugin's custom metadata. */
postprocess: PostprocessHelper<Data>; /** Attach custom plugin metadata to one node or an array of nodes. */
data: DataHelper<Data>;
};
/**
* Create a composed plugin from one or more typed plugin steps.
*
* The returned value is a plain array of plugins, so it can be placed directly
* inside `plugins: [...]` without spreading. Step ids are prefixed with the
* plugin id, e.g. `createPlugin('callouts', ({ renderElement }) => [
* renderElement('aside', fn),
* ])` creates the plugin id `callouts:aside`.
*/
declare function createPlugin<Data extends object = CustomData>(id: string, define: (api: PluginAuthoringApi<Data>) => readonly PluginStep<Data>[]): MarkdownPlugin<Data>[];
//#endregion
//#region src/plugins/frontmatter.d.ts
/** A scalar value that can be written into generated YAML frontmatter. */
type FrontmatterScalar = string | number | boolean | null;
/** A generated frontmatter value: either one scalar or a YAML list of scalars. */
type FrontmatterValue = FrontmatterScalar | FrontmatterScalar[];
/**
* Key/value metadata to prepend to the Markdown document.
*
* Set a key to `undefined` to omit it from the generated YAML block.
*/
type Frontmatter = Record<string, FrontmatterValue | undefined>;
/**
* Static frontmatter, or a function that is read at render time.
*
* Use a function when metadata depends on live browser state such as
* `document.title` or `location.href`.
*/
type FrontmatterSource = Frontmatter | (() => Frontmatter);
/**
* Add a transform plugin that prepends YAML frontmatter to the document.
*
* The plugin runs after DOM extraction, so it can add page-level metadata
* without changing the source DOM.
*/
declare function frontmatter(source: FrontmatterSource): TransformPlugin;
//#endregion
//#region src/plugins/outline.d.ts
type OutlineMarkdownOptions = {
/** Try to keep the generated outline under this many Markdown characters. */maxChars?: number; /** Maximum number of summary bullets to keep in each section. */
maxItemsPerSection?: number; /** Minimum number of summary bullets to keep before falling back to headings only. */
minItemsPerSection?: number; /** Maximum length of text snippets used inside outline bullets. */
textSnippetChars?: number;
};
/**
* Add a transform plugin that replaces the document with a compact outline.
*
* Use this when the shape of a page matters more than its full text, such as
* quick audits, navigation maps, or low-token previews.
*/
declare function outline(options?: OutlineMarkdownOptions): TransformPlugin;
//#endregion
//#region src/plugins/redact-secrets.d.ts
type RedactSecretsReplacement = string | ((value: string) => string);
type RedactSecretsOptions = {
/**
* Additional regex patterns to redact after dom-md's built-in secret
* patterns. Built-in patterns always remain enabled.
*/
patterns?: readonly RegExp[];
/**
* Replacement used for automatic and manual redaction. Functions receive the
* matched secret, URL password, or `data-md-redact` element text.
*/
replacement?: RedactSecretsReplacement;
};
/**
* Redact known sensitive secret formats from Markdown capture output.
*
* The plugin redacts structured Markdown tree fields before serialization and
* runs one final string pass during `render()`. Credentialed URLs preserve
* their scheme, username, host, and path while replacing the password. Add
* `data-md-redact` to a DOM element to replace that element manually.
*
* Combining `data-md-redact` with a `data-md` override on the same element is
* an error: authored overrides bypass manual redaction, so remove the
* sensitive content from the override instead. Built-in and custom patterns
* still scan authored override content.
*/
declare function redactSecrets(options?: RedactSecretsOptions): MarkdownPlugin[];
/** Redact known sensitive secret formats from a string. */
declare function redactSecretsFromString(value: string, options?: RedactSecretsOptions): string;
//#endregion
export { type AnnotateElementContext, type AnnotateElementPlugin, type BudgetOptions, type ElementAnnotation, type Frontmatter, type FrontmatterScalar, type FrontmatterSource, type FrontmatterValue, type MarkdownPlugin, type MarkdownPluginInput, type OutlineMarkdownOptions, type PluginAuthoringApi, type PostprocessContext, type PostprocessPlugin, type RedactSecretsOptions, type RedactSecretsReplacement, type RenderElementContext, type RenderElementPlugin, type TransformPlugin, budget, createPlugin, frontmatter, outline, redactSecrets, redactSecretsFromString };
//# sourceMappingURL=index.d.ts.map
import { i as collapseWhitespace, n as clampMarkdownWithNotice, t as clampInline } from "../utils-CVpYLNBk.js";
import { t as createPlugin } from "../authoring-1IXjCaLp.js";
import { visit } from "../tree.js";
//#region src/plugins/budget.ts
/**
* Add a transform plugin that keeps the final Markdown under a character budget.
*
* This is useful when passing page snapshots to tools or models with a known
* context limit. The plugin preserves headings and representative structure
* before truncating individual blocks.
*/
function budget(options) {
if (!Number.isFinite(options.maxChars) || options.maxChars <= 0) throw new Error("budget maxChars must be a positive number.");
const maxChars = Math.floor(options.maxChars);
const [plugin] = createPlugin("budget", ({ transform }) => [transform((tree, context) => {
if (context.stringify(tree).length <= maxChars) return tree;
for (const level of [
0,
1,
2
]) {
const candidate = compactTree(tree, level);
if (context.stringify(candidate).length <= maxChars) return candidate;
}
return emergencyTree(tree, maxChars, context.stringify);
})]);
return plugin;
}
function compactTree(tree, level) {
const children = [budgetNotice()];
for (const node of tree.children) children.push(...compactNode(node, level));
return {
type: "root",
children
};
}
function compactNode(node, level) {
if (node.type === "heading" || node.type === "yaml") return [clone(node)];
if (node.type === "paragraph") return [compactParagraph(node, [
180,
80,
36
][level])];
if (node.type === "rawMarkdown") return [compactRawMarkdown(node, [
180,
80,
36
][level])];
if (node.type === "table") return compactTable(node, level);
if (node.type === "list") return [compactList(node, level)];
if (node.type === "code") return compactCode(node, level);
return level === 2 ? [] : [clone(node)];
}
function compactRawMarkdown(node, maxChars) {
if (node.value.length <= maxChars) return clone(node);
const [firstLine = "", ...restLines] = node.value.split("\n");
const heading = /^#{1,6}\s+/.test(firstLine.trim()) ? firstLine.trim() : "";
if (!heading) return {
...clone(node),
value: clampInline(collapseRawMarkdown(node.value), maxChars)
};
const remaining = Math.max(0, maxChars - heading.length - 2);
const rest = collapseRawMarkdown(restLines.join("\n"));
return {
...clone(node),
value: rest && remaining > 1 ? `${heading}\n\n${clampInline(rest, remaining)}` : heading
};
}
function compactParagraph(node, maxChars) {
const value = plainText(node);
if (value.length <= maxChars) return clone(node);
return {
type: "paragraph",
children: [
{
type: "text",
value: clampInline(value, maxChars)
},
{
type: "text",
value: " "
},
{
type: "emphasis",
children: [{
type: "text",
value: "Budget: Paragraph truncated."
}]
}
]
};
}
function compactTable(node, level) {
const header = node.children[0];
if (!header) return [];
const body = node.children.slice(1);
const count = [
6,
3,
2
][level];
const kept = representative(body, count);
const table = {
...clone(node),
children: [clone(header), ...kept.map(clone)]
};
const omitted = body.length - kept.length;
return omitted > 0 ? [table, marker(`${omitted} table row${omitted === 1 ? "" : "s"} omitted.`)] : [table];
}
function compactList(node, level) {
const count = [
8,
6,
2
][level];
const kept = level === 0 ? [...node.children.slice(0, Math.max(0, count - 1)), ...node.children.slice(-1)] : node.children.slice(0, count);
const summarized = kept.map((item) => summarizeListItem(item, level));
const omitted = node.children.length - kept.length;
const summaryCount = kept.filter((item, index) => plainText(item) !== plainText(summarized[index])).length;
if (omitted || summaryCount) {
const parts = [omitted ? `${omitted} list items omitted` : "", summaryCount ? `${summaryCount} list items summarized` : ""].filter(Boolean).join("; ");
summarized.push({
type: "listItem",
children: [{
type: "paragraph",
children: [{
type: "emphasis",
children: [{
type: "text",
value: `Budget: ${parts}.`
}]
}]
}]
});
}
return {
...clone(node),
children: summarized
};
}
function summarizeListItem(item, level) {
const first = item.children[0];
const firstHasStrong = first?.type === "paragraph" && first.children.some((child) => child.type === "strong");
if (level > 0 && first && firstHasStrong) return {
...clone(item),
children: [clone(first)]
};
if (level === 0) {
const field = item.children.find((child) => child.type === "paragraph" && child.data?.domMd?.kind === "field");
return {
...clone(item),
children: [first, field].filter(Boolean).map((child) => clone(child))
};
}
return {
type: "listItem",
children: [{
type: "paragraph",
children: [{
type: "text",
value: clampInline(plainText(item), level === 1 ? 64 : 42)
}]
}]
};
}
function compactCode(node, level) {
const lines = node.value.split("\n");
const count = [
10,
5,
2
][level];
if (lines.length <= count) return [clone(node)];
return [{
...clone(node),
value: lines.slice(0, count).join("\n")
}, marker(`${lines.length - count} code lines omitted.`)];
}
function budgetNotice() {
return {
type: "paragraph",
children: [{
type: "strong",
children: [{
type: "text",
value: "Budgeted output (incomplete):"
}]
}]
};
}
function marker(value) {
return {
type: "paragraph",
children: [{
type: "emphasis",
children: [{
type: "text",
value: `Budget: ${value}`
}]
}]
};
}
function emergencyTree(tree, maxChars, stringify) {
const headings = tree.children.filter((node) => node.type === "heading");
let children = [budgetNotice(), ...headings.map(clone)];
let result = {
type: "root",
children
};
while (headings.length && stringify(result).length > maxChars) {
headings.pop();
children = [budgetNotice(), ...headings.map(clone)];
result = {
type: "root",
children
};
}
if (stringify(result).length <= maxChars) return result;
return {
type: "root",
children: [{
type: "paragraph",
children: [{
type: "text",
value: "Budgeted output".slice(0, maxChars)
}]
}]
};
}
function representative(items, count) {
if (items.length <= count) return items;
if (count <= 1) return items.slice(0, count);
return [
items[0],
...items.slice(1, count - 1),
items.at(-1)
];
}
function plainText(node) {
if (!node || typeof node !== "object") return "";
if ("value" in node && typeof node.value === "string") return node.value;
if ("children" in node && Array.isArray(node.children)) return node.children.map(plainText).filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
return "";
}
function collapseRawMarkdown(value) {
return value.replace(/\s+/g, " ").trim();
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
//#endregion
//#region src/plugins/frontmatter.ts
/**
* Add a transform plugin that prepends YAML frontmatter to the document.
*
* The plugin runs after DOM extraction, so it can add page-level metadata
* without changing the source DOM.
*/
function frontmatter(source) {
const [plugin] = createPlugin("frontmatter", ({ transform }) => [transform((tree) => {
const value = serializeFrontmatter(typeof source === "function" ? source() : source);
if (!value) return tree;
const yaml = {
type: "yaml",
value
};
return {
...tree,
children: [yaml, ...tree.children]
};
})]);
return plugin;
}
function serializeFrontmatter(frontmatter) {
return Object.entries(frontmatter).filter((entry) => entry[1] !== void 0).map(([key, value]) => serializeEntry(key, value)).join("\n");
}
function serializeEntry(key, value) {
const serializedKey = serializeKey(key);
if (Array.isArray(value)) {
if (!value.length) return `${serializedKey}: []`;
return [`${serializedKey}:`, ...value.map((item) => ` - ${serializeScalar(item)}`)].join("\n");
}
return `${serializedKey}: ${serializeScalar(value)}`;
}
function serializeScalar(value) {
if (value === null) return "null";
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
function serializeKey(key) {
return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(key) ? key : JSON.stringify(key);
}
//#endregion
//#region src/plugins/outline.ts
/**
* Add a transform plugin that replaces the document with a compact outline.
*
* Use this when the shape of a page matters more than its full text, such as
* quick audits, navigation maps, or low-token previews.
*/
function outline(options = {}) {
const [plugin] = createPlugin("outline", ({ transform }) => [transform((tree, context) => {
const markdown = renderSections(toSections(tree.children.flatMap((node) => describe(node, context, options))), options);
const outlined = rawMarkdownRoot(markdown);
if (options.maxChars && context.stringify(outlined).length > options.maxChars) return rawMarkdownRoot(headingsOnly(markdown));
return outlined;
})]);
return plugin;
}
function rawMarkdownRoot(value) {
return value.trim() ? {
type: "root",
children: [{
type: "rawMarkdown",
value
}]
} : {
type: "root",
children: []
};
}
function headingsOnly(markdown) {
return markdown.split("\n").filter((line) => /^#{1,6}\s+/.test(line)).join("\n\n");
}
function describe(node, context, options) {
const markdown = context.stringify(node);
if (!markdown) return [];
if (node.type === "heading") return [{
kind: "heading",
text: markdown
}];
const metadata = node.data?.domMd;
if (metadata?.authored) return [{
kind: "item",
priority: 4,
text: `- Override: ${snippet(markdown, 100)}`
}];
if (metadata?.kind === "action") return [];
if (metadata?.kind === "field") return [{
kind: "item",
priority: 3,
text: `- ${metadata.label === "Fields" ? "Fields" : "Field"}: ${collapseWhitespace(markdown)}`
}];
if (metadata?.kind === "visual") return [{
kind: "item",
priority: 2,
text: `- Visual: ${trimVisual(markdown)}`
}];
if (node.type === "table") {
const columns = (node.children[0]?.children ?? []).map((cell) => cellText(cell, context)).filter(Boolean);
const count = Math.max(0, node.children.length - 1);
return [{
kind: "item",
priority: 3,
text: `- Table: ${columns.join(", ") || "unlabeled"} (${count} row${count === 1 ? "" : "s"})`
}];
}
if (node.type === "list") {
const first = node.children[0] ? snippet(context.stringify(node.children[0]), options.textSnippetChars ?? 120) : "";
return [{
kind: "item",
priority: 3,
text: `- List (${node.children.length} items)${first ? `: ${first.replace(/^[-*+]\s+/, "")}` : ""}`
}];
}
if (node.type === "code") return [{
kind: "item",
priority: 3,
text: `- Code block (${node.value ? node.value.split("\n").length : 0} lines)`
}];
if (node.type === "paragraph") {
const text = snippet(markdown, options.textSnippetChars ?? 120);
return text ? [{
kind: "item",
priority: 1,
text: `- Text: ${text}`
}] : [];
}
return [];
}
function toSections(entries) {
const sections = [{ items: [] }];
let current = sections[0];
for (const entry of entries) if (entry.kind === "heading") {
current = {
heading: entry.text,
items: []
};
if (/^#\s/.test(entry.text) && sections[0]?.items.length) {
current.items.push(...sections[0].items);
sections[0].items = [];
}
sections.push(current);
} else current.items.push(entry);
return sections.filter((section) => section.heading || section.items.length);
}
function renderSections(sections, options) {
const maxItems = options.maxItemsPerSection ?? 4;
const included = sections.map((section) => Math.min(maxItems, section.items.length));
let markdown = serializeSections(sections, included, true);
if (!options.maxChars || markdown.length <= options.maxChars) return markdown;
const minimum = options.minItemsPerSection ?? 1;
for (let index = 0; index < included.length; index += 1) included[index] = Math.min(minimum, sections[index]?.items.length ?? 0);
markdown = serializeSections(sections, included, false);
if (markdown.length <= options.maxChars) return markdown;
return clampMarkdownWithNotice(markdown, Math.max(1, options.maxChars - 48), `[Outline truncated after ${options.maxChars} chars]`);
}
function serializeSections(sections, included, omissions) {
return sections.map((section, index) => {
const lines = section.heading ? [section.heading] : [];
const visible = [...section.items].sort((a, b) => b.priority - a.priority).slice(0, included[index] ?? 0);
lines.push(...visible.map((item) => item.text));
const omitted = section.items.length - visible.length;
if (omissions && omitted) lines.push(`- ${omitted} more item${omitted === 1 ? "" : "s"} omitted`);
return lines.join("\n");
}).filter(Boolean).join("\n\n");
}
function cellText(cell, context) {
return collapseWhitespace(context.stringify({
type: "paragraph",
children: cell.children
}));
}
function snippet(value, maxChars) {
const text = collapseWhitespace(value);
if (text.length <= maxChars) return text;
return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
}
function trimVisual(value) {
return value.replace(/^!?(?:\[|\[.*?\]\()[^:]*:?\s*/, "").replace(/[\])]+$/, "");
}
//#endregion
//#region src/plugins/redact-secrets.ts
const REDACT_ATTR = "data-md-redact";
const MARKDOWN_OVERRIDE_ATTR = "data-md";
const DEFAULT_REPLACEMENT = "[REDACTED]";
const URL_CREDENTIAL_PATTERN = /(\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^:\s/@]*:)([^@\s/]+)(@)/g;
const DEFAULT_SECRET_PATTERNS = [
/vck_[A-Za-z0-9]{20,}(?:\.{3,})?/g,
/gh[opsru]_[A-Za-z0-9_]{20,}/g,
/github_pat_[A-Za-z0-9_]{20,}/g,
/glpat-[A-Za-z0-9_-]{20,}/g,
/(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g,
/whsec_[A-Za-z0-9]{20,}/g,
/xox[abprs]-[A-Za-z0-9-]{20,}/g,
/xapp-[A-Za-z0-9-]{20,}/g,
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
/\bAIza[A-Za-z0-9_-]{30,}/g,
/sk-ant-[A-Za-z0-9_-]{20,}/g,
/sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/g,
/\bsk-[A-Za-z0-9]{32,}\b/g,
/npm_[A-Za-z0-9]{30,}/g,
/\bhf_[A-Za-z0-9]{30,}/g,
/\bre_[A-Za-z0-9_]{30,}/g,
/\bBearer\s+[A-Za-z0-9_\-+=/.]{20,}/gi
];
const REDACTABLE_NODE_FIELDS = [
"alt",
"lang",
"meta",
"title",
"url",
"value"
];
/**
* Redact known sensitive secret formats from Markdown capture output.
*
* The plugin redacts structured Markdown tree fields before serialization and
* runs one final string pass during `render()`. Credentialed URLs preserve
* their scheme, username, host, and path while replacing the password. Add
* `data-md-redact` to a DOM element to replace that element manually.
*
* Combining `data-md-redact` with a `data-md` override on the same element is
* an error: authored overrides bypass manual redaction, so remove the
* sensitive content from the override instead. Built-in and custom patterns
* still scan authored override content.
*/
function redactSecrets(options = {}) {
const redactor = createSecretRedactor(options);
return createPlugin("redact-secrets", ({ annotateElement, renderElement, transform, postprocess }) => [
renderElement("element", (element) => element.hasAttribute(REDACT_ATTR) ? {
type: "text",
value: replacementFor(redactor, element.textContent ?? "")
} : void 0),
annotateElement("guard", (element) => {
if (element.hasAttribute(REDACT_ATTR) && element.hasAttribute(MARKDOWN_OVERRIDE_ATTR)) throw new Error(`\`${REDACT_ATTR}\` cannot be combined with \`${MARKDOWN_OVERRIDE_ATTR}\` — remove the sensitive content from the authored override instead.`);
}),
transform("tree", (tree) => {
visit(tree, (node) => {
redactMarkdownNode(node, redactor);
});
}),
postprocess("markdown", (markdown) => applySecretRedactor(markdown, redactor))
]);
}
/** Redact known sensitive secret formats from a string. */
function redactSecretsFromString(value, options = {}) {
return applySecretRedactor(value, createSecretRedactor(options));
}
function applySecretRedactor(value, redactor) {
const withoutUrlCredentials = unescapeMarkdownReplacements(value, redactor).replace(URL_CREDENTIAL_PATTERN, (_match, prefix, password, suffix) => `${prefix}${redactValue(redactor, password)}${suffix}`);
return unescapeMarkdownReplacements(redactor.patterns.reduce((current, pattern) => current.replace(pattern, (match) => redactValue(redactor, match)), withoutUrlCredentials), redactor);
}
function createSecretRedactor(options) {
return {
patterns: [...DEFAULT_SECRET_PATTERNS, ...options.patterns ?? []],
replacement: options.replacement ?? DEFAULT_REPLACEMENT,
seenReplacements: /* @__PURE__ */ new Set()
};
}
function redactMarkdownNode(node, redactor) {
const target = node;
for (const field of REDACTABLE_NODE_FIELDS) {
const value = target[field];
if (typeof value !== "string") continue;
const redacted = applySecretRedactor(value, redactor);
if (redacted !== value) target[field] = redacted;
}
}
function replacementFor(redactor, value) {
const replacement = typeof redactor.replacement === "function" ? redactor.replacement(value) : redactor.replacement;
redactor.seenReplacements.add(replacement);
return replacement;
}
function redactValue(redactor, value) {
return redactor.seenReplacements.has(value) ? value : replacementFor(redactor, value);
}
function unescapeMarkdownReplacements(value, redactor) {
let current = value;
for (const replacement of redactor.seenReplacements) current = current.replaceAll(escapeMarkdownReplacement(replacement), replacement);
return current;
}
function escapeMarkdownReplacement(replacement) {
return replacement.replace(/([\[\]\\])/g, "\\$1");
}
//#endregion
export { budget, createPlugin, frontmatter, outline, redactSecrets, redactSecretsFromString };
//# sourceMappingURL=index.js.map
{"version":3,"file":"index.js","names":[],"sources":["../../src/plugins/budget.ts","../../src/plugins/frontmatter.ts","../../src/plugins/outline.ts","../../src/plugins/redact-secrets.ts"],"sourcesContent":["import type {\n Code,\n List,\n ListItem,\n Paragraph,\n Root,\n RootContent,\n Table,\n TableCell,\n TableRow,\n} from '../core/mdast';\nimport type { TransformPlugin } from '../core/types';\nimport { clampInline } from '../core/utils';\nimport { createPlugin } from './authoring';\n\nexport type BudgetOptions = {\n /**\n * Maximum number of Markdown characters to keep after compaction.\n *\n * The budget plugin first tries gentle summaries and only falls back to more\n * aggressive truncation when the rendered Markdown is still too long.\n */\n maxChars: number;\n};\n\n/**\n * Add a transform plugin that keeps the final Markdown under a character budget.\n *\n * This is useful when passing page snapshots to tools or models with a known\n * context limit. The plugin preserves headings and representative structure\n * before truncating individual blocks.\n */\nexport function budget(options: BudgetOptions): TransformPlugin {\n if (!Number.isFinite(options.maxChars) || options.maxChars <= 0) {\n throw new Error('budget maxChars must be a positive number.');\n }\n const maxChars = Math.floor(options.maxChars);\n const [plugin] = createPlugin('budget', ({ transform }) => [\n transform((tree, context) => {\n if (context.stringify(tree).length <= maxChars) return tree;\n for (const level of [0, 1, 2] as const) {\n const candidate = compactTree(tree, level);\n if (context.stringify(candidate).length <= maxChars) return candidate;\n }\n return emergencyTree(tree, maxChars, context.stringify);\n }),\n ]);\n return plugin as TransformPlugin;\n}\n\nfunction compactTree(tree: Root, level: 0 | 1 | 2): Root {\n const children: RootContent[] = [budgetNotice()];\n for (const node of tree.children) children.push(...compactNode(node, level));\n return { type: 'root', children };\n}\n\nfunction compactNode(node: RootContent, level: 0 | 1 | 2): RootContent[] {\n if (node.type === 'heading' || node.type === 'yaml') return [clone(node)];\n if (node.type === 'paragraph') return [compactParagraph(node, [180, 80, 36][level]!)];\n if (node.type === 'rawMarkdown') return [compactRawMarkdown(node, [180, 80, 36][level]!)];\n if (node.type === 'table') return compactTable(node, level);\n if (node.type === 'list') return [compactList(node, level)];\n if (node.type === 'code') return compactCode(node, level);\n return level === 2 ? [] : [clone(node)];\n}\n\nfunction compactRawMarkdown(\n node: Extract<RootContent, { type: 'rawMarkdown' }>,\n maxChars: number,\n): RootContent {\n if (node.value.length <= maxChars) return clone(node);\n const [firstLine = '', ...restLines] = node.value.split('\\n');\n const heading = /^#{1,6}\\s+/.test(firstLine.trim()) ? firstLine.trim() : '';\n if (!heading) {\n return { ...clone(node), value: clampInline(collapseRawMarkdown(node.value), maxChars) };\n }\n const remaining = Math.max(0, maxChars - heading.length - 2);\n const rest = collapseRawMarkdown(restLines.join('\\n'));\n return {\n ...clone(node),\n value: rest && remaining > 1\n ? `${heading}\\n\\n${clampInline(rest, remaining)}`\n : heading,\n };\n}\n\nfunction compactParagraph(node: Paragraph, maxChars: number): Paragraph {\n const value = plainText(node);\n if (value.length <= maxChars) return clone(node);\n return {\n type: 'paragraph',\n children: [\n { type: 'text', value: clampInline(value, maxChars) },\n { type: 'text', value: ' ' },\n { type: 'emphasis', children: [{ type: 'text', value: 'Budget: Paragraph truncated.' }] },\n ],\n };\n}\n\nfunction compactTable(node: Table, level: 0 | 1 | 2): RootContent[] {\n const header = node.children[0];\n if (!header) return [];\n const body = node.children.slice(1);\n const count = [6, 3, 2][level]!;\n const kept = representative(body, count);\n const table: Table = { ...clone(node), children: [clone(header), ...kept.map(clone)] };\n const omitted = body.length - kept.length;\n return omitted > 0\n ? [table, marker(`${omitted} table row${omitted === 1 ? '' : 's'} omitted.`)]\n : [table];\n}\n\nfunction compactList(node: List, level: 0 | 1 | 2): List {\n const count = [8, 6, 2][level]!;\n const kept = level === 0\n ? [...node.children.slice(0, Math.max(0, count - 1)), ...node.children.slice(-1)]\n : node.children.slice(0, count);\n const summarized = kept.map((item) => summarizeListItem(item, level));\n const omitted = node.children.length - kept.length;\n const summaryCount = kept.filter((item, index) => plainText(item) !== plainText(summarized[index]!)).length;\n if (omitted || summaryCount) {\n const parts = [\n omitted ? `${omitted} list items omitted` : '',\n summaryCount ? `${summaryCount} list items summarized` : '',\n ].filter(Boolean).join('; ');\n summarized.push({\n type: 'listItem',\n children: [{ type: 'paragraph', children: [{ type: 'emphasis', children: [{ type: 'text', value: `Budget: ${parts}.` }] }] }],\n });\n }\n return { ...clone(node), children: summarized };\n}\n\nfunction summarizeListItem(item: ListItem, level: 0 | 1 | 2): ListItem {\n const first = item.children[0];\n const firstHasStrong =\n first?.type === 'paragraph' &&\n first.children.some((child) => child.type === 'strong');\n if (level > 0 && first && firstHasStrong) {\n return { ...clone(item), children: [clone(first)] };\n }\n if (level === 0) {\n const field = item.children.find((child) => child.type === 'paragraph' && child.data?.domMd?.kind === 'field');\n return { ...clone(item), children: [first, field].filter(Boolean).map((child) => clone(child!)) };\n }\n const value = clampInline(plainText(item), level === 1 ? 64 : 42);\n return { type: 'listItem', children: [{ type: 'paragraph', children: [{ type: 'text', value }] }] };\n}\n\nfunction compactCode(node: Code, level: 0 | 1 | 2): RootContent[] {\n const lines = node.value.split('\\n');\n const count = [10, 5, 2][level]!;\n if (lines.length <= count) return [clone(node)];\n return [\n { ...clone(node), value: lines.slice(0, count).join('\\n') },\n marker(`${lines.length - count} code lines omitted.`),\n ];\n}\n\nfunction budgetNotice(): Paragraph {\n return {\n type: 'paragraph',\n children: [{ type: 'strong', children: [{ type: 'text', value: 'Budgeted output (incomplete):' }] }],\n };\n}\n\nfunction marker(value: string): Paragraph {\n return {\n type: 'paragraph',\n children: [{ type: 'emphasis', children: [{ type: 'text', value: `Budget: ${value}` }] }],\n };\n}\n\nfunction emergencyTree(\n tree: Root,\n maxChars: number,\n stringify: (node: import('../core/mdast').Nodes) => string,\n): Root {\n const headings = tree.children.filter((node) => node.type === 'heading');\n let children: RootContent[] = [budgetNotice(), ...headings.map(clone)];\n let result: Root = { type: 'root', children };\n while (headings.length && stringify(result).length > maxChars) {\n headings.pop();\n children = [budgetNotice(), ...headings.map(clone)];\n result = { type: 'root', children };\n }\n if (stringify(result).length <= maxChars) return result;\n return {\n type: 'root',\n children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Budgeted output'.slice(0, maxChars) }] }],\n };\n}\n\nfunction representative<T>(items: T[], count: number): T[] {\n if (items.length <= count) return items;\n if (count <= 1) return items.slice(0, count);\n return [items[0]!, ...items.slice(1, count - 1), items.at(-1)!];\n}\n\nfunction plainText(node: unknown): string {\n if (!node || typeof node !== 'object') return '';\n if ('value' in node && typeof node.value === 'string') return node.value;\n if ('children' in node && Array.isArray(node.children)) {\n return node.children.map(plainText).filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim();\n }\n return '';\n}\n\nfunction collapseRawMarkdown(value: string): string {\n return value.replace(/\\s+/g, ' ').trim();\n}\n\nfunction clone<T>(value: T): T {\n return JSON.parse(JSON.stringify(value)) as T;\n}\n","import type { Yaml } from '../core/mdast';\nimport type { TransformPlugin } from '../core/types';\nimport { createPlugin } from './authoring';\n\n/** A scalar value that can be written into generated YAML frontmatter. */\nexport type FrontmatterScalar = string | number | boolean | null;\n\n/** A generated frontmatter value: either one scalar or a YAML list of scalars. */\nexport type FrontmatterValue = FrontmatterScalar | FrontmatterScalar[];\n\n/**\n * Key/value metadata to prepend to the Markdown document.\n *\n * Set a key to `undefined` to omit it from the generated YAML block.\n */\nexport type Frontmatter = Record<string, FrontmatterValue | undefined>;\n\n/**\n * Static frontmatter, or a function that is read at render time.\n *\n * Use a function when metadata depends on live browser state such as\n * `document.title` or `location.href`.\n */\nexport type FrontmatterSource = Frontmatter | (() => Frontmatter);\n\n/**\n * Add a transform plugin that prepends YAML frontmatter to the document.\n *\n * The plugin runs after DOM extraction, so it can add page-level metadata\n * without changing the source DOM.\n */\nexport function frontmatter(\n source: FrontmatterSource,\n): TransformPlugin {\n const [plugin] = createPlugin('frontmatter', ({ transform }) => [\n transform((tree) => {\n const frontmatter = typeof source === 'function' ? source() : source;\n const value = serializeFrontmatter(frontmatter);\n if (!value) return tree;\n const yaml: Yaml = { type: 'yaml', value };\n return { ...tree, children: [yaml, ...tree.children] };\n }),\n ]);\n return plugin as TransformPlugin;\n}\n\nfunction serializeFrontmatter(frontmatter: Frontmatter): string {\n return Object.entries(frontmatter)\n .filter((entry): entry is [string, FrontmatterValue] => entry[1] !== undefined)\n .map(([key, value]) => serializeEntry(key, value))\n .join('\\n');\n}\n\nfunction serializeEntry(key: string, value: FrontmatterValue): string {\n const serializedKey = serializeKey(key);\n if (Array.isArray(value)) {\n if (!value.length) return `${serializedKey}: []`;\n return [`${serializedKey}:`, ...value.map((item) => ` - ${serializeScalar(item)}`)].join('\\n');\n }\n return `${serializedKey}: ${serializeScalar(value)}`;\n}\n\nfunction serializeScalar(value: FrontmatterScalar): string {\n if (value === null) return 'null';\n return typeof value === 'string' ? JSON.stringify(value) : String(value);\n}\n\nfunction serializeKey(key: string): string {\n return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(key) ? key : JSON.stringify(key);\n}\n","import type { Nodes, Root, RootContent, TableCell } from '../core/mdast';\nimport type {\n MarkdownTransformContext,\n TransformPlugin,\n} from '../core/types';\nimport { clampMarkdownWithNotice, collapseWhitespace } from '../core/utils';\nimport { createPlugin } from './authoring';\n\nexport type OutlineMarkdownOptions = {\n /** Try to keep the generated outline under this many Markdown characters. */\n maxChars?: number;\n /** Maximum number of summary bullets to keep in each section. */\n maxItemsPerSection?: number;\n /** Minimum number of summary bullets to keep before falling back to headings only. */\n minItemsPerSection?: number;\n /** Maximum length of text snippets used inside outline bullets. */\n textSnippetChars?: number;\n};\n\ntype Entry =\n | { kind: 'heading'; text: string }\n | { kind: 'item'; priority: number; text: string };\ntype Section = { heading?: string; items: Extract<Entry, { kind: 'item' }>[] };\n\n/**\n * Add a transform plugin that replaces the document with a compact outline.\n *\n * Use this when the shape of a page matters more than its full text, such as\n * quick audits, navigation maps, or low-token previews.\n */\nexport function outline(\n options: OutlineMarkdownOptions = {},\n): TransformPlugin {\n const [plugin] = createPlugin('outline', ({ transform }) => [\n transform((tree, context) => {\n const entries = tree.children.flatMap((node) => describe(node, context, options));\n const markdown = renderSections(toSections(entries), options);\n const outlined = rawMarkdownRoot(markdown);\n if (\n options.maxChars &&\n context.stringify(outlined).length > options.maxChars\n ) {\n return rawMarkdownRoot(headingsOnly(markdown));\n }\n return outlined;\n }),\n ]);\n return plugin as TransformPlugin;\n}\n\nfunction rawMarkdownRoot(value: string): Root {\n return value.trim()\n ? { type: 'root', children: [{ type: 'rawMarkdown', value }] }\n : { type: 'root', children: [] };\n}\n\nfunction headingsOnly(markdown: string): string {\n return markdown\n .split('\\n')\n .filter((line) => /^#{1,6}\\s+/.test(line))\n .join('\\n\\n');\n}\n\nfunction describe(\n node: RootContent,\n context: MarkdownTransformContext,\n options: OutlineMarkdownOptions,\n): Entry[] {\n const markdown = context.stringify(node);\n if (!markdown) return [];\n if (node.type === 'heading') return [{ kind: 'heading', text: markdown }];\n\n const metadata = node.data?.domMd;\n if (metadata?.authored) {\n return [{ kind: 'item', priority: 4, text: `- Override: ${snippet(markdown, 100)}` }];\n }\n if (metadata?.kind === 'action') return [];\n if (metadata?.kind === 'field') {\n const label = metadata.label === 'Fields' ? 'Fields' : 'Field';\n return [{ kind: 'item', priority: 3, text: `- ${label}: ${collapseWhitespace(markdown)}` }];\n }\n if (metadata?.kind === 'visual') {\n return [{ kind: 'item', priority: 2, text: `- Visual: ${trimVisual(markdown)}` }];\n }\n if (node.type === 'table') {\n const header = node.children[0]?.children ?? [];\n const columns = header.map((cell) => cellText(cell, context)).filter(Boolean);\n const count = Math.max(0, node.children.length - 1);\n return [{\n kind: 'item',\n priority: 3,\n text: `- Table: ${columns.join(', ') || 'unlabeled'} (${count} row${count === 1 ? '' : 's'})`,\n }];\n }\n if (node.type === 'list') {\n const first = node.children[0] ? snippet(context.stringify(node.children[0]), options.textSnippetChars ?? 120) : '';\n return [{\n kind: 'item',\n priority: 3,\n text: `- List (${node.children.length} items)${first ? `: ${first.replace(/^[-*+]\\s+/, '')}` : ''}`,\n }];\n }\n if (node.type === 'code') {\n const lines = node.value ? node.value.split('\\n').length : 0;\n return [{ kind: 'item', priority: 3, text: `- Code block (${lines} lines)` }];\n }\n if (node.type === 'paragraph') {\n const text = snippet(markdown, options.textSnippetChars ?? 120);\n return text ? [{ kind: 'item', priority: 1, text: `- Text: ${text}` }] : [];\n }\n return [];\n}\n\nfunction toSections(entries: Entry[]): Section[] {\n const sections: Section[] = [{ items: [] }];\n let current = sections[0]!;\n for (const entry of entries) {\n if (entry.kind === 'heading') {\n current = { heading: entry.text, items: [] };\n if (/^#\\s/.test(entry.text) && sections[0]?.items.length) {\n current.items.push(...sections[0].items);\n sections[0].items = [];\n }\n sections.push(current);\n } else current.items.push(entry);\n }\n return sections.filter((section) => section.heading || section.items.length);\n}\n\nfunction renderSections(sections: Section[], options: OutlineMarkdownOptions): string {\n const maxItems = options.maxItemsPerSection ?? 4;\n const included = sections.map((section) => Math.min(maxItems, section.items.length));\n let markdown = serializeSections(sections, included, true);\n if (!options.maxChars || markdown.length <= options.maxChars) return markdown;\n\n const minimum = options.minItemsPerSection ?? 1;\n for (let index = 0; index < included.length; index += 1) {\n included[index] = Math.min(minimum, sections[index]?.items.length ?? 0);\n }\n markdown = serializeSections(sections, included, false);\n if (markdown.length <= options.maxChars) return markdown;\n return clampMarkdownWithNotice(\n markdown,\n Math.max(1, options.maxChars - 48),\n `[Outline truncated after ${options.maxChars} chars]`,\n );\n}\n\nfunction serializeSections(sections: Section[], included: number[], omissions: boolean): string {\n return sections.map((section, index) => {\n const lines = section.heading ? [section.heading] : [];\n const ordered = [...section.items].sort((a, b) => b.priority - a.priority);\n const visible = ordered.slice(0, included[index] ?? 0);\n lines.push(...visible.map((item) => item.text));\n const omitted = section.items.length - visible.length;\n if (omissions && omitted) lines.push(`- ${omitted} more item${omitted === 1 ? '' : 's'} omitted`);\n return lines.join('\\n');\n }).filter(Boolean).join('\\n\\n');\n}\n\nfunction cellText(cell: TableCell, context: MarkdownTransformContext): string {\n return collapseWhitespace(context.stringify({ type: 'paragraph', children: cell.children }));\n}\n\nfunction snippet(value: string, maxChars: number): string {\n const text = collapseWhitespace(value);\n if (text.length <= maxChars) return text;\n return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;\n}\n\nfunction trimVisual(value: string): string {\n return value.replace(/^!?(?:\\[|\\[.*?\\]\\()[^:]*:?\\s*/, '').replace(/[\\])]+$/, '');\n}\n","import type { Nodes } from '../core/mdast';\nimport type { MarkdownPlugin } from '../core/types';\nimport { visit } from '../tree';\nimport { createPlugin } from './authoring';\n\nconst REDACT_ATTR = 'data-md-redact';\nconst MARKDOWN_OVERRIDE_ATTR = 'data-md';\nconst DEFAULT_REPLACEMENT = '[REDACTED]';\n\nexport type RedactSecretsReplacement = string | ((value: string) => string);\n\nexport type RedactSecretsOptions = {\n /**\n * Additional regex patterns to redact after dom-md's built-in secret\n * patterns. Built-in patterns always remain enabled.\n */\n patterns?: readonly RegExp[];\n /**\n * Replacement used for automatic and manual redaction. Functions receive the\n * matched secret, URL password, or `data-md-redact` element text.\n */\n replacement?: RedactSecretsReplacement;\n};\n\ntype SecretRedactor = {\n patterns: readonly RegExp[];\n replacement: RedactSecretsReplacement;\n seenReplacements: Set<string>;\n};\n\nconst URL_CREDENTIAL_PATTERN =\n /(\\b[a-zA-Z][a-zA-Z0-9+.-]*:\\/\\/[^:\\s/@]*:)([^@\\s/]+)(@)/g;\n\nconst DEFAULT_SECRET_PATTERNS: readonly RegExp[] = [\n /vck_[A-Za-z0-9]{20,}(?:\\.{3,})?/g,\n /gh[opsru]_[A-Za-z0-9_]{20,}/g,\n /github_pat_[A-Za-z0-9_]{20,}/g,\n /glpat-[A-Za-z0-9_-]{20,}/g,\n /(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g,\n /whsec_[A-Za-z0-9]{20,}/g,\n /xox[abprs]-[A-Za-z0-9-]{20,}/g,\n /xapp-[A-Za-z0-9-]{20,}/g,\n /\\b(?:AKIA|ASIA)[A-Z0-9]{16}\\b/g,\n /\\bAIza[A-Za-z0-9_-]{30,}/g,\n /sk-ant-[A-Za-z0-9_-]{20,}/g,\n /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/g,\n /\\bsk-[A-Za-z0-9]{32,}\\b/g,\n /npm_[A-Za-z0-9]{30,}/g,\n /\\bhf_[A-Za-z0-9]{30,}/g,\n /\\bre_[A-Za-z0-9_]{30,}/g,\n /\\bBearer\\s+[A-Za-z0-9_\\-+=/.]{20,}/gi,\n];\n\nconst REDACTABLE_NODE_FIELDS = [\n 'alt',\n 'lang',\n 'meta',\n 'title',\n 'url',\n 'value',\n] as const;\n\n/**\n * Redact known sensitive secret formats from Markdown capture output.\n *\n * The plugin redacts structured Markdown tree fields before serialization and\n * runs one final string pass during `render()`. Credentialed URLs preserve\n * their scheme, username, host, and path while replacing the password. Add\n * `data-md-redact` to a DOM element to replace that element manually.\n *\n * Combining `data-md-redact` with a `data-md` override on the same element is\n * an error: authored overrides bypass manual redaction, so remove the\n * sensitive content from the override instead. Built-in and custom patterns\n * still scan authored override content.\n */\nexport function redactSecrets(options: RedactSecretsOptions = {}): MarkdownPlugin[] {\n const redactor = createSecretRedactor(options);\n\n return createPlugin('redact-secrets', ({ annotateElement, renderElement, transform, postprocess }) => [\n renderElement('element', (element) =>\n element.hasAttribute(REDACT_ATTR)\n ? { type: 'text', value: replacementFor(redactor, element.textContent ?? '') }\n : undefined),\n annotateElement('guard', (element) => {\n if (\n element.hasAttribute(REDACT_ATTR) &&\n element.hasAttribute(MARKDOWN_OVERRIDE_ATTR)\n ) {\n throw new Error(\n `\\`${REDACT_ATTR}\\` cannot be combined with \\`${MARKDOWN_OVERRIDE_ATTR}\\` — ` +\n 'remove the sensitive content from the authored override instead.',\n );\n }\n }),\n transform('tree', (tree) => {\n visit(tree, (node) => {\n redactMarkdownNode(node, redactor);\n });\n }),\n postprocess('markdown', (markdown) => applySecretRedactor(markdown, redactor)),\n ]);\n}\n\n/** Redact known sensitive secret formats from a string. */\nexport function redactSecretsFromString(\n value: string,\n options: RedactSecretsOptions = {},\n): string {\n return applySecretRedactor(value, createSecretRedactor(options));\n}\n\nfunction applySecretRedactor(\n value: string,\n redactor: SecretRedactor,\n): string {\n const normalizedInput = unescapeMarkdownReplacements(value, redactor);\n const withoutUrlCredentials = normalizedInput.replace(\n URL_CREDENTIAL_PATTERN,\n (_match: string, prefix: string, password: string, suffix: string) =>\n `${prefix}${redactValue(redactor, password)}${suffix}`,\n );\n\n const redacted = redactor.patterns.reduce(\n (current, pattern) =>\n current.replace(pattern, (match) => redactValue(redactor, match)),\n withoutUrlCredentials,\n );\n\n return unescapeMarkdownReplacements(redacted, redactor);\n}\n\nfunction createSecretRedactor(options: RedactSecretsOptions): SecretRedactor {\n return {\n patterns: [...DEFAULT_SECRET_PATTERNS, ...(options.patterns ?? [])],\n replacement: options.replacement ?? DEFAULT_REPLACEMENT,\n seenReplacements: new Set(),\n };\n}\n\nfunction redactMarkdownNode(\n node: Nodes,\n redactor: SecretRedactor,\n): void {\n const target = node as Record<string, unknown>;\n for (const field of REDACTABLE_NODE_FIELDS) {\n const value = target[field];\n if (typeof value !== 'string') continue;\n const redacted = applySecretRedactor(value, redactor);\n if (redacted !== value) target[field] = redacted;\n }\n}\n\nfunction replacementFor(redactor: SecretRedactor, value: string): string {\n const replacement = typeof redactor.replacement === 'function'\n ? redactor.replacement(value)\n : redactor.replacement;\n redactor.seenReplacements.add(replacement);\n return replacement;\n}\n\nfunction redactValue(redactor: SecretRedactor, value: string): string {\n return redactor.seenReplacements.has(value) ? value : replacementFor(redactor, value);\n}\n\nfunction unescapeMarkdownReplacements(value: string, redactor: SecretRedactor): string {\n let current = value;\n for (const replacement of redactor.seenReplacements) {\n current = current.replaceAll(escapeMarkdownReplacement(replacement), replacement);\n }\n return current;\n}\n\nfunction escapeMarkdownReplacement(replacement: string): string {\n return replacement.replace(/([\\[\\]\\\\])/g, '\\\\$1');\n}\n"],"mappings":"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"}
import { a as escapeInlineText, c as escapeTableCell, d as readPositiveInteger, i as collapseWhitespace, l as inlineCode, o as escapeLeadingBlockMarker, r as codeBlock, s as escapeMarkdownText, t as clampInline, u as normalizeMarkdown } from "./utils-CVpYLNBk.js";
import { n as DomMarkdownError, o as MARKDOWN_MAX_CHARS_ATTR, r as MARKDOWN_ATTR } from "./types-ue0OR47d.js";
import { t as createPlugin } from "./authoring-1IXjCaLp.js";
//#region src/core/describe-element.ts
function describeElement(element, markdown = "") {
return element.getAttribute("aria-label") || headingFromMarkdown(markdown) || headingText(element) || void 0;
}
function headingFromMarkdown(markdown) {
const heading = markdown.match(/^#{1,6}\s+(.+)$/m)?.[1];
return collapseWhitespace(heading ?? "");
}
function headingText(element) {
if (element.tagName.toLowerCase().match(/^h[1-6]$/)) return collapseWhitespace(element.textContent ?? "");
return collapseWhitespace(element.querySelector("h1,h2,h3,h4,h5,h6")?.textContent ?? "");
}
//#endregion
//#region src/core/markdown.ts
function stringifyMarkdownNode(node) {
return normalizeMarkdown(serializeNode(node));
}
function isInline(node) {
return ![
"root",
"blockquote",
"code",
"heading",
"list",
"paragraph",
"rawMarkdown",
"table",
"thematicBreak",
"yaml"
].includes(node.type);
}
function serializeNode(node) {
if (!isNode(node)) return "";
const standard = node;
switch (standard.type) {
case "root": return serializeBlocks(standard.children);
case "yaml": return serializeYaml(standard);
case "rawMarkdown": return standard.value;
case "heading": return serializeHeading(standard);
case "paragraph": return serializeParagraph(standard);
case "blockquote": return serializeBlockquote(standard);
case "list": return serializeList(standard);
case "listItem": return serializeListItem(standard);
case "code": return codeBlock(standard.value, standard.lang ?? "");
case "table": return serializeTable(standard);
case "thematicBreak": return "---";
case "text": return serializeText(standard);
case "inlineCode": return serializeInlineCode(standard);
case "emphasis": return wrapInline(standard, "_");
case "strong": return wrapInline(standard, "**");
case "link": return serializeLink(standard);
case "image": return serializeImage(standard);
case "break": return "\\\n";
default: return "";
}
}
function serializeBlocks(nodes) {
return nodes.map((node) => serializeNode(node).trim()).filter(Boolean).join("\n\n");
}
function serializeInline(nodes) {
return nodes.map((node) => serializeNode(node)).join("");
}
function serializeYaml(node) {
return `---\n${node.value.replace(/\n+$/, "")}\n---`;
}
function serializeHeading(node) {
return `${"#".repeat(node.depth)} ${serializeInline(node.children)}`.trimEnd();
}
function serializeParagraph(node) {
return escapeLeadingBlockMarker(serializeInline(node.children));
}
function serializeBlockquote(node) {
return serializeBlocks(node.children).split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
}
function serializeList(node) {
const start = node.start ?? 1;
return node.children.map((item, index) => {
return serializeMarkedListItem(item, node.ordered ? `${start + index}.` : "-");
}).join("\n");
}
function serializeMarkedListItem(item, marker) {
const markdown = serializeBlocks(item.children);
if (!markdown) return marker;
const [first = "", ...rest] = markdown.split("\n");
const indent = " ".repeat(marker.length + 1);
return [`${marker} ${first}`, ...rest.map((line) => line ? `${indent}${line}` : "")].join("\n");
}
function serializeListItem(item) {
return serializeMarkedListItem(item, "-");
}
function serializeTable(node) {
const [header, ...rows] = node.children;
if (!header) return "";
const headerCells = header.children.map((cell) => serializeTableCell(cell));
return [
tableRow(headerCells),
tableRow(headerCells.map(() => "-")),
...rows.map((row) => tableRow(row.children.map((cell) => serializeTableCell(cell))))
].join("\n");
}
function serializeTableCell(cell) {
return escapeTableCell(serializeInline(cell.children));
}
function tableRow(cells) {
return `| ${cells.join(" | ")} |`;
}
function serializeText(node) {
return escapeInlineText(node.value);
}
function serializeInlineCode(node) {
return inlineCode(node.value);
}
function wrapInline(node, marker) {
return `${marker}${serializeInline(node.children)}${marker}`;
}
function serializeLink(node) {
return `[${serializeInline(node.children)}](${node.url})`;
}
function serializeImage(node) {
return `![${escapeMarkdownText(node.alt ?? "")}](${node.url})`;
}
function isNode(value) {
return !!value && typeof value === "object" && "type" in value && typeof value.type === "string";
}
//#endregion
//#region src/core/extract.ts
function extractMarkdownTree(parent, pipeline) {
return {
type: "root",
children: toBlocks(extractChildren(parent, {
nextSourceId: 1,
pipeline
}))
};
}
function extractNode(node, state) {
if (node.nodeType === 3) {
const value = collapseWhitespace(node.textContent ?? "");
return value ? [{
type: "text",
value
}] : [];
}
if (node.nodeType !== 1) return [];
return extractElement(node, state);
}
function extractElement(element, state) {
const sourceId = `e${state.nextSourceId++}`;
const { nodes, resolution } = resolveElement(element, state, sourceId);
const annotations = resolution.kind === "skipped" ? {} : collectAnnotations(element, state, sourceId);
const metadata = {
label: describeElement(element),
sourceId
};
const annotated = nodes.map((node) => attachCustomData(node, annotations)).map((node) => attachMetadata(node, metadata));
state.pipeline.observer?.elementResolved?.({
element,
nodes: annotated,
resolution
});
return annotated;
}
function resolveElement(element, state, sourceId) {
const tag = element.tagName.toLowerCase();
const reason = elementSkipReason(element, tag);
if (reason) return {
nodes: [],
resolution: {
kind: "skipped",
reason
}
};
return renderWithPlugins(element, state, sourceId, state.pipeline.renderers, 0, () => ({
nodes: extractFallback(element, tag, state),
resolution: { kind: "fallback" }
}));
}
function collectAnnotations(element, state, sourceId) {
let result = {};
for (const plugin of state.pipeline.annotators) {
const annotation = plugin.annotate(element, { sourceId });
if (annotation) result = mergeCustomData(result, annotation);
}
return result;
}
function renderWithPlugins(element, state, sourceId, plugins, startIndex = 0, continuation) {
for (let index = startIndex; index < plugins.length; index += 1) {
const plugin = plugins[index];
if (!plugin) continue;
const output = plugin.render(element, createRenderElementContext(element, state, sourceId, plugins, index + 1, continuation));
if (output !== void 0) return {
nodes: normalizeOutput(output),
resolution: {
kind: "plugin",
pluginId: plugin.id
}
};
}
return continuation();
}
function createRenderElementContext(element, state, sourceId, plugins, resumeIndex, continuation) {
return {
sourceId,
children(parent = element) {
return extractChildren(parent, state);
},
inline(parent = element) {
return toInline(extractChildren(parent, state));
},
extract(child) {
return extractElement(child, state);
},
renderDefault() {
return renderWithPlugins(element, state, sourceId, plugins, resumeIndex, continuation).nodes;
}
};
}
function extractFallback(element, tag, state) {
if (element.getAttribute("aria-busy") === "true") {
const label = describeElement(element);
return [paragraphText(label ? `[Loading: ${label}]` : "[Loading content]")];
}
const headingDepth = semanticHeadingDepth(element, tag);
if (headingDepth) return [{
type: "heading",
depth: headingDepth,
children: toInline(extractChildren(element, state))
}];
if (tag === "p") return [{
type: "paragraph",
children: toInline(extractChildren(element, state))
}];
if (tag === "br") return [{ type: "break" }];
if (tag === "pre") {
const language = (element.querySelector(":scope > code")?.getAttribute("class") ?? "").match(/(?:^|\s)language-([^\s]+)/)?.[1];
const node = {
type: "code",
value: element.textContent ?? ""
};
if (language) node.lang = language;
return [node];
}
if (tag === "code") return [{
type: "inlineCode",
value: collapseWhitespace(element.textContent ?? "")
}];
if (tag === "strong" || tag === "b") return [{
type: "strong",
children: toInline(extractChildren(element, state))
}];
if (tag === "em" || tag === "i") return [{
type: "emphasis",
children: toInline(extractChildren(element, state))
}];
if (tag === "a") {
const children = toInline(extractChildren(element, state));
const url = element.getAttribute("href");
return url ? [{
type: "link",
url,
children
}] : children;
}
const role = element.getAttribute("role")?.toLowerCase();
if (tag === "ul" || tag === "ol" || role === "list") {
const children = (role === "list" ? Array.from(element.querySelectorAll("[role=\"listitem\"]")).filter((item) => item.closest("[role=\"list\"]") === element) : Array.from(element.children).filter((child) => child.tagName.toLowerCase() === "li")).map((item) => ({
type: "listItem",
spread: false,
children: toBlocks(extractElement(item, state))
}));
return [{
type: "list",
ordered: tag === "ol",
spread: false,
children
}];
}
return extractChildren(element, state);
}
function extractChildren(parent, state) {
return Array.from(parent.childNodes).flatMap((node) => extractNode(node, state));
}
function toBlocks(nodes) {
const blocks = [];
let inline = [];
const flush = () => {
const children = trimInline(inline);
if (children.length) blocks.push({
type: "paragraph",
children
});
inline = [];
};
for (const node of nodes) if (isInline(node)) inline.push(node);
else {
flush();
blocks.push(node);
}
flush();
return blocks;
}
function toInline(nodes) {
const result = [];
for (const node of nodes) if (isInline(node)) result.push(node);
else if (node.type === "paragraph" || node.type === "heading") result.push(...node.children);
else {
const value = stringifyMarkdownNode(node);
if (value) result.push({
type: "text",
value
});
}
return trimInline(result);
}
function trimInline(nodes) {
const result = [];
for (const node of nodes) {
if (node.type === "text") {
const value = collapseWhitespace(node.value);
if (!value) continue;
const previous = result.at(-1);
if (previous?.type === "text") previous.value = /^[,.;:!?)]/.test(value) ? `${previous.value}${value}` : `${previous.value} ${value}`;
else {
if (previous && previous.type !== "break" && !/^[,.;:!?)]/.test(value)) result.push({
type: "text",
value: " "
});
result.push({
...node,
value
});
}
continue;
}
const previous = result.at(-1);
if (previous && previous.type !== "break") result.push({
type: "text",
value: " "
});
result.push(node);
}
return result;
}
function normalizeOutput(output) {
return output == null ? [] : Array.isArray(output) ? [...output] : [output];
}
function attachMetadata(node, metadata) {
const current = node.data?.domMd;
return {
...node,
data: {
...node.data,
domMd: {
...metadata,
...current,
sourceId: current?.sourceId ?? metadata.sourceId
}
}
};
}
function attachCustomData(node, data) {
if (!Object.keys(data).length) return node;
return {
...node,
data: mergeCustomData(node.data ?? {}, data)
};
}
function mergeCustomData(target, source) {
const result = { ...target };
for (const [key, value] of Object.entries(source)) {
if (key === "domMd") continue;
result[key] = mergeCustomValue(result[key], value);
}
return result;
}
function mergeCustomValue(current, next) {
if (Array.isArray(current) && Array.isArray(next)) return [...current, ...next];
if (isPlainObject(current) && isPlainObject(next)) return mergeCustomData(current, next);
return next;
}
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function paragraphText(value, metadata) {
return {
type: "paragraph",
children: [{
type: "text",
value
}],
...metadata ? { data: { domMd: metadata } } : {}
};
}
function semanticHeadingDepth(element, tag) {
if (/^h[1-6]$/.test(tag)) return Number(tag[1]);
if (element.getAttribute("role") !== "heading") return null;
const level = Number(element.getAttribute("aria-level"));
return Number.isInteger(level) && level >= 1 && level <= 6 ? level : null;
}
function elementSkipReason(element, tag) {
if (element.hasAttribute("data-md-ignore")) return "ignored";
if (tag === "script" || tag === "style" || tag === "template") return "excluded-tag";
return element.hasAttribute("hidden") || element.getAttribute("aria-hidden") === "true" || isVisuallyHidden(element) ? "hidden" : null;
}
function isVisuallyHidden(element) {
const view = element.ownerDocument.defaultView;
if (!view || typeof view.getComputedStyle !== "function") return false;
const style = view.getComputedStyle(element);
return style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse";
}
//#endregion
//#region src/plugins/defaults/accessible-name.ts
const MAX_ACCESSIBLE_NAME_CHARS = 160;
function accessibleName(element) {
return explicitAccessibleName(element) || visibleName(element);
}
function explicitAccessibleName(element) {
const labelledBy = element.getAttribute("aria-labelledby")?.split(/\s+/).flatMap((id) => {
const referenced = element.ownerDocument.getElementById(id);
return referenced ? [referenced.textContent ?? ""] : [];
}).map(collapseWhitespace).filter(Boolean).join(" ");
if (labelledBy) return boundAccessibleText(labelledBy);
const ariaLabel = collapseWhitespace(element.getAttribute("aria-label") ?? "");
if (ariaLabel) return boundAccessibleText(ariaLabel);
const id = element.getAttribute("id");
if (id) {
const label = Array.from(element.ownerDocument.querySelectorAll("label")).find((candidate) => candidate.getAttribute("for") === id);
if (label) {
const value = labelText(label);
if (value) return boundAccessibleText(value);
}
}
const parentLabel = element.closest("label");
if (parentLabel) {
const value = labelText(parentLabel);
if (value) return boundAccessibleText(value);
}
return "";
}
function boundAccessibleText(value) {
if (value.length <= MAX_ACCESSIBLE_NAME_CHARS) return value;
return `${value.slice(0, MAX_ACCESSIBLE_NAME_CHARS - 1).trimEnd()}…`;
}
function visibleName(element) {
const clone = element.cloneNode(true);
for (const descendant of clone.querySelectorAll([
"input",
"select",
"textarea",
"[aria-hidden=\"true\"]",
"[role=\"listbox\"]",
"[role=\"menu\"]",
"[role=\"option\"]"
].join(","))) descendant.remove();
return boundAccessibleText(collapseWhitespace(clone.textContent ?? ""));
}
function labelText(label) {
const clone = label.cloneNode(true);
for (const control of clone.querySelectorAll([
"button",
"input",
"select",
"textarea",
"[role=\"checkbox\"]",
"[role=\"combobox\"]",
"[role=\"radio\"]",
"[role=\"switch\"]"
].join(","))) control.remove();
return collapseWhitespace(clone.textContent ?? "");
}
//#endregion
//#region src/plugins/defaults/aria.ts
const CHECKED_ROLES = new Set([
"checkbox",
"radio",
"switch"
]);
const ariaWidgetMarkdownPlugin = {
id: "aria-widgets",
phase: "renderElement",
render(element) {
return parseAriaWidget(element);
}
};
function parseAriaWidget(element) {
const role = element.getAttribute("role")?.toLowerCase();
if (role === "tablist") {
const selectedTab = Array.from(element.querySelectorAll("[role=\"tab\"][aria-selected=\"true\"]")).find((tab) => tab.closest("[role=\"tablist\"]") === element);
if (!selectedTab) return null;
const value = accessibleName(selectedTab);
if (!value) return null;
return field$1(explicitAccessibleName(element) || "Selected tab", value);
}
if (role === "tab") {
if (element.closest("[role=\"tablist\"]") !== null) return null;
if (element.getAttribute("aria-selected") !== "true") return null;
const label = accessibleName(element);
return label ? field$1("Selected tab", label) : null;
}
if (role === "combobox") {
const label = accessibleName(element);
const value = comboboxValue(element);
return label && value ? field$1(label, value) : void 0;
}
if (role && CHECKED_ROLES.has(role)) {
const label = accessibleName(element);
const state = checkedState(element, role);
return label && state ? field$1(label, state) : void 0;
}
const pressed = tristateAttribute(element, "aria-pressed");
if (pressed) {
const label = accessibleName(element);
return label ? field$1(label, onOffState(pressed)) : void 0;
}
const expanded = booleanAttribute(element, "aria-expanded");
if (expanded !== void 0) {
const label = accessibleName(element);
return label ? field$1(label, expanded ? "expanded" : "collapsed") : void 0;
}
}
function comboboxValue(element) {
const ownValue = formControlValue(element);
if (ownValue) return boundAccessibleText(ownValue);
const nestedControl = element.querySelector("input, select, textarea");
const nestedValue = nestedControl ? formControlValue(nestedControl) : "";
if (nestedValue) return boundAccessibleText(nestedValue);
const valueText = collapseWhitespace(element.getAttribute("aria-valuetext") ?? "");
if (valueText) return boundAccessibleText(valueText);
const selectedOption = selectedOwnedOption(element);
return selectedOption ? boundAccessibleText(accessibleName(selectedOption)) : "";
}
function selectedOwnedOption(element) {
const controlledIds = element.getAttribute("aria-controls")?.split(/\s+/).filter(Boolean);
for (const id of controlledIds ?? []) {
const selected = element.ownerDocument.getElementById(id)?.querySelector("[role=\"option\"][aria-selected=\"true\"]");
if (selected) return selected;
}
const activeId = element.getAttribute("aria-activedescendant");
return activeId ? element.ownerDocument.getElementById(activeId) ?? void 0 : void 0;
}
function formControlValue(element) {
const tag = element.tagName.toLowerCase();
if (tag === "select") {
const select = element;
return collapseWhitespace(Array.from(select.options).find((option) => option.selected)?.textContent ?? select.value);
}
if (tag !== "input" && tag !== "textarea") return "";
const control = element;
if (tag === "input" && control.type === "password") return "";
return collapseWhitespace(control.value);
}
function checkedState(element, role) {
const ariaChecked = tristateAttribute(element, "aria-checked");
if (ariaChecked) return role === "switch" ? onOffState(ariaChecked) : checkedLabel(ariaChecked);
if (element.tagName.toLowerCase() !== "input") return void 0;
const checked = element.checked;
return role === "switch" ? checked ? "on" : "off" : checked ? "checked" : "unchecked";
}
function checkedLabel(state) {
if (state === "mixed") return "mixed";
return state === "true" ? "checked" : "unchecked";
}
function onOffState(state) {
if (state === "mixed") return "mixed";
return state === "true" ? "on" : "off";
}
function tristateAttribute(element, attribute) {
const value = element.getAttribute(attribute);
return value === "true" || value === "false" || value === "mixed" ? value : void 0;
}
function booleanAttribute(element, attribute) {
const value = element.getAttribute(attribute);
if (value === "true") return true;
if (value === "false") return false;
}
function field$1(label, value) {
return {
type: "paragraph",
children: [{
type: "text",
value: `${label}: ${value}`
}],
data: { domMd: {
chunk: true,
kind: "field",
label
} }
};
}
//#endregion
//#region src/plugins/defaults/field-pairs.ts
function parseFieldPairs(element, context) {
const nodes = Array.from(element.querySelectorAll("dt,dd"));
const children = [];
for (let index = 0; index < nodes.length; index += 1) {
const term = nodes[index];
const definition = nodes[index + 1];
if (!term || term.tagName.toLowerCase() !== "dt" || !definition || definition.tagName.toLowerCase() !== "dd") continue;
if (children.length) children.push({ type: "break" });
const label = context.inline(term);
const value = context.inline(definition);
children.push(...label);
if (value.length) children.push({
type: "text",
value: ": "
}, ...value);
index += 1;
}
if (!children.length) return [];
return [{
type: "paragraph",
children,
data: { domMd: {
chunk: true,
kind: "field",
label: "Fields"
} }
}];
}
function hasFieldPairs(element) {
return element.querySelector("dt") !== null && element.querySelector("dd") !== null;
}
function hasDirectFieldPairs(element) {
const children = Array.from(element.children).map((child) => child.tagName.toLowerCase());
return children.includes("dt") && children.includes("dd");
}
//#endregion
//#region src/plugins/defaults/buttons.ts
const buttonMarkdownPlugin = {
id: "buttons",
phase: "renderElement",
render(element, context) {
if (element.tagName.toLowerCase() !== "button") return void 0;
return parseButton(element, context);
}
};
function parseButton(element, context) {
if (hasFieldPairs(element)) return parseFieldPairs(element, context);
const label = accessibleName(element) || stringifyMarkdownNode({
type: "paragraph",
children: context.inline(element)
});
return label ? {
type: "paragraph",
children: [{
type: "text",
value: hasContentStructure(element) ? label : `Button: ${label}`
}],
data: { domMd: {
chunk: true,
kind: "action",
label
} }
} : null;
}
function hasContentStructure(element) {
return Boolean(element.querySelector([
"[data-md]",
"article",
"section",
"header",
"footer",
"main",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"dl",
"dt",
"dd",
"table",
"ul",
"ol",
"li"
].join(",")));
}
//#endregion
//#region src/plugins/defaults/ids.ts
const DATA_MD_PLUGIN_ID = "data-md";
//#endregion
//#region src/plugins/defaults/data-md.ts
const dataMdMarkdownPlugin = {
id: DATA_MD_PLUGIN_ID,
phase: "renderElement",
render(element, context) {
const authored = element.getAttribute(MARKDOWN_ATTR);
if (authored === null) return void 0;
return authoredMarkdownNodes(authored, element.getAttribute("data-md-children-token") ?? "{{children}}", element, context).map((node) => ({
...node,
data: {
...node.data,
domMd: {
...node.data?.domMd,
authored: true,
chunk: true,
kind: "override",
label: describeElement(element, authored)
}
}
}));
}
};
function authoredMarkdownNodes(markdown, token, element, context) {
const tokenLine = new RegExp(`^(?:[\\t ]*)${escapeRegExp(token)}(?:[\\t ]*)$`, "gm");
const parts = markdown.split(tokenLine);
if (parts.length === 1) return rawMarkdown(markdown);
const children = toBlocks(context.children(element));
return parts.flatMap((part, index) => [...rawMarkdown(part), ...index < parts.length - 1 ? children : []]);
}
function rawMarkdown(value) {
const trimmed = value.trim();
return trimmed ? [{
type: "rawMarkdown",
value: trimmed
}] : [];
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
//#endregion
//#region src/plugins/defaults/forms.ts
const formMarkdownPlugin = {
id: "forms",
phase: "renderElement",
render(element, context) {
const tag = element.tagName.toLowerCase();
if (tag === "label") return parseLabel(element, context);
if (tag === "legend") return {
type: "strong",
children: context.inline(element)
};
if (tag === "dl") return parseFieldPairs(element, context);
if (isFormControl(tag)) {
if (tag === "input" && element.type === "hidden") return null;
return parseFormControl(element, tag);
}
if (hasDirectFieldPairs(element)) return parseFieldPairs(element, context);
}
};
function parseFormControl(element, tag) {
const label = fieldLabel(element);
if (tag === "textarea") return field(label, element.value);
if (tag === "select") {
const select = element;
return field(label, selectedOptionText(select) || select.value);
}
const input = element;
if (input.type === "checkbox" || input.type === "radio") return field(label, input.checked ? "checked" : "unchecked");
if (input.type === "button" || input.type === "reset" || input.type === "submit") {
const actionLabel = input.value || label;
return {
type: "paragraph",
children: [{
type: "text",
value: `Button: ${actionLabel}`
}],
data: { domMd: {
chunk: true,
kind: "action",
label: actionLabel
} }
};
}
if (input.type === "password") return field(label, "[value omitted]");
return field(label, input.value);
}
function parseLabel(element, context) {
if (element.hasAttribute("for")) return null;
const controls = Array.from(element.querySelectorAll("input, textarea, select"));
if (controls.length > 0) return controls.flatMap((control) => context.extract(control));
return context.inline(element);
}
function isFormControl(tag) {
return tag === "input" || tag === "select" || tag === "textarea";
}
function fieldLabel(element) {
return accessibleName(element) || element.getAttribute("name") || "Field";
}
function selectedOptionText(select) {
return Array.from(select.options).find((option) => option.selected)?.textContent ?? "";
}
function field(label, value) {
const normalized = collapseWhitespace(value ?? "");
return {
type: "paragraph",
children: [{
type: "text",
value: normalized ? `${label}: ${normalized}` : label
}],
data: { domMd: {
chunk: true,
kind: "field",
label
} }
};
}
//#endregion
//#region src/plugins/defaults/tables.ts
const tableMarkdownPlugin = {
id: "tables",
phase: "renderElement",
render(element, context) {
const native = element.tagName.toLowerCase() === "table";
const aria = element.getAttribute("role") === "table";
if (!native && !aria) return void 0;
const rows = native ? nativeTableRows(element) : ariaTableRows(element);
if (!rows.length) return null;
const foundHeader = rows.findIndex((row) => row.isHeader);
const headerIndex = foundHeader === -1 ? 0 : foundHeader;
const header = rows[headerIndex];
if (!header) return null;
const ignoredColumns = new Set(header.cells.flatMap((cell, index) => cell.hasAttribute("data-md-ignore") ? [index] : []));
const children = rows.map((row) => ({
type: "tableRow",
children: row.cells.filter((_, index) => !ignoredColumns.has(index)).map((cell) => parseTableCell(cell, context))
}));
const label = explicitAccessibleName(element) || void 0;
const table = {
type: "table",
children: [children[headerIndex], ...children.filter((_, index) => index !== headerIndex)],
data: { domMd: {
chunk: true,
label
} }
};
const output = [];
if (label) output.push({
type: "paragraph",
children: [{
type: "strong",
children: [{
type: "text",
value: label
}]
}]
});
output.push(table);
return output;
}
};
function nativeTableRows(element) {
return Array.from(element.querySelectorAll("tr")).filter((row) => row.closest("table") === element).map((row, index) => ({
cells: Array.from(row.querySelectorAll("th,td")).filter((cell) => cell.closest("tr") === row),
isHeader: index === 0
}));
}
function ariaTableRows(element) {
return Array.from(element.querySelectorAll("[role=\"row\"]")).filter((row) => row.closest("[role=\"table\"]") === element).map((row) => {
const cells = Array.from(row.querySelectorAll("[role=\"columnheader\"],[role=\"rowheader\"],[role=\"cell\"]")).filter((cell) => cell.closest("[role=\"row\"]") === row);
return {
cells,
isHeader: cells.some((cell) => cell.getAttribute("role") === "columnheader")
};
}).filter((row) => row.cells.length > 0);
}
function parseTableCell(cell, context) {
let children = [];
if (!cell.hasAttribute("data-md-ignore")) {
const ariaLabel = !cell.hasAttribute("data-md") ? cell.getAttribute("aria-label") : null;
children = ariaLabel ? [{
type: "text",
value: collapseWhitespace(ariaLabel)
}] : toInline(context.extract(cell));
}
return {
type: "tableCell",
children,
data: { domMd: { label: cell.getAttribute("aria-label") ?? void 0 } }
};
}
//#endregion
//#region src/plugins/defaults/visuals.ts
const visualMarkdownPlugin = {
id: "visuals",
phase: "renderElement",
render(element) {
const tag = element.tagName.toLowerCase();
if (tag === "img") {
const alt = element.getAttribute("alt") || element.getAttribute("aria-label") || "image";
const url = element.getAttribute("src");
return url && !url.startsWith("data:") ? {
type: "image",
alt,
url,
data: { domMd: { chunk: true } }
} : visualText(`Image: ${alt}`, alt);
}
if (tag === "svg") {
const label = labelForVisual(element);
return label ? visualText(`SVG: ${label}`, label) : null;
}
if (tag === "canvas") {
const label = labelForVisual(element);
return visualText(`Canvas: ${label || "no label"}`, label);
}
}
};
function visualText(value, label) {
return {
type: "paragraph",
children: [{
type: "emphasis",
children: [{
type: "text",
value
}]
}],
data: { domMd: {
chunk: true,
kind: "visual",
label
} }
};
}
function labelForVisual(element) {
return (element.getAttribute("aria-label") || element.querySelector("title")?.textContent || element.querySelector("desc")?.textContent || "").trim();
}
//#endregion
//#region src/plugins/defaults/registry.ts
const defaultRenderElementOverrides = [dataMdMarkdownPlugin];
const defaultRenderElementPlugins = [
visualMarkdownPlugin,
tableMarkdownPlugin,
ariaWidgetMarkdownPlugin,
formMarkdownPlugin,
buttonMarkdownPlugin
];
//#endregion
//#region src/plugins/max-chars.ts
const maxCharsMarkdownPlugin = createPlugin("max-chars", ({ annotateElement, transform }) => [annotateElement("read-attribute", (element, context) => {
const limit = readMaxCharsLimit(element, context.sourceId);
return limit ? { maxChars: { limits: [limit] } } : void 0;
}), transform("enforce", (tree, context) => {
enforceMaxChars(tree, context.stringify);
})]);
function readMaxCharsLimit(element, sourceId) {
const maxChars = readPositiveInteger(element, MARKDOWN_MAX_CHARS_ATTR);
if (!maxChars) return void 0;
return {
sourceId,
maxChars,
label: describeElement(element)
};
}
function enforceMaxChars(tree, stringify) {
compactParent(tree, stringify);
}
function compactParent(parent, stringify) {
for (const child of parent.children) {
if (isParent(child)) compactParent(child, stringify);
if (child.type === "tableCell") compactTableCell(child, stringify);
}
const mode = childMode(parent.type);
if (!mode) return;
for (const limit of collectLimits(parent.children)) compactScopedRuns(parent, limit, mode, stringify);
}
function collectLimits(children) {
const limits = /* @__PURE__ */ new Map();
for (const child of children) for (const limit of child.data?.maxChars?.limits ?? []) if (!limits.has(limit.sourceId)) limits.set(limit.sourceId, limit);
return [...limits.values()];
}
function compactScopedRuns(parent, limit, mode, stringify) {
const runs = limitedRuns(parent.children, limit.sourceId);
for (const run of runs.reverse()) {
const nodes = parent.children.slice(run.start, run.end).map((node) => stripLimit(node, limit.sourceId));
const replacement = mode === "flow" ? fitFlow(nodes, limit.maxChars, stringify) : fitInline(nodes, limit.maxChars, stringify);
parent.children.splice(run.start, run.end - run.start, ...replacement);
}
}
function limitedRuns(children, sourceId) {
const runs = [];
let start = -1;
for (let index = 0; index <= children.length; index += 1) {
const child = children[index];
const included = child ? hasLimit(child, sourceId) : false;
if (included && start === -1) start = index;
if (!included && start !== -1) {
runs.push({
start,
end: index
});
start = -1;
}
}
return runs;
}
function hasLimit(node, sourceId) {
return node.data?.maxChars?.limits?.some((limit) => limit.sourceId === sourceId) ?? false;
}
function compactTableCell(cell, stringify) {
const limit = cell.data?.maxChars?.limits?.[0];
if (!limit || stringify(cell).length <= limit.maxChars) return;
cell.children = fitInline(cell.children, limit.maxChars, stringify);
cell.data = stripMaxCharsData(cell.data);
}
function fitFlow(nodes, maxChars, stringify) {
if (renderFlow(nodes, stringify).length <= maxChars) return nodes;
const fitted = [];
for (const node of nodes) {
if (renderFlow([...fitted, node], stringify).length <= maxChars) {
fitted.push(node);
continue;
}
const truncated = fitFlowText(fitted, plainText(node) || stringify(node), maxChars, node, node.data, stringify);
if (truncated) fitted.push(truncated);
break;
}
return fitted;
}
function fitFlowText(prefix, value, maxChars, source, metadata, stringify) {
let low = 1;
let high = value.length;
let best;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const candidate = flowTextNode(source, clampInline(value, middle), metadata);
if (renderFlow([...prefix, candidate], stringify).length <= maxChars) {
best = candidate;
low = middle + 1;
} else high = middle - 1;
}
return best;
}
function fitInline(nodes, maxChars, stringify) {
if (renderInline(nodes, stringify).length <= maxChars) return nodes;
const value = nodes.map(plainText).filter(Boolean).join(" ");
let low = 1;
let high = value.length;
let best;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const candidate = {
type: "text",
value: clampInline(value, middle),
data: nodes[0]?.data
};
if (renderInline([candidate], stringify).length <= maxChars) {
best = candidate;
low = middle + 1;
} else high = middle - 1;
}
return best ? [best] : [];
}
function renderFlow(nodes, stringify) {
return stringify({
type: "root",
children: nodes
});
}
function renderInline(nodes, stringify) {
return stringify({
type: "paragraph",
children: nodes
});
}
function childMode(type) {
if (type === "root" || type === "blockquote" || type === "listItem") return "flow";
if (type === "emphasis" || type === "heading" || type === "link" || type === "paragraph" || type === "strong" || type === "tableCell") return "inline";
}
function stripLimit(node, sourceId) {
const limits = node.data?.maxChars?.limits?.filter((limit) => limit.sourceId !== sourceId);
return {
...node,
data: stripMaxCharsData({
...node.data,
maxChars: {
...node.data?.maxChars,
limits
}
})
};
}
function stripMaxCharsData(data) {
if (data?.maxChars?.limits?.length) return data;
if (!data || !("maxChars" in data)) return data;
const { maxChars: _maxChars, ...rest } = data;
return rest;
}
function flowTextNode(source, value, data) {
if (source.type === "heading") return {
type: "heading",
depth: source.depth,
children: [{
type: "text",
value
}],
data
};
return {
type: "paragraph",
children: [{
type: "text",
value
}],
data
};
}
function plainText(value) {
if (!value || typeof value !== "object") return "";
if ("value" in value && typeof value.value === "string") return value.value;
if ("alt" in value && typeof value.alt === "string") return value.alt;
if ("children" in value && Array.isArray(value.children)) return value.children.map(plainText).filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
return "";
}
function isParent(node) {
return "children" in node && Array.isArray(node.children);
}
//#endregion
//#region src/core/validate.ts
const flow = new Set([
"blockquote",
"code",
"heading",
"list",
"paragraph",
"rawMarkdown",
"table",
"thematicBreak",
"yaml"
]);
const inline = new Set([
"break",
"emphasis",
"image",
"inlineCode",
"link",
"strong",
"text"
]);
const literals = new Set([
"code",
"inlineCode",
"rawMarkdown",
"text",
"yaml"
]);
const voids = new Set([
"break",
"image",
"thematicBreak"
]);
const known = new Set([
...flow,
...inline,
"listItem",
"root",
"tableCell",
"tableRow"
]);
function assertMarkdownNode(value) {
assertNode(value, void 0, "$");
}
function assertNode(value, parentType, path) {
if (!value || typeof value !== "object") throw new TypeError(`${path} must be a Markdown tree node.`);
const node = value;
if (typeof node.type !== "string" || !node.type) throw new TypeError(`${path} must have a non-empty string type.`);
const type = node.type;
if (!known.has(type)) throw new TypeError(`${path} (${type}) is not a supported Markdown node type.`);
if (type === "root" && parentType !== void 0) throw new TypeError(`${path}: root nodes cannot be nested.`);
if (literals.has(type) && typeof node.value !== "string") throw new TypeError(`${path} (${type}) must have a string value.`);
if ((literals.has(type) || voids.has(type)) && "children" in node) throw new TypeError(`${path} (${type}) cannot have children.`);
const expected = expectedChildren(type);
if (!("children" in node)) {
if (!expected) return;
throw new TypeError(`${path} (${type}) must have children.`);
}
if (!Array.isArray(node.children)) throw new TypeError(`${path} (${type}) children must be an array.`);
node.children.forEach((child, index) => {
assertNode(child, type, `${path}.children[${index}]`);
const childType = child.type;
if (expected && known.has(childType) && !expected.has(childType)) throw new TypeError(`${path}.children[${index}] (${childType}) is not valid inside ${type}.`);
});
}
function expectedChildren(type) {
if (type === "root" || type === "blockquote" || type === "listItem") return flow;
if (type === "paragraph" || type === "heading" || type === "emphasis" || type === "strong" || type === "link" || type === "tableCell") return inline;
if (type === "list") return new Set(["listItem"]);
if (type === "table") return new Set(["tableRow"]);
if (type === "tableRow") return new Set(["tableCell"]);
}
//#endregion
//#region src/core/create-renderer.ts
const defaultAnnotators = maxCharsMarkdownPlugin.filter((plugin) => plugin.phase === "annotateElement");
const defaultTransforms = maxCharsMarkdownPlugin.filter((plugin) => plugin.phase === "transform");
function createMarkdownRenderer(options = {}, observer) {
return createMarkdownRuntime(options, observer);
}
function createMarkdownRuntime(options = {}, observer) {
const pipeline = createPluginPipeline(options.plugins ?? [], observer);
const stringify = (tree) => {
validateTree(tree, "format");
return stringifyMarkdownNode(tree);
};
const finalize = (markdown, tree) => {
let output = markdown;
for (const plugin of pipeline.postprocessors) {
const before = output;
try {
output = plugin.postprocess(output, { tree });
} catch (cause) {
if (cause instanceof DomMarkdownError) throw cause;
throw new DomMarkdownError(`Markdown postprocess plugin "${plugin.id}" failed.`, {
phase: "postprocess",
pluginId: plugin.id,
cause
});
}
observer?.postprocessApplied?.({
pluginId: plugin.id,
before,
after: output,
tree
});
}
return output;
};
const toTree = (root) => {
const source = resolveRoot(root);
if (!source) return {
type: "root",
children: []
};
let tree = extractMarkdownTree(source, pipeline);
validateTree(tree, "normalize");
observer?.treeBuilt?.({
tree,
markdown: stringify(tree)
});
for (const plugin of pipeline.transforms) {
const before = observer?.transformApplied ? stringify(tree) : void 0;
try {
tree = plugin.transform(tree, { stringify }) ?? tree;
validateTree(tree, "transform", plugin.id);
} catch (cause) {
if (cause instanceof DomMarkdownError) throw cause;
throw new DomMarkdownError(`Markdown transform plugin "${plugin.id}" failed.`, {
phase: "transform",
pluginId: plugin.id,
cause
});
}
if (before !== void 0) observer?.transformApplied?.({
pluginId: plugin.id,
before,
after: stringify(tree),
tree
});
}
return tree;
};
return {
render(root) {
const tree = toTree(root);
return finalize(stringify(tree), tree);
},
finalize,
stringify,
toTree
};
}
function validateTree(tree, phase, pluginId) {
try {
assertMarkdownNode(tree);
} catch (cause) {
throw new DomMarkdownError(pluginId ? `Markdown plugin "${pluginId}" produced an invalid Markdown tree.` : "Invalid Markdown tree.", {
phase,
pluginId,
node: tree,
cause
});
}
}
function createPluginPipeline(plugins, observer) {
const annotators = [];
const renderers = [];
const transforms = [];
const postprocessors = [];
for (const plugin of flattenPlugins(plugins)) {
validatePlugin(plugin);
if (plugin.phase === "annotateElement") annotators.push(plugin);
else if (plugin.phase === "renderElement") renderers.push(plugin);
else if (plugin.phase === "transform") transforms.push(plugin);
else postprocessors.push(plugin);
}
const userAnnotators = dedupe(annotators);
const userRenderers = dedupe(renderers);
const userTransforms = dedupe(transforms);
const userPostprocessors = dedupe(postprocessors);
return {
annotators: [...withoutReplacedDefaults(defaultAnnotators, userAnnotators), ...userAnnotators],
renderers: [
...withoutReplacedDefaults(defaultRenderElementOverrides, userRenderers),
...userRenderers,
...withoutReplacedDefaults(defaultRenderElementPlugins, userRenderers)
],
transforms: [...withoutReplacedDefaults(defaultTransforms, userTransforms), ...userTransforms],
postprocessors: userPostprocessors,
observer
};
}
function flattenPlugins(inputs, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
if (depth > 20) throw new Error("Markdown plugins are nested too deeply.");
if (seen.has(inputs)) throw new Error("Markdown plugins cannot contain cyclic arrays.");
seen.add(inputs);
const plugins = [];
for (const input of inputs) if (isPluginInputArray(input)) plugins.push(...flattenPlugins(input, seen, depth + 1));
else plugins.push(input);
seen.delete(inputs);
return plugins;
}
function isPluginInputArray(input) {
return Array.isArray(input);
}
function validatePlugin(plugin) {
if (!plugin.id.trim()) throw new Error("Markdown plugins must have a non-empty id.");
if (plugin.phase === "annotateElement" && typeof plugin.annotate !== "function") throw new Error(`Annotate element plugin "${plugin.id}" must define annotate().`);
if (plugin.phase === "renderElement" && typeof plugin.render !== "function") throw new Error(`Render element plugin "${plugin.id}" must define render().`);
if (plugin.phase === "transform" && typeof plugin.transform !== "function") throw new Error(`Transform plugin "${plugin.id}" must define transform().`);
if (plugin.phase === "postprocess" && typeof plugin.postprocess !== "function") throw new Error(`Postprocess plugin "${plugin.id}" must define postprocess().`);
}
function dedupe(plugins) {
const last = /* @__PURE__ */ new Map();
for (const plugin of plugins) last.set(plugin.id, plugin);
return plugins.filter((plugin) => last.get(plugin.id) === plugin);
}
function withoutReplacedDefaults(defaults, replacements) {
const ids = new Set(replacements.map((plugin) => plugin.id));
return defaults.filter((plugin) => !ids.has(plugin.id));
}
function resolveRoot(root) {
const source = root ?? defaultRoot(globalThis.document);
if (!source) return null;
return isDocument(source) ? defaultRoot(source) : source;
}
function defaultRoot(documentLike) {
if (!documentLike) return null;
return documentLike.querySelector(`[data-md-root]`) ?? documentLike.querySelector("main") ?? documentLike.body ?? documentLike;
}
function isDocument(node) {
return node.nodeType === 9;
}
//#endregion
//#region src/core/serialize.ts
function renderMarkdown(root, options = {}) {
return createMarkdownRenderer(options).render(root);
}
function toMarkdownTree(root, options = {}) {
return createMarkdownRenderer(options).toTree(root);
}
function stringifyMarkdown(tree, options = {}) {
return createMarkdownRenderer(options).stringify(tree);
}
/** @internal Shared by rendering and devtools inspection. */
function runMarkdown(root, options = {}, observer) {
const renderer = createMarkdownRuntime(options, observer);
const tree = renderer.toTree(root);
return {
markdown: renderer.finalize(renderer.stringify(tree), tree),
tree
};
}
//#endregion
export { createMarkdownRenderer as a, toMarkdownTree as i, runMarkdown as n, DATA_MD_PLUGIN_ID as o, stringifyMarkdown as r, stringifyMarkdownNode as s, renderMarkdown as t };
//# sourceMappingURL=serialize-CVJfCHsE.js.map

Sorry, the diff of this file is too big to display

import { o as Nodes } from "./mdast-CsrWVudR.js";
//#region src/tree.d.ts
/** A node visited while walking a dom-md Markdown tree. */
type VisitNode<CustomData extends object = Record<string, unknown>> = Nodes<CustomData>;
/** Control traversal from a `visit()` callback. */
type VisitAction = 'skip' | 'stop' | void;
/** Extra location information passed to a `visit()` callback. */
type VisitContext<CustomData extends object = Record<string, unknown>> = {
/** The parent node, if the current node is not the root being visited. */parent?: VisitNode<CustomData>; /** The current node's index inside `parent.children`, when available. */
index?: number; /** Ancestors from the visited root down to the current node's parent. */
ancestors: readonly VisitNode<CustomData>[];
};
/** Options for Markdown tree traversal. */
type VisitOptions = {
/**
* Visit parent nodes before or after their children.
*
* Defaults to `'pre'`. Use `'post'` when a transform needs child nodes to be
* normalized before it edits the parent.
*/
order?: 'pre' | 'post';
};
/**
* Walk every node in a dom-md Markdown tree.
*
* Return `'skip'` from the visitor to avoid walking the current node's
* children, or `'stop'` to end traversal entirely. The helper does not clone
* nodes; transforms may mutate visited nodes in place.
*/
declare function visit<CustomData extends object = Record<string, unknown>>(root: VisitNode<CustomData>, visitor: (node: VisitNode<CustomData>, context: VisitContext<CustomData>) => VisitAction, options?: VisitOptions): void;
//#endregion
export { VisitAction, VisitContext, VisitNode, VisitOptions, visit };
//# sourceMappingURL=tree.d.ts.map
//#region src/tree.ts
/**
* Walk every node in a dom-md Markdown tree.
*
* Return `'skip'` from the visitor to avoid walking the current node's
* children, or `'stop'` to end traversal entirely. The helper does not clone
* nodes; transforms may mutate visited nodes in place.
*/
function visit(root, visitor, options = {}) {
const order = options.order ?? "pre";
let stopped = false;
function walk(node, parent, index, ancestors) {
if (stopped) return;
const context = { ancestors };
if (parent) context.parent = parent;
if (index !== void 0) context.index = index;
if (order === "pre") {
const action = visitor(node, context);
if (action === "stop") {
stopped = true;
return;
}
if (action === "skip") return;
}
const children = childNodes(node);
const childAncestors = children.length ? [...ancestors, node] : ancestors;
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
walk(children[childIndex], node, childIndex, childAncestors);
if (stopped) return;
}
if (order === "post") {
if (visitor(node, context) === "stop") stopped = true;
}
}
walk(root, void 0, void 0, []);
}
function childNodes(node) {
return "children" in node && Array.isArray(node.children) ? node.children : [];
}
//#endregion
export { visit };
//# sourceMappingURL=tree.js.map
{"version":3,"file":"tree.js","names":[],"sources":["../src/tree.ts"],"sourcesContent":["import type { Nodes } from './core/mdast';\n\n/** A node visited while walking a dom-md Markdown tree. */\nexport type VisitNode<CustomData extends object = Record<string, unknown>> = Nodes<CustomData>;\n\n/** Control traversal from a `visit()` callback. */\nexport type VisitAction = 'skip' | 'stop' | void;\n\n/** Extra location information passed to a `visit()` callback. */\nexport type VisitContext<CustomData extends object = Record<string, unknown>> = {\n /** The parent node, if the current node is not the root being visited. */\n parent?: VisitNode<CustomData>;\n /** The current node's index inside `parent.children`, when available. */\n index?: number;\n /** Ancestors from the visited root down to the current node's parent. */\n ancestors: readonly VisitNode<CustomData>[];\n};\n\n/** Options for Markdown tree traversal. */\nexport type VisitOptions = {\n /**\n * Visit parent nodes before or after their children.\n *\n * Defaults to `'pre'`. Use `'post'` when a transform needs child nodes to be\n * normalized before it edits the parent.\n */\n order?: 'pre' | 'post';\n};\n\n/**\n * Walk every node in a dom-md Markdown tree.\n *\n * Return `'skip'` from the visitor to avoid walking the current node's\n * children, or `'stop'` to end traversal entirely. The helper does not clone\n * nodes; transforms may mutate visited nodes in place.\n */\nexport function visit<CustomData extends object = Record<string, unknown>>(\n root: VisitNode<CustomData>,\n visitor: (\n node: VisitNode<CustomData>,\n context: VisitContext<CustomData>,\n ) => VisitAction,\n options: VisitOptions = {},\n): void {\n const order = options.order ?? 'pre';\n let stopped = false;\n\n function walk(\n node: VisitNode<CustomData>,\n parent: VisitNode<CustomData> | undefined,\n index: number | undefined,\n ancestors: readonly VisitNode<CustomData>[],\n ): void {\n if (stopped) return;\n\n const context: VisitContext<CustomData> = { ancestors };\n if (parent) context.parent = parent;\n if (index !== undefined) context.index = index;\n\n if (order === 'pre') {\n const action = visitor(node, context);\n if (action === 'stop') {\n stopped = true;\n return;\n }\n if (action === 'skip') return;\n }\n\n const children = childNodes(node);\n const childAncestors = children.length ? [...ancestors, node] : ancestors;\n for (let childIndex = 0; childIndex < children.length; childIndex += 1) {\n walk(children[childIndex]!, node, childIndex, childAncestors);\n if (stopped) return;\n }\n\n if (order === 'post') {\n const action = visitor(node, context);\n if (action === 'stop') stopped = true;\n }\n }\n\n walk(root, undefined, undefined, []);\n}\n\nfunction childNodes<CustomData extends object>(\n node: VisitNode<CustomData>,\n): readonly VisitNode<CustomData>[] {\n return 'children' in node && Array.isArray(node.children)\n ? node.children as readonly VisitNode<CustomData>[]\n : [];\n}\n"],"mappings":";;;;;;;;AAoCA,SAAgB,MACd,MACA,SAIA,UAAwB,CAAC,GACnB;CACN,MAAM,QAAQ,QAAQ,SAAS;CAC/B,IAAI,UAAU;CAEd,SAAS,KACP,MACA,QACA,OACA,WACM;EACN,IAAI,SAAS;EAEb,MAAM,UAAoC,EAAE,UAAU;EACtD,IAAI,QAAQ,QAAQ,SAAS;EAC7B,IAAI,UAAU,KAAA,GAAW,QAAQ,QAAQ;EAEzC,IAAI,UAAU,OAAO;GACnB,MAAM,SAAS,QAAQ,MAAM,OAAO;GACpC,IAAI,WAAW,QAAQ;IACrB,UAAU;IACV;GACF;GACA,IAAI,WAAW,QAAQ;EACzB;EAEA,MAAM,WAAW,WAAW,IAAI;EAChC,MAAM,iBAAiB,SAAS,SAAS,CAAC,GAAG,WAAW,IAAI,IAAI;EAChE,KAAK,IAAI,aAAa,GAAG,aAAa,SAAS,QAAQ,cAAc,GAAG;GACtE,KAAK,SAAS,aAAc,MAAM,YAAY,cAAc;GAC5D,IAAI,SAAS;EACf;EAEA,IAAI,UAAU;OACG,QAAQ,MAAM,OACpB,MAAM,QAAQ,UAAU;EAAA;CAErC;CAEA,KAAK,MAAM,KAAA,GAAW,KAAA,GAAW,CAAC,CAAC;AACrC;AAEA,SAAS,WACP,MACkC;CAClC,OAAO,cAAc,QAAQ,MAAM,QAAQ,KAAK,QAAQ,IACpD,KAAK,WACL,CAAC;AACP"}
import { c as Root, i as InlineContent, l as RootContent, o as Nodes } from "./mdast-CsrWVudR.js";
//#region src/core/types.d.ts
/** The Markdown document tree produced from the DOM. */
type MarkdownTree<CustomData extends object = Record<string, unknown>> = Root<CustomData>;
/** Any node supported by dom-md's Markdown tree. */
type MarkdownNode<CustomData extends object = Record<string, unknown>> = Nodes<CustomData>;
/** A node, or inline node, that a renderElement step may return. */
type ElementContent<CustomData extends object = Record<string, unknown>> = RootContent<CustomData> | InlineContent<CustomData>;
type DomMdKind = 'action' | 'field' | 'override' | 'visual';
type ElementOutput<CustomData extends object = Record<string, unknown>> = ElementContent<CustomData> | readonly ElementContent<CustomData>[] | null | undefined;
type ElementAnnotation<CustomData extends object = Record<string, unknown>> = (CustomData & {
domMd?: never;
}) | null | undefined | void;
/** Helpers available while a renderElement step converts DOM into Markdown nodes. */
type RenderElementContext<CustomData extends object = Record<string, unknown>> = {
/** Stable id for the current source element within this render pass. */readonly sourceId: string; /** Extract an element's children as block-capable Markdown nodes. */
children(parent?: ParentNode): ElementContent<CustomData>[]; /** Extract an element's children as inline Markdown nodes. */
inline(parent?: ParentNode): InlineContent<CustomData>[]; /** Extract one specific descendant element. */
extract(element: Element): ElementContent<CustomData>[]; /** Render this element with the later renderers/defaults in the chain. */
renderDefault(): ElementContent<CustomData>[];
};
/**
* A plugin step that can claim, omit, or rewrite individual DOM elements.
*
* Return Markdown nodes to handle the element, `null` to intentionally omit it,
* or `undefined` to let dom-md keep looking for another renderer.
*/
type RenderElementPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */id: string;
phase: 'renderElement';
render(element: Element, context: RenderElementContext<CustomData>): ElementOutput<CustomData>;
};
/** Context available while an annotateElement step reads DOM metadata. */
type AnnotateElementContext = {
/** Stable id for the current source element within this render pass. */readonly sourceId: string;
};
/**
* A plugin step that adds metadata to every Markdown node emitted for an element.
*
* Wrapper elements can emit nodes produced by descendants, so annotation
* metadata intentionally fans out to those nodes. When several annotations
* reach the same node, dom-md recursively merges plain objects, concatenates
* arrays, and lets later scalar values win. The reserved `data.domMd`
* namespace is ignored for custom annotations. Treat nested metadata as
* immutable in transforms because fanned-out nodes may share references.
*/
type AnnotateElementPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */id: string;
phase: 'annotateElement';
annotate(element: Element, context: AnnotateElementContext): ElementAnnotation<CustomData>;
};
/** Helpers available while a transform plugin edits the whole Markdown tree. */
type MarkdownTransformContext<CustomData extends object = Record<string, unknown>> = {
/** Render a node with dom-md's Markdown serializer. Useful for measuring output. */stringify(node: MarkdownNode<CustomData>): string;
};
/**
* A plugin that edits the complete Markdown tree after DOM extraction.
*
* Mutate the tree in place and return nothing, or return a replacement tree.
*/
type TransformPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */id: string;
phase: 'transform';
transform(tree: MarkdownTree<CustomData>, context: MarkdownTransformContext<CustomData>): MarkdownTree<CustomData> | void;
};
/** Context available to the final string postprocessing step. */
type PostprocessContext<CustomData extends object = Record<string, unknown>> = {
/** The tree that was serialized into the Markdown string. */readonly tree: MarkdownTree<CustomData>;
};
/**
* A plugin that edits the final Markdown string produced by `render()`.
*
* Postprocessors do not run during `stringify(tree)`, which keeps tree-to-text
* serialization predictable for tests and transform plugins.
*/
type PostprocessPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */id: string;
phase: 'postprocess';
postprocess(markdown: string, context: PostprocessContext<CustomData>): string;
};
/** Any low-level plugin phase accepted by the renderer. */
type MarkdownPlugin<CustomData extends object = Record<string, unknown>> = AnnotateElementPlugin<CustomData> | PostprocessPlugin<CustomData> | RenderElementPlugin<CustomData> | TransformPlugin<CustomData>;
/**
* A plugin, or nested arrays of plugins.
*
* This lets `createPlugin('name', define)` bundles be passed directly as
* `plugins: [myPlugin]` without manual spreading.
*/
type MarkdownPluginInput<CustomData extends object = Record<string, unknown>> = MarkdownPlugin<CustomData> | readonly MarkdownPluginInput<CustomData>[];
/** Options accepted by `renderMarkdown()` and `createMarkdownRenderer()`. */
type RenderMarkdownOptions = {
plugins?: readonly MarkdownPluginInput[];
};
/** Reusable renderer instance for DOM-to-tree, tree-to-Markdown, and full render. */
type MarkdownRenderer = {
/** Extract, transform, serialize, and postprocess a DOM root. */render(root?: ParentNode | null): string; /** Serialize an existing Markdown tree without running postprocessors. */
stringify(tree: MarkdownTree): string; /** Extract and transform a DOM root into a Markdown tree. */
toTree(root?: ParentNode | null): MarkdownTree;
};
type ElementResolution = {
kind: 'plugin';
pluginId: string;
} | {
kind: 'fallback';
} | {
kind: 'skipped';
reason: 'ignored' | 'hidden' | 'excluded-tag';
};
type RenderObserver = {
elementResolved?(event: {
element: Element;
nodes: ElementContent[];
resolution: ElementResolution;
}): void;
treeBuilt?(event: {
tree: MarkdownTree;
markdown: string;
}): void;
transformApplied?(event: {
pluginId: string;
before: string;
after: string;
tree: MarkdownTree;
}): void;
postprocessApplied?(event: {
pluginId: string;
before: string;
after: string;
tree: MarkdownTree;
}): void;
};
declare class DomMarkdownError extends Error {
readonly phase: 'extract' | 'format' | 'normalize' | 'postprocess' | 'transform';
readonly pluginId?: string;
readonly element?: Element;
readonly node?: MarkdownNode;
readonly cause?: unknown;
constructor(message: string, context: {
phase: DomMarkdownError['phase'];
pluginId?: string;
element?: Element;
node?: MarkdownNode;
cause?: unknown;
});
}
//#endregion
export { RenderElementPlugin as _, ElementAnnotation as a, TransformPlugin as b, MarkdownNode as c, MarkdownRenderer as d, MarkdownTransformContext as f, RenderElementContext as g, PostprocessPlugin as h, DomMdKind as i, MarkdownPlugin as l, PostprocessContext as m, AnnotateElementPlugin as n, ElementContent as o, MarkdownTree as p, DomMarkdownError as r, ElementOutput as s, AnnotateElementContext as t, MarkdownPluginInput as u, RenderMarkdownOptions as v, RenderObserver as y };
//# sourceMappingURL=types-AEJT1bfE.d.ts.map
//#region src/core/types.ts
const MARKDOWN_ROOT_ATTR = "data-md-root";
const MARKDOWN_IGNORE_ATTR = "data-md-ignore";
const MARKDOWN_ATTR = "data-md";
const MARKDOWN_CHILDREN_TOKEN_ATTR = "data-md-children-token";
const MARKDOWN_MAX_CHARS_ATTR = "data-md-max-chars";
const DEFAULT_CHILDREN_TOKEN = "{{children}}";
var DomMarkdownError = class extends Error {
phase;
pluginId;
element;
node;
cause;
constructor(message, context) {
super(message);
this.name = "DomMarkdownError";
this.phase = context.phase;
this.pluginId = context.pluginId;
this.element = context.element;
this.node = context.node;
this.cause = context.cause;
}
};
//#endregion
export { MARKDOWN_IGNORE_ATTR as a, MARKDOWN_CHILDREN_TOKEN_ATTR as i, DomMarkdownError as n, MARKDOWN_MAX_CHARS_ATTR as o, MARKDOWN_ATTR as r, MARKDOWN_ROOT_ATTR as s, DEFAULT_CHILDREN_TOKEN as t };
//# sourceMappingURL=types-ue0OR47d.js.map
{"version":3,"file":"types-ue0OR47d.js","names":[],"sources":["../src/core/types.ts"],"sourcesContent":["import type {\n DomMdMetadata,\n InlineContent,\n Nodes,\n Root,\n RootContent,\n} from './mdast';\n\nexport const MARKDOWN_ROOT_ATTR = 'data-md-root';\nexport const MARKDOWN_IGNORE_ATTR = 'data-md-ignore';\nexport const MARKDOWN_ATTR = 'data-md';\nexport const MARKDOWN_CHILDREN_TOKEN_ATTR = 'data-md-children-token';\nexport const MARKDOWN_MAX_CHARS_ATTR = 'data-md-max-chars';\nexport const DEFAULT_CHILDREN_TOKEN = '{{children}}';\n\nexport const ELEMENT_NODE = 1;\nexport const TEXT_NODE = 3;\nexport const DOCUMENT_NODE = 9;\n\n/** The Markdown document tree produced from the DOM. */\nexport type MarkdownTree<CustomData extends object = Record<string, unknown>> = Root<CustomData>;\n\n/** Any node supported by dom-md's Markdown tree. */\nexport type MarkdownNode<CustomData extends object = Record<string, unknown>> = Nodes<CustomData>;\n\n/** A node, or inline node, that a renderElement step may return. */\nexport type ElementContent<CustomData extends object = Record<string, unknown>> =\n | RootContent<CustomData>\n | InlineContent<CustomData>;\n\nexport type DomMdKind = 'action' | 'field' | 'override' | 'visual';\n\nexport type { DomMdMetadata };\n\nexport type ElementOutput<CustomData extends object = Record<string, unknown>> =\n | ElementContent<CustomData>\n | readonly ElementContent<CustomData>[]\n | null\n | undefined;\n\nexport type ElementAnnotation<CustomData extends object = Record<string, unknown>> =\n | (CustomData & { domMd?: never })\n | null\n | undefined\n | void;\n\n/** Helpers available while a renderElement step converts DOM into Markdown nodes. */\nexport type RenderElementContext<CustomData extends object = Record<string, unknown>> = {\n /** Stable id for the current source element within this render pass. */\n readonly sourceId: string;\n /** Extract an element's children as block-capable Markdown nodes. */\n children(parent?: ParentNode): ElementContent<CustomData>[];\n /** Extract an element's children as inline Markdown nodes. */\n inline(parent?: ParentNode): InlineContent<CustomData>[];\n /** Extract one specific descendant element. */\n extract(element: Element): ElementContent<CustomData>[];\n /** Render this element with the later renderers/defaults in the chain. */\n renderDefault(): ElementContent<CustomData>[];\n};\n\n/**\n * A plugin step that can claim, omit, or rewrite individual DOM elements.\n *\n * Return Markdown nodes to handle the element, `null` to intentionally omit it,\n * or `undefined` to let dom-md keep looking for another renderer.\n */\nexport type RenderElementPlugin<CustomData extends object = Record<string, unknown>> = {\n /** Stable id used in errors, devtools, and plugin grouping. */\n id: string;\n phase: 'renderElement';\n render(element: Element, context: RenderElementContext<CustomData>): ElementOutput<CustomData>;\n};\n\n/** Context available while an annotateElement step reads DOM metadata. */\nexport type AnnotateElementContext = {\n /** Stable id for the current source element within this render pass. */\n readonly sourceId: string;\n};\n\n/**\n * A plugin step that adds metadata to every Markdown node emitted for an element.\n *\n * Wrapper elements can emit nodes produced by descendants, so annotation\n * metadata intentionally fans out to those nodes. When several annotations\n * reach the same node, dom-md recursively merges plain objects, concatenates\n * arrays, and lets later scalar values win. The reserved `data.domMd`\n * namespace is ignored for custom annotations. Treat nested metadata as\n * immutable in transforms because fanned-out nodes may share references.\n */\nexport type AnnotateElementPlugin<CustomData extends object = Record<string, unknown>> = {\n /** Stable id used in errors, devtools, and plugin grouping. */\n id: string;\n phase: 'annotateElement';\n annotate(element: Element, context: AnnotateElementContext): ElementAnnotation<CustomData>;\n};\n\n/** Helpers available while a transform plugin edits the whole Markdown tree. */\nexport type MarkdownTransformContext<CustomData extends object = Record<string, unknown>> = {\n /** Render a node with dom-md's Markdown serializer. Useful for measuring output. */\n stringify(node: MarkdownNode<CustomData>): string;\n};\n\n/**\n * A plugin that edits the complete Markdown tree after DOM extraction.\n *\n * Mutate the tree in place and return nothing, or return a replacement tree.\n */\nexport type TransformPlugin<CustomData extends object = Record<string, unknown>> = {\n /** Stable id used in errors, devtools, and plugin grouping. */\n id: string;\n phase: 'transform';\n transform(\n tree: MarkdownTree<CustomData>,\n context: MarkdownTransformContext<CustomData>,\n ): MarkdownTree<CustomData> | void;\n};\n\n/** Context available to the final string postprocessing step. */\nexport type PostprocessContext<CustomData extends object = Record<string, unknown>> = {\n /** The tree that was serialized into the Markdown string. */\n readonly tree: MarkdownTree<CustomData>;\n};\n\n/**\n * A plugin that edits the final Markdown string produced by `render()`.\n *\n * Postprocessors do not run during `stringify(tree)`, which keeps tree-to-text\n * serialization predictable for tests and transform plugins.\n */\nexport type PostprocessPlugin<CustomData extends object = Record<string, unknown>> = {\n /** Stable id used in errors, devtools, and plugin grouping. */\n id: string;\n phase: 'postprocess';\n postprocess(markdown: string, context: PostprocessContext<CustomData>): string;\n};\n\n/** Any low-level plugin phase accepted by the renderer. */\nexport type MarkdownPlugin<CustomData extends object = Record<string, unknown>> =\n | AnnotateElementPlugin<CustomData>\n | PostprocessPlugin<CustomData>\n | RenderElementPlugin<CustomData>\n | TransformPlugin<CustomData>;\n\n/**\n * A plugin, or nested arrays of plugins.\n *\n * This lets `createPlugin('name', define)` bundles be passed directly as\n * `plugins: [myPlugin]` without manual spreading.\n */\nexport type MarkdownPluginInput<CustomData extends object = Record<string, unknown>> =\n | MarkdownPlugin<CustomData>\n | readonly MarkdownPluginInput<CustomData>[];\n\n/** Options accepted by `renderMarkdown()` and `createMarkdownRenderer()`. */\nexport type RenderMarkdownOptions = {\n plugins?: readonly MarkdownPluginInput[];\n};\n\n/** Reusable renderer instance for DOM-to-tree, tree-to-Markdown, and full render. */\nexport type MarkdownRenderer = {\n /** Extract, transform, serialize, and postprocess a DOM root. */\n render(root?: ParentNode | null): string;\n /** Serialize an existing Markdown tree without running postprocessors. */\n stringify(tree: MarkdownTree): string;\n /** Extract and transform a DOM root into a Markdown tree. */\n toTree(root?: ParentNode | null): MarkdownTree;\n};\n\n/** @internal Renderer surface used by one-shot rendering and devtools inspection. */\nexport type MarkdownRenderRuntime = MarkdownRenderer & {\n /** Apply render-only postprocess plugins to a serialized tree. */\n finalize(markdown: string, tree: MarkdownTree): string;\n};\n\nexport type ElementResolution =\n | { kind: 'plugin'; pluginId: string }\n | { kind: 'fallback' }\n | { kind: 'skipped'; reason: 'ignored' | 'hidden' | 'excluded-tag' };\n\nexport type RenderObserver = {\n elementResolved?(event: {\n element: Element;\n nodes: ElementContent[];\n resolution: ElementResolution;\n }): void;\n treeBuilt?(event: { tree: MarkdownTree; markdown: string }): void;\n transformApplied?(event: {\n pluginId: string;\n before: string;\n after: string;\n tree: MarkdownTree;\n }): void;\n postprocessApplied?(event: {\n pluginId: string;\n before: string;\n after: string;\n tree: MarkdownTree;\n }): void;\n};\n\nexport type PluginPipeline = {\n annotators: AnnotateElementPlugin[];\n observer?: RenderObserver;\n postprocessors: PostprocessPlugin[];\n renderers: RenderElementPlugin[];\n transforms: TransformPlugin[];\n};\n\nexport class DomMarkdownError extends Error {\n readonly phase: 'extract' | 'format' | 'normalize' | 'postprocess' | 'transform';\n readonly pluginId?: string;\n readonly element?: Element;\n readonly node?: MarkdownNode;\n override readonly cause?: unknown;\n\n constructor(\n message: string,\n context: {\n phase: DomMarkdownError['phase'];\n pluginId?: string;\n element?: Element;\n node?: MarkdownNode;\n cause?: unknown;\n },\n ) {\n super(message);\n this.name = 'DomMarkdownError';\n this.phase = context.phase;\n this.pluginId = context.pluginId;\n this.element = context.element;\n this.node = context.node;\n this.cause = context.cause;\n }\n}\n"],"mappings":";AAQA,MAAa,qBAAqB;AAClC,MAAa,uBAAuB;AACpC,MAAa,gBAAgB;AAC7B,MAAa,+BAA+B;AAC5C,MAAa,0BAA0B;AACvC,MAAa,yBAAyB;AAmMtC,IAAa,mBAAb,cAAsC,MAAM;CAC1C;CACA;CACA;CACA;CACA;CAEA,YACE,SACA,SAOA;EACA,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,QAAQ,QAAQ;EACrB,KAAK,WAAW,QAAQ;EACxB,KAAK,UAAU,QAAQ;EACvB,KAAK,OAAO,QAAQ;EACpB,KAAK,QAAQ,QAAQ;CACvB;AACF"}
//#region src/core/utils.ts
function normalizeMarkdown(markdown) {
return markdown.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function collapseWhitespace(value) {
return value.replace(/\s+/g, " ").trim();
}
function codeBlock(value, language = "") {
return `\`\`\`${language}\n${value.replace(/\n+$/, "")}\n\`\`\``;
}
function inlineCode(value) {
if (!value) return "``";
const ticks = value.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0;
const fence = "`".repeat(ticks + 1);
return `${fence}${value}${fence}`;
}
function escapeMarkdownText(value) {
return value.replace(/([\[\]\\])/g, "\\$1");
}
/**
* Contextual minimal escaping for DOM text content: neutralize characters
* that would change Markdown structure while leaving common prose (snake_case,
* intraword underscores) untouched.
*/
function escapeInlineText(value) {
return value.replace(/([\\`*[\]])/g, "\\$1").replace(/_+/g, (run, offset, source) => {
const before = offset > 0 ? source[offset - 1] : "";
const after = source[offset + run.length] ?? "";
return /\w/.test(before ?? "") && /\w/.test(after) ? run : run.replace(/_/g, "\\_");
});
}
/**
* Escape a paragraph-leading character that would otherwise start a heading,
* blockquote, or list.
*/
function escapeLeadingBlockMarker(markdown) {
if (/^(?:#{1,6} |>|[-+] )/.test(markdown)) return `\\${markdown}`;
return markdown.replace(/^(\d{1,9})([.)])( )/, "$1\\$2$3");
}
function escapeTableCell(value) {
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
}
function clampInline(value, maxChars) {
if (value.length <= maxChars) return value;
if (maxChars === 1) return "…";
return `${value.slice(0, maxChars - 1).trimEnd()}…`;
}
function clampMarkdownWithNotice(markdown, maxChars, notice) {
const suffix = notice.length > maxChars ? notice.slice(0, maxChars) : notice;
const availableChars = Math.max(0, maxChars - suffix.length - 2);
if (availableChars === 0) return suffix;
return `${markdown.slice(0, availableChars).trimEnd()}\n\n${suffix}`;
}
function readPositiveInteger(element, attribute) {
const value = element.getAttribute(attribute);
if (!value) return void 0;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return void 0;
return Math.floor(parsed);
}
//#endregion
export { escapeInlineText as a, escapeTableCell as c, readPositiveInteger as d, collapseWhitespace as i, inlineCode as l, clampMarkdownWithNotice as n, escapeLeadingBlockMarker as o, codeBlock as r, escapeMarkdownText as s, clampInline as t, normalizeMarkdown as u };
//# sourceMappingURL=utils-CVpYLNBk.js.map
{"version":3,"file":"utils-CVpYLNBk.js","names":[],"sources":["../src/core/utils.ts"],"sourcesContent":["export function normalizeMarkdown(markdown: string): string {\n return markdown\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nexport function collapseWhitespace(value: string): string {\n return value.replace(/\\s+/g, ' ').trim();\n}\n\nexport function codeBlock(value: string, language = ''): string {\n return `\\`\\`\\`${language}\\n${value.replace(/\\n+$/, '')}\\n\\`\\`\\``;\n}\n\nexport function inlineCode(value: string): string {\n if (!value) return '``';\n const ticks =\n value.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0;\n const fence = '`'.repeat(ticks + 1);\n return `${fence}${value}${fence}`;\n}\n\nexport function escapeMarkdownText(value: string): string {\n return value.replace(/([\\[\\]\\\\])/g, '\\\\$1');\n}\n\n/**\n * Contextual minimal escaping for DOM text content: neutralize characters\n * that would change Markdown structure while leaving common prose (snake_case,\n * intraword underscores) untouched.\n */\nexport function escapeInlineText(value: string): string {\n return value\n .replace(/([\\\\`*[\\]])/g, '\\\\$1')\n .replace(/_+/g, (run, offset: number, source: string) => {\n const before = offset > 0 ? source[offset - 1] : '';\n const after = source[offset + run.length] ?? '';\n const intraword = /\\w/.test(before ?? '') && /\\w/.test(after);\n return intraword ? run : run.replace(/_/g, '\\\\_');\n });\n}\n\n/**\n * Escape a paragraph-leading character that would otherwise start a heading,\n * blockquote, or list.\n */\nexport function escapeLeadingBlockMarker(markdown: string): string {\n if (/^(?:#{1,6} |>|[-+] )/.test(markdown)) return `\\\\${markdown}`;\n return markdown.replace(/^(\\d{1,9})([.)])( )/, '$1\\\\$2$3');\n}\n\nexport function escapeTableCell(value: string): string {\n return value.replace(/\\|/g, '\\\\|').replace(/\\n/g, ' ');\n}\n\nexport function clampInline(value: string, maxChars: number): string {\n if (value.length <= maxChars) return value;\n if (maxChars === 1) return '…';\n return `${value.slice(0, maxChars - 1).trimEnd()}…`;\n}\n\nexport function clampMarkdownWithNotice(\n markdown: string,\n maxChars: number,\n notice: string,\n): string {\n const suffix = notice.length > maxChars ? notice.slice(0, maxChars) : notice;\n const availableChars = Math.max(0, maxChars - suffix.length - 2);\n if (availableChars === 0) return suffix;\n return `${markdown.slice(0, availableChars).trimEnd()}\\n\\n${suffix}`;\n}\n\nexport function readPositiveInteger(\n element: Element,\n attribute: string,\n): number | undefined {\n const value = element.getAttribute(attribute);\n if (!value) return undefined;\n\n const parsed = Number(value);\n if (!Number.isFinite(parsed) || parsed <= 0) return undefined;\n return Math.floor(parsed);\n}\n"],"mappings":";AAAA,SAAgB,kBAAkB,UAA0B;CAC1D,OAAO,SACJ,QAAQ,aAAa,IAAI,CAAC,CAC1B,QAAQ,WAAW,MAAM,CAAC,CAC1B,KAAK;AACV;AAEA,SAAgB,mBAAmB,OAAuB;CACxD,OAAO,MAAM,QAAQ,QAAQ,GAAG,CAAC,CAAC,KAAK;AACzC;AAEA,SAAgB,UAAU,OAAe,WAAW,IAAY;CAC9D,OAAO,SAAS,SAAS,IAAI,MAAM,QAAQ,QAAQ,EAAE,EAAE;AACzD;AAEA,SAAgB,WAAW,OAAuB;CAChD,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,QACJ,MAAM,MAAM,KAAK,CAAC,EAAE,QAAQ,KAAK,QAAQ,KAAK,IAAI,KAAK,IAAI,MAAM,GAAG,CAAC,KAAK;CAC5E,MAAM,QAAQ,IAAI,OAAO,QAAQ,CAAC;CAClC,OAAO,GAAG,QAAQ,QAAQ;AAC5B;AAEA,SAAgB,mBAAmB,OAAuB;CACxD,OAAO,MAAM,QAAQ,eAAe,MAAM;AAC5C;;;;;;AAOA,SAAgB,iBAAiB,OAAuB;CACtD,OAAO,MACJ,QAAQ,gBAAgB,MAAM,CAAC,CAC/B,QAAQ,QAAQ,KAAK,QAAgB,WAAmB;EACvD,MAAM,SAAS,SAAS,IAAI,OAAO,SAAS,KAAK;EACjD,MAAM,QAAQ,OAAO,SAAS,IAAI,WAAW;EAE7C,OADkB,KAAK,KAAK,UAAU,EAAE,KAAK,KAAK,KAAK,KAAK,IACzC,MAAM,IAAI,QAAQ,MAAM,KAAK;CAClD,CAAC;AACL;;;;;AAMA,SAAgB,yBAAyB,UAA0B;CACjE,IAAI,uBAAuB,KAAK,QAAQ,GAAG,OAAO,KAAK;CACvD,OAAO,SAAS,QAAQ,uBAAuB,UAAU;AAC3D;AAEA,SAAgB,gBAAgB,OAAuB;CACrD,OAAO,MAAM,QAAQ,OAAO,KAAK,CAAC,CAAC,QAAQ,OAAO,GAAG;AACvD;AAEA,SAAgB,YAAY,OAAe,UAA0B;CACnE,IAAI,MAAM,UAAU,UAAU,OAAO;CACrC,IAAI,aAAa,GAAG,OAAO;CAC3B,OAAO,GAAG,MAAM,MAAM,GAAG,WAAW,CAAC,CAAC,CAAC,QAAQ,EAAE;AACnD;AAEA,SAAgB,wBACd,UACA,UACA,QACQ;CACR,MAAM,SAAS,OAAO,SAAS,WAAW,OAAO,MAAM,GAAG,QAAQ,IAAI;CACtE,MAAM,iBAAiB,KAAK,IAAI,GAAG,WAAW,OAAO,SAAS,CAAC;CAC/D,IAAI,mBAAmB,GAAG,OAAO;CACjC,OAAO,GAAG,SAAS,MAAM,GAAG,cAAc,CAAC,CAAC,QAAQ,EAAE,MAAM;AAC9D;AAEA,SAAgB,oBACd,SACA,WACoB;CACpB,MAAM,QAAQ,QAAQ,aAAa,SAAS;CAC5C,IAAI,CAAC,OAAO,OAAO,KAAA;CAEnB,MAAM,SAAS,OAAO,KAAK;CAC3B,IAAI,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GAAG,OAAO,KAAA;CACpD,OAAO,KAAK,MAAM,MAAM;AAC1B"}
# Using dom-md with coding agents
This directory contains the documentation for the installed version of
`dom-md`. Start with [Getting started](./getting-started.md), then use the
focused guides as needed:
- [Authoring with attributes](./attributes.md)
- [Markdown utility](./md-utility.md)
- [Plugins](./plugins.md)
- [Testing](./testing.md)
- [Security and privacy](./security.md)
The exact TypeScript implementation is available in `../src/`. Public runtime
exports are defined in `../package.json`; do not import internal files from
`src` in application code.
For a browser-based application audit, use
`../skills/audit-dom-markdown/SKILL.md`.
When changing an integration:
1. Prefer semantic HTML and truthful ARIA.
2. Use `data-md-ignore` for irrelevant interface chrome.
3. Use `data-md` for concise agent-facing overrides.
4. Add a plugin only for reusable behavior.
5. Treat generated Markdown as potentially sensitive.
# API reference
> Entry points and signatures exported by dom-md.
## Rendering
```ts
renderMarkdown(root?: ParentNode | null, options?: RenderMarkdownOptions): string;
toMarkdownTree(root?: ParentNode | null, options?: RenderMarkdownOptions): MarkdownTree;
stringifyMarkdown(tree: MarkdownTree, options?: RenderMarkdownOptions): string;
createMarkdownRenderer(options?: RenderMarkdownOptions): {
render(root?: ParentNode | null): string;
toTree(root?: ParentNode | null): MarkdownTree;
stringify(tree: MarkdownTree): string;
};
type RenderMarkdownOptions = {
plugins?: readonly MarkdownPluginInput[];
};
```
With no root, rendering uses the first `data-md-root`, then `main`, then
`document.body`. `toTree()` and `stringify(tree)` stay pure; `render(root)` also
applies terminal postprocess plugins.
The one-shot helpers match the renderer methods:
`renderMarkdown(root, options)` is `createMarkdownRenderer(options).render(root)`,
`toMarkdownTree(root, options)` is `.toTree(root)`, and `stringifyMarkdown(tree)`
is `.stringify(tree)`.
Invalid Markdown tree nodes at extraction, transform, or stringify boundaries throw
`DomMarkdownError`. Its `phase`, optional `pluginId`, and `cause` identify the
failed boundary.
## Plugins
```ts
type MarkdownPlugin =
| AnnotateElementPlugin
| RenderElementPlugin
| TransformPlugin
| PostprocessPlugin;
```
See [Built-in plugins](/plugins) for shipped behavior and
[Writing plugins](/custom-plugins) for the phase contracts.
## Entry points
| Entry point | Exports |
| --- | --- |
| `dom-md` | Rendering, tree access, Markdown tree and plugin types |
| `dom-md/plugins` | Built-in plugins and plugin authoring API |
| `dom-md/builders` | Markdown tree builders |
| `dom-md/tree` | Markdown tree traversal helpers |
| `dom-md/md` | Helpers for authored `data-md` strings |
| `dom-md/devtools` | Inspection, audit, and panel APIs |
| `dom-md/next` | `createMarkdownRoute()` |
# Attributes
> Control DOM serialization with data-md attributes.
Use `data-md` attributes to improve the Markdown for content that the rendered
DOM cannot express clearly. Most pages need only an override for a complex
region and an ignore marker for interface chrome.
## Replace an element
`data-md` replaces the element's normal output:
```tsx
<section
data-md={`## Consumption breakdown
${products.map((product) => `- ${product.name}: ${product.usage}`).join('\n')}`}
>
<UsageChart products={products} />
</section>
```
Or, use the [`md` helper](/md-utility) to keep the override composable:
```tsx
import { md } from 'dom-md/md';
<section
data-md={md.join([
md.heading('Consumption breakdown', 2),
md.list(products.map((product) => `${product.name}: ${product.usage}`)),
])}
>
<UsageChart products={products} />
</section>
```
## Compose with children
Include `{{children}}` when an override should wrap its rendered descendants:
```tsx
<section data-md={'## Request\n\n{{children}}'}>
<p>Status: 200</p>
<p>Path: /plugins.json</p>
</section>
```
Without the token, descendants are intentionally omitted.
Set a different token for one element with `data-md-children-token` when the
override content could contain the default token:
```tsx
<section data-md={'## Card\n\n[[content]]'} data-md-children-token="[[content]]">
...
</section>
```
## Ignore interface chrome
```tsx
<button data-md-ignore aria-label="Close">
<CloseIcon />
</button>
```
## Limit a region
```tsx
<section data-md-max-chars="2000">
<LongErrorDetails />
</section>
```
The limit is best effort and is applied after rendering the element. Prefer an
explicit summary when exact wording or Markdown syntax matters.
## Choose the right capture root
With no root argument, `renderMarkdown()` uses the first `data-md-root`
element, the page's `main` element, or `document.body`. Add `data-md-root`
only when the preferred page content is not the first `main` element:
```tsx
<section data-md-root>
<h1>Project activity</h1>
<ActivityFeed />
</section>
```
## Attribute reference
| Attribute | Purpose |
| ------------------------ | -------------------------- |
| `data-md-root` | Preferred capture root |
| `data-md` | Full Markdown override |
| `data-md-children-token` | Per-element children token |
| `data-md-ignore` | Exclude an element |
| `data-md-max-chars` | Limit one rendered region |
Plugins can define their own attributes. For example, the opt-in
[`redactSecrets()`](/plugins#redactsecrets) plugin reads `data-md-redact`; the
core renderer has no knowledge of it.
`dom-md` reads [semantic HTML and ARIA](/semantic-html) before applying these
overrides. Reach for `data-md` only when the accessible structure is correct
for people but agents need a different representation.
# Audit and improve your app
> Use the included agent skill to audit and improve how agents read your app.
`dom-md` includes an agent skill for auditing an existing application through
its real rendered pages. It gives a coding agent a repeatable way to inspect
the Markdown your app produces and improve what other agents receive.
Use it after adding `dom-md`, when a page does not read clearly as Markdown, or
before relying on the output for an in-app agent or Markdown endpoint.
## How it works
The skill works against the app's real `dom-md` configuration. It visits a
representative set of pages, captures their Markdown, and looks for missing
context, ambiguous structure, and interface noise.
It then places improvements at the lowest useful layer: semantic HTML and ARIA
first, local [`data-md` attributes](/attributes) where needed, and reusable
[plugins](/plugins) for application-wide behavior. After making changes, it
revisits the same pages and compares the results.
## Use the skill
The skill ships inside the installed package at:
```text
node_modules/dom-md/skills/audit-dom-markdown/SKILL.md
```
Point your coding agent at that file, or install the skill in the way your
agent supports, then use a prompt like:
```text
Use the audit-dom-markdown skill to inspect this app across representative
pages. Evaluate the Markdown produced by the real dom-md integration, fix the
highest-impact problems, and report before/after examples.
```
The packaged `SKILL.md` is the source of truth for setup, page coverage,
evaluation criteria, and reporting. Captured Markdown can contain authenticated
application data, so treat audit output as potentially sensitive.
# Comparison with converters
> How dom-md differs from HTML-to-Markdown conversion libraries.
HTML-to-Markdown converters such as Turndown transform whatever HTML they
receive, uniformly, with rules keyed by tag name. That model fits one-off
conversion of HTML you do not control. `dom-md` targets a different problem:
making an application you *do* control render well as Markdown, continuously.
## Granular overrides in the DOM
A converter offers global rules. When one element renders badly — a chart, a
custom widget, a noisy toolbar — there is no per-element recourse. `dom-md`
lets the component that owns the content fix its own representation:
```tsx
<Chart data-md={'- iad1: 1,284\n- sfo1: 942'} />
<button data-md-ignore>Change range</button>
```
The override lives next to the component, ships with it, and survives
refactors. See [Attributes](/attributes).
## A plugin layer converters do not have
Converters stop at syntax mapping. `dom-md` adds document-level policies:
- [`budget()`](/plugins#budget) fits output to a character limit
while preserving document structure
- [`outline()`](/plugins#outline) produces a compact inventory for a
cheap first pass
- [Custom plugins](/custom-plugins) encode your app's cleanup rules — strip
avatars, collapse icon buttons, drop "Copy" actions — instead of relying on
one-size-fits-all heuristics
## Defaults tuned for application UI
Converters were designed for documents. `dom-md`'s defaults are designed for
interfaces: ARIA tables, tabs, switches, and disclosures render as structure;
visible form values reflect the live DOM while passwords and hidden fields are
excluded; charts and canvases get placeholders that `data-md` can upgrade. See
[Semantic HTML and ARIA](/semantic-html).
## One render path instead of two
The deeper difference is architectural. Reaching output quality with a
converter usually means maintaining a second, Markdown-specific render path.
With `dom-md`, the DOM rendered for people is the single source of truth, and
quality comes from incremental enhancement — semantics first, then ARIA, then
overrides, then plugins.
## When a converter is the right tool
Converting foreign HTML you cannot annotate — scraped pages, email bodies,
third-party content — is exactly what converters are for. `dom-md` assumes
you own the DOM.
# In-app agents
> Capture the rendered page as token-efficient context for an in-app agent.
An embedded copilot answers questions about what the user is looking at. A
screenshot is expensive and lossy; raw DOM is noisy. `renderMarkdown()`
produces a concise, structured snapshot of the visible interface that a model
can reason about directly.
Define one renderer for the whole app so every capture shares the same
policies:
```ts
import { createMarkdownRenderer } from 'dom-md';
import { budget, frontmatter } from 'dom-md/plugins';
export const screenContext = createMarkdownRenderer({
plugins: [
budget({ maxChars: 8_000 }),
frontmatter(() => ({
title: document.title,
url: location.href,
})),
],
});
```
Capture when the user sends a message and include the snapshot as context:
```ts
const markdown = screenContext.render();
await sendChatMessage({
context: markdown,
message: userMessage,
});
```
`budget()` keeps the snapshot inside a predictable token envelope. When the
page exceeds the limit, it preserves the heading skeleton and spreads
representative content across sections instead of cutting off the end.
`frontmatter()` tells the agent which page it is looking at.
Captured output reflects what is on screen, including visible form values.
Mark sensitive controls and regions with `data-md-ignore` before wiring the
snapshot into a model.
## Start with an outline
On large pages, send a compact inventory first and render full regions on
demand:
```ts
import { renderMarkdown } from 'dom-md';
import { outline } from 'dom-md/plugins';
const overview = renderMarkdown(document, {
plugins: [outline({ maxChars: 2_000 })],
});
```
When the agent asks about a specific section, render just that region:
```ts
const panel = document.querySelector('[data-request-panel]');
const markdown = renderMarkdown(panel);
```
## Go deeper
- [Plugins](/plugins#built-ins) for `budget()` and `outline()` options
- [Attributes](/attributes) to refine what individual components contribute
# Copy as Markdown
> Add a copy-as-Markdown flow without a second render path.
A "Copy as Markdown" action lets people hand a region of the UI to an agent,
paste it into an issue, or archive it as text. Building this usually means
maintaining a parallel Markdown renderer next to every component. With
`dom-md`, the DOM you already render is the Markdown source.
```ts
import { renderMarkdown } from 'dom-md';
async function copyAsMarkdown(panel: HTMLElement) {
await navigator.clipboard.writeText(renderMarkdown(panel));
}
```
## Enhance instead of reimplementing
Where the default output falls short, fix it in place. The copy button itself
is a good first candidate for exclusion:
```tsx
<section ref={panelRef}>
<h2>Deployment summary</h2>
<DurationChart
data-md={'- Build: 42s\n- Upload: 8s\n- Total: 50s'}
/>
<button data-md-ignore onClick={() => copyAsMarkdown(panelRef.current)}>
Copy as Markdown
</button>
</section>
```
```md
## Deployment summary
- Build: 42s
- Upload: 8s
- Total: 50s
```
Each component carries its own representation, so the copy flow never goes
stale: new UI renders semantically by default, and only visual content like
charts needs an explicit summary.
## Go deeper
- [Attributes](/attributes) for overrides, composition, and exclusions
- [The md utility](/md-utility) to author multi-part overrides in JSX
- [Semantic HTML and ARIA](/semantic-html) so most content needs no
annotation at all
# Writing plugins
> Create and compose dom-md plugins for app-specific Markdown behavior.
In dom-md, everything you add to the renderer is a plugin.
Use `createPlugin()` to define one small feature. A plugin can contain one step
or several steps. Each step runs at the right point in the render pipeline.
```ts
import { createPlugin } from 'dom-md/plugins';
```
`renderMarkdown(root, options)` is the quick path. Use
`createMarkdownRenderer(options)` when you want to reuse the same plugin setup
or call `toTree()` and `stringify()` yourself.
## A first plugin
This plugin removes repeated “Copy” buttons from the Markdown output while
leaving every other element to dom-md:
```ts
import { renderMarkdown } from 'dom-md';
import { createPlugin } from 'dom-md/plugins';
const quietCopyButtons = createPlugin('quiet-copy-buttons', ({ renderElement }) => [
renderElement((element) => {
if (element.tagName !== 'BUTTON') return undefined;
const label =
element.getAttribute('aria-label') ??
element.textContent?.trim();
return label === 'Copy' ? null : undefined;
}),
]);
const markdown = renderMarkdown(document.body, {
plugins: [quietCopyButtons],
});
```
Input:
```html
<p>Deploy succeeded.</p>
<button>Copy</button>
```
Output:
```md
Deploy succeeded.
```
The important bit is the return value:
- return Markdown nodes to handle the element;
- return `null` to intentionally omit the element;
- return `undefined` to let dom-md keep looking for another renderer.
## Steps and phases
A plugin is made of steps. Each step runs in one phase:
| Step | Runs when | Use it for |
| --- | --- | --- |
| `renderElement()` | While reading DOM | Replace, omit, or custom-render one element. |
| `annotateElement()` | While reading DOM | Save DOM metadata for a later transform. |
| `transform()` | After the Markdown tree exists | Add, remove, reorder, summarize, or truncate nodes. |
| `postprocess()` | After Markdown is a string | Wrap, redact, or clean up the final string from `render()`. |
The pipeline is:
```txt
DOM
→ renderElement + built-in DOM rendering
→ annotateElement metadata
→ Markdown tree
→ transform
→ Markdown serializer
→ postprocess
→ final Markdown string
```
Choose the earliest step that has the information you need. If you only need to
remember something about a DOM element, prefer `annotateElement()`. If you need
to change what the element becomes, use `renderElement()`.
## Render DOM elements
Use `renderElement()` when DOM shape, ARIA, attributes, classes, or live state
should change the Markdown output.
```ts
import { paragraph, strong, text } from 'dom-md/builders';
const callouts = createPlugin('callouts', ({ renderElement }) => [
renderElement((element, context) => {
if (!element.matches('aside[data-callout]')) return undefined;
return paragraph([
strong([text('Note:')]),
text(' '),
...context.inline(element),
]);
}),
]);
```
`paragraph()`, `strong()`, and `text()` come from `dom-md/builders`. They create
Markdown tree nodes for you.
The render context has a few focused helpers:
| Method | What it does |
| --- | --- |
| `context.children(parent?)` | Extract child nodes as block-capable Markdown. |
| `context.inline(parent?)` | Extract child nodes as inline Markdown for paragraphs/headings. |
| `context.extract(element)` | Extract one specific element through the full pipeline. |
| `context.renderDefault()` | Render this same element with later plugins and built-ins. |
Common moves:
```ts
// Turn a custom element into one paragraph while still reading its children.
return paragraph([text('Plan: '), ...context.inline(element)]);
```
```ts
// Render a nested control exactly as dom-md would render it elsewhere.
return Array.from(
element.querySelectorAll('input, textarea, select'),
).flatMap((control) => context.extract(control));
```
`context.renderDefault()` is available when you need to build on the default
output for the same element, but most render plugins do not need it.
## Edit the Markdown tree
Use `transform()` when your change belongs to the whole document, not to one DOM
element.
```ts
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)]));
}),
]);
}
```
Transforms receive the complete Markdown tree. They can mutate it and return
nothing, or return a replacement tree. The context exposes
`context.stringify(node)`, which is useful when a transform needs to measure the
Markdown it is producing.
For transforms that need to inspect every node, use `visit()` from
`dom-md/tree`:
```ts
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 };
}
});
}),
]);
```
## Create Markdown nodes
Use `dom-md/builders` for common nodes. Builders keep examples short and attach
dom-md metadata in the shape dom-md expects:
```ts
import { heading, paragraph, strong, text } from 'dom-md/builders';
const nodes = [
heading(2, [text('Account')]),
paragraph([
strong([text('Status')]),
text(': Active'),
], {
kind: 'field',
label: 'Status',
}),
];
```
The second argument to builders is a shortcut for dom-md's own metadata:
| Field | Use it for |
| --- | --- |
| `kind` | Classify the node as `'action'`, `'field'`, `'override'`, or `'visual'`. |
| `label` | Preserve the source label for later plugins/devtools. |
| `chunk` | Mark the node as a useful standalone piece of Markdown. |
If you need to attach your own typed metadata to generated nodes, use the
`data()` helper:
```ts
type AppData = {
app?: {
source: string;
};
};
const generated = createPlugin<AppData>('generated', ({ data, transform }) => [
transform((tree) => {
tree.children.unshift(
data(paragraph([text('Generated summary')]), {
app: { source: 'summary-service' },
}),
);
}),
]);
```
Raw tree objects are also allowed when you need a node that does not have a
builder, such as YAML:
```ts
const yaml = {
type: 'yaml',
value: 'title: Usage',
};
```
## Attach metadata for a transform
Use `annotateElement()` when a DOM attribute should become metadata for a later
transform. Return an object from `annotateElement()`, then read it from
`node.data` in `transform()`.
This viewport plugin marks offscreen elements, then rewrites their paragraph
content in a transform:
```ts
import { text } from 'dom-md/builders';
import { createPlugin } from 'dom-md/plugins';
import { visit } from 'dom-md/tree';
type ViewportData = {
viewport?: {
visible: boolean;
};
};
export 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')];
}
});
}),
]);
```
The `ViewportData` type flows through both steps, so `node.data.viewport` is
typed inside the transform.
Custom metadata lives directly under your plugin-owned key on `node.data`.
`node.data.domMd` is reserved for dom-md's shared provenance and classification
metadata.
### Advanced metadata behavior
Annotations are merged onto every Markdown node emitted for that DOM element.
For wrapper elements, that can include block nodes produced by descendants:
```html
<section data-offscreen>
<h2>Details</h2>
<p>Long body text.</p>
</section>
```
If the section renders as its children, its annotation is merged onto the
emitted heading and paragraph.
When multiple annotations reach the same node, dom-md recursively merges plain
objects and concatenates arrays. That lets nested region metadata accumulate:
```ts
{
maxChars: {
limits: [outerLimit, innerLimit],
},
}
```
If two annotations set the same non-object value, the later annotation wins.
Treat nested metadata objects and arrays as immutable in transforms. When an
annotation fans out to several emitted nodes, those nodes may share nested
metadata references.
## Finish the rendered string
Use `postprocess()` for terminal string concerns that should happen only after
Markdown exists as a string.
```ts
import { createMarkdownRenderer } from 'dom-md';
import { createPlugin } from 'dom-md/plugins';
const xmlEnvelope = createPlugin('xml-envelope', ({ postprocess }) => [
postprocess((markdown) =>
`<page-markdown>\n${markdown}\n</page-markdown>`),
]);
const renderer = createMarkdownRenderer({
plugins: [xmlEnvelope],
});
```
Postprocess steps run during `render()`. They do not run for `toTree()` or
`stringify(tree)`, which keeps tree extraction and tree serialization
predictable.
## Use multiple steps
Most custom behavior starts with one step. Larger features can group several
steps behind one plugin id.
If a plugin has one anonymous step, that step uses the plugin id directly. That
keeps simple plugins and default replacements easy:
```ts
const buttons = createPlugin('buttons', ({ renderElement }) => [
renderElement((element) => {
if (element.tagName !== 'BUTTON') return undefined;
return paragraph([text('Custom button')]);
}),
]);
```
When a plugin has multiple steps, name each step so errors and devtools can show
scoped ids such as `app-snapshot:frontmatter`.
```ts
import { createPlugin } from 'dom-md/plugins';
export const appSnapshot = createPlugin('app-snapshot', ({
renderElement,
postprocess,
transform,
}) => [
renderElement('quiet-buttons', (element) => {
if (element.tagName !== 'BUTTON') return undefined;
if (element.textContent?.trim() === 'Copy') return null;
return undefined;
}),
transform('frontmatter', (tree) => {
// There is no builder for YAML, so use a raw Markdown tree node.
tree.children.unshift({
type: 'yaml',
value: `title: ${JSON.stringify(document.title)}`,
});
}),
postprocess('capture-note', (markdown) =>
`${markdown}\n\n_Captured from the live app._`),
]);
```
Consumers pass the composed plugin as one item:
```ts
import { renderMarkdown } from 'dom-md';
import { appSnapshot } from './markdown-plugins';
const markdown = renderMarkdown(document.body, {
plugins: [appSnapshot],
});
```
## Low-level plugin objects
`createPlugin()` returns an array of plain plugin steps under the hood. You can
write that shape directly for advanced integrations and tests:
```ts
import type { TransformPlugin } from 'dom-md';
const title: TransformPlugin = {
id: 'title',
phase: 'transform',
transform(tree) {
return tree;
},
};
```
Most application code is easier to read with `createPlugin()`.
## Use a Remark plugin
dom-md does not host unified, but its tree can pass through plugins that operate
on compatible MDAST shapes. Wrap a synchronous Remark plugin as a transform when
it belongs in the shared renderer. Install the Remark/unified packages in your
app if you want this adapter:
```ts
import remarkToc from 'remark-toc';
import { unified } from 'unified';
import {
createMarkdownRenderer,
type MdastRoot,
} from 'dom-md';
import { createPlugin } from 'dom-md/plugins';
const processor = unified().use(remarkToc);
const tableOfContents = createPlugin('remark-toc', ({ transform }) => [
transform((tree) =>
processor.runSync(tree as never) as MdastRoot),
]);
const renderer = createMarkdownRenderer({
plugins: [tableOfContents],
});
```
For async plugin chains, process `renderer.toTree(root)` outside the renderer
and pass the resulting root to `renderer.stringify()`.
# Getting started
> Render DOM as readable Markdown with dom-md.
`dom-md` turns the HTML already rendered for people into Markdown for agents,
copying, indexing, and other text-based workflows.
```tsx
<main>
<h1>Usage</h1>
<p>Included credit: $37.29 / $250.00</p>
</main>
```
```ts
import { renderMarkdown } from 'dom-md';
const markdown = renderMarkdown();
```
```md
# Usage
Included credit: $37.29 / $250.00
```
For most integrations, that is the whole rendering API. When a visual
component needs a clearer textual representation, use
[attributes](/attributes) to replace or exclude its output. Use the
[`md` utility](/md-utility) to build composable Markdown for those overrides:
```tsx
import { md } from 'dom-md/md';
<UsageChart
data-md={md.join([
md.heading('Usage by product', 2),
md.list(products.map((product) => `${product.name}: ${product.usage}`)),
])}
/>
```
With no root argument, `renderMarkdown()` uses the first `data-md-root`
element, the page's `main` element, or `document.body`.
Pass an element to capture a smaller region:
```ts
const panel = document.querySelector('[data-request-panel]');
const markdown = renderMarkdown(panel);
```
On a backend, parse HTML before passing it to the same `renderMarkdown()` entry
point:
```ts
import { renderMarkdown } from 'dom-md';
const parsed = new DOMParser().parseFromString(
'<main><h1>Hello</h1></main>',
'text/html',
);
const markdown = renderMarkdown(parsed);
```
The caller owns HTML parsing. `dom-md` accepts DOM nodes and does not select a
server-side DOM implementation for you. Continue with
[server-side rendering](/server-side) to serve pages as Markdown.
## Improve the output incrementally
Work in layers, each only where the previous one is not enough: semantic HTML
renders correctly with no annotations, accurate ARIA turns custom components
into real structure, and [attributes](/attributes) handle summaries,
exclusions, and visualizations the DOM cannot express. The
[Markdown utility](/md-utility) keeps authored overrides readable, while the
[Semantic HTML and ARIA guide](/semantic-html) covers the supported patterns.
## Next steps
- Pick a use case: [in-app agents](/copilot),
[copy as Markdown](/copy-markdown), or
[Markdown over HTTP](/serve-markdown)
- Apply document policies with [plugins](/plugins)
# Markdown utility
> Build safe, composable Markdown strings with the dom-md md helper.
Import `md` from the `dom-md/md` subpath when authoring `data-md`
overrides or custom plugin output.
```ts
import { md } from 'dom-md/md';
const markdown = md.join([
md.heading('External APIs', 2),
'Requests made during the current billing cycle.',
md.table({
columns: ['API', 'Requests', 'Errors'],
rows: [
['/v1/projects', '12,842', '4'],
['/v1/deployments', '8,391', '0'],
],
}),
]);
```
```md
## External APIs
Requests made during the current billing cycle.
| API | Requests | Errors |
| --- | --- | --- |
| /v1/projects | 12,842 | 4 |
| /v1/deployments | 8,391 | 0 |
```
## Authoring overrides
The helpers are especially useful in JSX, where manually joining Markdown
fragments becomes difficult to read.
```tsx
import { md } from 'dom-md/md';
export function UsageChart() {
return (
<section
data-md={md.join([
md.heading('Requests by region', 2),
md.list(['iad1: 42,184', 'sfo1: 31,205', 'fra1: 18,992']),
])}
>
<canvas aria-label="Requests by region" />
</section>
);
}
```
Use `md.children()` to include the element's semantic child output within an
override:
```tsx
<section
data-md={md.join([
md.heading('Request', 2),
md.children(),
])}
>
<p>Status: 200</p>
<p>Path: /plugins.json</p>
</section>
```
## API reference
| Method | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------- |
| `md.join(parts)` | Joins truthy fragments with a blank line. Useful with conditional `null` or `false` entries. |
| `md.heading(text, level?)` | Creates a heading. Levels are clamped between 1 and 6. |
| `md.code(value)` | Creates inline code and chooses a safe backtick fence for the value. |
| `md.codeBlock(value, language?)` | Creates a fenced code block and removes trailing newlines from the value. |
| `md.list(items, ordered?)` | Creates an unordered list, or a numbered list when `ordered` is `true`. |
| `md.link(text, href)` | Creates a link and escapes Markdown-sensitive characters in its label. |
| `md.table({ columns, rows })` | Creates a Markdown table, escaping pipes and replacing cell newlines with spaces. |
| `md.children(token?)` | Returns the configured children token. The default is `{{children}}`. |
## Examples
### Inline code
```ts
md.code('pnpm add dom-md');
// `pnpm add dom-md`
```
Backticks in the value are handled automatically:
```ts
md.code('Use `data-md` here');
// ``Use `data-md` here``
```
### Code blocks
```ts
md.codeBlock('const markdown = renderMarkdown();', 'ts');
```
````md
```ts
const markdown = renderMarkdown();
```
````
### Conditional composition
`md.join()` removes falsy entries before joining the remaining fragments.
```ts
const markdown = md.join([
title ? md.heading(title, 2) : null,
description,
rows.length > 0 && md.table({ columns, rows }),
]);
```
An empty string, `null`, `undefined`, or `false` is omitted. Other strings are
preserved exactly.
# Built-in plugins
> Ready-made plugins for budgets, redaction, outlines, and frontmatter.
Plugins are how you shape dom-md beyond the default DOM interpretation.
Use built-in plugins when you want a common document policy: keep output under
a budget, redact sensitive tokens, replace a page with an outline, add page
metadata, or make a final string-only adjustment. If you need app-specific
behavior, continue to [Writing plugins](/custom-plugins).
```ts
import { renderMarkdown } from 'dom-md';
import { budget, frontmatter } from 'dom-md/plugins';
const markdown = renderMarkdown(document.body, {
plugins: [
frontmatter(() => ({
title: document.title,
url: location.href,
})),
budget({ maxChars: 8_000 }),
],
});
```
You always pass one `plugins` list. dom-md runs each plugin at the right point
in the render pipeline.
## What plugins can do
A plugin can:
- read a DOM element differently than the default renderer;
- attach typed metadata to nodes produced from DOM elements;
- edit the completed Markdown tree;
- make a final string-only change during `render()`.
The built-ins below cover common cases. The [Writing plugins](/custom-plugins)
guide explains the phases and composition model for your own plugins.
## Built-in plugins
### `budget()`
Keeps Markdown under a character budget while preserving document shape.
Use it when the Markdown is going to an LLM, tool call, clipboard flow, or HTTP
response with a known size envelope. It tries structural compaction first:
headings stay visible, tables and lists keep representative rows/items, and
long prose/code blocks are shortened with notices.
```ts
import { renderMarkdown } from 'dom-md';
import { budget } from 'dom-md/plugins';
const markdown = renderMarkdown(document.body, {
plugins: [budget({ maxChars: 20_000 })],
});
```
### `outline()`
Replaces the page with a compact inventory of its headings and important
content.
Use it for quick audits, navigation maps, or low-token previews where the shape
of the page matters more than full text.
```ts
import { renderMarkdown } from 'dom-md';
import { outline } from 'dom-md/plugins';
const markdown = renderMarkdown(document.body, {
plugins: [outline({ maxChars: 2_000, maxItemsPerSection: 4 })],
});
```
### `frontmatter()`
Prepends YAML metadata to the Markdown tree.
Use it when a downstream consumer should know page-level context such as title,
URL, account, product area, or capture time. Pass an object for static metadata
or a function for values that should be read during each render.
```ts
import { renderMarkdown } from 'dom-md';
import { frontmatter } from 'dom-md/plugins';
const markdown = renderMarkdown(document.body, {
plugins: [
frontmatter(() => ({
title: document.title,
url: location.href,
capturedAt: new Date().toISOString(),
})),
],
});
```
### `redactSecrets()`
Redacts common sensitive token formats and credentialed URL passwords.
Use it for capture surfaces that may include API keys, Vercel AI Gateway
`vck_` tokens, GitHub tokens, bearer tokens, or database connection strings.
The plugin is opt-in so the base renderer remains a faithful DOM-to-Markdown
serializer.
```ts
import { renderMarkdown } from 'dom-md';
import { redactSecrets } from 'dom-md/plugins';
const markdown = renderMarkdown(document.body, {
plugins: [redactSecrets()],
});
```
Add app-specific patterns when your product has its own secret shape:
```ts
const markdown = renderMarkdown(document.body, {
plugins: [
redactSecrets({
patterns: [/tenant_secret_[a-z0-9]+/g],
replacement: (value) => `[REDACTED:${value.length}]`,
}),
],
});
```
`replacement` can also be a static string such as `"[SECRET]"`.
Use `data-md-redact` for manual redaction when the DOM already knows an element
is sensitive:
```html
<span data-md-redact>internal-only-value</span>
```
The attribute is owned and read by the plugin itself — the core renderer knows
nothing about it. Combining `data-md-redact` with a `data-md` override on the
same element throws an error, because authored overrides bypass manual
redaction — remove the sensitive content from the override instead. Secret
patterns still scan authored override content either way.
Credentialed URLs keep their scheme, username, host, and path, but replace the
password:
```md
postgres://user:[REDACTED]@db.example.com/app
```
You can also use `redactSecretsFromString(value)` directly when you only need the
string helper.
## Writing your own
Built-ins are just plugins that ship with dom-md. Your app can define plugins
with the same model, including multi-step plugins that read DOM, transform the
tree, and postprocess the final string as one composed feature.
Continue with [Writing plugins](/custom-plugins).
# Introduction
> Give agents a readable view of your application.
`dom-md` turns your live application UI into compact, structured Markdown for
agents. It works with any framework: start with semantic HTML and ARIA, then
add optional [attributes](/attributes) and [plugins](/plugins) for **precise
control over what agents see.**
```tsx
<section>
<h2>Requests by region</h2>
<Chart data-md={'- iad1: 1,284\n- sfo1: 942'} />
<button data-md-ignore>Change range</button>
</section>
```
```ts
import { renderMarkdown } from 'dom-md';
import { budget } from 'dom-md/plugins';
const markdown = renderMarkdown(document, {
plugins: [budget({ maxChars: 8_000 })],
});
```
```md title="Result"
## Requests by region
- iad1: 1,284
- sfo1: 942
```
The DOM stays the source of truth, so the Markdown evolves with your
application instead of becoming a second representation to maintain.
## Build agent context into the UI
The component that renders information is often best placed to explain it. A
chart can expose its data as a list, a custom widget can describe its current
state, and controls that add no context can disappear. These decisions live
with the components themselves, where they survive redesigns and refactors.
Most of the UI needs no special treatment. Add annotations only where the
visual representation needs a clearer textual one.
## Shape context for the task
Once the UI reads well as Markdown, [plugins](/plugins) can shape the document
for a particular agent or workflow. Produce an outline for a cheap first pass,
fit a detailed view to a context budget, add metadata, or encode cleanup rules
specific to your application:
```ts
import { renderMarkdown } from 'dom-md';
import { budget, outline } from 'dom-md/plugins';
const context = renderMarkdown(document, {
plugins: [outline(), budget({ maxChars: 8_000 })],
});
```
This gives agents the structure and state they need without spending tokens on
navigation, repeated controls, and other interface chrome.
## Use it wherever context moves
The same agent-readable representation can power several product surfaces:
- **[In-app agents](/copilot):** give a copilot a snapshot of what the user is
looking at.
- **[Copy as Markdown](/copy-markdown):** let people carry application context
into chats, documents, and issue trackers.
- **[Markdown over HTTP](/serve-markdown):** let agents and tools request a
readable version of any page.
One rendering model covers all three, with the live application remaining the
source of truth.
## Next steps
Start with the [getting started guide](/getting-started), explore the
live playground on the documentation site, or see how `dom-md`
[compares with converters](/comparison).
# Semantic HTML and ARIA
> Use native elements and accurate accessibility semantics to produce useful Markdown without annotations.
`dom-md` treats the DOM as the source of truth. It reads native HTML semantics
and supported Accessible Rich Internet Applications (ARIA) properties before
applying any `data-md` overrides.
That leads to a simple authoring order:
1. Use the native HTML element when one matches the content or interaction.
2. Use accurate ARIA when a custom component must behave like a native widget.
3. Use `data-md` only when the accessible representation is correct but the
Markdown needs a more concise or specialized form.
Good semantics benefit people using assistive technology and usually produce
better Markdown at the same time. Do not add roles or ARIA state only to change
the Markdown output.
## Prefer native HTML
Headings, paragraphs, links, lists, description lists, tables, forms, buttons,
images, code, and text emphasis render without annotations:
```tsx
<article>
<h2>Production deployment</h2>
<p>
Deployed by <a href="/team/ada">Ada</a>.
</p>
<dl>
<dt>Status</dt>
<dd>Ready</dd>
<dt>Region</dt>
<dd>Vienna</dd>
</dl>
<ul>
<li>Health checks passed</li>
<li>Traffic migrated</li>
</ul>
</article>
```
```md
## Production deployment
Deployed by [Ada](/team/ada).
Status: Ready
Region: Vienna
- Health checks passed
- Traffic migrated
```
Use `<button>` instead of a clickable `<div>`, `<h2>` instead of styled text,
and `<table>` for tabular data. ARIA can expose semantics when native HTML is
not practical, but it does not add the keyboard behavior or browser behavior
of the native element.
## Supported ARIA patterns
`dom-md` understands these patterns by default:
- `role="heading"` with a valid `aria-level` from `1` through `6`
- `role="list"` with direct `role="listitem"` descendants
- `role="table"` with `row`, `columnheader`, `rowheader`, and `cell`
- `role="tablist"` and `role="tab"` with `aria-selected="true"`
- `role="combobox"` with a current value or selected option
- `role="checkbox"`, `role="radio"`, and `role="switch"` with `aria-checked`
- `aria-pressed` for toggle buttons
- `aria-expanded` for disclosure controls
- `aria-label` and `aria-labelledby` for accessible names
- `aria-busy="true"` for loading regions
- `aria-hidden="true"` to exclude content
Names and states matter. A switch without an accessible name, or a selected tab
without `aria-selected="true"`, cannot produce a useful summary and falls back
to ordinary element rendering.
## Custom headings and lists
Use ARIA headings only when the component cannot render a native heading:
```tsx
<div role="heading" aria-level="3">
External APIs
</div>
```
```md
### External APIs
```
A custom collection can expose list semantics while keeping its visual layout:
```tsx
<div role="list" aria-label="Deployments">
<article role="listitem">
<strong>Documentation</strong>
<dl>
<dt>Status</dt>
<dd>Building</dd>
</dl>
</article>
<article role="listitem">
<strong>Package</strong>
<dl>
<dt>Status</dt>
<dd>Ready</dd>
</dl>
</article>
</div>
```
```md
- **Documentation**
Status: Building
- **Package**
Status: Ready
```
When a named region is loading, mark the region busy instead of rendering
meaningless skeleton text:
```tsx
<div role="list" aria-label="Deployments" aria-busy="true">
<DeploymentSkeleton />
<DeploymentSkeleton />
</div>
```
```md
[Loading: Deployments]
```
## Stateful controls
Accessible names and state attributes turn custom controls into durable,
compact fields:
```tsx
<div role="switch" aria-label="Live logs" aria-checked="true" />
<div role="checkbox" aria-label="Select all" aria-checked="mixed" />
<button aria-pressed="true">Live traffic</button>
<button aria-expanded="false" aria-label="Advanced filters">
Filters
</button>
```
```md
Live logs: on
Select all: mixed
Live traffic: on
Advanced filters: collapsed
```
Native controls remain preferable. A checkbox can opt into switch semantics
while preserving native input behavior:
```tsx
<label>
<input type="checkbox" role="switch" checked={liveLogs} />
Live logs
</label>
```
```md
Live logs: on
```
Keep ARIA state synchronized with the visible and interactive state. Supported
values are:
- `aria-checked`: `true`, `false`, or `mixed`
- `aria-pressed`: `true`, `false`, or `mixed`
- `aria-expanded`: `true` or `false`
Invalid or missing state is not guessed.
## Tabs and comboboxes
A tablist renders its accessible name and selected tab rather than every tab
as a separate button:
```tsx
<div role="tablist" aria-label="View">
<button role="tab" aria-selected={view === 'routes'}>
Routes
</button>
<button role="tab" aria-selected={view === 'functions'}>
Functions
</button>
</div>
```
```md
View: Routes
```
A combobox renders its accessible name and current value without copying the
entire popup:
```tsx
<>
<label id="region-label">Region</label>
<div
role="combobox"
aria-labelledby="region-label"
aria-controls="regions"
/>
<div id="regions" role="listbox" hidden>
<div role="option">Frankfurt</div>
<div role="option" aria-selected="true">
Vienna
</div>
</div>
</>
```
```md
Region: Vienna
```
For custom comboboxes, `dom-md` can read a nested form control,
`aria-valuetext`, a selected option in the element named by `aria-controls`,
or the option named by `aria-activedescendant`.
## Labels for controls and visuals
Icon-only controls need an accessible name:
```tsx
<button aria-label="Copy">
<CopyIcon aria-hidden="true" />
</button>
```
```md
Button: Copy
```
Images should use `alt`. Labeled SVG and canvas elements use their accessible
name as a text placeholder:
```tsx
<img src="/revenue.png" alt="Revenue increased 18% in May" />
<svg role="img" aria-label="Request volume by region">
{/* chart drawing */}
</svg>
```
Decorative content should be hidden from both accessibility APIs and Markdown:
```tsx
<SparkleIcon aria-hidden="true" />
```
Do not hide meaningful visible content merely to remove it from Markdown. Use
[`data-md-ignore`](/attributes#data-md-ignore) for agent-specific exclusion.
## ARIA tables
Use a native `<table>` whenever possible. For a custom data grid whose DOM
cannot use table elements, standard ARIA table roles produce a Markdown table:
```tsx
<div role="table" aria-label="External APIs">
<div role="row">
<span role="columnheader">Method</span>
<span role="columnheader">Request</span>
<span role="columnheader">Status</span>
<span role="columnheader" data-md-ignore>
Actions
</span>
</div>
<div role="rowgroup">
<div role="row">
<span role="cell">GET</span>
<span role="cell">
<a href="/api/data">/api/data</a>
</span>
<span role="cell" data-md="Successful">
200
</span>
<span role="cell">
<InspectButton />
</span>
</div>
</div>
</div>
```
```md
**External APIs**
| Method | Request | Status |
| ------ | ---------------------- | ---------- |
| GET | [/api/data](/api/data) | Successful |
```
`aria-label` becomes the table caption. `aria-labelledby` can reference a
visible heading instead. Ignoring a header removes the entire positional
column; ignoring only a body cell leaves an empty cell so later columns remain
aligned.
## When to use `data-md`
ARIA should describe what the interface is for people. `data-md` describes how
that truthful interface should be represented to an agent.
Use an override when:
- a visual needs a compact data summary instead of its accessible name
- interface chrome is useful to people but noisy in Markdown
- a correct accessible label is too verbose for repeated agent context
- a table cell needs a domain-specific value
```tsx
<section>
<h2>Requests by region</h2>
<Chart
aria-label="Bar chart showing requests by deployment region"
data-md={'- Vienna: 1,284\n- Frankfurt: 942'}
/>
<button data-md-ignore>Change date range</button>
</section>
```
```md
## Requests by region
- Vienna: 1,284
- Frankfurt: 942
```
See [Attributes](/attributes) for targeted overrides. If the same semantic
component needs specialized rendering throughout an application, use a
[custom plugin](/custom-plugins) instead.
# Markdown over HTTP
> Answer Accept text/markdown requests with high-fidelity Markdown for any page.
Content negotiation lets agents and tools request any page as Markdown:
```bash
curl -H 'Accept: text/markdown' https://app.example.com/usage
```
This is straightforward for article-like pages. Dashboards, charts, and
interactive panels are where naive conversion turns lossy — and where the
`data-md` annotations you author keep the Markdown faithful.
The flow is the same in every framework: route Markdown-preferring requests to
a handler, render or fetch the page HTML, parse it, and return
`renderMarkdown(document)` as `text/markdown`. Because the handler renders the
same HTML people receive, Server Components, authentication, and application
data are all reflected — and every `data-md` annotation and plugin you author
for in-app capture improves the served pages too.
```ts
const { document } = parseHTML(await pageResponse.text());
return new Response(renderMarkdown(document), {
headers: { 'content-type': 'text/markdown; charset=utf-8' },
});
```
The [server-side rendering guide](/server-side) covers the complete setup:
the framework-neutral pattern, the `dom-md/next` Route Handler adapter with a
content-negotiation rewrite for Next.js, and the response safeguards for
authenticated pages.
## Go deeper
- [Server-side rendering](/server-side) for the full Next.js and
framework-neutral setup
- [API reference](/api) for the `createMarkdownRoute` signature
- [Built-in plugins](/plugins) for response shaping
# Server-side rendering
> Render HTML as Markdown in Node.js, Next.js, and other server runtimes.
`dom-md` renders DOM nodes, so backend usage has two steps:
1. Render or fetch the HTML you want to expose.
2. Parse that HTML into a DOM and pass it to `renderMarkdown()`.
The caller owns HTML rendering and parsing. This keeps `dom-md` independent of
your web framework and lets you use the DOM implementation that fits your
runtime.
## Render an HTML string
In Node.js, use a server-side DOM implementation such as
[`linkedom`](https://github.com/WebReflection/linkedom):
```ts
import { renderMarkdown } from 'dom-md';
import { parseHTML } from 'linkedom';
export function markdownFromHtml(html: string) {
const { document } = parseHTML(html);
return renderMarkdown(document);
}
```
The same pattern works with HTML produced by a template engine, a server
component render, a CMS, or an upstream HTTP response:
```ts
const response = await fetch('https://app.example.com/reports/weekly');
const html = await response.text();
const markdown = markdownFromHtml(html);
```
Only parse trusted application HTML. If the source is user-controlled or
third-party HTML, a conventional sanitizing HTML-to-Markdown converter may be
a better fit.
## Return Markdown from a route
`renderMarkdown()` returns a string, so it can be used with any server
framework:
```ts
import { renderMarkdown } from 'dom-md';
import { parseHTML } from 'linkedom';
export async function handleMarkdownRequest(request: Request) {
const pageUrl = new URL('/reports/weekly', request.url);
const pageResponse = await fetch(pageUrl, {
headers: { accept: 'text/html' },
});
const { document } = parseHTML(await pageResponse.text());
return new Response(renderMarkdown(document), {
status: pageResponse.status,
headers: {
'content-type': 'text/markdown; charset=utf-8',
vary: 'Accept',
},
});
}
```
When fetching your own application, forward authentication deliberately and
prevent the internal HTML request from passing through Markdown content
negotiation again.
## Next.js
In a Next.js App Router application, use a catch-all route to render pages and
a header-matched rewrite to negotiate between HTML and Markdown:
```text
Accept: text/html -> the normal Next.js page
Accept: text/markdown -> /markdown-render/... -> Markdown response
```
This approach renders the normal page first, so Server Components, layouts,
authentication, and application data all contribute the same HTML that people
receive.
### Add the Markdown route
Create a catch-all route using `createMarkdownRoute()`:
```bash
pnpm add linkedom
```
```ts title="app/markdown-render/[[...path]]/route.ts"
import { renderMarkdown } from 'dom-md';
import { frontmatter } from 'dom-md/plugins';
import { createMarkdownRoute } from 'dom-md/next';
import { DOMParser } from 'linkedom';
export const GET = createMarkdownRoute(
({ document, pageUrl }) => {
return renderMarkdown(document, {
plugins: [
frontmatter({
title: document.title,
url: pageUrl.href,
}),
],
});
},
{
parser: new DOMParser(),
},
);
```
The adapter reconstructs the original page URL from the catch-all path,
fetches that page as HTML, parses it with the supplied `DOMParser`, and passes
the resulting document and original page URL to the callback. It preserves
the page status when your callback returns a string.
`dom-md` does not install or select a server-side DOM implementation. You can
replace `linkedom` with any parser that returns a standards-compatible
`Document`.
### Add a content-negotiation rewrite
Next.js recommends a `next.config` rewrite with header matching for this
[content-negotiation pattern](https://nextjs.org/docs/app/guides/backend-for-frontend#content-negotiation):
```ts title="next.config.ts"
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/:path*',
destination: '/markdown-render/:path*',
has: [
{
type: 'header',
key: 'accept',
value: '(.*)text/markdown(.*)',
},
],
},
];
},
};
export default nextConfig;
```
Requests whose `Accept` header contains `text/markdown` are internally
rewritten to the catch-all Route Handler. Other requests continue to the
normal page. The browser-visible URL does not change.
The adapter's internal page request sends `Accept: text/html`, so it continues
to the normal page rather than matching the Markdown rewrite.
### Test the integration
Start the application, then request the same URL in both formats:
```bash
curl -H 'Accept: text/html' http://localhost:3000/usage
curl -H 'Accept: text/markdown' http://localhost:3000/usage
```
Authenticated pages work because the route adapter forwards cookies and
authorization to the same application origin. Test with representative users
and confirm that private content is never cached publicly.
### Response safeguards
The route adapter:
- rejects cross-origin redirects
- limits redirect count, HTML size, and render time
- returns `x-robots-tag: noindex`
- defaults to `cache-control: private, no-store`
- varies the response on `Accept`
Configure the limits with `maxHtmlBytes`, `maxRedirects`, and `timeoutMs`:
```ts title="app/markdown-render/[[...path]]/route.ts"
export const GET = createMarkdownRoute(renderPage, {
parser: new DOMParser(),
maxHtmlBytes: 1_000_000,
maxRedirects: 3,
timeoutMs: 5_000,
});
```
## Next steps
- See the [Markdown over HTTP](/serve-markdown) use case
- Shape server output with [plugins](/plugins#built-ins)
- Review the [`dom-md/next` API](/api)
MIT License
Copyright (c) 2026 dom-md contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
interface:
display_name: "Audit DOM Markdown"
short_description: "Audit rendered pages through dom-md output"
default_prompt: "Use $audit-dom-markdown to inspect this app across representative pages and recommend improvements."
---
name: audit-dom-markdown
description: Audit a rendered web app through its real dom-md integration. Use when evaluating Markdown quality across pages, debugging missing or noisy content, testing plugins or budgets, or deciding whether a fix belongs in page semantics, data-md authoring, an app plugin, or dom-md itself.
---
# Audit DOM Markdown
Audit what an agent receives, not only what a person sees.
## Read The Relevant Docs
These links resolve inside the installed `node_modules/dom-md` package. Use
them instead of restating library behavior:
- [Getting started](../../docs/getting-started.md)
- [Authoring with attributes](../../docs/attributes.md)
- [Plugins](../../docs/plugins.md)
- [Testing](../../docs/testing.md)
- [Security and privacy](../../docs/security.md)
## Expose The Real Integration
Mount the app's real root, plugins, and settings in development. Do not
re-create the renderer inside browser evaluation. `mountMarkdownAudit()` exposes
a browser-serializable function and handles cleanup.
```ts
import { mountMarkdownAudit } from 'dom-md/devtools';
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return;
const audit = mountMarkdownAudit({
plugins: createAppMarkdownPlugins(),
});
return () => audit.unmount();
}, []);
```
Pass a config getter when root or plugin settings change at runtime:
```ts
const audit = mountMarkdownAudit(() => ({
root: getMarkdownRoot(),
plugins: createAppMarkdownPlugins(),
}));
```
Call this from a development-only app entry or effect. Prefer this hook over the
floating panel for systematic audits; the panel remains useful for people.
Remove the hook after the audit unless the app intentionally supports it.
## Inspect Representative Pages
Use an available browser tool, such as the `$agent-browser` skill, Codex's
built-in browser, or Playwright.
For each page:
1. Navigate through the real UI.
2. Wait for meaningful content, not only the load event. Wait for expected
headings, data, or the disappearance of loading UI.
3. Capture the visible page structure when it helps explain the output.
4. Evaluate `window.__DOM_MD_AUDIT__()` in the page.
5. Record the URL, page purpose, Markdown length, important preserved content,
missing or misleading content, noise, and notable contributors or plugins.
With agent-browser:
```bash
agent-browser open https://app.localhost
agent-browser snapshot -i
agent-browser wait --text "Expected page heading"
agent-browser eval 'window.__DOM_MD_AUDIT__?.()'
```
Use `eval --stdin` for more complex JavaScript. Re-run the function after every
navigation or meaningful state change.
Cover a useful variety: an overview, collection, detail page, form or settings
page, and a dense or unusual view. Include empty, loading, or error states when
they matter. Audit at least 5 pages for a broad review and 10 or more when the
user asks for an application-wide assessment.
## Judge The Output
Prefer Markdown that preserves:
- the page identity and current context;
- primary entities, values, statuses, and relationships;
- actionable labels and destinations;
- meaningful hierarchy and collection structure;
- explicit notices when content was summarized or truncated.
Flag:
- visible content missing from Markdown;
- hidden, duplicate, decorative, or navigation noise;
- flattened relationships that become ambiguous;
- controls without useful names or state;
- tables or collections that lose their field structure;
- stale loading output caused by inspecting too early;
- plugins that remove more meaning than they save.
Token reduction is useful only after the page remains understandable.
Treat captured Markdown as potentially sensitive and do not commit authenticated
page output or browser state to the repository.
## Place Fixes At The Lowest Useful Layer
Classify each recommendation:
1. **Page semantics:** improve HTML or truthful ARIA when that benefits people,
accessibility, and extraction.
2. **Page authoring:** use `data-md`, `data-md-ignore`, or `data-md-root` for
local intent and concise overrides.
3. **Reusable dom-md behavior:** improve a default parser or public API when the
same DOM pattern should work across applications.
4. **Optional plugin:** add app-specific cleanup or lossy transforms only when
semantics and attributes cannot express the intent.
Avoid custom plugins for one-off markup that semantic HTML or a local override
can represent.
## Iterate And Report
Make one class of change at a time, revisit the same pages, and compare output.
Do not claim improvement from character counts alone.
Report:
- representative before/after Markdown excerpts;
- findings grouped by severity or fix layer;
- repeated patterns that justify framework changes;
- page-specific fixes that should remain in the app;
- unresolved tradeoffs and the pages used as evidence.
import type {
Emphasis,
Heading,
InlineContent,
Link,
Paragraph,
Root,
RootContent,
Strong,
Text,
} from './core/mdast';
import type { DomMdMetadata } from './core/types';
type HeadingDepth = 1 | 2 | 3 | 4 | 5 | 6;
export function root(children: RootContent[]): Root {
return { type: 'root', children };
}
export function paragraph(
children: InlineContent[],
metadata?: DomMdMetadata,
): Paragraph {
return withMetadata({ type: 'paragraph', children }, metadata);
}
export function heading(
depth: HeadingDepth,
children: InlineContent[],
metadata?: DomMdMetadata,
): Heading {
return withMetadata({ type: 'heading', depth, children }, metadata);
}
export function text(value: string, metadata?: DomMdMetadata): Text {
return withMetadata({ type: 'text', value }, metadata);
}
export function strong(
children: InlineContent[],
metadata?: DomMdMetadata,
): Strong {
return withMetadata({ type: 'strong', children }, metadata);
}
export function emphasis(
children: InlineContent[],
metadata?: DomMdMetadata,
): Emphasis {
return withMetadata({ type: 'emphasis', children }, metadata);
}
export function link(
url: string,
children: InlineContent[],
metadata?: DomMdMetadata,
): Link {
return withMetadata({ type: 'link', url, children }, metadata);
}
function withMetadata<T>(
node: T,
metadata?: DomMdMetadata,
): T {
return metadata
? {
...(node as object),
data: {
domMd: metadata,
},
} as T
: node;
}
import { extractMarkdownTree } from './extract';
import { stringifyMarkdownNode } from './markdown';
import {
defaultRenderElementPlugins,
defaultRenderElementOverrides,
} from '../plugins/defaults/registry';
import { maxCharsMarkdownPlugin } from '../plugins/max-chars';
import { assertMarkdownNode } from './validate';
import {
DOCUMENT_NODE,
MARKDOWN_ROOT_ATTR,
DomMarkdownError,
type AnnotateElementPlugin,
type MarkdownPlugin,
type MarkdownPluginInput,
type MarkdownRenderRuntime,
type MarkdownRenderer,
type MarkdownTree,
type PostprocessPlugin,
type PluginPipeline,
type RenderMarkdownOptions,
type RenderObserver,
type RenderElementPlugin,
type TransformPlugin,
} from './types';
const defaultAnnotators = maxCharsMarkdownPlugin
.filter((plugin): plugin is AnnotateElementPlugin => plugin.phase === 'annotateElement');
const defaultTransforms = maxCharsMarkdownPlugin
.filter((plugin): plugin is TransformPlugin => plugin.phase === 'transform');
export function createMarkdownRenderer(
options: RenderMarkdownOptions = {},
observer?: RenderObserver,
): MarkdownRenderer {
return createMarkdownRuntime(options, observer);
}
export function createMarkdownRuntime(
options: RenderMarkdownOptions = {},
observer?: RenderObserver,
): MarkdownRenderRuntime {
const pipeline = createPluginPipeline(options.plugins ?? [], observer);
const stringify = (tree: MarkdownTree) => {
validateTree(tree, 'format');
return stringifyMarkdownNode(tree);
};
const finalize = (markdown: string, tree: MarkdownTree) => {
let output = markdown;
for (const plugin of pipeline.postprocessors) {
const before = output;
try {
output = plugin.postprocess(output, { tree });
} catch (cause) {
if (cause instanceof DomMarkdownError) throw cause;
throw new DomMarkdownError(
`Markdown postprocess plugin "${plugin.id}" failed.`,
{ phase: 'postprocess', pluginId: plugin.id, cause },
);
}
observer?.postprocessApplied?.({
pluginId: plugin.id,
before,
after: output,
tree,
});
}
return output;
};
const toTree = (root?: ParentNode | null): MarkdownTree => {
const source = resolveRoot(root);
if (!source) return { type: 'root', children: [] };
let tree = extractMarkdownTree(source, pipeline);
validateTree(tree, 'normalize');
observer?.treeBuilt?.({ tree, markdown: stringify(tree) });
for (const plugin of pipeline.transforms) {
const before = observer?.transformApplied ? stringify(tree) : undefined;
try {
tree = plugin.transform(tree, { stringify }) ?? tree;
validateTree(tree, 'transform', plugin.id);
} catch (cause) {
if (cause instanceof DomMarkdownError) throw cause;
throw new DomMarkdownError(
`Markdown transform plugin "${plugin.id}" failed.`,
{ phase: 'transform', pluginId: plugin.id, cause },
);
}
if (before !== undefined) observer?.transformApplied?.({
pluginId: plugin.id,
before,
after: stringify(tree),
tree,
});
}
return tree;
};
return {
render(root) {
const tree = toTree(root);
return finalize(stringify(tree), tree);
},
finalize,
stringify,
toTree,
};
}
function validateTree(
tree: MarkdownTree,
phase: DomMarkdownError['phase'],
pluginId?: string,
): void {
try {
assertMarkdownNode(tree);
} catch (cause) {
throw new DomMarkdownError(
pluginId
? `Markdown plugin "${pluginId}" produced an invalid Markdown tree.`
: 'Invalid Markdown tree.',
{ phase, pluginId, node: tree, cause },
);
}
}
export function createPluginPipeline(
plugins: readonly MarkdownPluginInput[],
observer?: RenderObserver,
): PluginPipeline {
const annotators: AnnotateElementPlugin[] = [];
const renderers: RenderElementPlugin[] = [];
const transforms: TransformPlugin[] = [];
const postprocessors: PostprocessPlugin[] = [];
for (const plugin of flattenPlugins(plugins)) {
validatePlugin(plugin);
if (plugin.phase === 'annotateElement') annotators.push(plugin);
else if (plugin.phase === 'renderElement') renderers.push(plugin);
else if (plugin.phase === 'transform') transforms.push(plugin);
else postprocessors.push(plugin);
}
const userAnnotators = dedupe(annotators);
const userRenderers = dedupe(renderers);
const userTransforms = dedupe(transforms);
const userPostprocessors = dedupe(postprocessors);
return {
annotators: [
...withoutReplacedDefaults(defaultAnnotators, userAnnotators),
...userAnnotators,
],
renderers: [
...withoutReplacedDefaults(defaultRenderElementOverrides, userRenderers),
...userRenderers,
...withoutReplacedDefaults(defaultRenderElementPlugins, userRenderers),
],
transforms: [
...withoutReplacedDefaults(defaultTransforms, userTransforms),
...userTransforms,
],
postprocessors: userPostprocessors,
observer,
};
}
function flattenPlugins(
inputs: readonly MarkdownPluginInput[],
seen = new WeakSet<readonly MarkdownPluginInput[]>(),
depth = 0,
): MarkdownPlugin[] {
if (depth > 20) throw new Error('Markdown plugins are nested too deeply.');
if (seen.has(inputs)) throw new Error('Markdown plugins cannot contain cyclic arrays.');
seen.add(inputs);
const plugins: MarkdownPlugin[] = [];
for (const input of inputs) {
if (isPluginInputArray(input)) plugins.push(...flattenPlugins(input, seen, depth + 1));
else plugins.push(input);
}
seen.delete(inputs);
return plugins;
}
function isPluginInputArray(
input: MarkdownPluginInput,
): input is readonly MarkdownPluginInput[] {
return Array.isArray(input);
}
function validatePlugin(plugin: MarkdownPlugin): void {
if (!plugin.id.trim()) throw new Error('Markdown plugins must have a non-empty id.');
if (plugin.phase === 'annotateElement' && typeof plugin.annotate !== 'function') {
throw new Error(`Annotate element plugin "${plugin.id}" must define annotate().`);
}
if (plugin.phase === 'renderElement' && typeof plugin.render !== 'function') {
throw new Error(`Render element plugin "${plugin.id}" must define render().`);
}
if (plugin.phase === 'transform' && typeof plugin.transform !== 'function') {
throw new Error(`Transform plugin "${plugin.id}" must define transform().`);
}
if (plugin.phase === 'postprocess' && typeof plugin.postprocess !== 'function') {
throw new Error(`Postprocess plugin "${plugin.id}" must define postprocess().`);
}
}
function dedupe<T extends MarkdownPlugin>(plugins: readonly T[]): T[] {
const last = new Map<string, T>();
for (const plugin of plugins) last.set(plugin.id, plugin);
return plugins.filter((plugin) => last.get(plugin.id) === plugin);
}
function withoutReplacedDefaults<T extends MarkdownPlugin>(
defaults: readonly T[],
replacements: readonly T[],
): T[] {
const ids = new Set(replacements.map((plugin) => plugin.id));
return defaults.filter((plugin) => !ids.has(plugin.id));
}
function resolveRoot(root?: ParentNode | null): ParentNode | null {
const source = root ?? defaultRoot(globalThis.document);
if (!source) return null;
return isDocument(source) ? defaultRoot(source as Document) : source;
}
function defaultRoot(documentLike: Document | undefined): ParentNode | null {
if (!documentLike) return null;
return (
documentLike.querySelector(`[${MARKDOWN_ROOT_ATTR}]`) ??
documentLike.querySelector('main') ??
documentLike.body ??
documentLike
);
}
function isDocument(node: ParentNode): boolean {
return (node as Node).nodeType === DOCUMENT_NODE;
}
import { collapseWhitespace } from './utils';
export function describeElement(
element: Element,
markdown = '',
): string | undefined {
const label =
element.getAttribute('aria-label') ||
headingFromMarkdown(markdown) ||
headingText(element);
return label || undefined;
}
function headingFromMarkdown(markdown: string): string {
const heading = markdown.match(/^#{1,6}\s+(.+)$/m)?.[1];
return collapseWhitespace(heading ?? '');
}
function headingText(element: Element): string {
if (element.tagName.toLowerCase().match(/^h[1-6]$/)) {
return collapseWhitespace(element.textContent ?? '');
}
const heading = element.querySelector('h1,h2,h3,h4,h5,h6');
return collapseWhitespace(heading?.textContent ?? '');
}
import type {
BlockContent,
Code,
Heading,
InlineContent,
List,
ListItem,
Paragraph,
Root,
RootContent,
} from './mdast';
import { describeElement } from './describe-element';
import { stringifyMarkdownNode, isInline } from './markdown';
import {
ELEMENT_NODE,
MARKDOWN_IGNORE_ATTR,
TEXT_NODE,
type DomMdMetadata,
type ElementContent,
type ElementOutput,
type ElementResolution,
type PluginPipeline,
type RenderElementContext,
type RenderElementPlugin,
} from './types';
import { collapseWhitespace } from './utils';
type ExtractionState = {
nextSourceId: number;
pipeline: PluginPipeline;
};
export function extractMarkdownTree(
parent: ParentNode,
pipeline: PluginPipeline,
): Root {
const state: ExtractionState = { nextSourceId: 1, pipeline };
return { type: 'root', children: toBlocks(extractChildren(parent, state)) };
}
function extractNode(node: Node, state: ExtractionState): ElementContent[] {
if (node.nodeType === TEXT_NODE) {
const value = collapseWhitespace(node.textContent ?? '');
return value ? [{ type: 'text', value }] : [];
}
if (node.nodeType !== ELEMENT_NODE) return [];
return extractElement(node as Element, state);
}
function extractElement(
element: Element,
state: ExtractionState,
): ElementContent[] {
const sourceId = `e${state.nextSourceId++}`;
const { nodes, resolution } = resolveElement(element, state, sourceId);
const annotations = resolution.kind === 'skipped'
? {}
: collectAnnotations(element, state, sourceId);
const metadata: DomMdMetadata = {
label: describeElement(element),
sourceId,
};
const annotated = nodes
.map((node) => attachCustomData(node, annotations))
.map((node) => attachMetadata(node, metadata));
state.pipeline.observer?.elementResolved?.({
element,
nodes: annotated,
resolution,
});
return annotated;
}
function resolveElement(
element: Element,
state: ExtractionState,
sourceId: string,
): { nodes: ElementContent[]; resolution: ElementResolution } {
const tag = element.tagName.toLowerCase();
const reason = elementSkipReason(element, tag);
if (reason) return { nodes: [], resolution: { kind: 'skipped', reason } };
return renderWithPlugins(
element,
state,
sourceId,
state.pipeline.renderers,
0,
() => ({
nodes: extractFallback(element, tag, state),
resolution: { kind: 'fallback' },
}),
);
}
function collectAnnotations(
element: Element,
state: ExtractionState,
sourceId: string,
): Record<string, unknown> {
let result: Record<string, unknown> = {};
for (const plugin of state.pipeline.annotators) {
const annotation = plugin.annotate(element, { sourceId });
if (annotation) result = mergeCustomData(result, annotation);
}
return result;
}
function renderWithPlugins(
element: Element,
state: ExtractionState,
sourceId: string,
plugins: readonly RenderElementPlugin[],
startIndex = 0,
continuation: () => { nodes: ElementContent[]; resolution: ElementResolution },
): { nodes: ElementContent[]; resolution: ElementResolution } {
for (let index = startIndex; index < plugins.length; index += 1) {
const plugin = plugins[index];
if (!plugin) continue;
const output = plugin.render(
element,
createRenderElementContext(element, state, sourceId, plugins, index + 1, continuation),
);
if (output !== undefined) {
return {
nodes: normalizeOutput(output),
resolution: { kind: 'plugin', pluginId: plugin.id },
};
}
}
return continuation();
}
function createRenderElementContext(
element: Element,
state: ExtractionState,
sourceId: string,
plugins: readonly RenderElementPlugin[],
resumeIndex: number,
continuation: () => { nodes: ElementContent[]; resolution: ElementResolution },
): RenderElementContext {
return {
sourceId,
children(parent = element) {
return extractChildren(parent, state);
},
inline(parent = element) {
return toInline(extractChildren(parent, state));
},
extract(child) {
return extractElement(child, state);
},
renderDefault() {
return renderWithPlugins(
element,
state,
sourceId,
plugins,
resumeIndex,
continuation,
).nodes;
},
};
}
function extractFallback(
element: Element,
tag: string,
state: ExtractionState,
): ElementContent[] {
if (element.getAttribute('aria-busy') === 'true') {
const label = describeElement(element);
return [paragraphText(label ? `[Loading: ${label}]` : '[Loading content]')];
}
const headingDepth = semanticHeadingDepth(element, tag);
if (headingDepth) {
return [{ type: 'heading', depth: headingDepth, children: toInline(extractChildren(element, state)) }];
}
if (tag === 'p') {
return [{ type: 'paragraph', children: toInline(extractChildren(element, state)) }];
}
if (tag === 'br') return [{ type: 'break' }];
if (tag === 'pre') {
const nestedCode = element.querySelector(':scope > code');
const className = nestedCode?.getAttribute('class') ?? '';
const language = className.match(/(?:^|\s)language-([^\s]+)/)?.[1];
const node: Code = { type: 'code', value: element.textContent ?? '' };
if (language) node.lang = language;
return [node];
}
if (tag === 'code') {
return [{ type: 'inlineCode', value: collapseWhitespace(element.textContent ?? '') }];
}
if (tag === 'strong' || tag === 'b') {
return [{ type: 'strong', children: toInline(extractChildren(element, state)) }];
}
if (tag === 'em' || tag === 'i') {
return [{ type: 'emphasis', children: toInline(extractChildren(element, state)) }];
}
if (tag === 'a') {
const children = toInline(extractChildren(element, state));
const url = element.getAttribute('href');
return url ? [{ type: 'link', url, children }] : children;
}
const role = element.getAttribute('role')?.toLowerCase();
if (tag === 'ul' || tag === 'ol' || role === 'list') {
const itemElements = role === 'list'
? Array.from(element.querySelectorAll('[role="listitem"]')).filter(
(item) => item.closest('[role="list"]') === element,
)
: Array.from(element.children).filter((child) => child.tagName.toLowerCase() === 'li');
const children: ListItem[] = itemElements.map((item) => ({
type: 'listItem',
spread: false,
children: toBlocks(extractElement(item, state)),
}));
const list: List = {
type: 'list',
ordered: tag === 'ol',
spread: false,
children,
};
return [list];
}
return extractChildren(element, state);
}
function extractChildren(parent: ParentNode, state: ExtractionState): ElementContent[] {
return Array.from(parent.childNodes).flatMap((node) => extractNode(node, state));
}
export function toBlocks(
nodes: readonly ElementContent[],
): BlockContent[] {
const blocks: BlockContent[] = [];
let inline: InlineContent[] = [];
const flush = () => {
const children = trimInline(inline);
if (children.length) blocks.push({ type: 'paragraph', children });
inline = [];
};
for (const node of nodes) {
if (isInline(node)) inline.push(node);
else {
flush();
blocks.push(node as BlockContent);
}
}
flush();
return blocks;
}
export function toInline(nodes: readonly ElementContent[]): InlineContent[] {
const result: InlineContent[] = [];
for (const node of nodes) {
if (isInline(node)) result.push(node);
else if (node.type === 'paragraph' || node.type === 'heading') result.push(...node.children);
else {
const value = stringifyMarkdownNode(node);
if (value) result.push({ type: 'text', value });
}
}
return trimInline(result);
}
function trimInline(nodes: InlineContent[]): InlineContent[] {
const result: InlineContent[] = [];
for (const node of nodes) {
if (node.type === 'text') {
const value = collapseWhitespace(node.value);
if (!value) continue;
const previous = result.at(-1);
if (previous?.type === 'text') {
previous.value = /^[,.;:!?)]/.test(value)
? `${previous.value}${value}`
: `${previous.value} ${value}`;
}
else {
if (previous && previous.type !== 'break' && !/^[,.;:!?)]/.test(value)) {
result.push({ type: 'text', value: ' ' });
}
result.push({ ...node, value });
}
continue;
}
const previous = result.at(-1);
if (previous && previous.type !== 'break') result.push({ type: 'text', value: ' ' });
result.push(node);
}
return result;
}
function normalizeOutput(output: ElementOutput): ElementContent[] {
return output == null ? [] : Array.isArray(output) ? [...output] : [output as ElementContent];
}
function attachMetadata<T extends ElementContent>(node: T, metadata: DomMdMetadata): T {
const current = node.data?.domMd;
return {
...node,
data: {
...node.data,
domMd: {
...metadata,
...current,
sourceId: current?.sourceId ?? metadata.sourceId,
},
},
};
}
function attachCustomData<T extends ElementContent>(node: T, data: Record<string, unknown>): T {
if (!Object.keys(data).length) return node;
return {
...node,
data: mergeCustomData(node.data ?? {}, data),
} as T;
}
function mergeCustomData(
target: Record<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...target };
for (const [key, value] of Object.entries(source)) {
if (key === 'domMd') continue;
result[key] = mergeCustomValue(result[key], value);
}
return result;
}
function mergeCustomValue(current: unknown, next: unknown): unknown {
if (Array.isArray(current) && Array.isArray(next)) return [...current, ...next];
if (isPlainObject(current) && isPlainObject(next)) {
return mergeCustomData(current, next);
}
return next;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function paragraphText(value: string, metadata?: DomMdMetadata): Paragraph {
return {
type: 'paragraph',
children: [{ type: 'text', value }],
...(metadata ? { data: { domMd: metadata } } : {}),
};
}
function semanticHeadingDepth(element: Element, tag: string): Heading['depth'] | null {
if (/^h[1-6]$/.test(tag)) return Number(tag[1]) as Heading['depth'];
if (element.getAttribute('role') !== 'heading') return null;
const level = Number(element.getAttribute('aria-level'));
return Number.isInteger(level) && level >= 1 && level <= 6
? (level as Heading['depth'])
: null;
}
function elementSkipReason(
element: Element,
tag: string,
): 'excluded-tag' | 'hidden' | 'ignored' | null {
if (element.hasAttribute(MARKDOWN_IGNORE_ATTR)) return 'ignored';
if (tag === 'script' || tag === 'style' || tag === 'template') return 'excluded-tag';
return element.hasAttribute('hidden') ||
element.getAttribute('aria-hidden') === 'true' ||
isVisuallyHidden(element)
? 'hidden'
: null;
}
function isVisuallyHidden(element: Element): boolean {
const view = element.ownerDocument.defaultView;
if (!view || typeof view.getComputedStyle !== 'function') return false;
const style = view.getComputedStyle(element);
return style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
}
import type {
BlockContent,
Emphasis,
Heading,
Image,
InlineCode,
InlineContent,
Link,
List,
ListItem,
Nodes,
Paragraph,
RootContent,
Strong,
Table,
Text,
Yaml,
} from './mdast';
import {
codeBlock,
escapeInlineText,
escapeLeadingBlockMarker,
escapeMarkdownText,
escapeTableCell,
inlineCode,
normalizeMarkdown,
} from './utils';
export function stringifyMarkdownNode(node: Nodes): string {
return normalizeMarkdown(serializeNode(node));
}
export function toRootContent(nodes: readonly Nodes[]): RootContent[] {
return nodes.filter((node): node is RootContent => node.type !== 'root');
}
export function isInline(node: Nodes): node is InlineContent {
return ![
'root',
'blockquote',
'code',
'heading',
'list',
'paragraph',
'rawMarkdown',
'table',
'thematicBreak',
'yaml',
].includes(node.type);
}
function serializeNode(node: unknown): string {
if (!isNode(node)) return '';
const standard = node as Nodes;
switch (standard.type) {
case 'root':
return serializeBlocks(standard.children);
case 'yaml':
return serializeYaml(standard);
case 'rawMarkdown':
return standard.value;
case 'heading':
return serializeHeading(standard);
case 'paragraph':
return serializeParagraph(standard);
case 'blockquote':
return serializeBlockquote(standard);
case 'list':
return serializeList(standard);
case 'listItem':
return serializeListItem(standard);
case 'code':
return codeBlock(standard.value, standard.lang ?? '');
case 'table':
return serializeTable(standard);
case 'thematicBreak':
return '---';
case 'text':
return serializeText(standard);
case 'inlineCode':
return serializeInlineCode(standard);
case 'emphasis':
return wrapInline(standard, '_');
case 'strong':
return wrapInline(standard, '**');
case 'link':
return serializeLink(standard);
case 'image':
return serializeImage(standard);
case 'break':
return '\\\n';
default:
return '';
}
}
function serializeBlocks(nodes: readonly unknown[]): string {
return nodes.map((node) => serializeNode(node).trim()).filter(Boolean).join('\n\n');
}
function serializeInline(nodes: readonly InlineContent[]): string {
return nodes.map((node) => serializeNode(node)).join('');
}
function serializeYaml(node: Yaml): string {
return `---\n${node.value.replace(/\n+$/, '')}\n---`;
}
function serializeHeading(node: Heading): string {
return `${'#'.repeat(node.depth)} ${serializeInline(node.children)}`.trimEnd();
}
// Authored markdown is a distinct rawMarkdown node, so paragraph output is
// always real content and safe to escape at the line start.
function serializeParagraph(node: Paragraph): string {
return escapeLeadingBlockMarker(serializeInline(node.children));
}
function serializeBlockquote(node: Extract<BlockContent, { type: 'blockquote' }>): string {
const markdown = serializeBlocks(node.children);
return markdown.split('\n').map((line) => line ? `> ${line}` : '>').join('\n');
}
function serializeList(node: List): string {
const start = node.start ?? 1;
return node.children.map((item, index) => {
const marker = node.ordered ? `${start + index}.` : '-';
return serializeMarkedListItem(item, marker);
}).join('\n');
}
function serializeMarkedListItem(item: ListItem, marker: string): string {
const markdown = serializeBlocks(item.children);
if (!markdown) return marker;
const [first = '', ...rest] = markdown.split('\n');
const indent = ' '.repeat(marker.length + 1);
return [`${marker} ${first}`, ...rest.map((line) => line ? `${indent}${line}` : '')].join('\n');
}
function serializeListItem(item: ListItem): string {
return serializeMarkedListItem(item, '-');
}
function serializeTable(node: Table): string {
const [header, ...rows] = node.children;
if (!header) return '';
const headerCells = header.children.map((cell) => serializeTableCell(cell));
return [
tableRow(headerCells),
tableRow(headerCells.map(() => '-')),
...rows.map((row) => tableRow(row.children.map((cell) => serializeTableCell(cell)))),
].join('\n');
}
function serializeTableCell(
cell: Extract<BlockContent, { type: 'table' }>['children'][number]['children'][number],
): string {
return escapeTableCell(serializeInline(cell.children));
}
function tableRow(cells: readonly string[]): string {
return `| ${cells.join(' | ')} |`;
}
function serializeText(node: Text): string {
return escapeInlineText(node.value);
}
function serializeInlineCode(node: InlineCode): string {
return inlineCode(node.value);
}
function wrapInline(node: Emphasis | Strong, marker: string): string {
return `${marker}${serializeInline(node.children)}${marker}`;
}
function serializeLink(node: Link): string {
return `[${serializeInline(node.children)}](${node.url})`;
}
function serializeImage(node: Image): string {
return `![${escapeMarkdownText(node.alt ?? '')}](${node.url})`;
}
function isNode(value: unknown): value is { type: string } {
return !!value && typeof value === 'object' && 'type' in value && typeof value.type === 'string';
}
export type DomMdMetadata = {
authored?: boolean;
chunk?: boolean;
kind?: 'action' | 'field' | 'override' | 'visual';
label?: string;
sourceId?: string;
};
export type Data<CustomData extends object = Record<string, unknown>> = {
domMd?: DomMdMetadata;
[key: string]: unknown;
} & CustomData;
export type Literal<CustomData extends object = Record<string, unknown>> = {
data?: Data<CustomData>;
value: string;
};
export type Parent<
CustomData extends object = Record<string, unknown>,
T extends Nodes<CustomData> = Nodes<CustomData>,
> = {
children: T[];
data?: Data<CustomData>;
};
export type Root<CustomData extends object = Record<string, unknown>> = {
type: 'root';
children: RootContent<CustomData>[];
data?: Data<CustomData>;
};
export type Paragraph<CustomData extends object = Record<string, unknown>> = {
type: 'paragraph';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
export type Heading<CustomData extends object = Record<string, unknown>> = {
type: 'heading';
depth: 1 | 2 | 3 | 4 | 5 | 6;
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
export type Text<CustomData extends object = Record<string, unknown>> = {
type: 'text';
value: string;
data?: Data<CustomData>;
};
export type Emphasis<CustomData extends object = Record<string, unknown>> = {
type: 'emphasis';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
export type Strong<CustomData extends object = Record<string, unknown>> = {
type: 'strong';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
export type Link<CustomData extends object = Record<string, unknown>> = {
type: 'link';
url: string;
title?: string | null;
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
export type Image<CustomData extends object = Record<string, unknown>> = {
type: 'image';
url: string;
alt?: string | null;
title?: string | null;
data?: Data<CustomData>;
};
export type InlineCode<CustomData extends object = Record<string, unknown>> = {
type: 'inlineCode';
value: string;
data?: Data<CustomData>;
};
export type Break<CustomData extends object = Record<string, unknown>> = {
type: 'break';
data?: Data<CustomData>;
};
export type Code<CustomData extends object = Record<string, unknown>> = {
type: 'code';
value: string;
lang?: string | null;
meta?: string | null;
data?: Data<CustomData>;
};
export type Blockquote<CustomData extends object = Record<string, unknown>> = {
type: 'blockquote';
children: BlockContent<CustomData>[];
data?: Data<CustomData>;
};
export type List<CustomData extends object = Record<string, unknown>> = {
type: 'list';
ordered?: boolean | null;
start?: number | null;
spread?: boolean;
children: ListItem<CustomData>[];
data?: Data<CustomData>;
};
export type ListItem<CustomData extends object = Record<string, unknown>> = {
type: 'listItem';
spread?: boolean;
checked?: boolean | null;
children: BlockContent<CustomData>[];
data?: Data<CustomData>;
};
export type Table<CustomData extends object = Record<string, unknown>> = {
type: 'table';
children: TableRow<CustomData>[];
data?: Data<CustomData>;
};
export type TableRow<CustomData extends object = Record<string, unknown>> = {
type: 'tableRow';
children: TableCell<CustomData>[];
data?: Data<CustomData>;
};
export type TableCell<CustomData extends object = Record<string, unknown>> = {
type: 'tableCell';
children: InlineContent<CustomData>[];
data?: Data<CustomData>;
};
export type ThematicBreak<CustomData extends object = Record<string, unknown>> = {
type: 'thematicBreak';
data?: Data<CustomData>;
};
export type Yaml<CustomData extends object = Record<string, unknown>> = {
type: 'yaml';
value: string;
data?: Data<CustomData>;
};
export type RawMarkdown<CustomData extends object = Record<string, unknown>> = {
type: 'rawMarkdown';
value: string;
data?: Data<CustomData>;
};
export type InlineContent<CustomData extends object = Record<string, unknown>> =
| Break<CustomData>
| Emphasis<CustomData>
| Image<CustomData>
| InlineCode<CustomData>
| Link<CustomData>
| Strong<CustomData>
| Text<CustomData>;
export type BlockContent<CustomData extends object = Record<string, unknown>> =
| Blockquote<CustomData>
| Code<CustomData>
| Heading<CustomData>
| List<CustomData>
| Paragraph<CustomData>
| RawMarkdown<CustomData>
| Table<CustomData>
| ThematicBreak<CustomData>
| Yaml<CustomData>;
export type RootContent<CustomData extends object = Record<string, unknown>> = BlockContent<CustomData>;
export type Nodes<CustomData extends object = Record<string, unknown>> =
| Root<CustomData>
| RootContent<CustomData>
| InlineContent<CustomData>
| ListItem<CustomData>
| TableRow<CustomData>
| TableCell<CustomData>;
import { createMarkdownRenderer, createMarkdownRuntime } from './create-renderer';
import type {
MarkdownRenderRuntime,
MarkdownRenderer,
MarkdownTree,
RenderMarkdownOptions,
RenderObserver,
} from './types';
export function renderMarkdown(
root?: ParentNode | null,
options: RenderMarkdownOptions = {},
): string {
return createMarkdownRenderer(options).render(root);
}
export function toMarkdownTree(
root?: ParentNode | null,
options: RenderMarkdownOptions = {},
): MarkdownTree {
return createMarkdownRenderer(options).toTree(root);
}
export function stringifyMarkdown(
tree: MarkdownTree,
options: RenderMarkdownOptions = {},
): string {
return createMarkdownRenderer(options).stringify(tree);
}
/** @internal Shared by rendering and devtools inspection. */
export function runMarkdown(
root: ParentNode | null | undefined,
options: RenderMarkdownOptions = {},
observer?: RenderObserver,
): { markdown: string; tree: MarkdownTree } {
const renderer: MarkdownRenderRuntime = createMarkdownRuntime(options, observer);
const tree = renderer.toTree(root);
const markdown = renderer.finalize(renderer.stringify(tree), tree);
return { markdown, tree };
}
import type {
DomMdMetadata,
InlineContent,
Nodes,
Root,
RootContent,
} from './mdast';
export const MARKDOWN_ROOT_ATTR = 'data-md-root';
export const MARKDOWN_IGNORE_ATTR = 'data-md-ignore';
export const MARKDOWN_ATTR = 'data-md';
export const MARKDOWN_CHILDREN_TOKEN_ATTR = 'data-md-children-token';
export const MARKDOWN_MAX_CHARS_ATTR = 'data-md-max-chars';
export const DEFAULT_CHILDREN_TOKEN = '{{children}}';
export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const DOCUMENT_NODE = 9;
/** The Markdown document tree produced from the DOM. */
export type MarkdownTree<CustomData extends object = Record<string, unknown>> = Root<CustomData>;
/** Any node supported by dom-md's Markdown tree. */
export type MarkdownNode<CustomData extends object = Record<string, unknown>> = Nodes<CustomData>;
/** A node, or inline node, that a renderElement step may return. */
export type ElementContent<CustomData extends object = Record<string, unknown>> =
| RootContent<CustomData>
| InlineContent<CustomData>;
export type DomMdKind = 'action' | 'field' | 'override' | 'visual';
export type { DomMdMetadata };
export type ElementOutput<CustomData extends object = Record<string, unknown>> =
| ElementContent<CustomData>
| readonly ElementContent<CustomData>[]
| null
| undefined;
export type ElementAnnotation<CustomData extends object = Record<string, unknown>> =
| (CustomData & { domMd?: never })
| null
| undefined
| void;
/** Helpers available while a renderElement step converts DOM into Markdown nodes. */
export type RenderElementContext<CustomData extends object = Record<string, unknown>> = {
/** Stable id for the current source element within this render pass. */
readonly sourceId: string;
/** Extract an element's children as block-capable Markdown nodes. */
children(parent?: ParentNode): ElementContent<CustomData>[];
/** Extract an element's children as inline Markdown nodes. */
inline(parent?: ParentNode): InlineContent<CustomData>[];
/** Extract one specific descendant element. */
extract(element: Element): ElementContent<CustomData>[];
/** Render this element with the later renderers/defaults in the chain. */
renderDefault(): ElementContent<CustomData>[];
};
/**
* A plugin step that can claim, omit, or rewrite individual DOM elements.
*
* Return Markdown nodes to handle the element, `null` to intentionally omit it,
* or `undefined` to let dom-md keep looking for another renderer.
*/
export type RenderElementPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */
id: string;
phase: 'renderElement';
render(element: Element, context: RenderElementContext<CustomData>): ElementOutput<CustomData>;
};
/** Context available while an annotateElement step reads DOM metadata. */
export type AnnotateElementContext = {
/** Stable id for the current source element within this render pass. */
readonly sourceId: string;
};
/**
* A plugin step that adds metadata to every Markdown node emitted for an element.
*
* Wrapper elements can emit nodes produced by descendants, so annotation
* metadata intentionally fans out to those nodes. When several annotations
* reach the same node, dom-md recursively merges plain objects, concatenates
* arrays, and lets later scalar values win. The reserved `data.domMd`
* namespace is ignored for custom annotations. Treat nested metadata as
* immutable in transforms because fanned-out nodes may share references.
*/
export type AnnotateElementPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */
id: string;
phase: 'annotateElement';
annotate(element: Element, context: AnnotateElementContext): ElementAnnotation<CustomData>;
};
/** Helpers available while a transform plugin edits the whole Markdown tree. */
export type MarkdownTransformContext<CustomData extends object = Record<string, unknown>> = {
/** Render a node with dom-md's Markdown serializer. Useful for measuring output. */
stringify(node: MarkdownNode<CustomData>): string;
};
/**
* A plugin that edits the complete Markdown tree after DOM extraction.
*
* Mutate the tree in place and return nothing, or return a replacement tree.
*/
export type TransformPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */
id: string;
phase: 'transform';
transform(
tree: MarkdownTree<CustomData>,
context: MarkdownTransformContext<CustomData>,
): MarkdownTree<CustomData> | void;
};
/** Context available to the final string postprocessing step. */
export type PostprocessContext<CustomData extends object = Record<string, unknown>> = {
/** The tree that was serialized into the Markdown string. */
readonly tree: MarkdownTree<CustomData>;
};
/**
* A plugin that edits the final Markdown string produced by `render()`.
*
* Postprocessors do not run during `stringify(tree)`, which keeps tree-to-text
* serialization predictable for tests and transform plugins.
*/
export type PostprocessPlugin<CustomData extends object = Record<string, unknown>> = {
/** Stable id used in errors, devtools, and plugin grouping. */
id: string;
phase: 'postprocess';
postprocess(markdown: string, context: PostprocessContext<CustomData>): string;
};
/** Any low-level plugin phase accepted by the renderer. */
export type MarkdownPlugin<CustomData extends object = Record<string, unknown>> =
| AnnotateElementPlugin<CustomData>
| PostprocessPlugin<CustomData>
| RenderElementPlugin<CustomData>
| TransformPlugin<CustomData>;
/**
* A plugin, or nested arrays of plugins.
*
* This lets `createPlugin('name', define)` bundles be passed directly as
* `plugins: [myPlugin]` without manual spreading.
*/
export type MarkdownPluginInput<CustomData extends object = Record<string, unknown>> =
| MarkdownPlugin<CustomData>
| readonly MarkdownPluginInput<CustomData>[];
/** Options accepted by `renderMarkdown()` and `createMarkdownRenderer()`. */
export type RenderMarkdownOptions = {
plugins?: readonly MarkdownPluginInput[];
};
/** Reusable renderer instance for DOM-to-tree, tree-to-Markdown, and full render. */
export type MarkdownRenderer = {
/** Extract, transform, serialize, and postprocess a DOM root. */
render(root?: ParentNode | null): string;
/** Serialize an existing Markdown tree without running postprocessors. */
stringify(tree: MarkdownTree): string;
/** Extract and transform a DOM root into a Markdown tree. */
toTree(root?: ParentNode | null): MarkdownTree;
};
/** @internal Renderer surface used by one-shot rendering and devtools inspection. */
export type MarkdownRenderRuntime = MarkdownRenderer & {
/** Apply render-only postprocess plugins to a serialized tree. */
finalize(markdown: string, tree: MarkdownTree): string;
};
export type ElementResolution =
| { kind: 'plugin'; pluginId: string }
| { kind: 'fallback' }
| { kind: 'skipped'; reason: 'ignored' | 'hidden' | 'excluded-tag' };
export type RenderObserver = {
elementResolved?(event: {
element: Element;
nodes: ElementContent[];
resolution: ElementResolution;
}): void;
treeBuilt?(event: { tree: MarkdownTree; markdown: string }): void;
transformApplied?(event: {
pluginId: string;
before: string;
after: string;
tree: MarkdownTree;
}): void;
postprocessApplied?(event: {
pluginId: string;
before: string;
after: string;
tree: MarkdownTree;
}): void;
};
export type PluginPipeline = {
annotators: AnnotateElementPlugin[];
observer?: RenderObserver;
postprocessors: PostprocessPlugin[];
renderers: RenderElementPlugin[];
transforms: TransformPlugin[];
};
export class DomMarkdownError extends Error {
readonly phase: 'extract' | 'format' | 'normalize' | 'postprocess' | 'transform';
readonly pluginId?: string;
readonly element?: Element;
readonly node?: MarkdownNode;
override readonly cause?: unknown;
constructor(
message: string,
context: {
phase: DomMarkdownError['phase'];
pluginId?: string;
element?: Element;
node?: MarkdownNode;
cause?: unknown;
},
) {
super(message);
this.name = 'DomMarkdownError';
this.phase = context.phase;
this.pluginId = context.pluginId;
this.element = context.element;
this.node = context.node;
this.cause = context.cause;
}
}
export function normalizeMarkdown(markdown: string): string {
return markdown
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export function codeBlock(value: string, language = ''): string {
return `\`\`\`${language}\n${value.replace(/\n+$/, '')}\n\`\`\``;
}
export function inlineCode(value: string): string {
if (!value) return '``';
const ticks =
value.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0;
const fence = '`'.repeat(ticks + 1);
return `${fence}${value}${fence}`;
}
export function escapeMarkdownText(value: string): string {
return value.replace(/([\[\]\\])/g, '\\$1');
}
/**
* Contextual minimal escaping for DOM text content: neutralize characters
* that would change Markdown structure while leaving common prose (snake_case,
* intraword underscores) untouched.
*/
export function escapeInlineText(value: string): string {
return value
.replace(/([\\`*[\]])/g, '\\$1')
.replace(/_+/g, (run, offset: number, source: string) => {
const before = offset > 0 ? source[offset - 1] : '';
const after = source[offset + run.length] ?? '';
const intraword = /\w/.test(before ?? '') && /\w/.test(after);
return intraword ? run : run.replace(/_/g, '\\_');
});
}
/**
* Escape a paragraph-leading character that would otherwise start a heading,
* blockquote, or list.
*/
export function escapeLeadingBlockMarker(markdown: string): string {
if (/^(?:#{1,6} |>|[-+] )/.test(markdown)) return `\\${markdown}`;
return markdown.replace(/^(\d{1,9})([.)])( )/, '$1\\$2$3');
}
export function escapeTableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\n/g, ' ');
}
export function clampInline(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
if (maxChars === 1) return '…';
return `${value.slice(0, maxChars - 1).trimEnd()}…`;
}
export function clampMarkdownWithNotice(
markdown: string,
maxChars: number,
notice: string,
): string {
const suffix = notice.length > maxChars ? notice.slice(0, maxChars) : notice;
const availableChars = Math.max(0, maxChars - suffix.length - 2);
if (availableChars === 0) return suffix;
return `${markdown.slice(0, availableChars).trimEnd()}\n\n${suffix}`;
}
export function readPositiveInteger(
element: Element,
attribute: string,
): number | undefined {
const value = element.getAttribute(attribute);
if (!value) return undefined;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return Math.floor(parsed);
}
import type { Nodes } from './mdast';
const flow = new Set([
'blockquote',
'code',
'heading',
'list',
'paragraph',
'rawMarkdown',
'table',
'thematicBreak',
'yaml',
]);
const inline = new Set([
'break',
'emphasis',
'image',
'inlineCode',
'link',
'strong',
'text',
]);
const literals = new Set(['code', 'inlineCode', 'rawMarkdown', 'text', 'yaml']);
const voids = new Set([
'break',
'image',
'thematicBreak',
]);
const known = new Set([
...flow,
...inline,
'listItem',
'root',
'tableCell',
'tableRow',
]);
export function assertMarkdownNode(value: unknown): asserts value is Nodes {
assertNode(value, undefined, '$');
}
function assertNode(value: unknown, parentType: string | undefined, path: string): void {
if (!value || typeof value !== 'object') {
throw new TypeError(`${path} must be a Markdown tree node.`);
}
const node = value as { type?: unknown; value?: unknown; children?: unknown };
if (typeof node.type !== 'string' || !node.type) {
throw new TypeError(`${path} must have a non-empty string type.`);
}
const type = node.type;
if (!known.has(type)) {
throw new TypeError(`${path} (${type}) is not a supported Markdown node type.`);
}
if (type === 'root' && parentType !== undefined) {
throw new TypeError(`${path}: root nodes cannot be nested.`);
}
if (literals.has(type) && typeof node.value !== 'string') {
throw new TypeError(`${path} (${type}) must have a string value.`);
}
if ((literals.has(type) || voids.has(type)) && 'children' in node) {
throw new TypeError(`${path} (${type}) cannot have children.`);
}
const expected = expectedChildren(type);
if (!('children' in node)) {
if (!expected) return;
throw new TypeError(`${path} (${type}) must have children.`);
}
if (!Array.isArray(node.children)) {
throw new TypeError(`${path} (${type}) children must be an array.`);
}
node.children.forEach((child, index) => {
assertNode(child, type, `${path}.children[${index}]`);
const childType = (child as { type: string }).type;
if (expected && known.has(childType) && !expected.has(childType)) {
throw new TypeError(
`${path}.children[${index}] (${childType}) is not valid inside ${type}.`,
);
}
});
}
function expectedChildren(type: string): Set<string> | undefined {
if (type === 'root' || type === 'blockquote' || type === 'listItem') {
return flow;
}
if (
type === 'paragraph' ||
type === 'heading' ||
type === 'emphasis' ||
type === 'strong' ||
type === 'link' ||
type === 'tableCell'
) {
return inline;
}
if (type === 'list') return new Set(['listItem']);
if (type === 'table') return new Set(['tableRow']);
if (type === 'tableRow') return new Set(['tableCell']);
return undefined;
}
import type { RenderMarkdownOptions } from '../core/types';
import {
inspectMarkdown,
type MarkdownInspection,
type PluginTimelineEntry,
} from './inspect';
export type MarkdownAudit = {
markdown: string;
domMarkdown: string;
contributors: MarkdownAuditContributor[];
elements: MarkdownAuditElementActivity;
timeline: PluginTimelineEntry[];
};
export type MarkdownAuditContributor = {
source: 'data-md' | 'fallback' | 'plugin';
pluginId?: string;
label?: string;
selector: string;
chars: number;
share: number;
excerpt: string;
};
export type MarkdownAuditElementActivity = {
authored: { selector: string }[];
claimed: { selector: string; pluginId: string }[];
omitted: {
selector: string;
reason: 'empty' | 'excluded-tag' | 'hidden' | 'ignored' | 'plugin-omitted';
}[];
fallbackCount: number;
};
export type MarkdownAuditConfig = RenderMarkdownOptions & {
root?: ParentNode | null;
};
export type MarkdownAuditHandle = {
inspect(): MarkdownAudit;
unmount(): void;
};
export type MarkdownAuditWindow = Window & {
__DOM_MD_AUDIT__?: () => MarkdownAudit;
};
/**
* Expose a browser-serializable inspection function for development audits.
* Pass a getter when the root or plugin configuration changes at runtime.
*/
export function mountMarkdownAudit(
config:
| MarkdownAuditConfig
| (() => MarkdownAuditConfig) = {},
): MarkdownAuditHandle {
if (typeof window === 'undefined') {
return {
inspect: () => createMarkdownAudit(),
unmount() {},
};
}
const auditWindow = window as MarkdownAuditWindow;
const previous = auditWindow.__DOM_MD_AUDIT__;
const inspect = () => {
const current = typeof config === 'function' ? config() : config;
const { root, ...options } = current;
return createMarkdownAudit(root, options);
};
auditWindow.__DOM_MD_AUDIT__ = inspect;
return {
inspect,
unmount() {
if (auditWindow.__DOM_MD_AUDIT__ !== inspect) return;
if (previous) {
auditWindow.__DOM_MD_AUDIT__ = previous;
} else {
delete auditWindow.__DOM_MD_AUDIT__;
}
},
};
}
export function createMarkdownAudit(
root?: ParentNode | null,
options: RenderMarkdownOptions = {},
): MarkdownAudit {
return toMarkdownAudit(inspectMarkdown(root, options));
}
function toMarkdownAudit(inspection: MarkdownInspection): MarkdownAudit {
return {
markdown: inspection.markdown,
domMarkdown: inspection.domMarkdown,
timeline: inspection.timeline,
contributors: inspection.contributors.map(({ element, ...entry }) => entry),
elements: {
authored: inspection.elements.authored.map(({ selector }) => ({
selector,
})),
claimed: inspection.elements.claimed.map(({ element, ...entry }) => entry),
omitted: inspection.elements.omitted.map(({ element, ...entry }) => entry),
fallbackCount: inspection.elements.fallbackCount,
},
};
}
export {
createMarkdownAudit,
mountMarkdownAudit,
type MarkdownAudit,
type MarkdownAuditConfig,
type MarkdownAuditContributor,
type MarkdownAuditElementActivity,
type MarkdownAuditHandle,
type MarkdownAuditWindow,
} from './audit';
export {
inspectMarkdown,
type ElementActivity,
type ElementRecord,
type MarkdownContributor,
type MarkdownInspection,
type PluginTimelineEntry,
} from './inspect';
export {
mountMarkdownDevtools,
type MarkdownDevtoolsConfig,
type MarkdownDevtoolsHandle,
} from './panel';
import type { Root } from '../core/mdast';
import { stringifyMarkdownNode } from '../core/markdown';
import { runMarkdown } from '../core/serialize';
import type {
ElementContent,
ElementResolution,
RenderMarkdownOptions,
RenderObserver,
} from '../core/types';
import { DATA_MD_PLUGIN_ID } from '../plugins/defaults/ids';
import { collapseWhitespace } from '../core/utils';
import { visit } from '../tree';
export type MarkdownInspection = {
markdown: string;
domMarkdown: string;
tree: Root;
contributors: MarkdownContributor[];
elements: ElementActivity;
timeline: PluginTimelineEntry[];
};
export type MarkdownContributor = {
source: 'data-md' | 'fallback' | 'plugin';
pluginId?: string;
label?: string;
selector: string;
element: Element;
chars: number;
share: number;
excerpt: string;
};
export type ElementRecord = { selector: string; element: Element };
export type ElementActivity = {
authored: ElementRecord[];
claimed: (ElementRecord & { pluginId: string })[];
omitted: (ElementRecord & {
reason: 'empty' | 'excluded-tag' | 'hidden' | 'ignored' | 'plugin-omitted';
})[];
fallbackCount: number;
};
export type PluginTimelineEntry = {
phase: 'postprocess' | 'transform';
pluginId: string;
chars: { before: number; after: number };
output: string;
};
export function inspectMarkdown(
root?: ParentNode | null,
options: RenderMarkdownOptions = {},
): MarkdownInspection {
const provenance = new Map<
string,
{ element: Element; resolution: ElementResolution }
>();
const elements: ElementActivity = {
authored: [], claimed: [], omitted: [], fallbackCount: 0,
};
const timeline: PluginTimelineEntry[] = [];
let contributors: MarkdownContributor[] = [];
let domMarkdown = '';
const observer: RenderObserver = {
elementResolved({ element, nodes, resolution }) {
if (!element.isConnected) return;
recordElementActivity(elements, element, nodes, resolution);
for (const node of nodes) {
const sourceId = node.data?.domMd?.sourceId;
if (sourceId && !provenance.has(sourceId)) {
provenance.set(sourceId, { element, resolution });
}
}
},
treeBuilt({ tree, markdown }) {
domMarkdown = markdown;
contributors = collectContributors(tree, provenance, markdown);
},
transformApplied({ pluginId, before, after }) {
timeline.push({
phase: 'transform',
pluginId,
chars: { before: before.length, after: after.length },
output: after,
});
},
postprocessApplied({ pluginId, before, after }) {
timeline.push({
phase: 'postprocess',
pluginId,
chars: { before: before.length, after: after.length },
output: after,
});
},
};
const { markdown, tree } = runMarkdown(root, options, observer);
return { markdown, domMarkdown, tree, contributors, elements, timeline };
}
function recordElementActivity(
activity: ElementActivity,
element: Element,
nodes: ElementContent[],
resolution: ElementResolution,
): void {
const selector = cssPath(element);
if (resolution.kind === 'skipped') {
activity.omitted.push({ selector, element, reason: resolution.reason });
} else if (resolution.kind === 'fallback') {
if (nodes.length) activity.fallbackCount += 1;
else activity.omitted.push({ selector, element, reason: 'empty' });
} else if (!nodes.length) {
activity.omitted.push({ selector, element, reason: 'plugin-omitted' });
} else if (isDataMdResolution(resolution)) {
activity.authored.push({ selector, element });
} else {
activity.claimed.push({ selector, element, pluginId: resolution.pluginId });
}
}
function collectContributors(
tree: Root,
provenance: Map<string, { element: Element; resolution: ElementResolution }>,
markdown: string,
): MarkdownContributor[] {
const result: MarkdownContributor[] = [];
const seen = new Set<string>();
visit(tree, (node) => {
const sourceId = node.data?.domMd?.sourceId;
if (!sourceId || seen.has(sourceId)) return;
const record = provenance.get(sourceId);
if (!record || record.resolution.kind === 'skipped') return;
const rendered = stringifyMarkdownNode(node);
if (!rendered) return;
seen.add(sourceId);
const resolution = record.resolution;
const source = contributorSource(resolution);
result.push({
source,
...(source === 'plugin' && resolution.kind === 'plugin' ? { pluginId: resolution.pluginId } : {}),
label: node.data?.domMd?.label,
selector: cssPath(record.element),
element: record.element,
chars: rendered.length,
share: rendered.length / Math.max(1, markdown.length),
excerpt: excerpt(rendered),
});
});
return result;
}
function contributorSource(
resolution: Exclude<ElementResolution, { kind: 'skipped' }>,
): MarkdownContributor['source'] {
return isDataMdResolution(resolution) ? 'data-md' : resolution.kind;
}
function isDataMdResolution(
resolution: ElementResolution,
): resolution is { kind: 'plugin'; pluginId: typeof DATA_MD_PLUGIN_ID } {
return resolution.kind === 'plugin' && resolution.pluginId === DATA_MD_PLUGIN_ID;
}
function excerpt(markdown: string, maxChars = 80): string {
const value = collapseWhitespace(markdown);
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1).trimEnd()}…`;
}
function cssPath(element: Element, maxDepth = 5): string {
const parts: string[] = [];
let current: Element | null = element;
while (current && parts.length < maxDepth) {
const id = current.getAttribute('id');
if (id) { parts.unshift(`#${id}`); break; }
const tag = current.tagName.toLowerCase();
if (tag === 'body' || tag === 'html') break;
const parent: Element | null = current.parentElement;
const siblings = parent
? Array.from(parent.children).filter((child) => child.tagName === current?.tagName)
: [];
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag);
current = parent;
}
return parts.join(' > ');
}
import { inspectMarkdown, type MarkdownInspection } from './inspect';
import {
MARKDOWN_IGNORE_ATTR,
type RenderMarkdownOptions,
} from '../core/types';
export type MarkdownDevtoolsConfig = RenderMarkdownOptions & {
root?: ParentNode | null;
};
export type MarkdownDevtoolsHandle = {
/** Update the config and, if the panel is open, re-inspect. */
refresh(config?: MarkdownDevtoolsConfig): void;
unmount(): void;
};
const MIN_WIDTH = 480;
const MIN_HEIGHT = 260;
/**
* Mount a floating dom-md inspector on the current page. The widget lives
* in a shadow root, is excluded from rendering via data-md-ignore, and only
* runs the pipeline while the panel is open.
*/
export function mountMarkdownDevtools(
config: MarkdownDevtoolsConfig = {},
): MarkdownDevtoolsHandle {
if (typeof document === 'undefined') {
return { refresh() {}, unmount() {} };
}
let currentConfig = config;
let inspection: MarkdownInspection | null = null;
let error: string | null = null;
let open = false;
let width = 680;
let height = 420;
let highlighted: { element: HTMLElement; outline: string } | null = null;
const host = document.createElement('div');
host.setAttribute(MARKDOWN_IGNORE_ATTR, '');
const shadow = host.attachShadow({ mode: 'open' });
shadow.appendChild(createStyles());
const toggle = el('button', 'toggle', 'MD');
toggle.setAttribute('aria-label', 'Toggle dom-md devtools');
const panel = el('div', 'panel');
panel.hidden = true;
shadow.append(toggle, panel);
document.body.appendChild(host);
const applySize = () => {
panel.style.width = `${width}px`;
panel.style.height = `${height}px`;
};
applySize();
// The panel is anchored bottom-right, so it resizes from its top-left corner.
const resizeHandle = el('div', 'resize');
resizeHandle.addEventListener('pointerdown', (event) => {
event.preventDefault();
resizeHandle.setPointerCapture(event.pointerId);
const start = { x: event.clientX, y: event.clientY, width, height };
const onMove = (move: PointerEvent) => {
width = Math.max(MIN_WIDTH, start.width + start.x - move.clientX);
height = Math.max(MIN_HEIGHT, start.height + start.y - move.clientY);
applySize();
};
const onUp = () => {
resizeHandle.removeEventListener('pointermove', onMove);
resizeHandle.removeEventListener('pointerup', onUp);
};
resizeHandle.addEventListener('pointermove', onMove);
resizeHandle.addEventListener('pointerup', onUp);
});
const clearHighlight = () => {
if (!highlighted) return;
highlighted.element.style.outline = highlighted.outline;
highlighted = null;
};
const highlight = (element: Element) => {
clearHighlight();
if (!(element instanceof HTMLElement) || !element.isConnected) return;
highlighted = { element, outline: element.style.outline };
element.style.outline = '2px solid #ff0080';
};
const inspect = () => {
try {
inspection = inspectMarkdown(currentConfig.root, {
plugins: currentConfig.plugins,
});
error = null;
} catch (cause) {
inspection = null;
error = cause instanceof Error ? cause.message : String(cause);
}
};
const render = () => {
clearHighlight();
panel.replaceChildren();
if (!open) return;
panel.append(resizeHandle, renderHeader());
if (error) {
panel.appendChild(el('div', 'error', `Inspection failed: ${error}`));
return;
}
if (!inspection) return;
const body = el('div', 'body');
const main = el('div', 'main');
main.appendChild(
el('pre', 'markdown', inspection.markdown || 'No Markdown output'),
);
const side = el('div', 'side');
renderPipeline(side, inspection);
renderSources(side, inspection);
body.append(main, side);
panel.appendChild(body);
};
const renderHeader = () => {
const header = el('div', 'header');
header.appendChild(el('span', 'title', 'dom-md'));
if (inspection) {
header.appendChild(
el('span', 'meta', `${formatCount(inspection.markdown.length)} chars`),
);
}
header.appendChild(el('span', 'spacer'));
if (inspection) {
const copyButton = el('button', 'copy', 'Copy');
copyButton.addEventListener('click', async () => {
if (!inspection) return;
try {
await navigator.clipboard.writeText(inspection.markdown);
copyButton.textContent = 'Copied';
copyButton.classList.add('copied');
setTimeout(() => {
copyButton.textContent = 'Copy';
copyButton.classList.remove('copied');
}, 1500);
} catch {
copyButton.textContent = 'Failed';
}
});
header.appendChild(copyButton);
}
const refreshButton = el('button', 'icon', '↻');
refreshButton.setAttribute('aria-label', 'Re-render');
refreshButton.addEventListener('click', () => {
inspect();
render();
});
const closeButton = el('button', 'icon', '×');
closeButton.setAttribute('aria-label', 'Close');
closeButton.addEventListener('click', () => {
open = false;
panel.hidden = true;
render();
});
header.append(refreshButton, closeButton);
return header;
};
const renderPipeline = (side: HTMLElement, current: MarkdownInspection) => {
side.appendChild(
el('div', 'sectionTitle', `Pipeline (${current.timeline.length})`),
);
if (current.timeline.length === 0) {
side.appendChild(el('div', 'empty', 'No plugins ran.'));
return;
}
for (const entry of current.timeline) {
const row = el('div', 'row');
row.append(
el('span', `badge ${entry.phase}`, entry.phase),
el('span', 'rowTitle', entry.pluginId),
el('span', 'spacer'),
el('span', 'meta', formatDelta(entry.chars.before, entry.chars.after)),
);
row.title = `${formatCount(entry.chars.before)} → ${formatCount(entry.chars.after)} chars`;
side.appendChild(row);
}
};
const renderSources = (side: HTMLElement, current: MarkdownInspection) => {
const { contributors, elements } = current;
side.appendChild(
el('div', 'sectionTitle', `Sources (${contributors.length})`),
);
if (contributors.length === 0) {
side.appendChild(el('div', 'empty', 'No tracked source regions.'));
}
for (const contributor of [...contributors].sort(
(a, b) => b.chars - a.chars,
)) {
const row = el('div', 'row hoverable');
row.append(
el(
'span',
`badge ${contributor.source === 'data-md' ? 'authored' : contributor.source}`,
contributor.source === 'data-md' ? 'data-md' : contributor.source,
),
el('span', 'rowTitle', contributor.label ?? contributor.selector),
el('span', 'spacer'),
el('span', 'meta', formatShare(contributor.share)),
);
row.title = `${formatCount(contributor.chars)} chars · ${contributor.selector}`;
row.addEventListener('mouseenter', () => highlight(contributor.element));
row.addEventListener('mouseleave', clearHighlight);
side.appendChild(row);
}
const ownOmissions = elements.omitted.filter(
({ element }) => element !== host,
);
side.appendChild(
el(
'div',
'summary',
`${elements.authored.length} authored · ${elements.claimed.length} claimed · ` +
`${elements.fallbackCount} fallback · ${ownOmissions.length} omitted`,
),
);
};
toggle.addEventListener('click', () => {
open = !open;
panel.hidden = !open;
if (open) inspect();
render();
});
return {
refresh(nextConfig) {
if (nextConfig) currentConfig = { ...currentConfig, ...nextConfig };
if (open) {
inspect();
render();
}
},
unmount() {
clearHighlight();
host.remove();
},
};
}
function formatCount(count: number): string {
return count.toLocaleString('en-US');
}
function formatShare(share: number): string {
const percent = Math.round(share * 100);
return percent < 1 ? '<1%' : `${percent}%`;
}
function formatDelta(before: number, after: number): string {
if (before === after) return '±0';
const change = Math.round(((after - before) / Math.max(1, before)) * 100);
return `${change > 0 ? '+' : ''}${change}%`;
}
function el(tag: string, className?: string, text?: string): HTMLElement {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function createStyles(): HTMLStyleElement {
const style = document.createElement('style');
style.textContent = `
:host {
all: initial;
position: fixed;
bottom: 16px;
right: 16px;
z-index: 2147483646;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
color: #ededed;
}
* { box-sizing: border-box; }
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
.toggle {
display: block;
margin-left: auto;
padding: 6px 12px;
border-radius: 999px;
background: #0a0a0a;
border: 1px solid #333;
font-weight: 600;
letter-spacing: 0.04em;
}
.toggle:hover { border-color: #666; }
.panel {
position: absolute;
bottom: 40px;
right: 0;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 72px);
display: flex;
flex-direction: column;
background: #0a0a0a;
border: 1px solid #2e2e2e;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.panel[hidden] { display: none; }
.resize {
position: absolute;
top: 0;
left: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
z-index: 1;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #1f1f1f;
flex-shrink: 0;
}
.title { font-weight: 600; }
.meta { color: #888; white-space: nowrap; }
.spacer { flex: 1; }
.icon { padding: 0 4px; color: #888; font-size: 14px; }
.icon:hover { color: #ededed; }
.copy {
padding: 2px 10px;
border: 1px solid #2e2e2e;
border-radius: 6px;
color: #a1a1a1;
font-size: 11px;
}
.copy:hover { color: #ededed; border-color: #454545; }
.copy.copied { color: #62c073; border-color: #16271c; }
.body { display: flex; flex: 1; min-height: 0; }
.main { flex: 1; min-width: 0; overflow: auto; padding: 10px 12px; }
.markdown {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
}
.side {
width: 240px;
flex-shrink: 0;
overflow: auto;
padding: 10px 12px;
border-left: 1px solid #1f1f1f;
}
.sectionTitle {
color: #666;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 6px;
}
.sectionTitle + .row { margin-top: 0; }
.row + .sectionTitle { margin-top: 16px; }
.summary + .sectionTitle, .empty + .sectionTitle { margin-top: 16px; }
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 3px 6px;
margin: 0 -6px;
border-radius: 6px;
}
.row.hoverable:hover { background: #1a1a1a; }
.rowTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 999px;
background: #1f1f1f;
color: #999;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge.authored { background: #10233d; color: #52a8ff; }
.badge.plugin { background: #16271c; color: #62c073; }
.summary { margin-top: 8px; color: #666; font-size: 11px; }
.empty { color: #666; padding: 2px 0 4px; }
.error { padding: 12px; color: #ff6166; }
`;
return style;
}
export { createMarkdownRenderer } from './core/create-renderer';
export {
renderMarkdown,
stringifyMarkdown,
toMarkdownTree,
} from './core/serialize';
export type {
AnnotateElementContext,
AnnotateElementPlugin,
DomMdKind,
DomMdMetadata,
ElementContent,
ElementAnnotation,
ElementOutput,
MarkdownPlugin,
MarkdownPluginInput,
MarkdownNode,
MarkdownRenderer,
MarkdownTransformContext,
MarkdownTree,
RenderMarkdownOptions,
RenderElementContext,
RenderElementPlugin,
PostprocessContext,
PostprocessPlugin,
TransformPlugin,
} from './core/types';
export { DomMarkdownError } from './core/types';
export type {
InlineContent,
Nodes as MdastNode,
Root as MdastRoot,
RootContent,
} from './core/mdast';
import { DEFAULT_CHILDREN_TOKEN } from './core/types';
import {
codeBlock as formatCodeBlock,
escapeMarkdownText,
escapeTableCell,
inlineCode as formatInlineCode,
} from './core/utils';
export const md = {
join(parts: Array<string | null | undefined | false>): string {
return parts.filter(Boolean).join('\n\n');
},
heading(text: string, level = 1): string {
return `${'#'.repeat(Math.max(1, Math.min(6, level)))} ${text}`;
},
code(value: string): string {
return formatInlineCode(value);
},
codeBlock(value: string, language = ''): string {
return formatCodeBlock(value, language);
},
list(items: string[], ordered = false): string {
return items
.map((item, index) => `${ordered ? `${index + 1}.` : '-'} ${item}`)
.join('\n');
},
link(text: string, href: string): string {
return `[${escapeMarkdownText(text)}](${href})`;
},
table({ columns, rows }: { columns: string[]; rows: string[][] }): string {
return [
`| ${columns.map(escapeTableCell).join(' | ')} |`,
`| ${columns.map(() => '---').join(' | ')} |`,
...rows.map((row) => `| ${row.map(escapeTableCell).join(' | ')} |`),
].join('\n');
},
children(token = DEFAULT_CHILDREN_TOKEN): string {
return token;
},
};
export type MarkdownHelpers = typeof md;
const DEFAULT_MAX_HTML_BYTES = 2_000_000;
const DEFAULT_MAX_REDIRECTS = 5;
const DEFAULT_TIMEOUT_MS = 10_000;
type MarkdownRouteParams = {
path?: string[];
};
type MarkdownRouteHandlerContext = {
params: MarkdownRouteParams | Promise<MarkdownRouteParams>;
};
type MarkdownRouteRenderer = (context: {
document: Document;
pageUrl: URL;
}) => Promise<Response | string> | Response | string;
type MarkdownRouteOptions = {
parser: {
parseFromString(html: string, mimeType: 'text/html'): unknown;
};
fetch?: typeof fetch;
maxHtmlBytes?: number;
maxRedirects?: number;
timeoutMs?: number;
};
export function createMarkdownRoute(
render: MarkdownRouteRenderer,
options: MarkdownRouteOptions,
): (
request: Request,
context: MarkdownRouteHandlerContext,
) => Promise<Response> {
const fetchPage = options.fetch ?? globalThis.fetch;
const parser = options.parser;
const maxHtmlBytes = options.maxHtmlBytes ?? DEFAULT_MAX_HTML_BYTES;
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!fetchPage) {
throw new Error('createMarkdownRoute requires a global fetch implementation.');
}
return async (request, routeContext) => {
if (request.method !== 'GET') {
return markdownError('Method not allowed.', 405, {
Allow: 'GET',
});
}
const params = await routeContext.params;
const pageUrl = pageUrlFromRequest(request, params.path ?? []);
let pageResponse: Response;
let html: string;
try {
pageResponse = await fetchSameOriginPage(pageUrl, request, {
fetchPage,
maxRedirects,
timeoutMs,
});
html = await readHtml(pageResponse, maxHtmlBytes);
} catch (error) {
if (error instanceof MarkdownRouteError) {
return markdownError(error.message, error.status);
}
return markdownError('Could not load the page HTML.', 502);
}
const document = parser.parseFromString(html, 'text/html') as Document;
const result = await render({ document, pageUrl });
return markdownResponse(result, pageResponse.status, pageUrl);
};
}
function pageUrlFromRequest(request: Request, path: string[]): URL {
const requestUrl = new URL(request.url);
const pageUrl = new URL(requestUrl);
pageUrl.pathname =
path.length === 0
? '/'
: `/${path.map((segment) => encodeURIComponent(segment)).join('/')}`;
return pageUrl;
}
async function fetchSameOriginPage(
initialUrl: URL,
request: Request,
options: {
fetchPage: typeof fetch;
maxRedirects: number;
timeoutMs: number;
},
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
let currentUrl = initialUrl;
try {
for (let redirectCount = 0; ; redirectCount += 1) {
const response = await options.fetchPage(currentUrl, {
cache: 'no-store',
headers: pageRequestHeaders(request),
redirect: 'manual',
signal: controller.signal,
});
if (!isRedirect(response.status)) return response;
if (redirectCount >= options.maxRedirects) {
throw new MarkdownRouteError('Too many redirects.', 502);
}
const location = response.headers.get('location');
if (!location) {
throw new MarkdownRouteError(
'The page returned a redirect without a location.',
502,
);
}
const nextUrl = new URL(location, currentUrl);
if (nextUrl.origin !== initialUrl.origin) {
throw new MarkdownRouteError(
'The page redirected outside the application origin.',
502,
);
}
currentUrl = nextUrl;
}
} catch (error) {
if (error instanceof MarkdownRouteError) throw error;
if (controller.signal.aborted) {
throw new MarkdownRouteError('The page render timed out.', 504);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function pageRequestHeaders(request: Request): Headers {
const headers = new Headers({
Accept: 'text/html',
});
for (const name of [
'accept-language',
'authorization',
'cookie',
'user-agent',
]) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
return headers;
}
async function readHtml(
response: Response,
maxHtmlBytes: number,
): Promise<string> {
const contentType = response.headers.get('content-type')?.toLowerCase();
if (
contentType &&
!contentType.includes('text/html') &&
!contentType.includes('application/xhtml+xml')
) {
throw new MarkdownRouteError(
`Expected an HTML response, received ${contentType}.`,
502,
);
}
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > maxHtmlBytes) {
throw new MarkdownRouteError('The page HTML is too large to render.', 413);
}
const bytes = await response.arrayBuffer();
if (bytes.byteLength > maxHtmlBytes) {
throw new MarkdownRouteError('The page HTML is too large to render.', 413);
}
return new TextDecoder().decode(bytes);
}
function markdownResponse(
result: Response | string,
pageStatus: number,
pageUrl: URL,
): Response {
const response =
typeof result === 'string'
? new Response(result, {
status: pageStatus,
headers: {
'content-type': 'text/markdown; charset=utf-8',
},
})
: new Response(result.body, {
headers: result.headers,
status: result.status,
statusText: result.statusText,
});
if (!response.headers.has('content-type')) {
response.headers.set('content-type', 'text/markdown; charset=utf-8');
}
response.headers.set('content-location', pageUrl.href);
response.headers.set('x-robots-tag', 'noindex');
appendVary(response.headers, 'Accept');
if (!response.headers.has('cache-control')) {
response.headers.set('cache-control', 'private, no-store');
}
return response;
}
function appendVary(headers: Headers, value: string): void {
const existing = headers.get('vary');
const values = new Set(
existing
?.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
);
values.add(value);
headers.set('vary', Array.from(values).join(', '));
}
function isRedirect(status: number): boolean {
return status >= 300 && status < 400;
}
function markdownError(
message: string,
status: number,
headers?: HeadersInit,
): Response {
return new Response(`# Markdown unavailable\n\n${message}\n`, {
status,
headers: {
'cache-control': 'private, no-store',
'content-type': 'text/markdown; charset=utf-8',
vary: 'Accept',
'x-robots-tag': 'noindex',
...headers,
},
});
}
class MarkdownRouteError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'MarkdownRouteError';
this.status = status;
}
}
import type {
AnnotateElementContext,
AnnotateElementPlugin,
ElementContent,
ElementAnnotation,
ElementOutput,
MarkdownNode,
MarkdownPlugin,
MarkdownTransformContext,
MarkdownTree,
PostprocessContext,
PostprocessPlugin,
RenderElementContext,
RenderElementPlugin,
TransformPlugin,
} from '../core/types';
type CustomData = Record<string, unknown>;
type AnonymousAnnotateElementPlugin<Data extends object = CustomData> =
Omit<AnnotateElementPlugin<Data>, 'id'> & { id?: string };
type AnonymousRenderElementPlugin<Data extends object = CustomData> =
Omit<RenderElementPlugin<Data>, 'id'> & { id?: string };
type AnonymousTransformPlugin<Data extends object = CustomData> =
Omit<TransformPlugin<Data>, 'id'> & { id?: string };
type AnonymousPostprocessPlugin<Data extends object = CustomData> =
Omit<PostprocessPlugin<Data>, 'id'> & { id?: string };
type PluginStep<Data extends object = CustomData> =
| AnonymousAnnotateElementPlugin<Data>
| AnonymousPostprocessPlugin<Data>
| AnonymousRenderElementPlugin<Data>
| AnonymousTransformPlugin<Data>
| MarkdownPlugin<Data>;
type AnnotateElementHelper<Data extends object> = {
(
annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,
): AnonymousAnnotateElementPlugin<Data>;
(
id: string,
annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,
): AnnotateElementPlugin<Data>;
};
type RenderElementHelper<Data extends object> = {
(
render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,
): AnonymousRenderElementPlugin<Data>;
(
id: string,
render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,
): RenderElementPlugin<Data>;
};
type TransformHelper<Data extends object> = {
(
transform: (
tree: MarkdownTree<Data>,
context: MarkdownTransformContext<Data>,
) => MarkdownTree<Data> | void,
): AnonymousTransformPlugin<Data>;
(
id: string,
transform: (
tree: MarkdownTree<Data>,
context: MarkdownTransformContext<Data>,
) => MarkdownTree<Data> | void,
): TransformPlugin<Data>;
};
type PostprocessHelper<Data extends object> = {
(
postprocess: (markdown: string, context: PostprocessContext<Data>) => string,
): AnonymousPostprocessPlugin<Data>;
(
id: string,
postprocess: (markdown: string, context: PostprocessContext<Data>) => string,
): PostprocessPlugin<Data>;
};
type DataHelper<Data extends object> = {
<Node extends MarkdownNode<Data>>(node: Node, metadata: Data & { domMd?: never }): Node;
<Node extends ElementContent<Data>>(nodes: readonly Node[], metadata: Data & { domMd?: never }): Node[];
};
export type PluginAuthoringApi<Data extends object = CustomData> = {
/** Create a DOM annotation step typed with this plugin's custom metadata. */
annotateElement: AnnotateElementHelper<Data>;
/** Create a DOM rendering step typed with this plugin's custom metadata. */
renderElement: RenderElementHelper<Data>;
/** Create a transform plugin step typed with this plugin's custom metadata. */
transform: TransformHelper<Data>;
/** Create a postprocess plugin step typed with this plugin's custom metadata. */
postprocess: PostprocessHelper<Data>;
/** Attach custom plugin metadata to one node or an array of nodes. */
data: DataHelper<Data>;
};
/**
* Create a composed plugin from one or more typed plugin steps.
*
* The returned value is a plain array of plugins, so it can be placed directly
* inside `plugins: [...]` without spreading. Step ids are prefixed with the
* plugin id, e.g. `createPlugin('callouts', ({ renderElement }) => [
* renderElement('aside', fn),
* ])` creates the plugin id `callouts:aside`.
*/
export function createPlugin<Data extends object = CustomData>(
id: string,
define: (api: PluginAuthoringApi<Data>) => readonly PluginStep<Data>[],
): MarkdownPlugin<Data>[] {
return prefixSteps(id, define(createPluginAuthoringApi<Data>()));
}
function prefixSteps<Data extends object>(
id: string,
steps: readonly PluginStep<Data>[],
): MarkdownPlugin<Data>[] {
const prefix = id.trim();
if (!prefix) throw new Error('Markdown plugins must have a non-empty id.');
if (steps.length === 1) {
const [step] = steps;
if (step && !step.id?.trim()) return [{ ...step, id: prefix } as MarkdownPlugin<Data>];
}
const counts: Record<MarkdownPlugin<Data>['phase'], number> = {
annotateElement: 0,
postprocess: 0,
renderElement: 0,
transform: 0,
};
return steps.map((step) => {
const stepId = step.id?.trim() || `${step.phase}:${counts[step.phase]++}`;
return { ...step, id: `${prefix}:${stepId}` } as MarkdownPlugin<Data>;
});
}
function createPluginAuthoringApi<Data extends object>(): PluginAuthoringApi<Data> {
return {
annotateElement: annotateElement as AnnotateElementHelper<Data>,
renderElement: renderElement as RenderElementHelper<Data>,
transform: transform as TransformHelper<Data>,
postprocess: postprocess as PostprocessHelper<Data>,
data: withCustomData as DataHelper<Data>,
};
}
/**
* Create an element annotation step.
*
* Annotation steps run for every non-skipped element and add metadata to the
* nodes produced from that element. They do not decide how the element renders.
*/
function annotateElement<Data extends object = CustomData>(
annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,
): AnonymousAnnotateElementPlugin<Data>;
function annotateElement<Data extends object = CustomData>(
id: string,
annotate: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,
): AnnotateElementPlugin<Data>;
function annotateElement<Data extends object = CustomData>(
idOrAnnotate: string | ((element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>),
maybeAnnotate?: (element: Element, context: AnnotateElementContext) => ElementAnnotation<Data>,
): AnonymousAnnotateElementPlugin<Data> | AnnotateElementPlugin<Data> {
return typeof idOrAnnotate === 'string'
? { id: idOrAnnotate, phase: 'annotateElement', annotate: maybeAnnotate! }
: { phase: 'annotateElement', annotate: idOrAnnotate };
}
/**
* Create an element rendering step.
*
* Render steps run during DOM extraction. Return nodes to claim an element,
* `null` to omit it, or `undefined` to let the next plugin/default extractor
* handle it.
*/
function renderElement<Data extends object = CustomData>(
render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,
): AnonymousRenderElementPlugin<Data>;
function renderElement<Data extends object = CustomData>(
id: string,
render: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,
): RenderElementPlugin<Data>;
function renderElement<Data extends object = CustomData>(
idOrRender: string | ((element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>),
maybeRender?: (element: Element, context: RenderElementContext<Data>) => ElementOutput<Data>,
): AnonymousRenderElementPlugin<Data> | RenderElementPlugin<Data> {
return typeof idOrRender === 'string'
? { id: idOrRender, phase: 'renderElement', render: maybeRender! }
: { phase: 'renderElement', render: idOrRender };
}
/**
* Create a transform plugin step.
*
* Transform plugins run after DOM extraction and receive the complete Markdown
* tree. Mutate the tree in place and return nothing, or return a replacement
* tree.
*/
function transform<Data extends object = CustomData>(
transform: (
tree: MarkdownTree<Data>,
context: MarkdownTransformContext<Data>,
) => MarkdownTree<Data> | void,
): AnonymousTransformPlugin<Data>;
function transform<Data extends object = CustomData>(
id: string,
transform: (
tree: MarkdownTree<Data>,
context: MarkdownTransformContext<Data>,
) => MarkdownTree<Data> | void,
): TransformPlugin<Data>;
function transform<Data extends object = CustomData>(
idOrTransform: string | ((
tree: MarkdownTree<Data>,
context: MarkdownTransformContext<Data>,
) => MarkdownTree<Data> | void),
maybeTransform?: (
tree: MarkdownTree<Data>,
context: MarkdownTransformContext<Data>,
) => MarkdownTree<Data> | void,
): AnonymousTransformPlugin<Data> | TransformPlugin<Data> {
return typeof idOrTransform === 'string'
? { id: idOrTransform, phase: 'transform', transform: maybeTransform! }
: { phase: 'transform', transform: idOrTransform };
}
/**
* Create a postprocess plugin step.
*
* Postprocess plugins run only in `render()` after the tree has already been
* serialized. Use them for terminal string concerns such as wrapping, final
* redaction, or transport-specific framing.
*/
function postprocess<Data extends object = CustomData>(
postprocess: (markdown: string, context: PostprocessContext<Data>) => string,
): AnonymousPostprocessPlugin<Data>;
function postprocess<Data extends object = CustomData>(
id: string,
postprocess: (markdown: string, context: PostprocessContext<Data>) => string,
): PostprocessPlugin<Data>;
function postprocess<Data extends object = CustomData>(
idOrPostprocess: string | ((markdown: string, context: PostprocessContext<Data>) => string),
maybePostprocess?: (markdown: string, context: PostprocessContext<Data>) => string,
): AnonymousPostprocessPlugin<Data> | PostprocessPlugin<Data> {
return typeof idOrPostprocess === 'string'
? { id: idOrPostprocess, phase: 'postprocess', postprocess: maybePostprocess! }
: { phase: 'postprocess', postprocess: idOrPostprocess };
}
function withCustomData<Data extends object, Node extends MarkdownNode<Data>>(
node: Node,
metadata: Data,
): Node;
function withCustomData<Data extends object, Node extends ElementContent<Data>>(
nodes: readonly Node[],
metadata: Data,
): Node[];
function withCustomData<Data extends object, Node extends MarkdownNode<Data> | ElementContent<Data>>(
value: Node | readonly Node[],
metadata: Data,
): Node | Node[] {
return Array.isArray(value)
? value.map((node) => mergeCustomData(node as MarkdownNode<Data>, metadata)) as Node[]
: mergeCustomData(value as MarkdownNode<Data>, metadata) as Node;
}
function mergeCustomData<Data extends object, Node extends MarkdownNode<Data>>(
node: Node,
metadata: Data,
): Node {
if ('domMd' in metadata) {
throw new Error('Plugin data cannot write reserved node.data.domMd metadata.');
}
return {
...node,
data: {
...node.data,
...metadata,
},
} as Node;
}
import type {
Code,
List,
ListItem,
Paragraph,
Root,
RootContent,
Table,
TableCell,
TableRow,
} from '../core/mdast';
import type { TransformPlugin } from '../core/types';
import { clampInline } from '../core/utils';
import { createPlugin } from './authoring';
export type BudgetOptions = {
/**
* Maximum number of Markdown characters to keep after compaction.
*
* The budget plugin first tries gentle summaries and only falls back to more
* aggressive truncation when the rendered Markdown is still too long.
*/
maxChars: number;
};
/**
* Add a transform plugin that keeps the final Markdown under a character budget.
*
* This is useful when passing page snapshots to tools or models with a known
* context limit. The plugin preserves headings and representative structure
* before truncating individual blocks.
*/
export function budget(options: BudgetOptions): TransformPlugin {
if (!Number.isFinite(options.maxChars) || options.maxChars <= 0) {
throw new Error('budget maxChars must be a positive number.');
}
const maxChars = Math.floor(options.maxChars);
const [plugin] = createPlugin('budget', ({ transform }) => [
transform((tree, context) => {
if (context.stringify(tree).length <= maxChars) return tree;
for (const level of [0, 1, 2] as const) {
const candidate = compactTree(tree, level);
if (context.stringify(candidate).length <= maxChars) return candidate;
}
return emergencyTree(tree, maxChars, context.stringify);
}),
]);
return plugin as TransformPlugin;
}
function compactTree(tree: Root, level: 0 | 1 | 2): Root {
const children: RootContent[] = [budgetNotice()];
for (const node of tree.children) children.push(...compactNode(node, level));
return { type: 'root', children };
}
function compactNode(node: RootContent, level: 0 | 1 | 2): RootContent[] {
if (node.type === 'heading' || node.type === 'yaml') return [clone(node)];
if (node.type === 'paragraph') return [compactParagraph(node, [180, 80, 36][level]!)];
if (node.type === 'rawMarkdown') return [compactRawMarkdown(node, [180, 80, 36][level]!)];
if (node.type === 'table') return compactTable(node, level);
if (node.type === 'list') return [compactList(node, level)];
if (node.type === 'code') return compactCode(node, level);
return level === 2 ? [] : [clone(node)];
}
function compactRawMarkdown(
node: Extract<RootContent, { type: 'rawMarkdown' }>,
maxChars: number,
): RootContent {
if (node.value.length <= maxChars) return clone(node);
const [firstLine = '', ...restLines] = node.value.split('\n');
const heading = /^#{1,6}\s+/.test(firstLine.trim()) ? firstLine.trim() : '';
if (!heading) {
return { ...clone(node), value: clampInline(collapseRawMarkdown(node.value), maxChars) };
}
const remaining = Math.max(0, maxChars - heading.length - 2);
const rest = collapseRawMarkdown(restLines.join('\n'));
return {
...clone(node),
value: rest && remaining > 1
? `${heading}\n\n${clampInline(rest, remaining)}`
: heading,
};
}
function compactParagraph(node: Paragraph, maxChars: number): Paragraph {
const value = plainText(node);
if (value.length <= maxChars) return clone(node);
return {
type: 'paragraph',
children: [
{ type: 'text', value: clampInline(value, maxChars) },
{ type: 'text', value: ' ' },
{ type: 'emphasis', children: [{ type: 'text', value: 'Budget: Paragraph truncated.' }] },
],
};
}
function compactTable(node: Table, level: 0 | 1 | 2): RootContent[] {
const header = node.children[0];
if (!header) return [];
const body = node.children.slice(1);
const count = [6, 3, 2][level]!;
const kept = representative(body, count);
const table: Table = { ...clone(node), children: [clone(header), ...kept.map(clone)] };
const omitted = body.length - kept.length;
return omitted > 0
? [table, marker(`${omitted} table row${omitted === 1 ? '' : 's'} omitted.`)]
: [table];
}
function compactList(node: List, level: 0 | 1 | 2): List {
const count = [8, 6, 2][level]!;
const kept = level === 0
? [...node.children.slice(0, Math.max(0, count - 1)), ...node.children.slice(-1)]
: node.children.slice(0, count);
const summarized = kept.map((item) => summarizeListItem(item, level));
const omitted = node.children.length - kept.length;
const summaryCount = kept.filter((item, index) => plainText(item) !== plainText(summarized[index]!)).length;
if (omitted || summaryCount) {
const parts = [
omitted ? `${omitted} list items omitted` : '',
summaryCount ? `${summaryCount} list items summarized` : '',
].filter(Boolean).join('; ');
summarized.push({
type: 'listItem',
children: [{ type: 'paragraph', children: [{ type: 'emphasis', children: [{ type: 'text', value: `Budget: ${parts}.` }] }] }],
});
}
return { ...clone(node), children: summarized };
}
function summarizeListItem(item: ListItem, level: 0 | 1 | 2): ListItem {
const first = item.children[0];
const firstHasStrong =
first?.type === 'paragraph' &&
first.children.some((child) => child.type === 'strong');
if (level > 0 && first && firstHasStrong) {
return { ...clone(item), children: [clone(first)] };
}
if (level === 0) {
const field = item.children.find((child) => child.type === 'paragraph' && child.data?.domMd?.kind === 'field');
return { ...clone(item), children: [first, field].filter(Boolean).map((child) => clone(child!)) };
}
const value = clampInline(plainText(item), level === 1 ? 64 : 42);
return { type: 'listItem', children: [{ type: 'paragraph', children: [{ type: 'text', value }] }] };
}
function compactCode(node: Code, level: 0 | 1 | 2): RootContent[] {
const lines = node.value.split('\n');
const count = [10, 5, 2][level]!;
if (lines.length <= count) return [clone(node)];
return [
{ ...clone(node), value: lines.slice(0, count).join('\n') },
marker(`${lines.length - count} code lines omitted.`),
];
}
function budgetNotice(): Paragraph {
return {
type: 'paragraph',
children: [{ type: 'strong', children: [{ type: 'text', value: 'Budgeted output (incomplete):' }] }],
};
}
function marker(value: string): Paragraph {
return {
type: 'paragraph',
children: [{ type: 'emphasis', children: [{ type: 'text', value: `Budget: ${value}` }] }],
};
}
function emergencyTree(
tree: Root,
maxChars: number,
stringify: (node: import('../core/mdast').Nodes) => string,
): Root {
const headings = tree.children.filter((node) => node.type === 'heading');
let children: RootContent[] = [budgetNotice(), ...headings.map(clone)];
let result: Root = { type: 'root', children };
while (headings.length && stringify(result).length > maxChars) {
headings.pop();
children = [budgetNotice(), ...headings.map(clone)];
result = { type: 'root', children };
}
if (stringify(result).length <= maxChars) return result;
return {
type: 'root',
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Budgeted output'.slice(0, maxChars) }] }],
};
}
function representative<T>(items: T[], count: number): T[] {
if (items.length <= count) return items;
if (count <= 1) return items.slice(0, count);
return [items[0]!, ...items.slice(1, count - 1), items.at(-1)!];
}
function plainText(node: unknown): string {
if (!node || typeof node !== 'object') return '';
if ('value' in node && typeof node.value === 'string') return node.value;
if ('children' in node && Array.isArray(node.children)) {
return node.children.map(plainText).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
return '';
}
function collapseRawMarkdown(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
import { collapseWhitespace } from '../../core/utils';
const MAX_ACCESSIBLE_NAME_CHARS = 160;
export function accessibleName(element: Element): string {
return explicitAccessibleName(element) || visibleName(element);
}
export function explicitAccessibleName(element: Element): string {
const labelledBy = element
.getAttribute('aria-labelledby')
?.split(/\s+/)
.flatMap((id) => {
const referenced = element.ownerDocument.getElementById(id);
return referenced ? [referenced.textContent ?? ''] : [];
})
.map(collapseWhitespace)
.filter(Boolean)
.join(' ');
if (labelledBy) return boundAccessibleText(labelledBy);
const ariaLabel = collapseWhitespace(
element.getAttribute('aria-label') ?? '',
);
if (ariaLabel) return boundAccessibleText(ariaLabel);
const id = element.getAttribute('id');
if (id) {
const label = Array.from(
element.ownerDocument.querySelectorAll('label'),
).find((candidate) => candidate.getAttribute('for') === id);
if (label) {
const value = labelText(label);
if (value) return boundAccessibleText(value);
}
}
const parentLabel = element.closest('label');
if (parentLabel) {
const value = labelText(parentLabel);
if (value) return boundAccessibleText(value);
}
return '';
}
export function boundAccessibleText(value: string): string {
if (value.length <= MAX_ACCESSIBLE_NAME_CHARS) return value;
return `${value.slice(0, MAX_ACCESSIBLE_NAME_CHARS - 1).trimEnd()}…`;
}
function visibleName(element: Element): string {
const clone = element.cloneNode(true) as Element;
for (const descendant of clone.querySelectorAll(
[
'input',
'select',
'textarea',
'[aria-hidden="true"]',
'[role="listbox"]',
'[role="menu"]',
'[role="option"]',
].join(','),
)) {
descendant.remove();
}
return boundAccessibleText(collapseWhitespace(clone.textContent ?? ''));
}
function labelText(label: Element): string {
const clone = label.cloneNode(true) as Element;
for (const control of clone.querySelectorAll(
[
'button',
'input',
'select',
'textarea',
'[role="checkbox"]',
'[role="combobox"]',
'[role="radio"]',
'[role="switch"]',
].join(','),
)) {
control.remove();
}
return collapseWhitespace(clone.textContent ?? '');
}
import type { Paragraph } from '../../core/mdast';
import type { RenderElementPlugin } from '../../core/types';
import { collapseWhitespace } from '../../core/utils';
import {
accessibleName,
boundAccessibleText,
explicitAccessibleName,
} from './accessible-name';
const CHECKED_ROLES = new Set(['checkbox', 'radio', 'switch']);
export const ariaWidgetMarkdownPlugin: RenderElementPlugin = {
id: 'aria-widgets',
phase: 'renderElement',
render(element) {
return parseAriaWidget(element);
},
};
function parseAriaWidget(element: Element): Paragraph | null | undefined {
const role = element.getAttribute('role')?.toLowerCase();
if (role === 'tablist') {
const selectedTab = Array.from(
element.querySelectorAll('[role="tab"][aria-selected="true"]'),
).find((tab) => tab.closest('[role="tablist"]') === element);
if (!selectedTab) return null;
const value = accessibleName(selectedTab);
if (!value) return null;
const label = explicitAccessibleName(element) || 'Selected tab';
return field(label, value);
}
if (role === 'tab') {
if (element.closest('[role="tablist"]') !== null) return null;
if (element.getAttribute('aria-selected') !== 'true') return null;
const label = accessibleName(element);
return label ? field('Selected tab', label) : null;
}
if (role === 'combobox') {
const label = accessibleName(element);
const value = comboboxValue(element);
return label && value ? field(label, value) : undefined;
}
if (role && CHECKED_ROLES.has(role)) {
const label = accessibleName(element);
const state = checkedState(element, role);
return label && state ? field(label, state) : undefined;
}
const pressed = tristateAttribute(element, 'aria-pressed');
if (pressed) {
const label = accessibleName(element);
return label ? field(label, onOffState(pressed)) : undefined;
}
const expanded = booleanAttribute(element, 'aria-expanded');
if (expanded !== undefined) {
const label = accessibleName(element);
return label
? field(label, expanded ? 'expanded' : 'collapsed')
: undefined;
}
return undefined;
}
function comboboxValue(element: Element): string {
const ownValue = formControlValue(element);
if (ownValue) return boundAccessibleText(ownValue);
const nestedControl = element.querySelector('input, select, textarea');
const nestedValue = nestedControl ? formControlValue(nestedControl) : '';
if (nestedValue) return boundAccessibleText(nestedValue);
const valueText = collapseWhitespace(
element.getAttribute('aria-valuetext') ?? '',
);
if (valueText) return boundAccessibleText(valueText);
const selectedOption = selectedOwnedOption(element);
return selectedOption
? boundAccessibleText(accessibleName(selectedOption))
: '';
}
function selectedOwnedOption(element: Element): Element | undefined {
const controlledIds = element
.getAttribute('aria-controls')
?.split(/\s+/)
.filter(Boolean);
for (const id of controlledIds ?? []) {
const controlled = element.ownerDocument.getElementById(id);
const selected = controlled?.querySelector(
'[role="option"][aria-selected="true"]',
);
if (selected) return selected;
}
const activeId = element.getAttribute('aria-activedescendant');
return activeId
? (element.ownerDocument.getElementById(activeId) ?? undefined)
: undefined;
}
function formControlValue(element: Element): string {
const tag = element.tagName.toLowerCase();
if (tag === 'select') {
const select = element as HTMLSelectElement;
const selected = Array.from(select.options).find(
(option) => option.selected,
);
return collapseWhitespace(selected?.textContent ?? select.value);
}
if (tag !== 'input' && tag !== 'textarea') return '';
const control = element as HTMLInputElement | HTMLTextAreaElement;
if (tag === 'input' && (control as HTMLInputElement).type === 'password') {
return '';
}
return collapseWhitespace(control.value);
}
function checkedState(element: Element, role: string): string | undefined {
const ariaChecked = tristateAttribute(element, 'aria-checked');
if (ariaChecked) {
return role === 'switch'
? onOffState(ariaChecked)
: checkedLabel(ariaChecked);
}
if (element.tagName.toLowerCase() !== 'input') return undefined;
const checked = (element as HTMLInputElement).checked;
return role === 'switch'
? checked
? 'on'
: 'off'
: checked
? 'checked'
: 'unchecked';
}
function checkedLabel(state: 'false' | 'mixed' | 'true'): string {
if (state === 'mixed') return 'mixed';
return state === 'true' ? 'checked' : 'unchecked';
}
function onOffState(state: 'false' | 'mixed' | 'true'): string {
if (state === 'mixed') return 'mixed';
return state === 'true' ? 'on' : 'off';
}
function tristateAttribute(
element: Element,
attribute: string,
): 'false' | 'mixed' | 'true' | undefined {
const value = element.getAttribute(attribute);
return value === 'true' || value === 'false' || value === 'mixed'
? value
: undefined;
}
function booleanAttribute(
element: Element,
attribute: string,
): boolean | undefined {
const value = element.getAttribute(attribute);
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
function field(label: string, value: string): Paragraph {
return {
type: 'paragraph',
children: [{ type: 'text', value: `${label}: ${value}` }],
data: { domMd: { chunk: true, kind: 'field', label } },
};
}
import type { Paragraph, RootContent } from '../../core/mdast';
import { stringifyMarkdownNode } from '../../core/markdown';
import type {
RenderElementContext,
RenderElementPlugin,
} from '../../core/types';
import { accessibleName } from './accessible-name';
import { hasFieldPairs, parseFieldPairs } from './field-pairs';
export const buttonMarkdownPlugin: RenderElementPlugin = {
id: 'buttons',
phase: 'renderElement',
render(element, context) {
if (element.tagName.toLowerCase() !== 'button') return undefined;
return parseButton(element, context);
},
};
function parseButton(
element: Element,
context: RenderElementContext,
): Paragraph | readonly RootContent[] | null {
if (hasFieldPairs(element)) {
return parseFieldPairs(element, context);
}
const label =
accessibleName(element) ||
stringifyMarkdownNode({ type: 'paragraph', children: context.inline(element) });
return label
? {
type: 'paragraph',
children: [{
type: 'text',
value: hasContentStructure(element) ? label : `Button: ${label}`,
}],
data: { domMd: { chunk: true, kind: 'action', label } },
}
: null;
}
function hasContentStructure(element: Element): boolean {
return Boolean(
element.querySelector(
[
'[data-md]',
'article',
'section',
'header',
'footer',
'main',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'dl',
'dt',
'dd',
'table',
'ul',
'ol',
'li',
].join(','),
),
);
}
import type { RootContent } from '../../core/mdast';
import {
DEFAULT_CHILDREN_TOKEN,
MARKDOWN_ATTR,
MARKDOWN_CHILDREN_TOKEN_ATTR,
type RenderElementPlugin,
} from '../../core/types';
import { describeElement } from '../../core/describe-element';
import { toBlocks } from '../../core/extract';
import { DATA_MD_PLUGIN_ID } from './ids';
export const dataMdMarkdownPlugin: RenderElementPlugin = {
id: DATA_MD_PLUGIN_ID,
phase: 'renderElement',
render(element, context) {
const authored = element.getAttribute(MARKDOWN_ATTR);
if (authored === null) return undefined;
const token =
element.getAttribute(MARKDOWN_CHILDREN_TOKEN_ATTR) ??
DEFAULT_CHILDREN_TOKEN;
return authoredMarkdownNodes(authored, token, element, context).map(
(node) => ({
...node,
data: {
...node.data,
domMd: {
...node.data?.domMd,
authored: true,
chunk: true,
kind: 'override',
label: describeElement(element, authored),
},
},
}),
);
},
};
function authoredMarkdownNodes(
markdown: string,
token: string,
element: Element,
context: Parameters<RenderElementPlugin['render']>[1],
): RootContent[] {
const tokenLine = new RegExp(`^(?:[\\t ]*)${escapeRegExp(token)}(?:[\\t ]*)$`, 'gm');
const parts = markdown.split(tokenLine);
if (parts.length === 1) return rawMarkdown(markdown);
const children = toBlocks(context.children(element));
return parts.flatMap((part, index) => [
...rawMarkdown(part),
...(index < parts.length - 1 ? children : []),
]);
}
function rawMarkdown(value: string): RootContent[] {
const trimmed = value.trim();
return trimmed ? [{ type: 'rawMarkdown', value: trimmed }] : [];
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
import type { InlineContent, Paragraph, RootContent } from '../../core/mdast';
import type { RenderElementContext } from '../../core/types';
export function parseFieldPairs(
element: Element,
context: RenderElementContext,
): RootContent[] {
const nodes = Array.from(element.querySelectorAll('dt,dd'));
const children: InlineContent[] = [];
for (let index = 0; index < nodes.length; index += 1) {
const term = nodes[index];
const definition = nodes[index + 1];
if (
!term || term.tagName.toLowerCase() !== 'dt' ||
!definition || definition.tagName.toLowerCase() !== 'dd'
) continue;
if (children.length) children.push({ type: 'break' });
const label = context.inline(term);
const value = context.inline(definition);
children.push(...label);
if (value.length) children.push({ type: 'text', value: ': ' }, ...value);
index += 1;
}
if (!children.length) return [];
const paragraph: Paragraph = {
type: 'paragraph',
children,
data: { domMd: { chunk: true, kind: 'field', label: 'Fields' } },
};
return [paragraph];
}
export function hasFieldPairs(element: Element): boolean {
return element.querySelector('dt') !== null && element.querySelector('dd') !== null;
}
export function hasDirectFieldPairs(element: Element): boolean {
const children = Array.from(element.children).map((child) => child.tagName.toLowerCase());
return children.includes('dt') && children.includes('dd');
}
import type { Paragraph } from '../../core/mdast';
import type { RenderElementContext, RenderElementPlugin } from '../../core/types';
import { collapseWhitespace } from '../../core/utils';
import { accessibleName } from './accessible-name';
import { hasDirectFieldPairs, parseFieldPairs } from './field-pairs';
export const formMarkdownPlugin: RenderElementPlugin = {
id: 'forms',
phase: 'renderElement',
render(element, context) {
const tag = element.tagName.toLowerCase();
if (tag === 'label') return parseLabel(element, context);
if (tag === 'legend') return { type: 'strong', children: context.inline(element) };
if (tag === 'dl') return parseFieldPairs(element, context);
if (isFormControl(tag)) {
if (tag === 'input' && (element as HTMLInputElement).type === 'hidden') return null;
return parseFormControl(element, tag);
}
if (hasDirectFieldPairs(element)) return parseFieldPairs(element, context);
return undefined;
},
};
function parseFormControl(element: Element, tag: string): Paragraph {
const label = fieldLabel(element);
if (tag === 'textarea') return field(label, (element as HTMLTextAreaElement).value);
if (tag === 'select') {
const select = element as HTMLSelectElement;
return field(label, selectedOptionText(select) || select.value);
}
const input = element as HTMLInputElement;
if (input.type === 'checkbox' || input.type === 'radio') {
return field(label, input.checked ? 'checked' : 'unchecked');
}
if (input.type === 'button' || input.type === 'reset' || input.type === 'submit') {
const actionLabel = input.value || label;
return {
type: 'paragraph',
children: [{ type: 'text', value: `Button: ${actionLabel}` }],
data: { domMd: { chunk: true, kind: 'action', label: actionLabel } },
};
}
if (input.type === 'password') return field(label, '[value omitted]');
return field(label, input.value);
}
function parseLabel(element: Element, context: RenderElementContext) {
if (element.hasAttribute('for')) return null;
const controls = Array.from(element.querySelectorAll('input, textarea, select'));
if (controls.length > 0) return controls.flatMap((control) => context.extract(control));
return context.inline(element);
}
function isFormControl(tag: string): boolean {
return tag === 'input' || tag === 'select' || tag === 'textarea';
}
function fieldLabel(element: Element): string {
return accessibleName(element) || element.getAttribute('name') || 'Field';
}
function selectedOptionText(select: HTMLSelectElement): string {
return Array.from(select.options).find((option) => option.selected)?.textContent ?? '';
}
function field(label: string, value?: string): Paragraph {
const normalized = collapseWhitespace(value ?? '');
return {
type: 'paragraph',
children: [{ type: 'text', value: normalized ? `${label}: ${normalized}` : label }],
data: { domMd: { chunk: true, kind: 'field', label } },
};
}
export const DATA_MD_PLUGIN_ID = 'data-md';
import type { RenderElementPlugin } from '../../core/types';
import { ariaWidgetMarkdownPlugin } from './aria';
import { buttonMarkdownPlugin } from './buttons';
import { dataMdMarkdownPlugin } from './data-md';
import { formMarkdownPlugin } from './forms';
import { tableMarkdownPlugin } from './tables';
import { visualMarkdownPlugin } from './visuals';
export const defaultRenderElementOverrides: RenderElementPlugin[] = [
dataMdMarkdownPlugin,
];
export const defaultRenderElementPlugins: RenderElementPlugin[] = [
visualMarkdownPlugin,
tableMarkdownPlugin,
ariaWidgetMarkdownPlugin,
formMarkdownPlugin,
buttonMarkdownPlugin,
];
import type { InlineContent, RootContent, Table, TableCell, TableRow } from '../../core/mdast';
import {
MARKDOWN_ATTR,
MARKDOWN_IGNORE_ATTR,
type RenderElementContext,
type RenderElementPlugin,
} from '../../core/types';
import { toInline } from '../../core/extract';
import { collapseWhitespace } from '../../core/utils';
import { explicitAccessibleName } from './accessible-name';
type DomTableRow = { cells: Element[]; isHeader: boolean };
export const tableMarkdownPlugin: RenderElementPlugin = {
id: 'tables',
phase: 'renderElement',
render(element, context) {
const native = element.tagName.toLowerCase() === 'table';
const aria = element.getAttribute('role') === 'table';
if (!native && !aria) return undefined;
const rows = native ? nativeTableRows(element) : ariaTableRows(element);
if (!rows.length) return null;
const foundHeader = rows.findIndex((row) => row.isHeader);
const headerIndex = foundHeader === -1 ? 0 : foundHeader;
const header = rows[headerIndex];
if (!header) return null;
const ignoredColumns = new Set(header.cells.flatMap((cell, index) =>
cell.hasAttribute(MARKDOWN_IGNORE_ATTR) ? [index] : [],
));
const children: TableRow[] = rows.map((row) => ({
type: 'tableRow',
children: row.cells
.filter((_, index) => !ignoredColumns.has(index))
.map((cell) => parseTableCell(cell, context)),
}));
const label = explicitAccessibleName(element) || undefined;
const table: Table = {
type: 'table',
children: [children[headerIndex]!, ...children.filter((_, index) => index !== headerIndex)],
data: { domMd: { chunk: true, label } },
};
const output: RootContent[] = [];
if (label) {
output.push({
type: 'paragraph',
children: [{ type: 'strong', children: [{ type: 'text', value: label }] }],
});
}
output.push(table);
return output;
},
};
function nativeTableRows(element: Element): DomTableRow[] {
return Array.from(element.querySelectorAll('tr'))
.filter((row) => row.closest('table') === element)
.map((row, index) => ({
cells: Array.from(row.querySelectorAll('th,td')).filter((cell) => cell.closest('tr') === row),
isHeader: index === 0,
}));
}
function ariaTableRows(element: Element): DomTableRow[] {
return Array.from(element.querySelectorAll('[role="row"]'))
.filter((row) => row.closest('[role="table"]') === element)
.map((row) => {
const cells = Array.from(row.querySelectorAll('[role="columnheader"],[role="rowheader"],[role="cell"]'))
.filter((cell) => cell.closest('[role="row"]') === row);
return {
cells,
isHeader: cells.some((cell) => cell.getAttribute('role') === 'columnheader'),
};
})
.filter((row) => row.cells.length > 0);
}
function parseTableCell(cell: Element, context: RenderElementContext): TableCell {
let children: InlineContent[] = [];
if (!cell.hasAttribute(MARKDOWN_IGNORE_ATTR)) {
const ariaLabel = !cell.hasAttribute(MARKDOWN_ATTR) ? cell.getAttribute('aria-label') : null;
children = ariaLabel
? [{ type: 'text', value: collapseWhitespace(ariaLabel) }]
: toInline(context.extract(cell));
}
return {
type: 'tableCell',
children,
data: { domMd: { label: cell.getAttribute('aria-label') ?? undefined } },
};
}
import type { RenderElementPlugin } from '../../core/types';
export const visualMarkdownPlugin: RenderElementPlugin = {
id: 'visuals',
phase: 'renderElement',
render(element) {
const tag = element.tagName.toLowerCase();
if (tag === 'img') {
const alt = element.getAttribute('alt') || element.getAttribute('aria-label') || 'image';
const url = element.getAttribute('src');
// Inline data: URLs can embed megabytes of base64 into the output.
return url && !url.startsWith('data:')
? { type: 'image', alt, url, data: { domMd: { chunk: true } } }
: visualText(`Image: ${alt}`, alt);
}
if (tag === 'svg') {
const label = labelForVisual(element);
return label ? visualText(`SVG: ${label}`, label) : null;
}
if (tag === 'canvas') {
const label = labelForVisual(element);
return visualText(`Canvas: ${label || 'no label'}`, label);
}
return undefined;
},
};
function visualText(value: string, label: string) {
return {
type: 'paragraph' as const,
children: [{
type: 'emphasis' as const,
children: [{ type: 'text' as const, value }],
}],
data: { domMd: { chunk: true, kind: 'visual' as const, label } },
};
}
function labelForVisual(element: Element): string {
return (
element.getAttribute('aria-label') ||
element.querySelector('title')?.textContent ||
element.querySelector('desc')?.textContent ||
''
).trim();
}
import type { Yaml } from '../core/mdast';
import type { TransformPlugin } from '../core/types';
import { createPlugin } from './authoring';
/** A scalar value that can be written into generated YAML frontmatter. */
export type FrontmatterScalar = string | number | boolean | null;
/** A generated frontmatter value: either one scalar or a YAML list of scalars. */
export type FrontmatterValue = FrontmatterScalar | FrontmatterScalar[];
/**
* Key/value metadata to prepend to the Markdown document.
*
* Set a key to `undefined` to omit it from the generated YAML block.
*/
export type Frontmatter = Record<string, FrontmatterValue | undefined>;
/**
* Static frontmatter, or a function that is read at render time.
*
* Use a function when metadata depends on live browser state such as
* `document.title` or `location.href`.
*/
export type FrontmatterSource = Frontmatter | (() => Frontmatter);
/**
* Add a transform plugin that prepends YAML frontmatter to the document.
*
* The plugin runs after DOM extraction, so it can add page-level metadata
* without changing the source DOM.
*/
export function frontmatter(
source: FrontmatterSource,
): TransformPlugin {
const [plugin] = createPlugin('frontmatter', ({ transform }) => [
transform((tree) => {
const frontmatter = typeof source === 'function' ? source() : source;
const value = serializeFrontmatter(frontmatter);
if (!value) return tree;
const yaml: Yaml = { type: 'yaml', value };
return { ...tree, children: [yaml, ...tree.children] };
}),
]);
return plugin as TransformPlugin;
}
function serializeFrontmatter(frontmatter: Frontmatter): string {
return Object.entries(frontmatter)
.filter((entry): entry is [string, FrontmatterValue] => entry[1] !== undefined)
.map(([key, value]) => serializeEntry(key, value))
.join('\n');
}
function serializeEntry(key: string, value: FrontmatterValue): string {
const serializedKey = serializeKey(key);
if (Array.isArray(value)) {
if (!value.length) return `${serializedKey}: []`;
return [`${serializedKey}:`, ...value.map((item) => ` - ${serializeScalar(item)}`)].join('\n');
}
return `${serializedKey}: ${serializeScalar(value)}`;
}
function serializeScalar(value: FrontmatterScalar): string {
if (value === null) return 'null';
return typeof value === 'string' ? JSON.stringify(value) : String(value);
}
function serializeKey(key: string): string {
return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(key) ? key : JSON.stringify(key);
}
export { budget } from './budget';
export type { BudgetOptions } from './budget';
export {
createPlugin,
} from './authoring';
export type { PluginAuthoringApi } from './authoring';
export type {
AnnotateElementContext,
AnnotateElementPlugin,
ElementAnnotation,
MarkdownPlugin,
MarkdownPluginInput,
PostprocessContext,
PostprocessPlugin,
RenderElementContext,
RenderElementPlugin,
TransformPlugin,
} from '../core/types';
export {
frontmatter,
type Frontmatter,
type FrontmatterScalar,
type FrontmatterSource,
type FrontmatterValue,
} from './frontmatter';
export {
outline,
type OutlineMarkdownOptions,
} from './outline';
export {
redactSecrets,
redactSecretsFromString,
type RedactSecretsOptions,
type RedactSecretsReplacement,
} from './redact-secrets';
import type {
BlockContent,
Heading,
InlineContent,
Nodes,
} from '../core/mdast';
import {
MARKDOWN_MAX_CHARS_ATTR,
type MarkdownTransformContext,
type MarkdownTree,
} from '../core/types';
import { describeElement } from '../core/describe-element';
import { clampInline, readPositiveInteger } from '../core/utils';
import { createPlugin } from './authoring';
type ChildMode = 'flow' | 'inline';
type MaxCharsLimit = {
sourceId: string;
maxChars: number;
label?: string;
};
type MaxCharsData = {
maxChars?: {
limits?: MaxCharsLimit[];
};
};
type MaxCharsNode = Nodes<MaxCharsData>;
type MarkdownParent = {
type: string;
children: MaxCharsNode[];
};
type ScopedRun = {
start: number;
end: number;
};
type Stringify = MarkdownTransformContext<MaxCharsData>['stringify'];
export const maxCharsMarkdownPlugin = createPlugin<MaxCharsData>(
'max-chars',
({ annotateElement, transform }) => [
annotateElement('read-attribute', (element, context) => {
const limit = readMaxCharsLimit(element, context.sourceId);
return limit ? { maxChars: { limits: [limit] } } : undefined;
}),
transform('enforce', (tree, context) => {
enforceMaxChars(tree, context.stringify);
}),
],
);
function readMaxCharsLimit(
element: Element,
sourceId: string,
): MaxCharsLimit | undefined {
const maxChars = readPositiveInteger(element, MARKDOWN_MAX_CHARS_ATTR);
if (!maxChars) return undefined;
return {
sourceId,
maxChars,
label: describeElement(element),
};
}
function enforceMaxChars(
tree: MarkdownTree<MaxCharsData>,
stringify: Stringify,
): void {
compactParent(tree, stringify);
}
function compactParent(
parent: MarkdownParent,
stringify: Stringify,
): void {
for (const child of parent.children) {
if (isParent(child)) compactParent(child, stringify);
if (child.type === 'tableCell') compactTableCell(child, stringify);
}
const mode = childMode(parent.type);
if (!mode) return;
for (const limit of collectLimits(parent.children)) {
compactScopedRuns(parent, limit, mode, stringify);
}
}
function collectLimits(children: MaxCharsNode[]): MaxCharsLimit[] {
const limits = new Map<string, MaxCharsLimit>();
for (const child of children) {
for (const limit of child.data?.maxChars?.limits ?? []) {
if (!limits.has(limit.sourceId)) limits.set(limit.sourceId, limit);
}
}
return [...limits.values()];
}
function compactScopedRuns(
parent: MarkdownParent,
limit: MaxCharsLimit,
mode: ChildMode,
stringify: Stringify,
): void {
const runs = limitedRuns(parent.children, limit.sourceId);
for (const run of runs.reverse()) {
const nodes = parent.children
.slice(run.start, run.end)
.map((node) => stripLimit(node, limit.sourceId));
const replacement =
mode === 'flow'
? fitFlow(nodes, limit.maxChars, stringify)
: fitInline(nodes, limit.maxChars, stringify);
parent.children.splice(run.start, run.end - run.start, ...replacement);
}
}
function limitedRuns(
children: MaxCharsNode[],
sourceId: string,
): ScopedRun[] {
const runs: ScopedRun[] = [];
let start = -1;
for (let index = 0; index <= children.length; index += 1) {
const child = children[index];
const included = child ? hasLimit(child, sourceId) : false;
if (included && start === -1) start = index;
if (!included && start !== -1) {
runs.push({ start, end: index });
start = -1;
}
}
return runs;
}
function hasLimit(
node: MaxCharsNode,
sourceId: string,
): boolean {
return node.data?.maxChars?.limits?.some(
(limit) => limit.sourceId === sourceId,
) ?? false;
}
function compactTableCell(
cell: Extract<MaxCharsNode, { type: 'tableCell' }>,
stringify: Stringify,
): void {
const limit = cell.data?.maxChars?.limits?.[0];
if (!limit || stringify(cell).length <= limit.maxChars) return;
cell.children = fitInline(cell.children, limit.maxChars, stringify) as InlineContent[];
cell.data = stripMaxCharsData(cell.data);
}
function fitFlow(
nodes: MaxCharsNode[],
maxChars: number,
stringify: Stringify,
): MaxCharsNode[] {
if (renderFlow(nodes, stringify).length <= maxChars) return nodes;
const fitted: MaxCharsNode[] = [];
for (const node of nodes) {
if (renderFlow([...fitted, node], stringify).length <= maxChars) {
fitted.push(node);
continue;
}
const value = plainText(node) || stringify(node);
const truncated = fitFlowText(fitted, value, maxChars, node, node.data, stringify);
if (truncated) fitted.push(truncated);
break;
}
return fitted;
}
function fitFlowText(
prefix: MaxCharsNode[],
value: string,
maxChars: number,
source: MaxCharsNode,
metadata: MaxCharsNode['data'],
stringify: Stringify,
): BlockContent | undefined {
let low = 1;
let high = value.length;
let best: BlockContent | undefined;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const candidate = flowTextNode(source, clampInline(value, middle), metadata);
if (renderFlow([...prefix, candidate], stringify).length <= maxChars) {
best = candidate;
low = middle + 1;
} else {
high = middle - 1;
}
}
return best;
}
function fitInline(
nodes: MaxCharsNode[],
maxChars: number,
stringify: Stringify,
): MaxCharsNode[] {
if (renderInline(nodes, stringify).length <= maxChars) return nodes;
const value = nodes.map(plainText).filter(Boolean).join(' ');
let low = 1;
let high = value.length;
let best: InlineContent | undefined;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const candidate: InlineContent = {
type: 'text',
value: clampInline(value, middle),
data: nodes[0]?.data,
};
if (renderInline([candidate], stringify).length <= maxChars) {
best = candidate;
low = middle + 1;
} else {
high = middle - 1;
}
}
return best ? [best] : [];
}
function renderFlow(
nodes: MaxCharsNode[],
stringify: Stringify,
): string {
return stringify({
type: 'root',
children: nodes as BlockContent[],
});
}
function renderInline(
nodes: MaxCharsNode[],
stringify: Stringify,
): string {
return stringify({
type: 'paragraph',
children: nodes as InlineContent[],
});
}
function childMode(type: string): ChildMode | undefined {
if (
type === 'root' ||
type === 'blockquote' ||
type === 'listItem'
) {
return 'flow';
}
if (
type === 'emphasis' ||
type === 'heading' ||
type === 'link' ||
type === 'paragraph' ||
type === 'strong' ||
type === 'tableCell'
) {
return 'inline';
}
return undefined;
}
function stripLimit(node: MaxCharsNode, sourceId: string): MaxCharsNode {
const limits = node.data?.maxChars?.limits?.filter((limit) => limit.sourceId !== sourceId);
return {
...node,
data: stripMaxCharsData({
...node.data,
maxChars: {
...node.data?.maxChars,
limits,
},
}),
} as MaxCharsNode;
}
function stripMaxCharsData<Data extends MaxCharsNode['data']>(data: Data): Data {
if (data?.maxChars?.limits?.length) return data;
if (!data || !('maxChars' in data)) return data;
const { maxChars: _maxChars, ...rest } = data;
return rest as Data;
}
function flowTextNode(
source: MaxCharsNode,
value: string,
data: MaxCharsNode['data'],
): BlockContent {
if (source.type === 'heading') {
const heading: Heading = {
type: 'heading',
depth: source.depth,
children: [{ type: 'text', value }],
data,
};
return heading;
}
return {
type: 'paragraph',
children: [{ type: 'text', value }],
data,
};
}
function plainText(value: unknown): string {
if (!value || typeof value !== 'object') return '';
if ('value' in value && typeof value.value === 'string') return value.value;
if ('alt' in value && typeof value.alt === 'string') return value.alt;
if ('children' in value && Array.isArray(value.children)) {
return value.children
.map(plainText)
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
return '';
}
function isParent(node: MaxCharsNode): node is MaxCharsNode & MarkdownParent {
return 'children' in node && Array.isArray(node.children);
}
import type { Nodes, Root, RootContent, TableCell } from '../core/mdast';
import type {
MarkdownTransformContext,
TransformPlugin,
} from '../core/types';
import { clampMarkdownWithNotice, collapseWhitespace } from '../core/utils';
import { createPlugin } from './authoring';
export type OutlineMarkdownOptions = {
/** Try to keep the generated outline under this many Markdown characters. */
maxChars?: number;
/** Maximum number of summary bullets to keep in each section. */
maxItemsPerSection?: number;
/** Minimum number of summary bullets to keep before falling back to headings only. */
minItemsPerSection?: number;
/** Maximum length of text snippets used inside outline bullets. */
textSnippetChars?: number;
};
type Entry =
| { kind: 'heading'; text: string }
| { kind: 'item'; priority: number; text: string };
type Section = { heading?: string; items: Extract<Entry, { kind: 'item' }>[] };
/**
* Add a transform plugin that replaces the document with a compact outline.
*
* Use this when the shape of a page matters more than its full text, such as
* quick audits, navigation maps, or low-token previews.
*/
export function outline(
options: OutlineMarkdownOptions = {},
): TransformPlugin {
const [plugin] = createPlugin('outline', ({ transform }) => [
transform((tree, context) => {
const entries = tree.children.flatMap((node) => describe(node, context, options));
const markdown = renderSections(toSections(entries), options);
const outlined = rawMarkdownRoot(markdown);
if (
options.maxChars &&
context.stringify(outlined).length > options.maxChars
) {
return rawMarkdownRoot(headingsOnly(markdown));
}
return outlined;
}),
]);
return plugin as TransformPlugin;
}
function rawMarkdownRoot(value: string): Root {
return value.trim()
? { type: 'root', children: [{ type: 'rawMarkdown', value }] }
: { type: 'root', children: [] };
}
function headingsOnly(markdown: string): string {
return markdown
.split('\n')
.filter((line) => /^#{1,6}\s+/.test(line))
.join('\n\n');
}
function describe(
node: RootContent,
context: MarkdownTransformContext,
options: OutlineMarkdownOptions,
): Entry[] {
const markdown = context.stringify(node);
if (!markdown) return [];
if (node.type === 'heading') return [{ kind: 'heading', text: markdown }];
const metadata = node.data?.domMd;
if (metadata?.authored) {
return [{ kind: 'item', priority: 4, text: `- Override: ${snippet(markdown, 100)}` }];
}
if (metadata?.kind === 'action') return [];
if (metadata?.kind === 'field') {
const label = metadata.label === 'Fields' ? 'Fields' : 'Field';
return [{ kind: 'item', priority: 3, text: `- ${label}: ${collapseWhitespace(markdown)}` }];
}
if (metadata?.kind === 'visual') {
return [{ kind: 'item', priority: 2, text: `- Visual: ${trimVisual(markdown)}` }];
}
if (node.type === 'table') {
const header = node.children[0]?.children ?? [];
const columns = header.map((cell) => cellText(cell, context)).filter(Boolean);
const count = Math.max(0, node.children.length - 1);
return [{
kind: 'item',
priority: 3,
text: `- Table: ${columns.join(', ') || 'unlabeled'} (${count} row${count === 1 ? '' : 's'})`,
}];
}
if (node.type === 'list') {
const first = node.children[0] ? snippet(context.stringify(node.children[0]), options.textSnippetChars ?? 120) : '';
return [{
kind: 'item',
priority: 3,
text: `- List (${node.children.length} items)${first ? `: ${first.replace(/^[-*+]\s+/, '')}` : ''}`,
}];
}
if (node.type === 'code') {
const lines = node.value ? node.value.split('\n').length : 0;
return [{ kind: 'item', priority: 3, text: `- Code block (${lines} lines)` }];
}
if (node.type === 'paragraph') {
const text = snippet(markdown, options.textSnippetChars ?? 120);
return text ? [{ kind: 'item', priority: 1, text: `- Text: ${text}` }] : [];
}
return [];
}
function toSections(entries: Entry[]): Section[] {
const sections: Section[] = [{ items: [] }];
let current = sections[0]!;
for (const entry of entries) {
if (entry.kind === 'heading') {
current = { heading: entry.text, items: [] };
if (/^#\s/.test(entry.text) && sections[0]?.items.length) {
current.items.push(...sections[0].items);
sections[0].items = [];
}
sections.push(current);
} else current.items.push(entry);
}
return sections.filter((section) => section.heading || section.items.length);
}
function renderSections(sections: Section[], options: OutlineMarkdownOptions): string {
const maxItems = options.maxItemsPerSection ?? 4;
const included = sections.map((section) => Math.min(maxItems, section.items.length));
let markdown = serializeSections(sections, included, true);
if (!options.maxChars || markdown.length <= options.maxChars) return markdown;
const minimum = options.minItemsPerSection ?? 1;
for (let index = 0; index < included.length; index += 1) {
included[index] = Math.min(minimum, sections[index]?.items.length ?? 0);
}
markdown = serializeSections(sections, included, false);
if (markdown.length <= options.maxChars) return markdown;
return clampMarkdownWithNotice(
markdown,
Math.max(1, options.maxChars - 48),
`[Outline truncated after ${options.maxChars} chars]`,
);
}
function serializeSections(sections: Section[], included: number[], omissions: boolean): string {
return sections.map((section, index) => {
const lines = section.heading ? [section.heading] : [];
const ordered = [...section.items].sort((a, b) => b.priority - a.priority);
const visible = ordered.slice(0, included[index] ?? 0);
lines.push(...visible.map((item) => item.text));
const omitted = section.items.length - visible.length;
if (omissions && omitted) lines.push(`- ${omitted} more item${omitted === 1 ? '' : 's'} omitted`);
return lines.join('\n');
}).filter(Boolean).join('\n\n');
}
function cellText(cell: TableCell, context: MarkdownTransformContext): string {
return collapseWhitespace(context.stringify({ type: 'paragraph', children: cell.children }));
}
function snippet(value: string, maxChars: number): string {
const text = collapseWhitespace(value);
if (text.length <= maxChars) return text;
return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
}
function trimVisual(value: string): string {
return value.replace(/^!?(?:\[|\[.*?\]\()[^:]*:?\s*/, '').replace(/[\])]+$/, '');
}
import type { Nodes } from '../core/mdast';
import type { MarkdownPlugin } from '../core/types';
import { visit } from '../tree';
import { createPlugin } from './authoring';
const REDACT_ATTR = 'data-md-redact';
const MARKDOWN_OVERRIDE_ATTR = 'data-md';
const DEFAULT_REPLACEMENT = '[REDACTED]';
export type RedactSecretsReplacement = string | ((value: string) => string);
export type RedactSecretsOptions = {
/**
* Additional regex patterns to redact after dom-md's built-in secret
* patterns. Built-in patterns always remain enabled.
*/
patterns?: readonly RegExp[];
/**
* Replacement used for automatic and manual redaction. Functions receive the
* matched secret, URL password, or `data-md-redact` element text.
*/
replacement?: RedactSecretsReplacement;
};
type SecretRedactor = {
patterns: readonly RegExp[];
replacement: RedactSecretsReplacement;
seenReplacements: Set<string>;
};
const URL_CREDENTIAL_PATTERN =
/(\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^:\s/@]*:)([^@\s/]+)(@)/g;
const DEFAULT_SECRET_PATTERNS: readonly RegExp[] = [
/vck_[A-Za-z0-9]{20,}(?:\.{3,})?/g,
/gh[opsru]_[A-Za-z0-9_]{20,}/g,
/github_pat_[A-Za-z0-9_]{20,}/g,
/glpat-[A-Za-z0-9_-]{20,}/g,
/(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g,
/whsec_[A-Za-z0-9]{20,}/g,
/xox[abprs]-[A-Za-z0-9-]{20,}/g,
/xapp-[A-Za-z0-9-]{20,}/g,
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
/\bAIza[A-Za-z0-9_-]{30,}/g,
/sk-ant-[A-Za-z0-9_-]{20,}/g,
/sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/g,
/\bsk-[A-Za-z0-9]{32,}\b/g,
/npm_[A-Za-z0-9]{30,}/g,
/\bhf_[A-Za-z0-9]{30,}/g,
/\bre_[A-Za-z0-9_]{30,}/g,
/\bBearer\s+[A-Za-z0-9_\-+=/.]{20,}/gi,
];
const REDACTABLE_NODE_FIELDS = [
'alt',
'lang',
'meta',
'title',
'url',
'value',
] as const;
/**
* Redact known sensitive secret formats from Markdown capture output.
*
* The plugin redacts structured Markdown tree fields before serialization and
* runs one final string pass during `render()`. Credentialed URLs preserve
* their scheme, username, host, and path while replacing the password. Add
* `data-md-redact` to a DOM element to replace that element manually.
*
* Combining `data-md-redact` with a `data-md` override on the same element is
* an error: authored overrides bypass manual redaction, so remove the
* sensitive content from the override instead. Built-in and custom patterns
* still scan authored override content.
*/
export function redactSecrets(options: RedactSecretsOptions = {}): MarkdownPlugin[] {
const redactor = createSecretRedactor(options);
return createPlugin('redact-secrets', ({ annotateElement, renderElement, transform, postprocess }) => [
renderElement('element', (element) =>
element.hasAttribute(REDACT_ATTR)
? { type: 'text', value: replacementFor(redactor, element.textContent ?? '') }
: undefined),
annotateElement('guard', (element) => {
if (
element.hasAttribute(REDACT_ATTR) &&
element.hasAttribute(MARKDOWN_OVERRIDE_ATTR)
) {
throw new Error(
`\`${REDACT_ATTR}\` cannot be combined with \`${MARKDOWN_OVERRIDE_ATTR}\` — ` +
'remove the sensitive content from the authored override instead.',
);
}
}),
transform('tree', (tree) => {
visit(tree, (node) => {
redactMarkdownNode(node, redactor);
});
}),
postprocess('markdown', (markdown) => applySecretRedactor(markdown, redactor)),
]);
}
/** Redact known sensitive secret formats from a string. */
export function redactSecretsFromString(
value: string,
options: RedactSecretsOptions = {},
): string {
return applySecretRedactor(value, createSecretRedactor(options));
}
function applySecretRedactor(
value: string,
redactor: SecretRedactor,
): string {
const normalizedInput = unescapeMarkdownReplacements(value, redactor);
const withoutUrlCredentials = normalizedInput.replace(
URL_CREDENTIAL_PATTERN,
(_match: string, prefix: string, password: string, suffix: string) =>
`${prefix}${redactValue(redactor, password)}${suffix}`,
);
const redacted = redactor.patterns.reduce(
(current, pattern) =>
current.replace(pattern, (match) => redactValue(redactor, match)),
withoutUrlCredentials,
);
return unescapeMarkdownReplacements(redacted, redactor);
}
function createSecretRedactor(options: RedactSecretsOptions): SecretRedactor {
return {
patterns: [...DEFAULT_SECRET_PATTERNS, ...(options.patterns ?? [])],
replacement: options.replacement ?? DEFAULT_REPLACEMENT,
seenReplacements: new Set(),
};
}
function redactMarkdownNode(
node: Nodes,
redactor: SecretRedactor,
): void {
const target = node as Record<string, unknown>;
for (const field of REDACTABLE_NODE_FIELDS) {
const value = target[field];
if (typeof value !== 'string') continue;
const redacted = applySecretRedactor(value, redactor);
if (redacted !== value) target[field] = redacted;
}
}
function replacementFor(redactor: SecretRedactor, value: string): string {
const replacement = typeof redactor.replacement === 'function'
? redactor.replacement(value)
: redactor.replacement;
redactor.seenReplacements.add(replacement);
return replacement;
}
function redactValue(redactor: SecretRedactor, value: string): string {
return redactor.seenReplacements.has(value) ? value : replacementFor(redactor, value);
}
function unescapeMarkdownReplacements(value: string, redactor: SecretRedactor): string {
let current = value;
for (const replacement of redactor.seenReplacements) {
current = current.replaceAll(escapeMarkdownReplacement(replacement), replacement);
}
return current;
}
function escapeMarkdownReplacement(replacement: string): string {
return replacement.replace(/([\[\]\\])/g, '\\$1');
}
import type { Nodes } from './core/mdast';
/** A node visited while walking a dom-md Markdown tree. */
export type VisitNode<CustomData extends object = Record<string, unknown>> = Nodes<CustomData>;
/** Control traversal from a `visit()` callback. */
export type VisitAction = 'skip' | 'stop' | void;
/** Extra location information passed to a `visit()` callback. */
export type VisitContext<CustomData extends object = Record<string, unknown>> = {
/** The parent node, if the current node is not the root being visited. */
parent?: VisitNode<CustomData>;
/** The current node's index inside `parent.children`, when available. */
index?: number;
/** Ancestors from the visited root down to the current node's parent. */
ancestors: readonly VisitNode<CustomData>[];
};
/** Options for Markdown tree traversal. */
export type VisitOptions = {
/**
* Visit parent nodes before or after their children.
*
* Defaults to `'pre'`. Use `'post'` when a transform needs child nodes to be
* normalized before it edits the parent.
*/
order?: 'pre' | 'post';
};
/**
* Walk every node in a dom-md Markdown tree.
*
* Return `'skip'` from the visitor to avoid walking the current node's
* children, or `'stop'` to end traversal entirely. The helper does not clone
* nodes; transforms may mutate visited nodes in place.
*/
export function visit<CustomData extends object = Record<string, unknown>>(
root: VisitNode<CustomData>,
visitor: (
node: VisitNode<CustomData>,
context: VisitContext<CustomData>,
) => VisitAction,
options: VisitOptions = {},
): void {
const order = options.order ?? 'pre';
let stopped = false;
function walk(
node: VisitNode<CustomData>,
parent: VisitNode<CustomData> | undefined,
index: number | undefined,
ancestors: readonly VisitNode<CustomData>[],
): void {
if (stopped) return;
const context: VisitContext<CustomData> = { ancestors };
if (parent) context.parent = parent;
if (index !== undefined) context.index = index;
if (order === 'pre') {
const action = visitor(node, context);
if (action === 'stop') {
stopped = true;
return;
}
if (action === 'skip') return;
}
const children = childNodes(node);
const childAncestors = children.length ? [...ancestors, node] : ancestors;
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
walk(children[childIndex]!, node, childIndex, childAncestors);
if (stopped) return;
}
if (order === 'post') {
const action = visitor(node, context);
if (action === 'stop') stopped = true;
}
}
walk(root, undefined, undefined, []);
}
function childNodes<CustomData extends object>(
node: VisitNode<CustomData>,
): readonly VisitNode<CustomData>[] {
return 'children' in node && Array.isArray(node.children)
? node.children as readonly VisitNode<CustomData>[]
: [];
}
+71
-8
{
"name": "dom-md",
"version": "0.0.1",
"description": "DOM to Markdown.",
"version": "0.1.0",
"description": "Render DOM into readable Markdown.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/dom-md.git",
"directory": "packages/dom-md"
},
"sideEffects": false,
"type": "module",
"source": "./src/index.ts",
"files": [
"dist",
"docs",
"skills",
"src",
"README.md"
],
"keywords": [
"dom",
"html",
"markdown"
],
"license": "UNLICENSED"
"directories": {
"doc": "./docs"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./builders": {
"types": "./dist/builders.d.ts",
"import": "./dist/builders.js"
},
"./devtools": {
"types": "./dist/devtools.d.ts",
"import": "./dist/devtools.js"
},
"./md": {
"types": "./dist/md.d.ts",
"import": "./dist/md.js"
},
"./tree": {
"types": "./dist/tree.d.ts",
"import": "./dist/tree.js"
},
"./plugins": {
"types": "./dist/plugins/index.d.ts",
"import": "./dist/plugins/index.js"
},
"./next": {
"types": "./dist/next.d.ts",
"import": "./dist/next.js"
}
},
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public",
"provenance": false
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"docs:build": "node ../../scripts/build-package-docs.mjs",
"docs:clean": "node ../../scripts/build-package-docs.mjs --clean",
"postpack": "pnpm docs:clean",
"prepack": "pnpm build && pnpm docs:build",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@swc/jest": "0.2.23",
"@types/jest": "29.5.9",
"jest": "29.7.0",
"jest-environment-jsdom": "29.2.2",
"tsdown": "0.22.2",
"typescript": "5.9.3"
}
}
+353
-1
# dom-md
DOM to Markdown. More soon.
`dom-md` renders the DOM already on screen as readable Markdown.
```ts
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`.
```ts
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:
```ts
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:
```tsx
<button data-md-ignore aria-label="Close"><CloseIcon /></button>
```
Replace a region with authored Markdown:
```tsx
<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:
```tsx
<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:
```ts
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:
```ts
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.
```ts
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 nodes
- `inline(parent?)` — extract child content as inline nodes for a paragraph or heading
- `extract(element)` — extract another element through the full pipeline
- `renderDefault()` — 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.
```ts
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.
```ts
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:
```ts
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:
```html
<span data-md-redact>internal-only-value</span>
```
Use `visit()` from `dom-md/tree` when a transform needs to inspect every node:
```ts
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:
```ts
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`:
```ts
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:
```ts
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`.
```ts
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
```ts
renderMarkdown(root?, options?): string;
toMarkdownTree(root?, options?): MarkdownTree;
stringifyMarkdown(tree, options?): string;
createMarkdownRenderer(options?): {
render(root?): string;
toTree(root?): MarkdownTree;
stringify(tree): string;
};
```