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: Mermaid, MarkMap, tab persistence, and click-to-copy ride the plugin loop as theme-default registry entries (registry shape, 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.
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’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 registry 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 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). 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 a registry 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
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.