This is the multi-page printable view of this section. Click here to print.
Design
1 - 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: MarkMap, tab persistence, and click-to-copy ride the plugin loop as theme-default registry entries, their legacy params aliased for a deprecation cycle (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; Mermaid, whose pinned version is validated at build time while the browser imports the module straight from the CDN; 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
Mermaid and KaTeX (.Page.Store flags); MarkMap’s plugin shim carries the same
page-flag pattern (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.
Registry shape: a map, layered by Hugo’s config merge
The registry 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 dependency’s version pin is an entry field, not a top-level
params.NAME.*key:- Plugin settings share one key and one environment-override prefix.
- The pin never reaches the built JavaScript, which has no use for it.
- The loop validates the pin once, for every companion that builds a fetch URL from it.
- 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 registry entries.
Alternatives considered, and why not:
- A list of entries (the initial shape, superseded before release): lists are replaced, not merged, by Hugo’s config merge, so theme defaults had to live in template code and every override, turn-off, or duplicate needed loop logic, which grew 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 registry 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). MarkMap (hook-flagged) is 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 a registry field. The plugin’s hook sets a
flag and its shim reads it, the pairing the dispatcher uses for
hasmermaidandhasMath; 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).
Related pages
- Implementation: script loading
- Quality notes: the test nets that pin this behavior
2 - 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.