# Script loading

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

---

Site [llms.txt](/fr/llms.txt)

---

Docsy loads its body-end JavaScript through
[`_partials/scripts.html`][scripts.html]: a small dispatcher over per-feature
sub-partials under [`_partials/scripts/`][scripts-dir]. (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](#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](#registry-shape), [implementation notes][impl]).
- **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](#gating-decisions)).

## 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](#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 `define`s 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][ug-files]).

## The plugin loop {#plugin-loop}

[`scripts/plugins.html`][plugins.html] emits each eligible plugin registered in
`params.docsy.plugins`. For the configuration reference and plugin file
contract, see the [plugins guide][ug-plugins]; for the loop's mechanics, the
[implementation notes][impl].

### Registry shape: a map, layered by Hugo's config merge {#registry-shape}

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][ug-config-merge]), 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][hugo-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][ug-loading]): 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][impl-shims]).
- **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][ug-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][impl-security]).
- **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][ug-flags]). 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][ug-markmap-render]). 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.

## Related pages

- [Implementation: script loading][impl]
- [Quality notes][quality]: the test nets that pin this behavior

<!-- prettier-ignore-start -->
[#2789]: https://github.com/docsy/docsy/issues/2789
[hugo-case]: https://github.com/gohugoio/hugo/issues/7483
[impl]: /project/implementation/script-loading/
[impl-shims]: /project/implementation/script-loading/#shims
[impl-security]: /project/implementation/script-loading/#security-constraints
[plugins.html]: https://github.com/docsy/docsy/blob/main/theme/layouts/_partials/scripts/plugins.html
[quality]: /project/quality/script-loading/
[ug-config-merge]: /docs/content/configuration/#theme-defaults-and-your-overrides
[ug-flags]: /docs/content/plugins/#page-flags-in-included-content
[ug-loading]: /docs/content/plugins/#loading-strategy
[ug-shims]: /docs/content/plugins/#adjust-a-plugin-per-page
[ug-markmap-render]: /docs/content/diagrams-and-formulae/#when-a-markmap-doesnt-render
[ug-files]: /docs/content/plugins/#plugin-files
[ug-plugins]: /docs/content/plugins/
[scripts-dir]: https://github.com/docsy/docsy/blob/main/theme/layouts/_partials/scripts/
[scripts.html]: https://github.com/docsy/docsy/blob/main/theme/layouts/_partials/scripts.html
<!-- prettier-ignore-end -->
