HMD-0002: MkDocs book-mode rendering¶
Status: drafted (implemented; three open questions block acceptance) Created: 2026-08-06 Source: HMD-0001 §9, open questions 8 and 9
Abstract¶
This proposal maps a HyperMarkDown namespace onto a MkDocs site. It pins the
output URL for a card, derives the nav from the namespace tree with an optional
nav frontmatter key, fixes embed expansion at on_page_markdown so the toc
extension sees the finished document, and defines how unresolved links and plain
.md files behave in a build. It adds hmd render and the embed expander that
the plugin depends on. Wiki mode, backlinks, and generated pages stay out.
Motivation¶
hmd lint proves a tree resolves; it does not produce anything a reader can
use. MkDocs is the shortest path from a linted tree to a site, and the plugin is
small because §5 already decided every link's destination.
The gap is that a resolver answers which file a name means, and a site needs which URL. Nothing in HMD-0001 answers that, so the plugin cannot be written without deciding it here.
Goals¶
- One card maps to one stable URL, and a link rewrite is pure arithmetic on it.
- Nav order is derived by default and declarable when the default is wrong.
- A tree with red links still builds green.
Non-goals¶
- Wiki mode, backlinks, category and special pages, health reports.
- Templates, queries, and the plugin API.
- Round-tripping flat markdown back into
.hmd. Sketched, without consequence for this proposal, inmd-hmd-interop.
Specification¶
1. Output URLs¶
- A card at root-relative
a/b.hmdMUST render toa/b/index.html, served ata/b/. A folder notea/b/index.hmdMUST render to the same URL, since §5.1 already makes the two names address the same page. - The plugin MUST therefore require
use_directory_urls: true, and MUST fail the build with a usage error otherwise rather than emitting links it knows are wrong. - Link rewriting MUST emit a path relative to the source file, not to the
page's URL —
kanban.md, not../kanban/. MkDocs resolves and validates every link against the source tree and computes the final URL itself, so a source-relative path gets the URL for free and gets the link checked. Emitting the finished URL bypasses validation and duplicates arithmetic MkDocs is already doing. - A fragment MUST be appended unchanged; §3 of HMD-0001 already guarantees the slug the resolver matched is the slug the renderer emits.
2. Nav¶
- Nav MUST be derived from the namespace tree. A directory becomes a section,
its
index.hmdbecomes that section's landing page, and cards become entries. - Default order within a directory is folder notes first, then cards sorted by root-relative POSIX path. Deterministic, and stable under filesystem iteration order (P1).
- A fourth reserved frontmatter key,
nav, MAY carry a mapping describing where a card sits in a published site. Its only key so far isorder, an integer. Cards withnav.ordersort ahead of cards without, ascending; ties fall back to the default order. This amends the closed reserved set of HMD-0001 §5.3 totags,use,import,nav. navis a mapping rather than a bare integer so that a second placement key can be added without inventing a second spelling for the first. A scalarnav: 10MUST therefore be reported rather than accepted: the two forms would otherwise both have to be honoured forever, and a card that carries the old spelling asked to be ordered and would silently sort last instead.- A second key,
visibility, gates publication. It takespublicorprivate; any other value MUST be reported as HMD013 rather than coerced, because guessing publishes a card nobody asked to publish. - Publication is opt-in. A card reaches a built site only if its effective
visibility is
public, and the default isprivate. A card that says nothing about itself has not asked to be published, and the cost of guessing the other way is a leak rather than a missing page. - An unpublished card MUST NOT be registered at all: no page, no URL, nothing in the output directory. Omitting it from the nav alone is not sufficient — a page reachable by typing its address is published, whatever the sidebar says.
visibilityinherits the wayusedoes: a card's own value, then the nearest ancestorindex.hmdthat sets one, then the default. A folder is the unit an author thinks in, so publishing one MUST NOT require editing every card inside it. The corollary is worth stating plainly:publicon a root folder note publishes that entire subtree, and a card opts out with its ownprivate.- A published card that links to an unpublished one MUST render as a red link and MUST be reported as HMD017, a warning. The build stays green: this is a work item, not a defect that should stop a deploy.
- A published card that embeds an unpublished one MUST NOT expand it — expansion copies the target's bytes into the page, which is the one outcome the gate exists to prevent. It degrades to the same red link and the same HMD017. Expansion itself stays policy-free and takes the predicate from its caller, since rendering a card named directly on the command line is not the same situation as building a site.
- A malformed
navvalue, an unknown key inside it, or a non-integernav.order, MUST be reported as HMD013. - An explicit
nav:inmkdocs.ymlMUST win, except where it names the wiki: a nav entry whose value ishmd://wikiMUST be replaced by the derived section — as a mapping value it becomes that entry's children, as a list item it is spliced in place. Without this a site is a book or a wiki; the placeholder is what lets it be a book with a wiki in it. - A page the authored nav already names MUST NOT appear again in the derived section. An authored placement is still a placement, and the derived section is what is left over. Listing one page twice is not a harmless repetition: MkDocs gives a page a single parent, so the card renders its own URL with the wrong section open, and every title after the first is silently discarded.
- The namespace root MAY be a subtree of
docs_dir, named by the plugin'srootoption. Pages then serve under that subtree's path. A site therefore covers a whole documentation tree while[[…]]stays restricted to the part that is a namespace, and the rest builds as ordinary MkDocs pages.
3. Expansion¶
- Embeds MUST be expanded in
on_page_markdown, before Python-Markdown runs, sotoc,footnotes, and the rest see one finished document. - Expansion is textual and MUST NOT shift heading levels (HMD-0001 §6).
- Heading slugs introduced by expansion MUST participate in the page's normal
tocdeduplication. A section embedded twice therefore yields#sand#s_1, and the page reports HMD011.
4. Red links and .md¶
- An unresolved link MUST render as
<a class="hmd-redlink">carrying the link text and nohref, and MUST NOT fail the build. Lint tracks the work item; the site stays viewable. - Plain
.mdfiles under the docs dir MUST build as ordinary MkDocs pages. They remain invisible to the resolver (§4) and are therefore not wikilink targets; a[[…]]naming one is a red link.
5. Commands¶
hmd render PATH [--to markdown|html]— expand embeds and rewrite resolved links.markdownis a one-way build product with no round-trip guarantee.mkdocs serveMUST watch the namespace root for.hmdchanges viaon_serve.
Backwards Compatibility¶
nav is a new reserved key, so a card already using nav for its own purposes
changes meaning. Nothing is released and no card in this tree uses it.
Security Considerations¶
The build reads the same tree hmd lint already reads under the containment
rules of HMD-0001 §4. Expansion is bounded by the depth limit of 16 and cycle
detection, which the plugin inherits rather than reimplements.
Deployment / Activation¶
- M4 —
embed.py(expansion, HMD007, HMD008),render/flat.py,hmd render. Independently testable, no MkDocs involved. - M5 —
mkdocs_plugin.py(on_files, nav,on_page_markdown,on_serve),render/markdown_ext.py,mkdocs.yml, the entry point inpyproject.toml. - Add
mkdocs build --strictto CI.
Reference Implementation¶
tools/hmd/src/hypermarkdown/embed.py— expansion, cycle detection, depth limittools/hmd/src/hypermarkdown/render/flat.py— the flat-markdown emittertools/hmd/src/hypermarkdown/mkdocs_plugin.py—on_files, nav,on_page_markdowntools/hmd/src/hypermarkdown/urls.py— §1, the one place a URL is computedpyproject.toml—[project.entry-points."mkdocs.plugins"]
No render/markdown_ext.py. Once §3 fixed expansion at on_page_markdown, a
Python-Markdown extension had nothing left to do: expansion and link rewriting
share a single walk over the source, because a link inside embedded content must
be resolved from the card it was written in while the text it becomes belongs to
the host page. Splitting that across two components would have meant resolving
twice.
Test Plan¶
Unit tests MUST include:
- URLs:
a/b.hmdanda/b/index.hmdproduce the same URL; a rewrite between two cards is relative and correct in both directions. - Nav: default order is deterministic;
nav.order: 10sorts ahead of an unkeyed sibling; a scalarnav, an unknown key inside it, and a non-integerordereach raise HMD013. - Visibility: a card with no
nav.visibilityand no ancestor that sets one produces no file in the output directory; a public folder note publishes its subtree; a card under it may still opt out withprivate; a link from a published card to an unpublished one rendershmd-redlinkand raises HMD017; an embed of one does not appear in the built HTML. - Expansion: section embed stops at the next same-or-higher heading; a two-page cycle raises HMD007; a 17-deep chain raises HMD008.
- A section embedded twice yields
#sand#s_1and reports HMD011.
Integration tests MUST include:
- A build of
examples/small/succeeding, with every.hmdpresent in the output and the deliberate red link carryingclass="hmd-redlink". use_directory_urls: falsefailing the build with a usage error.
python -m pytest
hmd render examples/small/specs/auth/login.hmd --to markdown
mkdocs build --strict
Open Questions¶
- Does the plugin own
mkdocs.yml's extension list, or only document it? - Should
navinherit down a subtree the wayusedoes, so a folder can order itself relative to its siblings in one place? - Is a red link with no
hrefright, or should it link to a "create this page" target once one exists?
Changelog¶
- 2026-08-06: drafted
- 2026-08-07: implemented. Three corrections forced by the implementation:
§1 link rewriting is relative to the source path rather than the page URL,
because MkDocs validates links against the source tree and computes URLs
itself;
render/markdown_ext.pyis dropped, since fixing expansion aton_page_markdownleft a Python-Markdown extension nothing to do; and a site needsexclude_docs: "*.hmd"plusvalidation.links.not_found: info, the latter because cards link out to the repository — proposals, styles, source — with ordinary relative links whose targets are real files but not site pages - 2026-08-07: §2 gains the
hmd://wikinav placeholder and a namespace root that may be a subtree ofdocs_dir, so one build holds a book and a wiki. Implemented directly as a feature (issue 0001), not respecified. - 2026-08-08: §2's
navbecomes a mapping whose only key isorder, where it was a bare integer. A breaking change to a key shipped in 0.1.0, taken while the format is pre-1.0 and whiledoc/wikiwas its first user: placement has more than one plausible dimension, and a scalar would have needed a second spelling the moment a second key was wanted. The old form is reported as HMD013 rather than accepted, so no card silently loses its ordering. - 2026-08-08: §2 gains
nav.visibility, and with it the second key that justified the mapping the same day. Publication becomes opt-in and defaults to private, an unpublished card is not registered at all, and the key inherits from folder notes. HMD017 is added for a published card reaching into an unpublished one. The expander grows acan_embedpredicate so a site can refuse to inline unpublished content whileembed.pystays free of publication policy. - 2026-08-08: §2 gains the rule that the derived section leaves out whatever the authored nav has already placed. Promoting the language specification to the top bar left it listed twice — once as its own tab and once inside the wiki — and its own page then rendered with the wiki section open under a duplicated title. Deriving the leftovers rather than everything also retires the ordering constraint that a promoted card had to be listed before the placeholder or lose its title.