Markdown (one or several documents) → ONE self-contained, sanitised HTML document: all-level heading anchors, sidebar, per-document TOC, cross-links, print CSS and a licence notice.
Module mdHtmlDocument | Source packages/front/office/md/src/document/html-document.js | Deps mdErrors, md, sanitize, mdToc, mdFrontmatter, mdFootnotes, mdAdmonitions, mdHtmlTheme | Worker-safe yes
Pure string factory: no DOM, no network, no clock, no randomness. build(x) called twice returns the same string, byte for byte. The output is one .html file with no external reference. The stylesheet (mdHtmlTheme) and, in multi-document mode, a small hash router are embedded in it.
Resolve
mdHtmlDocument is registered in modules. Four of its dependencies (mdToc, mdFrontmatter, mdFootnotes, mdAdmonitions) live in extras, so register both arrays:
import { runtime } from '@awacloud/fw';
import { fw_require, modules, extras } from '@awacloud/md';
runtime.registerAll(fw_require);
runtime.registerAll(modules);
runtime.registerAll(extras);
const { renderFragment, build } = runtime.resolve('mdHtmlDocument');
Or in one call, through bootstrapMd, which registers all four manifest arrays:
import { bootstrapMd } from '@awacloud/md/bootstrap.js';
const { htmlDocument } = bootstrapMd();
const { html } = htmlDocument.build({ documents: [{ path: 'README.md', source: '# Hello\n' }] });
Types
Document = { path: string, source: string, title?: string }
Extension = { name: string, install(md) } // a RESOLVED extra, e.g. runtime.resolve('mdEmoji')
Warning = { code: 'link/unresolved' | 'title/fallback' | 'heading/unanchored', document: string, detail: string }
Heading = { level: 1|2|3|4|5|6, text: string, id: string }
Fragment = { id: string, path: string, title: string, html: string, headings: Heading[], warnings: Warning[] }
LinkOptions = {
external?: 'new-tab' | 'same-tab', // default 'new-tab'
unresolved?: 'neutralise' | 'keep', // default 'neutralise'
resolve?: (target: { path: string, fragment: string|null, markdown: boolean }, from: Document) => string | null
}
TocOptions = false | { minLevel?: number, maxLevel?: number, minHeadings?: number } // default { 2, 3, 2 }
FragmentOptions = {
extras?: Extension[], links?: LinkOptions, toc?: TocOptions, id?: string, idPrefix?: string,
reservedIds?: Iterable<string> // ids already used on the page; a colliding heading id is renamed (see Anchors)
}
BuildOptions = {
documents: Document[], // length ≥ 1
title?: string, // <title> + brand; default: 1 doc → its title, N docs → 'Documents'
lang?: string, // default 'en'; must match /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/
theme?: 'light' | 'dark' | 'auto', // default 'auto' (mdHtmlTheme.THEMES)
classification?: { label?: string, version?: string, date?: string },
extras?: Extension[], // default DEFAULT_EXTRAS
links?: LinkOptions,
toc?: TocOptions,
css?: string, // TRUSTED caller CSS appended inside <style>
notice?: { source?: string, extra?: string } // appended lines of the notice block
}
BuildResult = { html: string, documents: Fragment[], warnings: Warning[] } // warnings = flattened documents[].warnings, document order
DEFAULT_EXTRAS = [frontmatter, footnotes, toc, admonitions]. These are the four resolved instances the factory receives, installed in that order (the canonical md-full relative order: frontmatter first, toc before admonitions).
FragmentOptions.toc is accepted but renderFragment does not use it. FragmentOptions.reservedIds is validated by renderFragment itself: anything but an iterable of strings (or absent) throws md/document-bad-option with context.option === 'reservedIds'; a bare string is rejected, as it would iterate as characters. build renders the per-document TOC itself (see Pipeline).
API
| Member | Signature | Returns |
|---|---|---|
renderFragment |
(document: Document, options?: FragmentOptions) => Fragment |
One Markdown source as a sanitised body fragment, with heading ids, the heading list and warnings |
build |
(options: BuildOptions) => BuildResult |
One complete HTML page for N ≥ 1 documents |
Errors
| Code | Class | Thrown by | When |
|---|---|---|---|
md/document-bad-input |
ContractError |
renderFragment, build |
documents not an array or empty; an entry not an object; path not a non-empty string; source not a string; title present but not a string. context: { index, field }. index is null for renderFragment and for the documents array itself; field is null when the entry is not an object. |
md/document-bad-option |
ContractError |
build, renderFragment (reservedIds only) |
theme not in THEMES; lang fails the regex; links.external / links.unresolved outside their enums; links.resolve present but not a function; toc neither false nor an object with integer levels in 1..6, minLevel ≤ maxLevel and an integer minHeadings ≥ 0; extras not an array of { name, install }; title / css / notice.source / notice.extra / classification.* present but not strings (or links / notice / classification not objects). context: { option, got }. renderFragment throws it only for reservedIds not being an iterable of strings. |
build validates everything before it does any work. renderFragment validates only its document.
Pipeline
renderFragment(document, options) runs these steps in this exact order:
const m = md.createMd(); for (const e of extras) m.use(e);, whereextras=options.extras ?? DEFAULT_EXTRAS.const ast = m.parse(document.source).headings = toc.collectHeadings(ast, { minLevel: 1, maxLevel: 6 })→[{level,text,slug}].mdTocis the package's single slug owner, and the-1,-2dedupe comes from it. Then reserved ids: a heading whose final id (idPrefix + slug) is inoptions.reservedIdsis renamedslug-1,slug-2, … (the first id that is neither reserved nor the final id of another heading of the fragment). With no collision the headings are untouched and the output is byte-identical to a call withoutreservedIds.- Title:
document.title, if present.- Otherwise, when
ast.data.frontmatter && ast.data.frontmatter.lang === 'yaml', the first line ofast.data.frontmatter.contentmatching/^title:\s*(.+?)\s*$/m, with one pair of surrounding"/'removed. - Otherwise the text of the first level-1 heading.
- Otherwise the basename of
pathwithout its extension, plus atitle/fallbackwarning whose detail is that basename.
let html = m.render(ast, { safe: false }). An explicitsafe: false(the renderer hardens by default) and nosanitizehere: both would run before steps 6 to 8 and strip author raw HTML the step-9 allowlist keeps; step 9 is the single boundary.- Heading anchors: collect the renderer's bare tags
/<h([1-6])>([\s\S]*?)<\/h\1>/gin order.- Positional pairing: when there are exactly as many bare tags as
headings, with the same level at each position, tag i is paired withheadings[i]. This means no raw-HTML heading is interleaved. - Text walk: otherwise, keep a cursor into
headings. A tag is anchored when itslevelequalsheadings[cursor].levelANDnorm(innerHtml)equalsnorm(headings[cursor].text).normstrips tags, decodes& < > ", collapses whitespace and trims. A tag that does not match is left untouched: it came from raw HTML. - An anchored tag becomes
<hN id="${idPrefix}${slug}">…. - Any heading still unpaired at the end gets a
heading/unanchoredwarning, with its text as the detail. See Anchors for when this can happen.
- Positional pairing: when there are exactly as many bare tags as
- Id prefix (multi-document): when
idPrefix !== '', every remainingid="X"attribute becomesid="${idPrefix}X", and everyhref="#X"becomeshref="#${idPrefix}X"(when step 3 renamed the headingX, the link follows it to the new slug first). This covers footnotefn-/fnref-pairs and[[TOC]]links. The heading tags anchored in step 6 are not prefixed a second time. In single mode (idPrefix === '') nothing changes, so[[TOC]]output resolves as it does today. - Links: each
<a href="…"( [^>]*)>open tag is rewritten. See Links. - Sanitise, always, last, unconditionally:
html = sanitize.sanitizeHtml(html, ALLOWLIST), whereALLOWLIST = { allowedTags: new Set([...sanitize.defaultAllowlist.tags, 'input']), allowedAttributes: { ...sanitize.defaultAllowlist.attributes, input: new Set(['type', 'checked', 'disabled']) } }. GFM task-list checkboxes survive.inputnever keepsname/value/form*, andformaction/actionare blocked by fw. There is nourlSchemesoverride. - Return
{ id, path, title, html, headings: headings.map(h => ({ level, text, id: idPrefix + slug })), warnings }, whereid=options.id ?? docIdOf(path).
docIdOf(path) = toc.slugify(path.replace(/\.[^./\\]+$/, '').replace(/[\\/]+/g, '-')), or 'doc' when that is empty.
build(options) runs these steps:
- Validate.
multi = documents.length > 1. - Compute doc ids:
docIdOfper document, deduped in order with-1,-2(thecollectHeadingsscheme). BuildbyPath: Map<normalisedPath, id>. - Reserve the ids that must stay unique:
used= the article ids, plusmd-nav-togglein multi mode. Then, in document order, callrenderFragment(doc, { extras, links: { ...links, resolve: setResolver }, toc, id, idPrefix: multi ? id + '--' : '', reservedIds: used })and add everyheadings[].idof the fragment tousedbefore the next document.setResolver(target, from)first calls the caller'slinks.resolveif there is one (for every relative target, see Links); a string return wins. Otherwise it lookstarget.pathup inbyPath:- multi mode:
'#' + id + (target.fragment ? '--' + target.fragment : ''); - single mode:
'#' + target.fragment, or'#' + idwhen there is no fragment; - not found:
null.
- multi mode:
- Per-document TOC (when
toc !== false): take the headings withminLevel ≤ level ≤ maxLevel. Render them only when there are at leastminHeadings:<nav class="md-toc"><span class="md-toc-label">On this page</span> <a href="#id" data-level="N">text</a> · … </nav>, with the text escaped. - Assemble the shell (see Shell structure).
Anchors
- Scheme: heading ids are the
mdToc.collectHeadingsslugs on every level 1..6. Duplicates are suffixed-1,-2, …mdToc.slugifyis ASCII-only (\w), for consistency with[[TOC]]. - Single document: ids are bare (
#section), so the links[[TOC]]emits resolve unchanged. - Uniqueness: an id is never emitted twice in one page.
buildreserves the article ids, the shell'smd-nav-toggle(multi mode) and the ids of the documents already rendered, so a heading that would collide is renamedslug-1,slug-2, … (first free).renderFragmentdoes the same for the ids inoptions.reservedIds.- Single mode:
a.mdholding## Ayields<article id="a">and<h2 id="a-1">; the headings list and the per-document TOC carrya-1, and so do the in-document#alinks ([[TOC]]entries included). With## Aand## A 1the second keepsa-1and the first becomesa-2. - Multi mode:
a--b.md(## c) anda.md(## b--c) both slug toa--b--c; the first document keeps it, the second getsa--b--c-1. Document order decides, so output stays deterministic. A cross link to the contested id (a.md#b--c) resolves to#a--b--c, the first owner. - The
<docId>--<slug>scheme,docIdOfandslugifyare unchanged: anchors of documents without a collision do not move. - Not reserved: footnote ids (
fn-…/fnref-…), which can still collide with a heading of the same name.
- Single mode:
- Multi-document: every id inside document
<docId>is prefixed<docId>--. This covers heading ids, footnotefn-/fnref-ids and every in-documenthref="#…". A headingIntroinguide.mdis therefore#guide--intro. - Raw-HTML headings are not anchored: a
<h2>…</h2>typed as raw HTML never receives an id, and a heading tag that already carries attributes is never a bare renderer tag. heading/unanchored: with positional pairing (no raw-HTML heading in the source), every heading is anchored. The warning is only reachable in text-walk mode, when a raw-HTML heading is present AND a Markdown heading's rendered text differs from its AST text. Examples of such headings are an image-only heading, a setext heading with a soft break, a code span that contains tag-like text, or a footnote reference.
Links
Rewriting applies to each <a href="…"> open tag the renderer (or raw HTML) produced:
href |
Result |
|---|---|
#… |
handled by the id prefix (step 7), never touched here |
| `/^(https?: | mailto:)/i` |
relative, path part ending in .md (case-insensitive) |
target = { path: normalise(dirname(document.path) + '/' + decodeURIComponent(pathPart)), fragment, markdown: true }. normalise is a pure POSIX normaliser: ./.. segments are resolved and \ is treated as /. links.resolve(target, document) is called when given; a string return wins verbatim (attribute-escaped). A null or absent return applies the unresolved policy: 'neutralise' (default) → <a class="md-dead-link" title="…original href…">, href removed, plus a link/unresolved warning whose detail is the original href; 'keep' → untouched, plus the same warning. |
relative, any other path part (img/p.png, dir/, ?q=1) |
same target, with markdown: false. links.resolve is consulted; a string return rewrites the href (attribute-escaped, sanitised after). A null or absent return leaves the tag untouched: no unresolved policy, no link/unresolved warning. |
| anything else | untouched. This includes an empty href="", root-relative (/x.md) and protocol-relative (//host/x.md) targets, and other schemes (which the sanitiser then filters). links.resolve is not called for them. |
Inside build, resolve is wrapped so that a link to another document of the set resolves to its in-page anchor. A link to a document outside the set stays unresolved.
Sanitisation contract
- Every byte of
Fragment.htmlhas passed@awacloud/fwsanitizeHtmlwith the module allowlist; the shell around it is module-authored and every consumer-supplied string in the shell is escaped. There is no option that skips the sanitiser. - Survives: the fw default allowlist (
sanitize.defaultAllowlist) +input[type|checked|disabled];id/class/titleon any element;hrefonly withhttp,https,mailto,tel,ftp, relative,#,/,?,.targets. - Never survives:
script,style,iframe,object,embed,svg,math,template,link,meta, HTML comments, everyon*attribute,style=,srcdoc,formaction,javascript:/vbscript:/data:inhref/src(so nodata:image ever renders),</>/"inside attribute values. options.cssandoptions.notice.*are trusted caller input (only the</style/*//-->breakouts are neutralised);Document.source,Document.path,Document.title,classification.*,titleare untrusted and fully escaped.- Remote images (
<img src="https://…">) are kept; the shell'sreferrer: no-referrermeta is the only mitigation.
The sanitiser runs after the link pass. A string returned by a resolve hook therefore goes through it too: a javascript: target returned by a hook is dropped.
Notice
A licence notice covering the embedded stylesheet and script (not the document content) is emitted three times:
- as an HTML comment in
<head>; - as a
/*! … */comment at byte 0 of the<style>body; - in multi mode, as a
/*! … */comment at byte 0 of the<script>body.
Its lines, in order:
@awacloud/md html-document — the stylesheet and script embedded in this file
Copyright (c) 2026 AwaCloud SAS
SPDX-License-Identifier: AGPL-3.0-only
Dual-licensed; see the NOTICE file of @awacloud/md for licensing and any additional terms.
This notice covers the embedded stylesheet and script only, not the document content.
[Source: <notice.source>]
[<notice.extra>]
In notice.source and notice.extra, --> becomes --\u003e and */ becomes * /. There is no default source pointer; the Source: line appears only when notice.source is given. In addition, </style is neutralised (<\/style) across the whole <style> body, and </script (<\/script) across the whole <script> body.
Shell structure and class contract
<!DOCTYPE html>
<html lang="…">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>…</title>
<!-- NOTICE -->
<style>/*! NOTICE */ …mdHtmlTheme.css(theme)… …options.css (every `</style` → `<\/style`)… </style>
</head>
<body class="md-document md-theme-<theme> md-single|md-multi">
<header class="md-top">[<button id="md-nav-toggle" aria-label="Menu">☰</button>]<span class="md-brand">title</span>[<span class="md-classification">label</span>]</header>
[<aside class="md-nav"> one <a class="md-nav-item" href="#id" data-doc="id"><span class="md-nav-title">…</span><span class="md-nav-path">path</span></a> per document </aside>] (multi only)
<main class="md-main">
one <article class="md-doc" id="id"[ hidden — multi only, every article but the first]>
<div class="md-doc-meta"><span class="md-doc-path">path</span>[<span class="md-doc-version">version</span>][<span class="md-doc-date">date</span>]</div>
[nav.md-toc]
<div class="md-body"> fragment.html </div>
[<div class="md-pager"><a class="md-pager-prev" href="#prev">← title</a><a class="md-pager-next" href="#next">title →</a></div>] (multi only; a missing side is an empty <span>)
</article>
</main>
<footer class="md-footer">[label · version · date]</footer>
[<script>/*! NOTICE */ router </script>] (multi only)
</body>
</html>
The toggle button is emitted in multi mode only. Every class and attribute above is styled by mdHtmlTheme; the selector table there is the shared contract.
Router (multi only). The router is a plain ES2015 IIFE with no external reference. The document ids are emitted via JSON.stringify(ids).replace(/</g, '\\u003c'). On load and on hashchange:
- The current document is the hash when it is a known id. Otherwise it is the hash's part before the first
--, when that part is a known id. Otherwise it isids[0]. - Each
articlegetshidden = (id !== current). .md-nav-item.activeis toggled according todata-doc.- When the hash names a fragment inside the current document, that element is
scrollIntoView()'d. Otherwise the page scrolls to(0, 0).
#md-nav-toggle toggles body.md-nav-open.
Examples
Single document
const { build } = runtime.resolve('mdHtmlDocument');
const { html, warnings } = build({
documents: [{ path: 'README.md', source: '# Guide\n\n## Install\n\n## Usage\n' }],
theme: 'light',
lang: 'en'
});
// html: '<!DOCTYPE html>…<h2 id="install">Install</h2>…' (bare ids, no sidebar, no script)
// warnings: []
Several documents with cross-links
const { html, documents } = build({
documents: [
{ path: 'docs/a.md', source: '# A\n\nSee [B](b.md#setup).\n' },
{ path: 'docs/b.md', source: '# B\n\n## Setup\n' }
],
title: 'Handbook',
classification: { label: 'Internal', version: '1.2', date: '2026-09-24' },
notice: { source: 'https://example.org/source' }
});
// documents.map(d => d.id) → ['docs-a', 'docs-b']
// the link in a.md → <a href="#docs-b--setup">B</a>
// the heading in b.md → <h2 id="docs-b--setup">Setup</h2>
Custom extras
const emoji = runtime.resolve('mdEmoji');
const toc = runtime.resolve('mdToc');
const documents = [{ path: 'a.md', source: '# A\n\n[[TOC]]\n\n## B :rocket:\n' }];
build({ documents, extras: [toc, emoji] }); // replaces DEFAULT_EXTRAS entirely
renderFragment({ path: 'x.md', source: '> [!NOTE]\n> hi\n' }, { extras: [] }).html;
// '<blockquote>\n<p>[!NOTE]\nhi</p>\n</blockquote>\n' (no admonition extra)
resolve hook
const documents = [{ path: 'a.md', source: '# A\n\nSee [setup](guides/setup.md#linux).\n' }];
build({
documents,
links: {
resolve(target, from) {
// target = { path: 'guides/setup.md', fragment: 'linux' | null, markdown: true }
// (also called for non-.md relative links, e.g. images, with markdown: false)
if (!target.markdown) return 'https://cdn.example.org/' + target.path;
if (target.path.startsWith('guides/')) return 'https://example.org/' + target.path.replace(/\.md$/, '.html');
return null; // fall through to the document set, then to the `unresolved` policy (.md only)
},
unresolved: 'keep'
}
});
Notes
- Surface choice: the module is part of the package's source graph (
src/main.jsmodules, re-exported by name) and is reached through aModuleRuntimeorbootstrapMd. There is no dedicateddist/standaloneroot for it. mdToc.slugifyis ASCII-only (\w), so non-ASCII letters are dropped from the slug (## Été→#t). A heading with no ASCII word character at all slugs tosection, thensection-1, …- No image embedding. The fw sanitiser drops every
data:URL, so adata:image never renders; this is documented, not worked around. Remote images are kept as-is. options.cssis appended after the theme inside the same<style>element, so it can override any rule.- Output is deterministic: no date, no random id, and no dependency on the environment.
See also
document/theme: the embedded stylesheet and the class contractbootstrap:bootstrapMd().htmlDocumentextra/toc: the slug scheme and[[TOC]]md: themdfacade (createMd,use,parse,render)errors:md/document-bad-input,md/document-bad-option