cudoc

Adapter APIs and pipelines

English | 한국어 · API reference

This page states the signatures and contracts of the remark and markdown-it adapters, compiler capture, embed splicing, the host packages and cudoc-export.

remark

cudocPrepare, the default export of cudoc-remark and cudoc-remark/prepare, is a unified plugin that accepts CudocRemarkOptions. Source: prepare.ts, options.ts.

The recommended options are the DocumentOptions fields syntax, host, format, calloutTypes, components, headingIds, tableColumnWidths, ignoreDiagnostics, variables and linkReferences, plus tableColumnLayout, toc, transforms and tabElements. tabElements names the site's own components to draw tabs with on an MDX host (presentation) and is validated by resolveTabElements. Omitting options or passing syntax: {} uses component-free defaults.

tableColumnWidths resolves with the same validation as tableColumnLayout and runs before it. ignoreDiagnostics, variables and linkReferences are handed to normalizeDocument unchanged, validated first by resolveVariables and resolveLinkReferences (document options).

The plugin brings no parser of its own: the host's processor parses Markdown and MDX, and GFM comes from the host or remark-gfm. It installs frontmatter parsing and adds directive parsing in the portable Docusaurus pipeline.

The plugin runs in five steps:

  1. Resolve options and reject unknown top-level keys.
  2. Remove the [cudoc-library] link reference definition that cudoc-remark/loader appends, so it never reaches the rendered page or an exported tree.
  3. By default, call normalizeDocument with the source text, which leaves front matter as written, and add diagnostics to the VFile. Then present the page for the configured host, the file's format and tabElements, and keep that target in file.data.cudocPresentation, where the embed plugin finds it for the content it splices into the same file. This step also applies without a file path and when format: "md" is explicit.
  4. Run optional custom transforms.pre/post in a traversal after normalization. Here pre/post mean traversal entry/exit, not before/after the built-in normalization stage.
  5. Collect optional TOC; add its ESM export only when the effective format is mdx. An explicit format takes precedence over the file extension.

The plugin turns on YAML front matter parsing (remark-frontmatter), so a host that parses none, such as @next/mdx, does not read it as a rule and a heading. The yaml node stays in the tree for the plugins after it, such as a host's page metadata (Nextra's) or remark-mdx-frontmatter. Nothing renders it.

Explicitly setting headingMetadata, top-level badge or top-level tableCellList opts an MDX pipeline into the low-level named-component transforms. Each owns its feature, so syntax naming the same one (headingAnchor, badge or tableCellList) beside it throws; syntax may still choose every other feature. A pipeline that names none of the three is portable whatever syntax holds.

Markdown files always use portable normalization, so configure their features through syntax, not these component-only options. An .mdx page in a component pipeline is left to the component transforms, so the syntax features, variables and linkReferences do not reach it. The default authoring setup never requires Anchor or Badge registration: low-level transform tests opt in explicitly, and default rendering is tested without any component provider.

toc defaults to false; toc: true uses { titleDepth: 1, depths: [2,3], exportName: "toc" }. Title depth can be false; collected depths can have one or two entries from 1 to 6. Toc is { title: string | null, headings: { id, text, children: { id, text }[] }[] }, and only linkable headings are collected. Additional options customize anchor naming and badge delimiters. Docusaurus and Nextra reject toc because the host owns its TOC.

getFileSource(file) → string | null, resolveOptions(options?) and buildTransforms(resolved) → {pre, post} are exported support functions. buildTransforms builds the named-component transform sequence; it is not a substitute for normalizeDocument.

remark entry points

Each subpath of cudoc-remark exports one part of the adapter:

Import Exports/purpose
cudoc-remark Default/named cudocPrepare, option types/resolver, transforms, TOC helpers, heading promotion, host plugin factory, createCompilerCapture and CompilerCaptureOptions
cudoc-remark/prepare Preparation plugin and transform/source helpers
cudoc-remark/heading-ids Default/named promoteAnchorIds; copies configured anchor IDs to heading HTML data
cudoc-remark/host-plugins createHostPlugins, HostPluginOptions
cudoc-remark/badge Badge transform/resolver and defaults
cudoc-remark/table-cell-list Cell-list transform and parsing helpers, re-exported from @cudoment/cudoc/transforms/table-cell-list
cudoc-remark/table-column-layout Layout transform, options, table construction and splitting, re-exported from @cudoment/cudoc/transforms/table-column-layout
cudoc-remark/toc createToc, resolveTocOptions, collectHeadingToc, addTocExport, TOC types/default export name
cudoc-remark/embed Prepared-embed splicing plugin; restoreExpressions
cudoc-remark/loader Bundler loader tying documents to the prepared library (the default export); libraryLoader, libraryFingerprint, stripLibraryMarker, LIBRARY_LOADER, LIBRARY_MARKER_LABEL, LibraryLoaderOptions
cudoc-remark/components Named Anchor, Badge, cudocComponents for low-level named-component output

Transform functions can run in-process; function-valued options cannot cross a JSON-only bundler worker boundary. Next.js Turbopack configuration uses package-name plugin strings and serializable options.

Compiler capture

createCompilerCapture, imported from cudoc-remark, records the tree of one compilation for the library. Source: capture.ts.

createCompilerCapture(options?: CompilerCaptureOptions): {
  remark: Plugin<[], Root>
  rehype: Plugin
  read(): CompiledDocument
}

Create one capture object per compilation. Its remark plugin keeps the current tree reference and its yaml node then, before a later host plugin can turn the node into something else, as Nextra turns it into a metadata export. Its rehype plugin clones that tree after native remark plugins, lowers static native JSX, takes off what presentation added and removes YAML/ESM nodes. read() throws if compilation has not reached the capture stage.

The front matter it records is what the host parsed itself, file.data.frontMatter or file.data.frontmatter when that holds any key, as Docusaurus's does, or else the kept node read with frontmatterOf, which is parsed only then. A collector that hands Docusaurus frontMatter: {} therefore still stores the page's own, and a host's own parse is never repeated.

The remark plugin also records each image node's url, alt and title. A host plugin can rewrite such a node in place before the rehype plugin runs, as Docusaurus turns a local image into an img JSX element whose src is a require() call. The capture marks the node such a plugin leaves with data.cudocImage holding those three values (captured images), so the stored tree still says which image the author wrote. A plugin that puts a new node in its place leaves nothing to mark.

CompilerCaptureOptions.aliases maps path prefixes the host resolves by itself to the prefix an image is recorded under instead, the longest matching prefix first. The recorded url of every image changes, whether the host kept the image or made a component of it, and the host's own page does not. Only images are recorded this way; a link, even one Docusaurus resolves through @site/, keeps the path it was written with. A value that is not an object mapping non-empty prefixes to strings throws capture aliases must map path prefixes to strings.

Docusaurus reads @site/static/img/logo.png from the site directory and serves the file at /img/logo.png, so its collector passes { "@site/static/": "/" }, one entry per static directory, and the check and the export find the file through their asset directories. An @site/ path outside the static directories matches no alias and has no address the site serves, so it is recorded as written and the check reports it as missing-asset.

The current capture result has diagnostics: []; warnings emitted on the host VFile are not copied into it. Capture concerns the final remark tree, not arbitrary later rehype transforms or executed component output. Keep source positions until collection has made its source snapshot.

During collection, install cudoc syntax and capture, but omit the prepared-embed insertion plugin. Preserve the cudoc-embed fence so the resolver can read it after all documents have been collected. Use the actual host processor with the same plugin/configuration settings for collection and replacement.

Prepared-embed splicing

The cudoc-remark/embed plugin splices prepared embed blocks into a compiled page, and cudoc-remark/loader keeps the bundler aware of the library they come from. Source: embed.ts, loader.ts.

The default export of cudoc-remark/embed accepts { sourceRoot?: string, roots?: SourceRoot[], outDir?: string }, defaulting to sourceRoot: "docs" and .cudoc/documents. Given the same roots collection used, it derives the same ID: the base of the innermost root containing file.path, then the path under it, without the extension. The roots are resolved only when a fence is met, so a file outside every root compiles as long as it embeds nothing, and fails naming the file when it does.

The splicing itself is expandPreparedEmbeds from @cudoment/cudoc/node/prepare-embeds, the function the markdown-it adapters and cudoc-export use too (prepared embeds). It numbers fences, validates prepared data against file.value without the loader's marker, and replaces each fence with the children of the prepared block from embeds.json, cloned and spliced into the tree where the fence was.

Only documents with fences read prepared data. Data prepared from another source fails with cudoc: stale prepared embeds for <id>; recollect documents, and a fence without a prepared block with cudoc: prepared embed missing in <id>; recollect documents.

The spliced nodes are ordinary mdast from that point on. The host's remaining remark plugins, remark-rehype and its component mapping treat them exactly like the document's own content. A component in an embedded section therefore renders through the host's components, and a code block reaches the host's highlighter. Nothing is rendered to an HTML string, no wrapper element is added, and no runtime component or data import is generated. Authors register no cudoc components.

Once a page has spliced a block, the plugin presents the page for the target cudoc's normalization plugin left in file.data.cudocPresentation when it presented the page. Without that target, it presents the page for the host <outDir>/manifest.json records (markdown when it cannot be read). An embedded code title or tab group is therefore drawn as the page's own are.

A stored block carries no estree, which the AST export strips. For every mdxFlowExpression, mdxTextExpression, mdxJsxAttributeValueExpression and mdxJsxExpressionAttribute in the block, restoreExpressions(node, documentId) therefore parses one again from the expression text the export kept, with acorn and its JSX extension. It parses a value or flow/text expression as (value), a spread attribute as ({value}) the way the MDX parser builds it, and an expression holding only a comment as an empty program. Text that does not parse is an error naming the document and the expression.

Embedded headings keep their hProperties.id, so they render as anchors. They are not in cudoc-remark's own toc export, which cudoc-remark computed before this plugin ran. Whether the host's table of contents lists them depends on when the host reads the tree. Docusaurus computes its TOC in a default remark plugin, which runs before docs.remarkPlugins where this plugin is installed, so embedded headings are not in it. Nextra builds its TOC from the final tree and lists them.

A cudoc-embed fence inside an embedded section was already expanded by the resolver, so nested embeds arrive expanded.

cudoc-remark/loader is a webpack/Turbopack loader registered on the same files, ahead of the MDX loader, because the compiled page now holds the library's content and depends on embeds.json, which a bundler cannot see a remark plugin read. It does three things:

The embed plugin removes the marker with stripLibraryMarker before comparing the source with the library's snapshot, so the freshness check sees the file as written; running the loader twice does not stack markers. libraryFingerprint(outDir?) is the SHA-256 of embeds.json, "" before it exists, cached on the file's size and mtime.

libraryLoader(outDir?) returns { loader: "cudoc-remark/loader", options: { outDir, fingerprint } } as plain JSON for a webpack use entry or a Turbopack loaders entry; in a build script the configuration is evaluated after cudoc collect, so the fingerprint is the new library's.

Verified on the Next.js example: with the loader, a recollection between two next build runs that keep .next/cache reaches the embedding page under both bundlers. Without it, the page compiled from the previous library is served until its own file changes or the cache is cleared.

Docusaurus and Nextra

Both packages export cudocRemarkPlugins(options?) → PluggableList, promoteAnchorIds and option types, and each cudocRemarkPlugins is the shared factory from cudoc-remark/host-plugins called with its own package name and host. Sources: host-plugins.ts, Docusaurus, Nextra.

createHostPlugins(
  options: HostPluginOptions | undefined,
  adapter: string,
  host: "docusaurus" | "nextra",
): PluggableList

HostPluginOptions is CudocRemarkOptions without toc, host and headingIds, plus promoteHeadingIds?: boolean (default true). Those three are the adapter's to set, so passing one is an unknown-option TypeError naming the adapter, like any other unknown key; a host argument other than the two is a TypeError too. The remaining options are resolved when the factory is called, so a rejected value fails as the site configuration loads.

The factory sets host, forces headingIds: "host" and disables cudoc's TOC, and returns these entries in order, each a plugin or a [plugin, options] pair. The Docusaurus-only plugin is bare, because Docusaurus refuses a one-element array when it validates its configuration:

  1. cudocPrepare with those options;
  2. promoteAnchorIds, unless promoteHeadingIds: false, which copies each anchor id the component transforms drew onto its heading's hProperties.id so the host uses it instead of slugging the heading text; a page cudoc normalized, which has file.data.cudocPresentation, keeps the ids its syntax settled;
  3. on Docusaurus only, a plugin that moves a heading's hProperties.id into {#id} text at the end of the heading when Docusaurus's slugger would change it.

The third plugin exists because Docusaurus runs an id already on a heading through its slugger again, which turns v1.2 into v12, but keeps a trailing {#id} as written. Its table of contents reads the same value, so the heading, its anchor and the entry agree. An id made only of letters, digits, hyphens and underscores stays on the heading, where the slugger returns it unchanged and records it. A later heading whose text slugs to the same value therefore gets -1 rather than the same id, and a second heading written with the same id gets -1 too.

The slugger records the ids it makes from text as well. In the reverse order, ## Setup followed by ## Intro (#setup), it is therefore the explicit id that comes out as setup-1 on Docusaurus, and on Nextra, whose slugger also takes every id in document order. The markdown-it hosts and standalone compilation keep setup on the explicit one and number the earlier heading instead. Write the heading with the explicit id first, or give the other heading an id of its own, and every host agrees.

The page's # heading always keeps its id there: Docusaurus reads the page title from that heading's text before it takes a {#id} out, so the marker would become part of the title. An id on that heading that the slugger changes, (#v1.0) for instance, therefore comes out changed there, so give a page heading a slug-shaped id.

Nextra runs every heading id through its slugger, its own [#id] included, so there an id keeps its spelling only when it is slug-shaped already: (#v1.2) renders as id="v12", (#release-2) unchanged. That slugger takes the ids in document order, so, as on Docusaurus, an explicit id that an earlier heading's text already produced is the one numbered -1. An anchor written in lowercase letters, digits and hyphens, on a heading that comes before any heading whose text would make the same id, resolves the same on every host.

Host Installation position Real collection example
Docusaurus docs.beforeDefaultRemarkPlugins; embed insertion in docs.remarkPlugins collect.mjs, actual MDX processor
Nextra mdxOptions.remarkPlugins, which Nextra prepends to its native plugins collect.mjs, nextra/compile

Docusaurus additionally exports a default plugin returning { name, getThemePath } and ./package.json. That plugin supplies named-component theme mappings; it is unnecessary for the guide's explicit syntax setup. Nextra additionally exports ./components and ./package.json; component mappings are likewise not required for that setup.

The Docusaurus example uses an internal version-specific MDX processor entry point. Recheck it with dependency upgrades. Host setting alone does not make standalone parsing equivalent to either native pipeline.

markdown-it

cudoc-markdown-it is the markdown-it counterpart of cudoc-remark: the shared arrangement lives here and each host adapter contributes only a MarkdownItHost. Import from cudoc-markdown-it. Sources: tokens.ts, plugin.ts, compiler.ts, options.ts, host.ts, text.ts.

installHostPlugin(md: MarkdownIt, options: HostPluginOptions, host: MarkdownItHost): void
createHostCompiler(md: MarkdownIt, host: MarkdownItHost): DocumentCompiler
tokensToAst(
  tokens: Token[],
  source: string,
  options?: DocumentOptions,
  conversion?: TokenConversion,
): Root
resolveHostOptions(options: HostPluginOptions, adapter: string): HostPluginOptions
markdownItText(source: string): { text: string; toSource: (offset: number) => number }

type TokenNode = (token: Token, context: TokenContext) => DocumentNode | undefined
type TokenContext = {
  source: string // the Markdown the tokens came from, front matter excluded
  options: DocumentOptions
  attrs: Record<string, unknown> // the token's attributes
  position: Position | undefined
}
type TokenConversion = {
  adapter?: string
  token?: TokenNode
  render?: (tokens: Token[], start: number, end: number, inline: boolean) => string
  origins?: Map<DocumentNode, Token>
  textReferences?: boolean
}

HostPluginOptions is DocumentOptions without host, format and components (so tableColumnWidths and ignoreDiagnostics are included), plus optional onDocument(tree, source, env), onDiagnostic(diagnostic, documentId), library and outDir (prepared-data path defaults to .cudoc/documents). The plugin forces the host's own semantics, format: "md" and native heading generation. @types/markdown-it is an optional peer dependency of this package and of both markdown-it adapters, for sites that type check their configuration.

resolveHostOptions runs while the plugin is installed, so each of these fails as the site configuration loads rather than from inside a core rule:

With linkReferences, a core rule before inline adds each shared definition to env.references under markdown-it's normalizeReference key, its address through md.normalizeLink, unless the page defines that label itself. The host therefore parses the references as links and rewrites their addresses as its own.

With variables, the plugin's own core rule, pushed last, first replaces the markers in the href of every link_open and the src of every image, each marker once with every value. It does so on the page's tokens and on those of text normalization parses again, and normalizes the result with md.normalizeLink. An address md.validateLink refuses, as javascript:, makes no link, as a written one does not: a link keeps its text and an image its alternative text.

The address as written stays in the token's meta and reaches the node as data.cudocWrittenAddress, from which normalization reports a marker naming nothing and replaces nothing in the address again (document options). The host's link rules then read the address as if it were written out. VitePress writes reference.md as ./reference.html and leaves https:// alone, where (=target) would have become ./(=target).html. A value it decodes into a marker, such as docs/%28=x).md, is not replaced again.

That rule then reads the tree from the tokens and replaces the markers in the content and info string of every fence, code_block and html_block token except cudoc's own fences, since the host renders those blocks from its tokens. Normalization replaces every marker in the tree once and reports one naming nothing, so the page and the stored tree hold the same text even when a value holds a marker.

MarkdownItHost names everything a host generator does differently:

Field Required Effect
adapter yes Package name; it prefixes every error the shared pipeline raises.
host yes The Host value handed to normalizeDocument, which selects native syntax.
documentId(env) yes Collected document ID read from the host's markdown-it env; embeds resolve against it.
compilerEnv(context) no Rebuilds that env during collection, which runs outside the host's own build.
token(token, context) no Converts a token contributed by the host's own markdown-it plugins before the shared mapping.
resolveInlineAttributes no Re-renders cloned inline tokens first, for a host that resolves link destinations in renderer rules.
textReferences no The host's text tokens keep character references as written, as VitePress's text_join does for Vue; conversion decodes them, in text and in container and alert titles, and keeps a brace a reference.
frontmatter(source, context) no Splits front matter for a host that strips it outside markdown-it, so collection reaches the same tokens; context holds the collected document's id and filePath, for naming it in an error.

tokensToAst converts real markdown-it tokens to mdast after native processing and never parses the Markdown a second time. The host's token hook is consulted before the shared mapping, so a host's own element can replace the default conversion, and table-cell inline parsing uses the same configured parser. tokensToAst maps the block and inline tokens every host produces alike, emoji tokens (markdown-it-emoji, which VitePress enables) as the character they hold, github_alert_*, and markdown-it-container blocks.

A container becomes a callout when its name, case-insensitive, is note, tip, important, warning, caution, info, danger, one of the host names default, error and warn that the callout types alias, or a registered calloutTypes entry. A container_details_* block becomes an expandable details blockquote. Unless callouts take host syntax, every markdown-it host draws such a container as VitePress does: a div of the classes custom-block and its name, titled by a custom-block-title paragraph. Under callout: "off" an alert stays a box as well, with the class github-alert and the host's title.

Any other container, such as VitePress's code-group and raw or one the site registers, opens and closes the host's way. Its open and its close token each become an html node the host renders. What lies between is converted like the rest of the page, so a heading anchor or an embed inside a code group or a box is still one.

While tabs takes host syntax, a code-group container is not rendered the host's way: it becomes a blockquote marked data.cudoc.kind: "codeGroup" holding its fences, which normalization makes a group of tabs.

A link and an image keep the token's attributes as hProperties, href and src included, and carry the token's title as their own. An image's alt is its label read as plain text, as mdast reads one (code and entities kept, a soft line break a newline, a hard one nothing). The empty alt placeholder markdown-it keeps among the attributes is left out, since it would otherwise override that text.

A heading names its id the host's way only with {#id} at the end outside code spans, so ## Write `{#id}` here documents the syntax rather than using it.

A code block's language and meta come from the fence's info as written, which a core rule right after block keeps in the token's meta before any plugin rewrites the info. The markdown-it-attrs plugin, which VitePress enables, takes {2,4-5} off it as an attribute, and a site that allows only id drops it. Line ranges written onto the language, VitePress's js{4}, become meta.

A token nothing here maps, other than a container opened and closed the host's way, is kept as an html node holding conversion.render(tokens, start, end, inline), together with its matching close when it opens a block. start and end are indexes into tokens and inline says whether they are an inline token's children. Without render such a token fails with conversion.adapter in the message rather than disappearing. When origins is given, it is filled with the block token each code and html node came from.

With textReferences, a text token and a container's or an alert's title are taken to hold character references as written and are decoded with decodeCharacterReferences. The exception is a reference that stands for { or } (&#123;, &#x7B;, &lbrace; and the like), which stays one as an inline html node, &#123; or &#125;.

The brace stays a reference because VitePress compiles a page as a Vue template, where an author writes the reference so that {{ is not read as an interpolation, and a library collected there is embedded into such pages. A list in a table cell, which is parsed apart from the page's text, keeps such a brace the same way. A container's title is Markdown VitePress renders inline, so a backslash escape in it is read too; an alert's title is written into the page as it is.

Three details keep the converted tree equivalent to the remark lineage's:

The plugin pushes its md.core rule last, so native anchors, links and containers are already tokens when cudoc reads them. Two earlier core rules prepare for it.

cudoc-fence-info, after block, keeps each fence's info as written, before a plugin such as markdown-it-attrs takes {2,4-5} off it, so a code block's title and lines are read from that copy. With linkReferences, cudoc-link-references, before inline, adds each shared definition the page does not write itself to env.references, so the host reads [text][label] as a link and normalizes its address as it would its own, skipping one validateLink refuses.

The host slugged every heading before this rule without knowing the IDs cudoc settles from (#id). The IDs it gave headings without an explicit one are therefore held aside during normalization, where the duplicate check sees only IDs written in the document. They are then put back numbered past any ID already taken: ## Intro (#setup) followed by ## Setup gives setup and setup-1, as on the other hosts.

After normalization the rule copies the resolved heading IDs and titles back onto the host's own heading_open tokens, which is what keeps the host's heading anchors and table of contents in agreement. A heading without a resolved id is left to the host, and a permalink's aria-label in cudoc's output names the resolved title rather than the heading as written. env.cudoc stores { tree, source, diagnostics } once the rule has run, and onDocument receives a cloned tree before embed expansion.

The page is then rendered in two layers. The host renders its own token stream first, exactly as it would without cudoc, so its render-time work happens once: VitePress lifting <script setup> and <style> into the page component, a host recording links for its dead-link check. cudoc then renders the normalized document and puts the host's own output back for every fence, code_block and html_block it did not change. Highlighting, line numbers, copy buttons and snippet imports therefore survive, and an HTML block the host rendered to nothing stays nothing.

A code block that arrived through an embed has no host token, so one is made from its language and meta and handed to the host's fence rule, which gives it the page's highlighting and copy button. The meta was collected as written, so line-highlight marks that markdown-it-attrs took off the original's info reach the copy. The cudoc-embed and cudoc-pagebreak fences never reach the host's fence rule, at collection or on the page, since cudoc puts something else where they were.

Tokens the conversion does not map, such as a [[toc]] table of contents, math or footnotes, are rendered by the host's rules with the whole token list, so a rule that reads its neighbours still finds them. A block is rendered once the resolved heading IDs and titles are on the host's tokens, so a table of contents links to #setup and reads Install for ## Install (#setup).

Before composing, the page's tree is presented for the host, so a code block with a title is framed around the host's own output; collection stored the tree before that. env.cudocRendered is set to the returned HTML when the page is rendered.

Diagnostics normalization reports while the site renders a page, such as an unregistered callout type or two headings sharing an id, go to onDiagnostic(diagnostic, documentId); by default each is a console.warn line <adapter>: <document id>:<line>: <code>: <message>.

The host hands the renderer a page without its front matter. When library holds that page, positions are shifted to count lines from the top of the collected file, and otherwise they count from the first line after the front matter. Nothing is reported there while collecting, because collection returns the diagnostics with the compiled document.

For normal rendering, a library that still has its sync compiler resolves embeds on the spot; a loaded library without the compiler reads embeds.json through expandPreparedEmbeds. A page with embeds and no library fails with build documents and provide library before rendering embeds. A fence without a prepared block fails as in prepared-embed splicing.

The page must be the source that was collected. The collected text, read as markdown-it reads it, has to end with the text the renderer was given, with nothing ahead of it but front matter, possibly empty, and a byte order mark. Otherwise rendering fails with stale collected source <id>; recollect documents. When the page pulls in other files with VitePress's <!--@include: ...-->, the error says so instead, because collection does not expand includes and an embed in such a page cannot be matched.

createHostCompiler renders with env.cudocCollect: true, suppressing embed expansion. It returns front matter and the diagnostics normalization reported, with their positions mapped back to the file, and requires the adapter already installed on md. React .mdx is rejected. Use the same configured renderer for replacements.

Positions are mapped back to the file after front-matter processing, taking the body as the end of the source so a front-matter field that repeats the body's text cannot move it. markdown-it reads every \r\n and lone \r as \n and every NUL as U+FFFD before it parses, so the body is found in the source read the same way. Each offset is moved back past the \r of every \r\n before it, into the file's own text that the source snapshot and its section ranges slice. Lines count the same in both.

markdownItText(source) is that reading: text is the source as markdown-it reads it and toSource maps an offset in text back to source, for an adapter that compares a source with state.src.

VitePress and Eleventy

Both packages export a default markdown-it plugin and createDocumentCompiler(md), and both delegate to cudoc-markdown-it; VitePressOptions and EleventyOptions are both HostPluginOptions. Sources: VitePress, Eleventy.

// default plugin: md.use(cudocVitePress, options) or md.use(cudocEleventy, options)
createDocumentCompiler(md: MarkdownIt): DocumentCompiler
createMarkdownRenderer( // cudoc-eleventy only
  options?: EleventyOptions,
  configure?: (md: MarkdownIt) => void,
): MarkdownIt
Host Renderer the site and collector share Document ID Host definition
VitePress createMarkdownRenderer from vitepress The file read, from env.realPath/path <Badge> token conversion, resolveInlineAttributes, textReferences, compilerEnv
Eleventy createMarkdownRenderer from cudoc-eleventy env.page.filePathStem front-matter split by gray-matter's rules, rewritten-source check

VitePress puts the served page in env.relativePath, which rewrites and dynamic routes make differ from the file the page was read from, and collection names documents by file. The adapter therefore takes the id from env.realPath under the source directory (env.path without env.relativePath) when the two differ, and from env.relativePath otherwise, without .md; a rewritten page resolves its embeds against the document it was collected as.

VitePress resolves some links during inline rendering, so its definition sets resolveInlineAttributes and the pipeline renders cloned tokens to capture those URLs without applying base paths twice to the original tokens. While badge takes host syntax, its token hook converts a static <Badge type="tip" text="1.0" /> into cudoc's badge, reading the text attribute only; a badge carrying a Vue binding stays raw HTML because its text is not known until the component runs. Static native containers, links and badges are handled, and dynamic Vue expressions are not evaluated.

Eleventy passes the page data object as the markdown-it env and strips front matter with gray-matter before markdown-it, so its definition supplies both documentId and frontmatter. A page with embeds rendered without Eleventy's page data in the env fails with the markdown-it env carries no Eleventy page data, and one without page.filePathStem fails naming it.

The adapter does not depend on gray-matter. It splits the file by the rules gray-matter follows with Eleventy's default front-matter options, so collection renders the text Eleventy renders. A byte order mark is dropped, and the block opens with --- at the start of the file (---- opens nothing). The rest of that line names the language. The block ends at the first line starting with ---, and one line break after it is dropped.

A site that changes those options with setFrontMatterParsingOptions, for an excerpt or other delimiters, has Eleventy render a different text than collection does. A site that sets another default language has Eleventy read a block that names no language as that language while collection reads it as YAML.

The data is read as cudoc reads front matter on the other hosts, as YAML 1.2 with yaml, so a date stays the text it was written as and << is an ordinary key rather than a merge. YAML, also declared ---yaml or ---yml, and ---json are read; any other language, Eleventy's ---js included, fails naming the file, since reading it would mean running it, and so does front matter that is not a mapping.

createMarkdownRenderer applies Eleventy's own renderer defaults (html: true, indented code blocks disabled), runs the site's configure callback, then installs cudoc last so it wraps whatever fence renderer the site registered. Native syntax comes from the plugins the site registers: markdown-it-attrs for {#id} and markdown-it-container for ::: warning Title. The default export is a markdown-it plugin: handed to eleventyConfig.addPlugin, it receives the Eleventy configuration and throws this is a markdown-it plugin, not an Eleventy plugin, naming setLibrary.

Two site settings are contractual rather than advisory:

Embedded content is spliced into cudoc's tree, not into the host's tokens, so a table of contents is only as complete as what it reads. VitePress's default theme builds its outline in the browser from the rendered headings, so embedded headings, which keep their ids, are in it. A [[toc]] in the page is rendered from the host's tokens and lists the page's own headings only. Eleventy has no table of contents of its own: one built by a markdown-it rule sees the page's own headings, and one built from the rendered HTML sees the embedded ones too.

See the collectors for renderer lifecycle (VitePress, Eleventy) and host setup for routing (VitePress, Eleventy).

Export

buildSite from cudoc-export writes the HTML output of a collection, a site, single pages or review pages, from one output plan that the paginated formats share. Source/import: site.ts, cudoc-export.

type SiteLinkMode = "relative" | "host" | "none"
type HtmlMode = "site" | "standalone" | "annotate"

buildSite(options: SiteOptions): SiteResult

type SiteResult = {
  outDir: string
  documentCount: number // the pages this run wrote
  libraryDir: string
  files: string[] // what the builder wrote itself, relative to outDir, sorted
  diagnostics: NavigationDiagnostic[]
  dependencies: SiteDependency[] // single pages only, sorted by document, kind and url
  omitted: OmittedDocument[]
}

type SiteDependency = {
  kind: "remote" | "file" | "page"
  document: string
  url: string // as the document wrote it
}

type OmittedDocument = {
  document: string
  reason: "private" | "not-in-navigation"
}

type NavigationDiagnostic = {
  code:
    | "missing-translation"
    | "unmatched-navigation-order"
    | "unmatched-navigation-exclude"
  message: string
  document: string // a document id, or where the entry was written
}

SiteOptions extends DocumentOptions with the fields below. SiteOptions, SiteResult, SiteDependency, SiteLinkMode, HtmlMode, OmittedDocument, AnnotateOptions, StandaloneOptions, NavigationOption, NavigationSpec, NavigationItem, NavigationTitle, NavigationDiagnostic, LocaleOption and UiStrings are exported types; returned paths are absolute. siteStyles exports the built-in CSS string. A key that is neither a field below nor a collection option is an error naming it; annotations, which mode: "annotate" replaced, names its replacement.

files lists what the builder wrote itself: the pages, cudoc.css, the css files and what they load, the scripts, sitemap.xml and the print HTML. The images and files copied from the roots, the cover image and the mounted folders are written beside them without being listed.

The package root also exports the constants and types the other formats and the review notes use:

Export What it is
ANNOTATION_SCRIPT, ANNOTATION_STYLESHEET, THEME_SCRIPT The reserved file names cudoc-annotations.js, cudoc-annotations.css and cudoc-theme.js; see Annotations and Theme switch
CONTENT_SECURITY_POLICY The policy text the script-carrying pages put in their <meta http-equiv="Content-Security-Policy">
ANNOTATION_CONTEXT, EMBEDDED_DATA_ID, FRAGMENT_KEY The notes file's @context URL, the id of a saved copy's notes block (cudoc-annotations-data) and the share token's fragment key (cudoc-notes)
parseCollection(input: unknown, generator = "unknown"): AnnotationCollection Reads a notes file as Annotations describes; generator fills a file that names none. Invalid input throws an Error named AnnotationFormatError
Annotation, AnnotationCollection The types of one note and of a notes file
DesignTokens, DesignTokenOverrides The resolved tokens and the tokens option's type
PageOptions, PageGeometry, PageLength, RunningText, ResolvedGeometry, ResolvedPageOptions The page option's types and what resolvePageGeometry and resolvePageOptions return; see Paginated output
Option Type / default Contract
sourceRoot string; one of sourceRoot/roots required One source directory at the top of the library; the shorthand for roots: [{ dir }]. Needed even with library, for asset resolution and output protection.
roots SourceRoot[]; one of sourceRoot/roots required The directories documents live in, each under its base. With library the bases must be the ones the library was collected with.
exclude string[], collection only Files that are not documents; passed to collection. See collection.
private string[], collection only Documents collected but never exported; passed to collection. A private document is rendered by no format, and naming one in navigation, home or documents is an error.
imageSizes boolean, false, collection only Also write images.json, each local image's pixel size, beside the library; passed to collection. See collection.
externalPaths string[], [] Root-relative prefixes another application serves on the same host, such as /sdk. A link into one is treated as external: kept as written under relative and host, removed under none, and never resolved or copied.
extractors Record<string, TableExtractor>, collection only Cell extractors an embed table may name; passed to collection. See embedding.
outDir required string Dedicated output, separate from source, library, asset and mounted roots.
title string, "Documentation" Site header and page-title suffix.
mode HtmlMode, "site" site: every published page with shared stylesheets, navigation, homes and the print HTML. standalone: single pages. annotate: the review-note runtime, as single pages or a site per annotate.target.
annotate AnnotateOptions, {} target ("file", the default, or "hosted"), reviewId (required for hosted; up to 64 letters, digits, ., - and _, starting with a letter or digit) and inbox (hosted only); see Annotations.
documents optional string[] The documents a single-page run writes, by id or source path; each must be one the site publishes. An error in a site, which writes every page it publishes.
strict boolean, false Fail a single-page run whose pages have any dependencies, before anything is published. An error in a site.
standalone StandaloneOptions, {} maxAssetBytes (5 MiB) bounds one resource written inline, maxPageBytes (20 MiB) one single page; each is a whole number of bytes up to 28 MiB.
home optional string The document written as index.html, by source path such as README.md; its translations become index.<code>.html. Every language needs one.
header { links?: { title: NavigationTitle; url: string }[] }, {} Links beside the site title, each an absolute http(s) or mailto URL.
navigation NavigationOption, every document by folder The left-hand list and what the site publishes; see Site structure.
locales optional Record<string, LocaleOption> The site's languages, the default first, each found by a file-name suffix; see Site structure.
toc false | { depth?: number }, { depth: 6 } The headings the right-hand contents lists, down to depth (2–6). false drops the column on every page; front matter toc: false drops it on one.
css optional string | string[] Local stylesheets loaded after the built-in one: linked as files in a site, carried inline on a single page. HTML presentation only; it never reaches a format that emits no CSS.
sourceLinks optional { root: string; url: string } Where a link to an uncollected file under root points: url followed by its library path. url is absolute http(s) without query or fragment.
mounts { from: string; to: string }[], [] Directories copied into the output under to; a link into from points at the copy. A site with relative links only.
tokens optional DesignTokenOverrides Design token overrides merged over the defaults, one level into each group. Read by every output format. A group or key the defaults do not have is an error naming it.
page optional PageOptions Paper, margins, running header and footer, heading page breaks and printed link addresses; see paginated output.
volume optional VolumeOptions The bound file's name, cover, contents and order; see paginated output.
libraryDir string, path.join(path.dirname(outDir), ".cudoc", "documents") Collection output when library is absent. Ignored when library is provided.
library optional string Existing collected library directory, read without compilation or publication.
links SiteLinkMode, "relative" Policy for all output hyperlinks.
hostUrl optional string, required for "host" Absolute HTTP(S) deployment URL including any base path. No credentials, query or fragment. A trailing slash is normalized.
siteUrl optional string Absolute HTTP(S) URL this exported site is served from, including any base path; validated like hostUrl; another mode with it is an error. Each page names its address under it as <link rel="canonical">, a document page with versions in other languages lists them as <link rel="alternate" hreflang> (a generated home has none), and sitemap.xml lists every page in the order written.
assetDirs string[], [] Additional URL-root resource directories, searched in order after the roots. Without library they are passed to collection too, which reads image sizes under them for imageSizes (collection).
renderOptions optional RenderOptions HTML component callbacks and code-highlighting override; see rendering.
themeSwitch boolean, false Add a header menu that chooses light or dark, showing the system setting until the reader chooses, and remembers the choice in the browser; see Theme switch.

The synchronous builder has two input paths:

  1. Without library, collect to libraryDir with host: "html", then resolve document embeds with the collected compiler. Other DocumentOptions apply to this collection.
  2. With library, use loadLibrary and clone the stored trees. library.ts inserts the prepared blocks with expandPreparedEmbeds, matching document ID, source text and fence order. This supports asynchronous native compilation and original-source replacements without recompiling at export time.

Any DocumentOptions supplied alongside library cause an error; collection owns those settings. With library, no shared library files are written.

Prepared data is loaded only for documents with embed fences. Missing/stale preparation or missing blocks fail. Validation uses the stored source snapshot and manifest fingerprints; the exporter does not compare every live source file with its snapshot. Recollect and prepare after source, routes or compiler changes. The returned libraryDir is the input directory when library is set.

Rendering uses renderDocument, with highlight.js by default, on a copy of each document presented as html. A code block with a title is therefore framed under it, and a group of tabs is drawn as radio tabs, which the print stylesheet lays out as every tab under its label. Unknown languages fall back to escaped code.

The default highlighter wraps every line of a block whose data.cudoc.lines marks any in <span class="line">, adding highlighted to the marked ones, and a span highlight.js runs across a line break is closed and opened again around each line. renderOptions overrides these render defaults and supplies explicit component callbacks.

documentTitle, front matter title or else the first heading's text, with the document id as the last resort, sets the navigation and page title, and documentDescription each page's <meta name="description"> when it is not empty. Without locales, frontmatter lang sets <html lang> (default en); with locales the file name decides the language, as Site structure describes. The builder adds the page shell and, for a language without a home document, a landing page.

siteStyles is a token-driven, theme-aware stylesheet, generated by buildStyles(tokens) from the object in design/tokens.ts. designTokens, resolveTokens and buildStyles are exported; siteStyles is buildStyles() with the defaults. The declarations and the four highlight.js colour rules are generated from that object, and __tests__/styles.test.ts compares the result against a byte-for-byte golden fixture.

The light palette is defined as custom properties on :root, and only those properties are redefined under prefers-color-scheme: dark, so a viewer's system setting selects the theme without any script. No rule contains a raw colour outside the print block, and every foreground/surface pair clears WCAG AA: 4.5:1 for text and 3:1 for the focus ring, in both themes.

The dark colours are declared twice at the specificity of :root: under prefers-color-scheme: dark for :root:where(:not([data-theme="light"])), and under :root:where([data-theme="dark"]), so data-theme="light" or "dark" on <html> overrides the system setting (and sets color-scheme) while an appended stylesheet's own :root rules still win. Theme switch is what sets that attribute; without it nothing does.

Token group Properties Purpose
Surfaces --canvas, --paper, --wash, --row-alt Page ground, content surface, code and panel fill, alternating table rows
Text --ink, --muted, --faint Reading colour, secondary prose, small labels
Lines --line, --line-soft Region separators and row separators
Accent and status --accent, --accent-soft, --warn, --warn-wash, --danger, --danger-wash Links and active navigation, plus each callout severity with its own tinted surface
Code --code-keyword, --code-string, --code-comment, --code-number highlight.js token colours
Type --font-sans, --font-mono, --text-xs … --text-3xl, --leading-body, --leading-tight Font stacks and the modular scale. Word sizes its runs from text against print.baseSize, and its line spacing is the CSS leading divided by Word's own 1.2
Layout --space-1 … --space-12, --radius, --measure, --head-h, --ease 4px spacing grid, corner radius, line-length bound, header height, transition
Word none: word.sans, word.mono, word.eastAsia, word.paragraphSpacing … word.padding The Word template's faces and rhythm, read by no stylesheet; see Word template

A callout is drawn in --accent and --wash unless its type is in the severity table, CALLOUT_SEVERITY in design/tokens.ts: warning takes --warn and --warn-wash; caution, danger and error take --danger and --danger-wash. The stylesheet's .cudoc-callout-<type> rules are generated from that table and the Word callout styles read it too, so a callout is one colour in every format.

Type scales from the browser's own base size rather than a fixed pixel root, so a reader's font-size setting is respected: html is font-size: 100% and body copy is --text-base (0.9375rem) at --leading-body (1.7). The font stacks name IBM Plex Sans and JetBrains Mono first and fall through to the platform UI and Korean faces; nothing is fetched, so the export stays usable from file:// and offline. Prose is bounded to --measure (72ch) while headings, tables, code blocks and callouts use the full content column.

The page shell is a sticky header with a sticky document sidebar and heading TOC beside that column, collapsing to two columns at 1024px and to one at 768px, where navigation targets and menu items grow to 2.75rem. Tables use horizontal rules, alternating row backgrounds, label-style headers and tabular figures rather than a full border grid, and scroll horizontally instead of widening the page. Interactive states transition over --ease, which prefers-reduced-motion collapses to 1ms.

css files load after these styles, so a site restyles the export by redefining properties rather than rewriting rules. Prefer tokens for a change that should hold across formats: it is data, and a writer that emits no CSS reads the same object, while css reaches only the outputs that load a stylesheet. resolveTokens merges one level into each group, so redefining a single colour keeps the rest of the palette, and it replaces a font stack whole rather than merging it. --radius, --measure, --head-h, --ease and the dark palette have no counterpart outside CSS.

The print block drops the header and both navigation columns, removes the measure bound and keeps the reading colour. It repeats table headers across pages, and lets a code line wider than the page wrap instead of scaling every page down to fit it. The separate print stylesheet, cudoc-print.css, adds the option-dependent rules described under Paginated output, so the site's own print block never changes with an option.

Site structure

Every run computes one output plan from the library and the options before anything is written, and every format reads it. Source: plan.ts, navigation.ts, locales.ts, design/shell.ts.

The plan holds the published set (each document the navigation or hidden lists, plus the homes) and the documents left out with their reason. It also holds each language's navigation and home, each document's output path, the documents this run writes, and the bound volume's members and order. No format can therefore publish a document another leaves out. A private document is never published; a document that is neither listed nor hidden is omitted as not-in-navigation, and a link to it is an error under relative.

Navigation. NavigationOption is a YAML file path, a list of entries, or a NavigationSpec:

type NavigationOption = string | NavigationItem[] | NavigationSpec
type NavigationSpec = { items: NavigationItem[]; hidden?: string[] }
type NavigationTitle = string | Record<string, string> // text per language code
type NavigationItem =
  | string // "guide.md" a document, "guides" a folder, "." the whole collection
  | {
      folder: string
      title?: NavigationTitle
      page?: string // a document in the folder, such as README.md
      exclude?: string[] // globs, relative to the folder
      order?: string[] // names or titles first; "..." once, for every other
      depth?: number // from 1: how many levels are drawn as groups
      collapsed?: boolean
    }
  | { title: NavigationTitle; items: NavigationItem[]; collapsed?: boolean }
  | { title: NavigationTitle; url: string } // http(s) or mailto

A YAML file is read with duplicate keys refused, and an error in it or in one of its entries names file:line; an inline entry is named by its path, such as navigation[2].items[0].

A document is written with .md or .mdx and is found by its translation key, so the default language's file names every translation. A name without an extension is a folder. A folder entry that names a document is an error that spells the document's form, and a document entry that names nothing is an error.

Entries resolve in two passes. First every explicit entry claims its document: a file entry, a folder's page and a hidden file. A document claimed twice is an error naming both places, and a private one is an error.

Then each folder lists what is left inside it: not a claimed document, not the page that stands for a listed folder, not what a more specific listed folder lists, and not what its exclude matches. An exclude entry is a glob tested against the path inside the folder and each of its parent folders, so drafts leaves out a subfolder.

Inside a folder, X.md stands for the folder X/, else X/index.md does, as resolveTree reads a tree, and the collection's own index.md stands for .; a README.md stands for nothing unless page names it. A folder nothing stands for is kept as a group named after the folder, since a menu that dissolved its folders would lose the reader's bearings.

A folder's entries sort with compareNames by title, then name, after the ones order names by name or title; ... places all others. Below depth levels no group is drawn: a deeper folder's page and documents join the list of the last group drawn, sorted with the rest. Without navigation, the whole collection is one folder entry drawn in place, index first. An order or exclude entry that matched nothing is reported as unmatched-navigation-order or unmatched-navigation-exclude, with where it was written.

Languages. locales maps each language code, shaped like en, ko or pt-BR, to a label or to { label, suffix?, ui? }:

type LocaleOption =
  string | { label: string; suffix?: string; ui?: Partial<UiStrings> }
type UiStrings = Record<
  "skip" | "documents" | "onThisPage" | "language" | "theme" | "light" | "dark",
  string
>

The first language is the default and has no suffix. Another's suffix defaults to .<code>, is a dot followed by letters, digits or -, and differs from every other language's. A document's language is the suffix that ends its file name, read from the last dotted part only, so notes.v2.ko.md is the Korean notes.v2; its translation key is its id without the suffix.

Front matter lang may only make that code more specific (ko-KR for ko) and then becomes <html lang>; naming another language is an error. Without locales, every page's lang is its front matter lang or en.

Every language draws the navigation from the same entries, each document entry, folder page and folder member drawn as that language's translation of its key. A translation that does not exist is left out and reported once as missing-translation, and a group left with no entries is dropped. A title object gives the language's text, then the default language's; a key that locales does not name is an error. ui replaces any of the shell's words; cudoc carries them for English and Korean, and another language starts from the English ones.

Homes and output paths. A language's home is the translation of home, which must exist in every language, or of index. It is written to index.html in the default language and index.<code>.html in the others, and every other document to <id>.html. A document whose own file would be a home's file and is not that home is an error. A language without a home gets a generated landing page that lists its navigation. Single pages write no home: every document, index included, is <id>.html.

The shell. A site page has a header (the site title linking to the language's home, header.links, a language menu when there are two or more languages, the theme menu), the navigation sidebar and the main column. It also has the contents of headings down to toc.depth, unless toc or the page's front matter turns it off or the page has no heading to list. A home document is such a page too; a generated landing page lists the navigation in its body, across the whole width, instead of beside it.

Groups are <details> elements, open unless collapsed, and open around the current page. A single page has the title, header.links, the theme menu and the contents, without navigation, home link or language menu.

The language menu is a <details class="menu"> inside <nav class="languages">. Its summary shows a globe and the page's language, labelled ":

The language menu needs no script. While it is open, the summary's ::before is a transparent layer over the viewport under the list, and the header rises above the review runtime's controls. A press anywhere else therefore lands on the summary and closes it without reaching the page.

Links the shell generates carry data-cudoc-final while the page is assembled, so the link policy does not rewrite them a second time; the attribute is removed from authored content and does not reach the written page.

Stylesheets. css files are parsed with css-tree. An @import is refused. Each url() resolves like a rendering resource from the stylesheet's own directory and must stay inside a root, an assetDirs directory or that directory. No copied resource may be written under cudoc-css/.

In a site the files are linked after cudoc.css as cudoc-css/<n>-<name>.css, in the order given, and what they load is copied to cudoc-css/files/<first 12 hex digits of its SHA-256>-<name>, so a rebuild names the same content the same way; a remote url() is kept. On a single page their text goes into the page's one <style>, each url() becomes a data: URL, and a remote url() is refused.

sourceLinks and mounts. Under relative and host, a hyperlink to a file that is neither a document nor found under a root or assetDirs, but exists under sourceLinks.root at its library path, becomes sourceLinks.url followed by that path. Each segment is percent-encoded and its query and fragment are kept; a directory keeps its trailing slash, and the private-document check applies.

A mount's from is a directory and its to a folder path inside the output, without ., .. or a leading /. Neither may overlap a source, library, asset or output directory, nor one mount another mount's from or to. A mount over a root would publish documents the navigation leaves out.

A link into from, whether its file is under a root or under sourceLinks.root, is rewritten to the copy, and the folder is copied whole after the pages; a symbolic link or a private document inside it is an error. No copied resource may be written under a mount's to/. A mount on single pages, or with host or none links, is an error, since only a relative link inside a site reaches the copy.

Single pages

standalone, and annotate with target: "file", write single pages: each written document is one <id>.html that renders the same from any folder.

Paginated output

buildExport(options) from cudoc-export produces every format from one collection, inside one publish transaction, and returns a promise.

type ExportFormat = "html" | "pdf" | "docx"
type ExportGranularity = "documents" | "volume" | "both"

function buildExport(options: ExportOptions): Promise<ExportResult>

type ExportOptions = SiteOptions & {
  formats?: ExportFormat[] // ["html"]; the HTML is written whatever it holds
  granularity?: ExportGranularity // "documents"
  pdf?: { executablePath?: string } // an existing browser instead of the installed shell
  docx?: DocxWriterOptions
}

type DocxWriterOptions = {
  rawHtml?: "drop" | "text" // "drop"
  calloutStyle?: "paragraph" | "table" // "paragraph"
  components?: Record<
    string,
    (node: DocumentNode) => readonly (Paragraph | Table | ParagraphChild)[]
  >
}

type ExportResult = Omit<SiteResult, "files" | "diagnostics"> & {
  formats: ExportFormat[]
  files: Record<ExportFormat, string[]> // relative to outDir; html as SiteResult, the others in output order
  diagnostics: ExportDiagnostic[]
}

type ExportDiagnostic =
  | NavigationDiagnostic // see Export
  | {
      code:
        | "dropped-html"
        | "html-as-text"
        | "image-as-text"
        | "dropped-footnote-table"
        | "unsafe-link"
      message: string
      document: string
    }

type RunningText = string | { left?: string; center?: string; right?: string }

type PageLength = string | number // "20mm", "1in", "54pt", or millimetres

type PageOptions = {
  paper?:
    | "A4"
    | "A5"
    | "A3"
    | "Letter"
    | "Legal"
    | { width: PageLength; height: PageLength }
  orientation?: "portrait" | "landscape"
  margin?: {
    top?: PageLength
    right?: PageLength
    bottom?: PageLength
    left?: PageLength
  }
  header?: RunningText | false // "{title}"
  footer?: RunningText | false // { center: "{page} / {pages}" }
  date?: string // "" — never read from the clock
  breakBefore?: 0 | 1 | 2 | 3 // 0
  linkUrls?: boolean // false
  authoredBreaks?: boolean // true
  wideTables?: false | { minColumns: number } // false
}

type VolumeOptions = {
  fileName?: string // "volume"
  cover?: false | { image?: string } // {}
  contents?: false | { title?: string; pageNumbers?: boolean } // { title: "Contents", pageNumbers: true }
  order?: string[] // [] — documents moved to the front, by id or source path
}

page and volume are on SiteOptions, so buildSite writes the print-ready HTML at the same geometry and with the same front matter. resolvePageOptions(page) validates every field and resolves the geometry once; resolvePageGeometry(page) is the geometry alone. Both are exported, and a key page, page.margin, pdf or docx does not have is an error naming it.

formats adds to the HTML output, which every run writes: ["pdf", "docx"] writes the HTML and both paginated formats, and "html" in the list changes nothing. A caller's afterStaging or printOutputs is refused, as buildSite refuses it.

One geometry drives every format: the @page rule, the print call's margins, the Word section's twips and the running lines' tab stops all derive from that one value. A4 portrait with 20/20/22/20mm is the default, giving a 170×255mm content box.

Every length (the paper's width, its height and each margin) is a number in mm, cm, in, pt or px, a bare number meaning millimetres. It is converted once to millimetres rounded to 0.01mm, because the browser's print call takes only px, in, cm and mm and reads a bare number as pixels. Anything else fails with cudoc-export: unusable page length <value>.

A header or footer needs a margin of at least 15mm on its side, because Chrome clips a running line that does not fit; a narrower margin with a running line is an error. A {page}, {pages}, {title} or {date} field is substituted in either format; {title} is the document's title in a per-document file and the volume title in the bound file; {date} is page.date verbatim.

The PDF draws a running line in a template document of its own that reads no stylesheet, so its face, size and colour are written inline from the tokens: the fonts.sans stack, text.xs at print.baseSize (8.53pt by default) and colors.light.faint. Word's CudocRunning style takes the same size and colour in the Word face, word.sans.

The four remaining page fields act in the print stylesheet and in Word alike:

Field Effect
breakBefore: n Breaks before every heading of depth n or shallower except a document's first block, in the print stylesheet and as a paragraph property in Word.
linkUrls Appends (url) after an http(s) link in both, and in the print HTML after a volume link that carries data-cudoc-url.
authoredBreaks: false Makes the print stylesheet's .cudoc-page-break rule break-after: auto and the Word writer skip the node.
wideTables: { minColumns: n } Gives every table with at least n columns in its first row, spans counted, a landscape page.

A break that opens a document, a top-level break that only front matter, definitions or an HTML comment precede, is dropped by both writers whatever the setting, because the document starts on a page already. The block after it counts as the first. A break nested in a container is left alone.

For a wide table, the print HTML wraps the table in <div class="cudoc-wide">, printed on the named page @page cudoc-wide whose size is the portrait page turned and whose margins are inherited. The Word writer splits the document into sections around the table, the table's section carrying w:orient="landscape" and the same running text on a tab stop sized to the wider column.

A table inside another table's cell is never wide, and neither is a document's first block. Chrome answers a named page on the first element with a blank page in front of it, and the Word writer applies the same exclusion so the two outputs agree.

A page break directly before or after a wide table, blank text and comments aside, is dropped in both: the landscape page or section already breaks there, and the empty break element after it would print a blank page. A break anywhere else stays.

volume.fileName is a plain basename that must not equal a document id, since the volume's files sit beside the documents' at the output root. volume.cover.image is a local file path; it must be PNG, JPEG, GIF or BMP, because Word embeds no SVG, and it is copied to the output as cudoc-cover.<type>.

Every file a build can write is reserved, compared without letter case, and an asset that would land on one fails. The names are cudoc.css, cudoc-print.css, cudoc-cover.*, cudoc-annotations.js, cudoc-annotations.css, cudoc-theme.js, sitemap.xml and .cudoc-output. Every language's home file is reserved too: index.html, and index.<code>.html for the others. So are <id>.html, <id>.print.html, <id>.pdf and <id>.docx for every document, and <volume>.print.html, <volume>.pdf and <volume>.docx. No copied file may be written under cudoc-css/ or a mount's to/.

What each format writes. A site writes cudoc-print.css, <id>.print.html per document and <volume>.print.html, whether or not a browser exists; single pages write them only when formats asks for a PDF. docx writes <id>.docx and <volume>.docx with the docx package, walking the same mdast the HTML renderer consumes.

pdf prints the print HTML to <id>.pdf and <volume>.pdf with playwright-core driving chromium-headless-shell in one browser session, each file once. The browser writes every link as the absolute URL it resolved, so each link the page wrote relatively is written back relative to the PDF (relativeLinks), and no PDF names the directory it was printed in.

A postinstall installs that shell and never fails an install. It is skipped when CUDOC_SKIP_BROWSER_DOWNLOAD or PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD holds anything but an empty value, 0 or false, and npm 12 and later run it only once the project approves it (npm install-scripts approve cudoc-export, then reinstall). cudoc-export install-browser runs the same installer either way. When the browser is absent and a PDF is requested, the export throws naming that command.

The bound volume is the default language's documents in navigation order, then its hidden documents, with the ones volume.order names moved to the front in that order (each must be a member, named once, by id or source path). Each document sits in its own <article class="cudoc-doc" id="cudoc-<token>"> and in its own Word section, preceded by a cover section and a contents section unless volume turns either off.

The token is idToken(id) from @cudoment/cudoc/document: the document id with every character other than an ASCII letter, a digit or - written as _<hex code point>_, so guide/개요 becomes guide_2f__ac1c__c694_.

Every id inside a document is prefixed with cudoc-<token>- and keeps its own spelling after it. A link into another document is spelled as that prefixed fragment, its anchor percent-decoded, so two documents cannot collide and every cross-document link resolves inside the file.

The cover fills the page's content box, so the running header and footer keep their place on it. A cover image is its background-image, cropped as background-size: cover, and in Word a floating picture anchored to the margins behind a title paragraph on a paper-coloured band.

The PDF contents carries the page each document starts on, measured by printing the front matter alone and each document alone, then checked. The volume must be exactly that sum, or the export fails rather than print wrong numbers, with … so the contents page numbers would be wrong. A document prints at a different length inside the volume than alone, or does not start on a page boundary.

The number column (.cudoc-contents-page) is at least 4ch wide and right-aligned, so a title wraps the same around the placeholder the measuring print holds as around its number. contents.pageNumbers: false skips the measuring print.

The Word contents is a TOC field over first-level headings with the document list as its current value, each entry linked to a bookmark at the document's start, and updateFields set so Word fills the page numbers on opening. Viewers that do not update fields show the titles. Under links: "none" the entries are plain text in both formats.

Links in the paginated outputs follow links exactly as the site does, resolved once to a target and spelled per format. Inside the volume a link to another exported document is a fragment (#cudoc-<token>-<anchor> in HTML, a bookmark in Word), and one to a private document, which no file holds, is its deployed URL, as in the linking document's own print. A published document the volume does not bind, a translation or a home the navigation leaves out, is linked at its site page <id>.html, or its deployed URL under host.

A fragment is percent-decoded before it names a bookmark in Word or an element in the volume. A document's own print HTML keeps it as written for the browser to match, so #%EA%B0%9C%EC%9A%94 reaches the heading 개요 in every format.

In a per-document file a link to another document is the sibling <id>.pdf or <id>.docx without a fragment under relative, the deployed URL under host, and removed under none.

Under host the volume's fragment also carries data-cudoc-url, the deployed URL that document's own print links to. With linkUrls the print stylesheet appends it as it does after an http(s) link (a[data-cudoc-url]::after), so a document prints at the same length inside the volume as alone. A same-document fragment stays inside the file under every policy. Other local files are copied and linked as on the site, with paths re-expressed from the volume's root, so a document in a subdirectory keeps its images in the bound file.

The Word writer's dispatch order is part of its contract: data.cudoc.kind, then data.hName, then node.type. A page break is carried on a thematicBreak, and a lowered <table> arrives as a blockquote, so reading node.type first turns a break into a rule and a table into a quote. Phrasing content met where flow content is expected, text directly inside an MDX <div>, is wrapped in a paragraph rather than dropped.

Reference-style links and images resolve through their definition; footnotes become Word footnotes numbered across the file. A Word footnote holds paragraphs only, so a table inside one is dropped and reported as dropped-footnote-table. A javascript:, vbscript:, data: or file: link keeps its text without the link and is reported as unsafe-link.

A table written as HTML in an MDX document, which MDX lowers to elements, keeps its rowspan and colspan (either spelling). Word merges the covered cells, the rows below leave those columns out as HTML does, and a short row is padded only in the columns no cell above covers.

A cell spans rows only within its row group, a thead, tbody or tfoot or a run of rows written straight in the table, as the browser draws it. rowspan="0" reaches the group's last row, and a longer span stops there. Any other element between rows, such as a caption, ends a run, and an empty <tr> takes its place in a span, as it does in the browser, without becoming a row of the Word table. Rows keep their source order, so a tfoot written before the body stays there, where a browser draws it last.

In a .md document the same table is an html node, dropped as dropped-html. A Markdown table whose cells were merged with ^^ and >> carries numeric colSpan and rowSpan on its cells and its alignment on each cell, and Word merges the same cells.

A <br> is a line break, and an inline html node that is one character reference, as a VitePress library keeps a brace, is that character. A host's heading permalink (a link marked permalink, such as VitePress's &ZeroWidthSpace; anchor) is left out. A task item's first paragraph leads with ☑ or ☐.

A top-level ordered list numbers from its start, and a nested one from 1. Word sets a start only per numbering definition, so every start other than 1 that a file uses adds cudoc-ordered-from-<n> and cudoc-ordered-from-<n>-cell beside cudoc-ordered, cudoc-bullet and their -cell sets. A sublist of the other kind numbers on its own.

Colour, font, size, shading and border appear only in the declared styles, whose ids are Cudoc*, Word's Heading1–Heading6, TOC1 and IndexLink; every callout type, built in or registered through calloutTypes, gets a body and a title style. The styles, their spacing and the token group that sets it are listed under Word template.

Bookmark names are cudoc + 26 characters of a hash of document id and heading id, and the numeric bookmark ids are renumbered uniquely across the file because docx 9.7.1 gives every bookmark the id 1.

Raw html nodes are dropped and each drop is reported once per document in diagnostics and on the CLI's stderr; docx.rawHtml: "text" writes them as CudocCodeBlock paragraphs (inline ones as CudocCode runs) and reports html-as-text instead. An image that is not a PNG, JPEG, GIF or BMP, or that resolves to no local file, becomes its alt text and is reported as image-as-text. An image Word can embed is scaled down, keeping its proportions, until it fits both the content width and the content height, the same bound the print stylesheet puts on it.

docx.calloutStyle: "table" puts each callout in a one-cell table whose cell carries the left rule and the wash, with its paragraphs in CudocCallout<Type>Plain and ...PlainTitle styles that are declared only in that mode.

A component that survived normalization (mdx* or directive nodes) throws no Word renderer for <name>, as renderDocument throws for one without an HTML renderer, unless docx.components[name] is given. That renderer is called with the node and returns docx objects. Where the component is flow content it returns paragraphs and tables, with any runs among them wrapped in a paragraph; where it is phrasing content it returns runs only, and a paragraph there is an error.

Page breaks are shared through @cudoment/cudoc/paged: PAGE_BREAK_FENCE, PAGE_BREAK_KIND, PAGE_BREAK_CLASS, isPageBreak(node) and pageBreakNode(). normalizeDocument converts a ```cudoc-pagebreak fence into a thematicBreak carrying hName: "div", className: ["cudoc-page-break"], hidden: true and data.cudoc.kind: "pageBreak".

Trees are read through the same module: TREE_KIND, TREE_CLASS, TREE_PRINT_ATTRIBUTE, isTree(node) and treePrintDepth(value). A tree embed prints as the nested list it is down to its print level, read from the outer list's data-cudoc-print. Each item's summary line becomes the item's own text, the lists in its details follow it, and the levels past print are left out.

The print HTML does this to its hast with printTrees before it opens the other details, and the print stylesheet bullets those items again. The Word writer does the same to the mdast before writing the list, so every line is a list paragraph at its own level.

Print rules correct three defects the screen stylesheet had for paper. A table was display: block, which silently disabled table-header-group. pre and callouts promised break-inside: avoid, which cannot hold once a block is taller than a page, and body text was repainted black. The builder also opens every <details>, because Chrome prints a closed one as its summary alone; a tree's are written out first, as described above.

Three subpaths export the writers' parts:

Subpath Exports
cudoc-export/docx buildDocx, writeDocx and bookmarkName, with the Docx* types, DocxWriterOptions and DocxComponentRenderer among them
cudoc-export/pdf openPrinter(page, options?, tokens?), printPdfs(jobs, page, options?, tokens?), runningTemplate(text, title, date, geometry, tokens?), whose tokens default to designTokens and set the running line's type, browserAvailable, launchBrowser, pdfPageCount, relativeLinks(bytes, links), relativeUrl(from, target), browserInstallCommand and BROWSER_CHANNEL, with the PdfOptions, PrintJob and Printer types
cudoc-export/print writePrintOutputs, printStylesheet, fillVolumePageNumbers, resolveVolumeOptions, volumeId, printFileName, PRINT_STYLESHEET, DEFAULT_VOLUME_NAME and VOLUME_FILE, with the PrintableDocument, PrintOutputOptions, VolumeOptions, ResolvedVolumeOptions, AssetMark and CopiedAsset types

Word template

The .docx is a template as much as a document. Every appearance is a named style, so the styles pane changes the whole file, and the values those styles start from are the word token group. Nothing else reaches Word's spacing: css is never read, and the --space-* scale drives the stylesheet rather than the Word styles. A document that reads too loose or too tight in Word is therefore tuned here without touching the site or the PDF.

Page margins are page.margin (20/20/22/20 mm by default), the value the PDF prints with, and a landscape section for a wide table keeps them. Everything inside the margins is tokens.word. Lengths are rem multiples of print.baseSize (10.5pt), converted to twips.

Key Default Sets
sans, mono, eastAsia Calibri, Consolas, Malgun Gothic The Latin, monospace and Hangul faces of every style. One family per script: Word cannot walk a stack and substitutes silently for a face it does not have.
paragraphSpacing 1rem Space after a body paragraph: CudocBody, CudocQuote, CudocCaption, CudocTabLabel, CudocTableNote and the document default.
headingSpacing 1.5rem Space before Heading2 to Heading6. Heading1 takes a third more, and every heading takes half of it after.
blockSpacing 1rem Space before and after a code block and a rule, before a code title (CudocCodeTitle) and a tab label (CudocTabLabel), and before whatever follows a table or a callout.
listSpacing 0.25rem Space after a list item (CudocListItem).
listIndent 1.5rem Indent per list level, with the marker hanging at 60% of it; two thirds of it inside a table cell.
indent 1rem Left indent of CudocQuote and CudocDetailsBody, and both indents of a callout box.
padding 0.5rem Inside padding of a code block and a callout box, as border space in whole points (at most 31), and of a table cell, whose sides take half again.
tokens: {
  word: { paragraphSpacing: "0.75rem", blockSpacing: "0.75rem", listIndent: "1.25rem" },
}

The group merges one level like every other, so one key changes and the rest keep their defaults; resolveTokens({ word }).word is the resolved group.

Where the spacing is applied is part of the contract, because it is what a reader sees when the styles are edited:

The paragraph styles are CudocBody, CudocListItem, CudocSpacer, CudocQuote, CudocCodeBlock, CudocCodeTitle, CudocCodeHighlighted, CudocTabLabel, CudocTableHeader, CudocTableCell, CudocCaption, CudocTableNote, CudocRule, CudocDetailsSummary, CudocDetailsBody, CudocRunning, CudocCoverTitle, CudocCoverTitlePanel, CudocContentsTitle, TOC1 and CudocFootnote, plus Word's Heading1 to Heading6. Every callout type also gets the paragraph styles CudocCallout<Type> and CudocCallout<Type>Title (...Plain and ...PlainTitle under calloutStyle: "table").

The character styles are IndexLink, CudocCode, CudocCodeKeyword, CudocCodeString, CudocCodeComment, CudocCodeNumber, CudocLinkUrl and CudocBadge. Their colours are colors.light, their sizes text against print.baseSize, and their line spacing leading divided by Word's own 1.2. A callout style's rule and wash follow the severity table the stylesheet reads (warn for warning; danger for caution, danger and error; accent and wash for every other type), described under Export.

Theme switch

themeSwitch: true (CLI --theme-switch) adds a theme menu to every page's header. It is off by default; the default output stays script-free and follows the system setting.

The menu is a <details class="menu theme-switch"> drawn like the language menu: its summary shows a sun or moon and the theme in effect, and its list holds a Light and a Dark button, the one in effect marked aria-pressed="true". A choice closes the menu and returns focus to the summary. Until the reader chooses, the page follows the system setting and the menu shows which of the two that is, following a change of the setting while the page is open. The menu marks the theme data-theme holds, so a choice the browser refuses to store is still the one shown for the page.

The browser opens and closes the menu from the keyboard and announces it as expanded or collapsed, and the stylesheet's layer closes it on a press elsewhere. The script closes the open menu of the header, the language menu included, on Escape (focus returns to its summary) or when focus moves to an element outside it.

The menu's words come from data-cudoc-ui on <html>, which the builder writes from the page language's ui strings (theme, light, dark), and otherwise from the document's lang (ko, else English). The summary's accessible name is ": ". Neither summary has a tooltip, which the open menu's layer would show over the whole page.

In a site themeSwitch ships one reserved file at the output root, cudoc-theme.js, copied from the package's dist/browser/. Every page loads it with a plain <script src> at the end of <head>, without defer, so a remembered choice is applied before the body paints. A single page carries the same script inline there.

The same Content-Security-Policy meta as for annotations is added once, whichever option asks first. The menu is created by the script, so a page without the script has none.

The script sets or removes data-theme="light" or "dark" on <html>, which the stylesheet answers to as described under siteStyles. It remembers the choice under localStorage["cudoc-theme"]: absent means the system's, storage is best effort, as for annotations, and a change in another tab is followed through the storage event.

Annotations

mode: "annotate" (CLI --mode annotate) ships a review-note runtime with the pages, so whoever receives them can select text or a block, leave plain-text notes, reply, mark them resolved, and hand them back. The other modes are unchanged byte for byte by it: no script, no policy, no block ids.

type AnnotateOptions = {
  target?: "file" | "hosted" // "file"
  reviewId?: string // required for "hosted"
  inbox?: { github: { repo: string; template: string; field?: string } }
}

target: "file", the default, writes single pages that carry the runtime inline. target: "hosted" writes a site with the runtime beside the pages, for a static host, and requires reviewId, because sites on one host share an origin and therefore browser storage.

reviewId keys that storage; without it a file-target page uses a hash of the title and every published document id, the same whichever documents a run writes. inbox (hosted only) names a GitHub repository (owner/name), an issue form file in it (*.yml or *.yaml) and the form's field id (notes by default, letters, digits, _ and -).

What the mode adds. In a site, two reserved files at the output root, copied from the package's dist/browser/: cudoc-annotations.js, one classic deferred script with no dependencies and no network access, and cudoc-annotations.css. Every page gets <link> and <script defer> tags for them (a generated landing page loads the script, which finds no document and does nothing); a single page carries both inline.

The print HTML, PDF and Word are built from the shared tree and do not change. Every site or single page gets, right after the charset, <meta http-equiv="Content-Security-Policy" content="object-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'none'">. The policy names only origin-free directives, because 'self' does not match the opaque origin some browsers give a file:// page and would block the stylesheet. object-src 'none' and form-action 'none' affect an author's raw <embed> or <form> only in this mode or with themeSwitch.

<main> carries data-cudoc-document (the document id), data-cudoc-ast-hash and data-cudoc-source-hash (the library manifest's astHash and hash for that document), data-cudoc-site (the storage key above), data-cudoc-generator and, with an inbox, data-cudoc-inbox, its JSON.

Every p, li, tr, pre, blockquote, dt, dd, heading and aside.cudoc-callout carries data-cudoc-block="<heading id>:<8 hex>". The value is the nearest preceding heading's id (empty before the first) and the first eight hex digits of the SHA-256 of the block's whitespace-collapsed text, with ~2, ~3 … when one section repeats a text. A content hash survives blocks inserted or moved anywhere else, and an edited block gets a new id, so its notes fall back to the quote search.

The runtime. Selecting text shows a note button; hovering a block shows + in its gutter, and the button stays while the pointer crosses the margin to reach it; each opens a composer for plain text. A round toggle at the bottom right, a speech-bubble icon with the number of notes on this document, opens the panel.

The note button follows the selection. A press anywhere but on it or in the composer hides it at once (pointerdown, captured). It comes back on release only for a press that started in main outside a control (button, select, input, textarea, label, summary, [role=button], editable text) and that started a selection (selectstart) or moved the old one. Using the theme menu, a details summary or pressing a picture, which leave the selection in place, therefore never brings it back beside an old selection.

A selection that collapses or leaves main hides it (selectionchange); a touch or pen selection shows it once the selection has been still for 350 ms. Escape hides it, Tab from a keyboard selection moves focus to it, and focus moving anywhere else hides it.

The panel starts with a name field (optional, applied as it is typed, remembered in the browser, empty by default). Then come two primary actions, copy a share token and clear this browser's storage, and a collapsed More with the file actions. The file actions are download <name>.annotations.json, save <name>.annotated.html, and load a .json or .annotated.html file, also by drag and drop anywhere on the layer. <name> is the document id's last segment, so guide/setup saves setup.annotations.json.

The saved .annotated.html is a copy of the page with the notes in a <script type="application/json" id="cudoc-annotations-data"> block (every < escaped) and without the runtime's UI. It opens anywhere when it is a single page, and must sit in the same folder as the original to load its stylesheet when it is a site page.

Below them the panel lists this document's notes as cards: state and position pills, author and UTC time, the quoted passage (block boundaries collapsed to spaces, at most three lines), the text and replies. Each card also has icon buttons with tooltips for go to, reply, edit, resolve or reopen, and delete. The panel's header controls are icon buttons too. Highlights are painted with the CSS Custom Highlight API (::highlight(cudoc-note), -resolved, -active) and never change the DOM; a browser without it marks the block with one class, which the saved copy strips.

Notes travel in from the embedded block, then from this browser's storage (localStorage under cudoc-annotations:<site>:<document>, best effort: Firefox refuses it over file://), merged by id with the later modified winning. An embedded block that does not read, in a copy edited by hand or cut short, is reported in the panel's message line (Could not load: and the reason) and counts as no notes, so the stored notes still load.

A #cudoc-notes=<token> fragment is decoded but only offered in a bar as unverified until the reader accepts it; accepted or not, the fragment is removed from the address. A token that does not decode, a malformed % escape included, is reported in the panel instead of offered. The token is z. plus base64url of deflate-raw JSON (j. plus plain base64url where compression is unavailable), at most 64 KiB, decoding to at most 1 MiB. On a file:// page the bare #cudoc-notes=… is offered rather than the full address, which would carry a local path.

The panel's strings are English by default; a selector in its header switches to Korean, and the document's own lang is not consulted.

A second header button chooses the panel's placement. Narrow the page, the default, sets the class cudoc-ann-push on html while the panel is open, which pads body on the right by the panel's width (22rem) so the sidebar and contents stay visible. The header sits in the body's flow, so it narrows by that width too and its right-aligned controls move exactly that far. Cover the page lays the panel over them. Below 768px the panel always covers the page.

Language, placement and name are remembered per browser under cudoc-annotations:lang, :layout and :author.

With an inbox, the panel adds Compose on GitHub beside the token: it encodes the notes as a share token and builds https://github.com/<repo>/issues/new?template=<template>&title=Review%20notes%3A%20<page title>&<field>=%23cudoc-notes%3D<token>. An address longer than 6,000 characters downloads the JSON file instead, with a message to attach it, since GitHub answers a long address with 414 and publishes no limit.

Otherwise the panel first shows what travels (the note text, the quotes and the name, public on a public repository, and that a compressed token is not encryption). It opens the address with window.open(url, "_blank", "noopener,noreferrer") only on the reader's confirmation. Nothing is marked as sent, since opening the page is not submitting it. Without notes it says there is nothing to send.

window.cudocAnnotations exposes create(exact, text), createOnBlock(blockId, text), reply(id, text), list(), anchors(), load(text), collection(), embeddedCopy() and token() for automation.

The file is a W3C Web Annotation AnnotationCollection (@context http://www.w3.org/ns/anno.jsonld) with generator, total and items. Each item is an Annotation with id (urn:uuid:…), created and modified (UTC ISO 8601 with milliseconds, as Date.prototype.toISOString writes them), optional creator: { type: "Person", name }, motivation (commenting; highlighting for a note without text; replying), body (TextualBody, text/plain) and target: { source: <document id>, selector }. A reply repeats its root's target.

The selector is a TextQuoteSelector (exact, 32-character prefix and suffix), a TextPositionSelector (offsets into the text of main, a block boundary counting as one newline) and a CssSelector ([data-cudoc-block="…"]). Each item carries a cudoc extension: document, astHash, sourceHash, block, heading, scope (text or block), state (open or resolved) and, on a reply, parent.

Everything read from outside (a file, a token, the embedded block, storage) is copied field by field into fresh objects by parseCollection. Unknown selector types are dropped and unknown fields ignored. The input is refused when @context, type, motivation, purpose or format are not the values above, a quote is missing, an offset is negative or reversed, a date is not ISO 8601, or a limit is exceeded. The limits are 2 MiB per file, 500 notes, and in UTF-16 code units 10,240 per note text, 2,048 per quote, 64 per prefix or suffix and 200 per name or id.

A saved .annotated.html may be up to 32 MiB (LIMITS.htmlBytes), and the notes block inside it is held to the 2 MiB of a notes file.

A date is a calendar date (2026-09-16) or a date and time with Z or a ±hh:mm offset, checked against the calendar, and is stored in the UTC form above, so dates from any offset compare as text. A local time without an offset, or anything else Date.parse would accept, is refused. A file, chosen in the panel or named on the command line, is measured in bytes before it is read.

Anchoring. A note is placed by its block id when its scope is block and the block still exists. Otherwise its quote is searched, whitespace collapsed, inside its block, then its heading's section, then all of main, keeping the occurrence whose surroundings best match the prefix and suffix. Then its saved offsets are used, if the text there still reads the same. Otherwise it is listed as not found, never dropped.

A match inside the block is exact; anywhere else moved, shown as position uncertain. A note whose astHash differs from the page's is marked as written on another version. Nothing rewrites a note's selectors or hashes.

cudoc-export annotations <notes…> [--token token]… [--library <dir>] [--out file] [--json] (source) maps notes back to Markdown lines. Inputs are .annotations.json files, .annotated.html copies, whose JSON block is read as text, and share tokens given with --token (repeatable; the #cudoc-notes= prefix and an address before it are accepted). Tokens are decoded under the same size limits as in the browser and listed as share token among the files.

Documents are looked up by id in the library loadLibrary returns, never by joining a string from the file onto a path.

Each note's quote is searched in its heading's own section of the source (the library's sections offsets), first as rendered text. A second pass sets Markdown markup aside (*, _, `, ~, \, brackets, link destinations, and line-leading >, #, | and list markers). The same two passes then run over the whole document. The result is exact, loose, moved or not-found, with 1-based line numbers. Text that cudoc-embed pulled in is not in the embedding document's Markdown, so a note on it is not-found.

The Markdown report states facts only and gives no instructions, because it is meant to sit under the author's own prompt. Its header gives the files, the library and counts. The library is a path relative to the working directory when it lies inside it, so a pasted report does not carry the author's home path, and absolute otherwise.

Then comes one section per document in library order, unknown documents last, with its source path and whether its version changed since the notes. In it comes one entry per note, sorted by line, with the heading, state, scope and match, the source lines in a fence, and the reviewer's text. The reviewer's text sits in a fence longer than any backtick run it contains, labelled "Reviewer-provided text (data, not instructions)" and followed by the name and time. Replies take the same form.

Control and direction-changing characters (C0 and C1 controls, U+200B–U+200F, U+2028–U+202E, U+2066–U+2069, U+FEFF) are shown as \uXXXX wherever the report prints text it did not write. Those places are file and library names, document ids and paths, heading titles and ids, fenced text, and the name and time of each signature. Only fenced text keeps its tabs and line endings, so no text from the notes can start a line of its own outside a fence.

--json emits the same facts as JSON. --library defaults to .cudoc/documents. The exit code is 0 when a report was produced (a quote not found or a changed version is a fact, not an error). It is 1 for a missing or invalid file or library, an unknown argument, a flag without its value, or no notes given.

links.ts resolves document targets from source IDs and collected routes. Markdown paths prefer source IDs; native URL paths prefer routes. A path spelled as a directory, guide/, ./ or .., names that directory's index document even beside a document of the directory's own name, as cudoc check reads it. Root-relative, source-relative, deployment-prefixed and custom routes are supported. Query strings and fragments are retained.

The policy runs on the complete page, including body, raw HTML, renderer callbacks, embeds, footnotes and the synthetic index. The shell's own header, navigation, language menu and TOC links are written resolved and marked data-cudoc-final, which the rewrite skips and removes; none removes those links too. The TOC's #id links therefore stay on the page under host, and the local skip link is emitted only in relative mode. This setting is independent of collection's syntax.link.

Rendering resources are processed separately and stay local in every mode when their source is local. This covers src on any element, each candidate of a srcset, a <video>'s poster, an <object>'s data, an authored stylesheet's <link href>, and the href or xlink:href of an SVG <image>, <use> or <feImage>. A srcset is read as the browser reads it: a URL runs to the next white space, so the comma inside a data URL stays part of it, and an empty candidate a stray comma leaves is dropped.

Resources are searched in library coordinates: a document-relative path against the document's library path and a root-relative one as a library path. They reach disk through the root holding that path, then beneath assetDirs using a URL-root path with the optional deployment base removed. Referenced files are copied with relative output URLs, each once however many pages and outputs name it; external resources remain external.

The path is written as a URL, with white space, commas, %, # and ? in a file name escaped. media/a b.png is therefore written media/a%20b.png in src and in srcset alike, and a link to a copied file is written the same way in the site, the print HTML and Word. A css file's url() files are handled as Site structure describes, and its @import is refused; on a single page every one of these resources is written inline as Single pages describes.

Invalid link modes/URLs, empty inputs, navigation entries naming no document or folder, missing assets, asset/output collisions, different assets sharing one output path, unsupported nodes and unsafe output directories fail. Output overlap with source, library, asset or mounted roots is rejected before writing.

A local rendering resource that reaches outside every root and every assetDirs root is reported under every policy as a missing local target naming both the URL and the document that carries it. A hyperlink there is reported so only under relative, which would copy it, while host resolves it against the deployment and none removes it. Nothing is ever copied from outside those roots.

A link from an exported document to a private one is an error naming both under relative, because the page is not in the output and copying its source would publish it. Under host it points at the deployment, which serves the page, in every output, the bound volume included. none removes it like any other link.

A resource, or under relative a link to a local file, that reaches a private document's file fails under every policy. This holds whichever path reaches it, its root, an assetDirs directory, a symbolic link or another spelling of the name, because the check compares the file itself, not the path. The error reads cudoc-export: <document> loads the private document <library path> as a resource (<url>), which would publish its source.

Publication uses staging: a failed run preserves the previous output, and a successful one replaces it whole, so nothing an earlier run or mode wrote is left beside the new output. In collection mode, library and site publication are separate; a failed site write does not roll back a newly collected library. In reuse mode the library remains unchanged. Source HTML is not sanitized and React/Vue code is not executed. The generated shell has no client-side JavaScript dependency unless mode: "annotate" or themeSwitch adds its script, described under Annotations and Theme switch.

The CLI (source) runs the same builder:

cudoc-export build [sourceRoot] [--out-dir site] [--external-path /prefix]
cudoc-export build --config site.config.mjs
cudoc-export build docs --library .cudoc/documents --out-dir shared-html \
  --links host --host-url https://docs.example.com/project/ --asset-dir public
cudoc-export build docs --library .cudoc/documents --out-dir shared-html --links none
cudoc-export build docs --out-dir out --mode standalone --document guide.md --strict
cudoc-export build --config review.yml --mode annotate
cudoc-export build docs --out-dir out --format pdf --format docx \
  --granularity both --paper Letter --landscape
cudoc-export annotations review.annotations.json --library .cudoc/documents --out review.md
cudoc-export install-browser

These build flags set or replace options:

Flag Effect
--site-url Sets siteUrl.
--mode Sets mode.
--document (repeatable) Forms the documents list, which replaces the config's.
--strict, --theme-switch Turn those options on.
--annotations Retired; fails with a message naming --mode annotate.
--format (repeatable) Replaces config formats with the list.
--granularity Sets granularity.
--paper <name>, --landscape Set page.paper (a named size: A4, A5, A3, Letter, Legal) and page.orientation over the config's page.
--asset-dir (repeatable) Replaces config assetDirs with the array.
--external-path (repeatable) Replaces config externalPaths.

cudoc-export annotations <notes…> [--token token]… [--library <dir>] [--out file] [--json] prints or writes the review report described under Annotations, with the exit codes given there. cudoc-export install-browser runs the browser installer and exits with its status. There is no watch command.

CLI defaults are docs and site. ESM config must default-export an object; JSON and YAML (.json, .yml, .yaml) are supported, while callback functions require ESM or the programmatic API. Explicit source, --out-dir, --library, --links, --host-url, --site-url and --granularity override config. A config that lists roots keeps them unless a source root is given on the command line, which replaces them. Relative paths use the invoking working directory.

Success prints the build result as JSON on standard output, and on standard error each diagnostic as cudoc-export: <document>: <message> and each single-page dependency as cudoc-export: <document>: needs a page beside it: <url> (or a file beside it, a remote resource). Errors, including an unknown flag or a value flag without a value, set exit code 1.

cudoc