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 - Script loading

Why body-end scripts load through a dispatcher and a config-merged plugin registry

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 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.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.

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’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 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 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.

2 - 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.