Version imprimable multipages. Cliquer ici pour imprimer.
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 an option and not
a top-level
params.NAME.*key:- Plugin settings share one key and one environment-override prefix.
- Keeping the pin outside
optionskeeps it out of the built JavaScript, which never reads 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
- Neutral weight group: the emission-order contract uses zero
as the normal group, leaving room for earlier and later plugins without an
automode or dependency graph. Sorting ties by name keeps output reproducible, but is an implementation detail, not a dependency guarantee. - 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.