This is the multi-page printable view of this section. Click here to print.
Design
1 - Root page
llms.txt and shared chrome find the page published at each language root: the home page, or a doc-rooted site’s docs section pageSelection rule
_partials/td/root-page.html returns a site’s root page: it
takes a Site and returns the page at logical path /docs when the URL of its
html output format equals the home page’s, otherwise the home. Consumers share
the result through partialCached, which caches per site.
Consumers
llms.txtdiscovery: therel="describedby"link in every page head, the in-body agent directive, the Markdown versions’llms.txtlink, and the Docsy-private_root-llms-txt-pathshortcode all need the language’sllms.txtURL, which exists when the root page has anLLMSoutput (Discovery).- The
llms.txttemplate (all.llms.txt):- The home-page link targets the language’s root page; the
Site localeslist links each language’s root page. - The description is the root page’s, falling back to the home page’s.
- The documentation index is a separate
/docslookup.
- The home-page link targets the language’s root page; the
- Shared chrome: in the
sharedchrome build mode, the navbar and footer are kept on one donor page per language, the root page, and restored from it elsewhere. The chrome applies the definition without the lookup: a page is the donor when itshtmlURL equals the home page’s. The placeholders carrydata-chrome-donor, the home page’shtmlURL, where the client fetches the chrome; the navbar’s also carriesdata-canonical-path, the page’s ownhtmlURL, which keys the client’s active-state restore. The left-nav has a donor rule of its own (donor pages).
Why this rule
- Absolute logical path,
site.GetPage "/docs": a bare"docs"would be wrong. When there is no/docssection, aSitelookup falls back to a site-wide lookup by name, which fails the build on two pages nameddocs(page reference "docs" is ambiguous) and silently returns the one such page otherwise (#2875). - The URL comparison keeps an ordinary
/docs/section from being taken for the root. - The
htmloutput URL, not the URL of the format being rendered: the cached result serves every render pass, and a permalinkable format can render before HTML. - The home page’s
htmlURL as the reference: Hugo computes it under every URL setting (baseURLpaths, language prefixes, multihost, site-wideuglyURLs, the site matrix’s version dimension), which no configuration-derived root reproduces in full.
The docs name is the doc-rooted recipe’s own: the recipe
redefines that section’s permalinks, and the llms.txt documentation index
lists its subsections. Two alternatives that avoided the name were set aside:
- Scanning
site.Sectionsfor a section at the home’s URL depends on Hugo’s listing rules: a section page withbuild.list: neverpublishes but is not listed. - A configuration switch would duplicate what
permalinksalready states.
Covered cases
The table records supported behavior; the Evidence column names the fixture
test (tests/fixture-site/) exercising the row. Chrome consequences assume
shared mode.
| Case | Root page and consequences | Evidence |
|---|---|---|
Ordinary site (home renders, docs under /docs/) | The home. llms.txt at /llms.txt when LLMS is among the home outputs; the home is the donor page. | llms-directive, chrome-render |
| Doc-rooted site | The docs section page, published at the home’s URL. Publishes llms.txt when its outputs include LLMS; it is the donor page. | llms-doc-rooted, chrome-doc-rooted |
No /docs section, pages named docs elsewhere (one or many) | The home; the build succeeds, and llms.txt has no documentation index. | root-page |
| Multilingual project | One root page per language (/, /fr/), each able to publish its own llms.txt and each its own donor page; defaultContentLanguageInSubdir only moves the URLs. | root-page, chrome-doc-rooted |
docs section page without a translated _index file | Hugo’s auto-created section is still the root when published there; its outputs come from configuration or cascade, not from a file. | root-page |
baseURL with a subpath | Both compared URLs include the subpath, so the selection is unchanged. | root-page |
| Several output formats (HTML, Markdown, print, RSS, LLMS) | Selection is unchanged whatever format renders first or carries rel=canonical (the rule compares html URLs). The donor is the root page’s HTML render; its print render gets a placeholder like any other page. | root-page, llms-doc-rooted, chrome-doc-rooted (print), chrome-render (permalinkable print) |
Site-wide uglyURLs | Both compared URLs end in index.html, so the selection is unchanged; the donor is named as /index.html. | root-page |
Doc-rooted home with LLMS configured | The home publishes no file; root-level llms.txt and discovery require LLMS on the docs section page. | llms-doc-rooted |
Root page without LLMS | No describedby link, directive, or Markdown link, even when another section publishes an llms.txt; the shortcode fails the build when invoked. | llms-directive, llms-doc-rooted |
LLMS on a non-root section | Its llms.txt is the site overview, not a section index; discovery still depends on the root page’s LLMS output. | llms-doc-rooted |
Limits
The design leaves these unresolved:
- A section other than
docsremapped to the site root: the lookup keeps the home, sollms.txtdiscovery follows the home, while the chrome’s self-test makes that section the donor; Hugo need not warn (a link-only home leaves no duplicate output). - Two pages publishing the same root URL: a path conflict of the site’s own.
- A doc-rooted language without a
/docssection: it has no root page, sollms.txtlists the language at an unpublished URL, and the site root that shared chrome names as donor serves nothing. - A doc-rooted home whose output formats (site
outputs.home, or its own front matter) don’t start withHTML: a link-only page keeps only its first configured format, so the home has nohtmlURL to compare with; the home stays the root page, no render is the donor, and the placeholders name an empty donor (the client fetches the page itself once);llms.txtdiscovery follows the home’s kept formats, present only whenLLMSis among them. The same holds forbuild.render: neverand an emptyoutputs.home, wherellms.txt’s home and locale links are empty too (thedocssection page still publishes itsllms.txt). Hugo’soutputsconvention isHTMLfirst. - A
docssection page whoseoutputsleave outHTML: it is neither selected nor a donor. uglyURLsset for thedocssection only (uglyURLs: { docs: true }): the section page publishes at/index.htmlwhile the home page, in no section, keeps/, so the URLs differ and the home stays the root page, with no discovery and no donor.HTMLmarkednotAlternative: the donor page’s other renders then carry the chrome too (the current-render test is “HTMLis not among the alternatives”); the placeholders still name the site root, so the restore works.- A paginated root page: each
/page/N/continuation renders the chrome too. The theme’s own layouts paginate only the blog list.
2 - Script loading
Docsy loads its body-end JavaScript through
_partials/scripts.html: a small dispatcher over per-feature
sub-partials under _partials/scripts/. (Head-side JS, such as
theme initialization and analytics, is emitted by _partials/head.html and is
out of scope here.)
Loading mechanisms
Before 0.18, scripts.html mixed a few sub-partial dispatches (MarkMap,
Mermaid, KaTeX) with the other mechanisms’ logic inline. The decomposition moved
every mechanism out of the dispatcher into sub-partials without changing the
default rendered output; the 0.18 plugin conversions then moved the first
integrations onto the plugin loop:
- Static theme scripts, emitted as plain script tags:
deflate.js(PlantUML),prism.js. - The main bundle: Bootstrap plus the theme’s core and feature scripts
(search, PlantUML, draw.io; dark mode and ScrollSpy when enabled),
concatenated into
main.js(scripts/main-bundle.html), minified and fingerprinted in production. A site param picks which search script is bundled,search.jsoroffline-search.js. - Theme plugins: Mermaid, MarkMap, tab persistence, and click-to-copy ride the plugin loop as theme-default plugin entries (plugins map, implementation notes).
- Pinned CDN tags with inline configuration: Algolia DocSearch.
- Build-time remote fetches: KaTeX, whose CSS and fonts are copied and re-served as local assets, and the MarkMap autoloader, vendored at build time and served same-origin with SRI.
Gating lives at two levels. The dispatcher gates PlantUML (site param) and KaTeX
(.Page.Store flag); the Mermaid and MarkMap plugin shims carry the same
page-flag pattern (hasmermaid, hasMarkmap), while the remaining sub-partials
gate internally (Algolia search configuration, Prism, search bundle choice, dark
mode, ScrollSpy). Tab persistence ships ungated (why).
The dispatcher as a seam
The decomposition has two design consequences:
- Independent overrides: each sub-partial resolves through Hugo’s union file
system, so a site can replace one sub-partial by shadowing one file instead of
copying all of
scripts.html. - Plugin dispatch: the dispatcher is where the plugin loop plugs in (#2789).
Override points
- Every sub-partial the dispatcher routes to under
_partials/scripts/. _partials/algolia/head.htmland_partials/scripts/algolia.html: real partials as of 0.18, replacing inlinedefines whose documented override paths did not work (the internal template namesalgolia/headandalgolia/scriptsno longer exist).- Per plugin: the script asset
assets/js/plugins/NAME.js, its companion partial, its companion stylesheet, and its shim (file contract).
The plugin loop
scripts/plugins.html emits each eligible plugin registered in
params.docsy.plugins. For the configuration reference and plugin file
contract, see the plugins guide; for the loop’s mechanics, the
implementation notes.
params.docsy.plugins: a map, layered by Hugo’s config merge
params.docsy.plugins is a map keyed by plugin name, and the theme declares
its own plugins in theme/hugo.yaml under the same key. Hugo’s theme-to-site
configuration merge is deep for maps (Configuration § Theme
defaults), so a site’s map layers over the theme’s:
- Supersession and inheritance come free: a site entry for a theme plugin
merges field by field (
markmap: { enable: true }keeps the theme’sversion). - Duplicates are impossible: map keys are unique. The loop needs no deduplication, no first-wins rule, no supersession bookkeeping.
- A plugin’s whole configuration lives on its entry, the dependency’s
version pin on
versionand the plugin’s own settings onoptions, not under a top-levelparams.NAME.*key:- One home per plugin, one environment-override prefix; a migrated package’s
old
params.NAMEnamespace fails the build, naming the entry: a setting is a value the site moves once, while an alias needs a precedence rule between two homes that the guide would then have to explain. - The pin never reaches the built JavaScript, which has no use for it; the loop validates it once, for every companion that builds a fetch URL from it.
optionsis a string, opaque to the loop and owned by the plugin: its format and validation are the plugin’s, and the loop passes the value through unchanged. Hugo lowercases the keys of every configuration map, so a string is the one shape that reaches a case-sensitive library intact; Docsy’s plugins take a JSON object in it (Mermaid’s shim decodes it). The type is documented in the schema and enforced by each plugin; a loop guard, or a loop decode keyed on a schemaformat, earns its place when a second Docsy plugin takes options. Alternatives considered, each costing more than the string’s authoring quirk (Hugo params key case):- re-casing a map against the library’s defaults object: incomplete, a fifth of Mermaid 12’s schema has no default to recover the case from;
- a snake_case authoring convention: authors translate from the library’s docs, and acronym keys break the rule;
- a data-file home: a second home, not language-scoped;
- Hugo’s case preservation inside lists: undocumented.
- One home per plugin, one environment-override prefix; a migrated package’s
old
- Author fields are
_-prefixed (_defer, the schema’s one so far; guide): the prefix marks a schema field as the plugin’s rather than a site setting, as Hugo’s_mergeis a meta key, not a setting. The loop doesn’t track who set a field: a site that overrides one owns the outcome unless the plugin’s shim pins it (implementation). - The schema is data:
data/docsy/schema/params/docsy.yamldeclares the entry contract once, for the loop and the docs alike. Enforcement stays hand-coded in the loop: Hugo offers no validation forparams, and no surveyed theme validates site params (Hinode’s data-drivenArgs.htmlcovers shortcode arguments only). - The loop is generic: it knows no plugin names. Theme defaults are configuration, not template code; plugin-specific behavior lives in the plugin’s own files: its script, its companions, and its shim, which adjusts the entry per page (shims).
- Plugins use site configuration: language-specific site parameters apply; page front matter does not define plugin entries.
Alternatives considered, and why not:
- A list of entries: lists are replaced, not merged, by Hugo’s config merge, so theme defaults would have to live in template code, and every override, turn-off, or duplicate would need loop logic (a name-keyed defaults table and plugin-specific branches inside the generic loop).
- A per-plugin manifest file next to the script: plugin-owned defaults, but a third artifact per plugin, and the theme still needs a configuration home for which plugins are on by default. Revisit if module-shipped plugins need self-describing metadata (module trust: implementation § Security constraints).
- Metadata partials returning a defaults dict: pure Hugo, but metadata as template code is less inspectable than configuration.
Named collections in Hugo’s own configuration (outputFormats, mediaTypes,
languages, taxonomies) are maps keyed by name; the plugins map follows that
idiom.
Gating decisions
- A theme default gates only on render-hook flags. A shortcode’s flag stays on the page whose file contains it, so included content loses it (the mechanics, for site authors: Plugins § Page flags in included content). Mermaid and MarkMap (hook-flagged) are gated by default; tab persistence (shortcode-produced) ships ungated on every page, as before 0.18: no flag is set for it.
- Gating is the plugin’s, not an entry field. The plugin’s hook sets a flag
and its shim reads it (
hasmermaid,hasMarkmap), the pairing the dispatcher uses forhasMath; a site widens a gate by setting the flag fromhooks/head-end.html(MarkMap guide). A gate field in configuration would be a flag name kept in sync with the hook by convention, and no site needs one; across static-site generators, per-page loading is the theme’s call with no switch, and where a switch exists it is an enum, never a flag name. - Design of record for a switch, should a second gated core plugin or a
plugin author ask for one:
scope: site | pageon the entry, with the theme declaring each plugin’s default. For an including page that needs a gated plugin, the shape is a per-page front-matter override instead. - The markmap render hook sets the flag and renders Hugo’s default code
block (
transform.HighlightCodeBlock), leaving the browser-side transform to the plugin script, so a disabled plugin leaves the fence exactly as Hugo would render it. Mermaid’s hook keeps its library-shaped markup (<pre class="mermaid">) because the library reads it; whether Mermaid should move to the default-render shape is a queued question (#2789). - Known limitation: section print. The
printoutput format for sections renders descendants’.Contentunder the section page, whose Store never receives the children’s flags, so gated plugins don’t ship in a printed section (Mermaid and KaTeX have had the same gap since their flags were introduced). Accepted for 0.18.
Ordering decisions
- No ordering field: entries emit in name order, the order Hugo ranges a map in. That keeps output reproducible, but is an implementation detail, not a contract: a plugin that depends on another uses the dependency’s readiness mechanism, not its position.
- Companions before the script: a plugin’s companion partial and stylesheet emit before its script tag, so a synchronous plugin script can rely on companion markup and styles being present.
- Body-end CSS (interim placement): the companion stylesheet’s
<link>is emitted where the loop runs (at the end of<body>), not in<head>, because gating shims read.Page.Storeflags that are only reliable after content render. Moving companion CSS into the head is a possible later refinement, and has to solve that constraint or gated CSS silently drops (#2789). - Mermaid starts explicitly, no earlier than
load: the plugin’s entry is deferred, so the companion’s config block and the render hook’s markup are parsed before it runs; it imports the pinned library and callsrun()itself (Mermaid’s documented integration; its load-bound auto-start could fire before a dynamic import settles), waiting forloadso fonts loaded through CSS are in and label geometry matches the pre-plugin rendering. Mermaid has no reinitialization, so a change of rendered theme reloads the page; the observer is installed before any await so a toggle during a pending import or render is not missed.
Related pages
- Implementation: script loading
- Quality notes: the test nets that pin this behavior
3 - Semantic classes
td- CSS classesFor what semantic classes are and the consumer contract (public td- classes
and state attributes), see Semantic classes in the user guide.
Naming
New semantic classes use td--prefixed light BEM (td-block__element), with
modifier suffixes reserved for variants, following the pattern of existing
names like td-sidebar-nav--search-disabled. Other pre-existing td- names
remain until a component’s migration renames or removes them; a migration may
also keep pre-existing names unchanged (the breadcrumb kept td-breadcrumbs).
State styling
When migrating a component, style each state through a semantic attribute, never
a state class. Reuse the ARIA state attribute the markup already exposes for
assistive technology when one applies: keying styling on it keeps visual and
accessibility state inseparable by construction. For a state with no ARIA home,
introduce a data-td-* attribute and announce it in the component’s upgrade
post.
Skins
A skin binds the semantic classes to a styling source: in CSS only, never in markup. The current skin binds to Bootstrap:
- Component styling binds by reference:
@extend .breadcrumb-style rules, so styling tracks the installed Bootstrap version instead of drifting as a vendored copy. - State rules are written out against Bootstrap’s component CSS variables
(
--bs-*by default), since Bootstrap defines these components’ state styling in compound selectors (like.breadcrumb-item.active), which@extendcan’t reference. Each written-out rule carries aBS mirror: FILE SELECTORcomment; from the repo root,grep -rn 'BS mirror:' theme/assets/scss/td/inventories the mirrored rule bodies to re-check on a Bootstrap upgrade.