This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Design

Design decisions and conventions for the Docsy theme

1 - Root page

How llms.txt and shared chrome find the page published at each language root: the home page, or a doc-rooted site’s docs section page

Selection 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.txt discovery: the rel="describedby" link in every page head, the in-body agent directive, the Markdown versions’ llms.txt link, and the Docsy-private _root-llms-txt-path shortcode all need the language’s llms.txt URL, which exists when the root page has an LLMS output (Discovery).
  • The llms.txt template (all.llms.txt):
    • The home-page link targets the language’s root page; the Site locales list 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 /docs lookup.
  • Shared chrome: in the shared chrome 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 its html URL equals the home page’s. The placeholders carry data-chrome-donor, the home page’s html URL, where the client fetches the chrome; the navbar’s also carries data-canonical-path, the page’s own html URL, 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 /docs section, a Site lookup falls back to a site-wide lookup by name, which fails the build on two pages named docs (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 html output 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 html URL as the reference: Hugo computes it under every URL setting (baseURL paths, language prefixes, multihost, site-wide uglyURLs, 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.Sections for a section at the home’s URL depends on Hugo’s listing rules: a section page with build.list: never publishes but is not listed.
  • A configuration switch would duplicate what permalinks already 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.

CaseRoot page and consequencesEvidence
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 siteThe 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 projectOne 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 fileHugo’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 subpathBoth 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 uglyURLsBoth 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 configuredThe home publishes no file; root-level llms.txt and discovery require LLMS on the docs section page.llms-doc-rooted
Root page without LLMSNo 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 sectionIts 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 docs remapped to the site root: the lookup keeps the home, so llms.txt discovery 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 /docs section: it has no root page, so llms.txt lists 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 with HTML: a link-only page keeps only its first configured format, so the home has no html URL 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.txt discovery follows the home’s kept formats, present only when LLMS is among them. The same holds for build.render: never and an empty outputs.home, where llms.txt’s home and locale links are empty too (the docs section page still publishes its llms.txt). Hugo’s outputs convention is HTML first.
  • A docs section page whose outputs leave out HTML: it is neither selected nor a donor.
  • uglyURLs set for the docs section only (uglyURLs: { docs: true }): the section page publishes at /index.html while the home page, in no section, keeps /, so the URLs differ and the home stays the root page, with no discovery and no donor.
  • HTML marked notAlternative: the donor page’s other renders then carry the chrome too (the current-render test is “HTML is 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

Why body-end scripts load through a dispatcher and a config-merged plugins map

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.js or offline-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.html and _partials/scripts/algolia.html: real partials as of 0.18, replacing inline defines whose documented override paths did not work (the internal template names algolia/head and algolia/scripts no 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’s version).
  • 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 version and the plugin’s own settings on options, not under a top-level params.NAME.* key:
    • One home per plugin, one environment-override prefix; a migrated package’s old params.NAME namespace 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.
    • options is 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 schema format, 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.
  • 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 _merge is 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.yaml declares the entry contract once, for the loop and the docs alike. Enforcement stays hand-coded in the loop: Hugo offers no validation for params, and no surveyed theme validates site params (Hinode’s data-driven Args.html covers 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 for hasMath; a site widens a gate by setting the flag from hooks/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 | page on 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 print output format for sections renders descendants’ .Content under 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.Store flags 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 calls run() itself (Mermaid’s documented integration; its load-bound auto-start could fire before a dynamic import settles), waiting for load so 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.

3 - Semantic classes

Naming, state-styling, and framework-binding conventions for the theme’s td- CSS classes

For 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 @extend can’t reference. Each written-out rule carries a BS mirror: FILE SELECTOR comment; from the repo root, grep -rn 'BS mirror:' theme/assets/scss/td/ inventories the mirrored rule bodies to re-check on a Bootstrap upgrade.