Defining and validating reusable arguments in Hugo

Hinode is a Hugo theme in which most shortcodes wrap a partial that handles the actual rendering. This lets the same component be used from Markdown content and from layout templates, but it also means the component’s arguments need to stay consistent across both entry points. Here’s the approach I’ve settled on for keeping argument definitions, defaults, and documentation aligned.

A component may accept a title, a color, a few boolean flags, and eventually some nested configuration. Each argument brings questions: Is it required? What type does it accept? Which values are permitted? Where does its default come from?

Hinode describes these rules in YAML data files and uses a shared utility partial to validate and initialize the arguments. The same definitions also supply the argument tables in the documentation.

The examples below use a simplified notice component to illustrate the conventions. The validator is provided by Hinode’s mod-utils module.

Define the arguments once

Start with a definition in data/structures/notice.yml:

arguments:
  message:
    type: string
    optional: false
    comment: Text displayed in the notice.
  tone:
    type: select
    optional: true
    default: info
    options:
      values: [info, warning, success]
    comment: Visual style of the notice.
  dismissible:
    type: bool
    optional: true
    default: false
    comment: Whether the notice can be dismissed.

This gives the component an explicit contract. message is required, tone accepts a limited set of strings, and dismissible defaults to false.

Validate and initialize before rendering

The rendering partial can validate its input before using it:

{{ $result := partial "utilities/Args.html" (dict
    "structure" "notice"
    "args" .
    "group" "partial"
) }}

{{ range $result.warnmsg }}
    {{ warnf "%s" . }}
{{ end }}

{{ if $result.err }}
    {{ errorf "notice: %s" (delimit $result.errmsg "; ") }}
{{ else }}
    {{ $args := $result.args }}
    <aside class="notice notice-{{ $args.tone }}{{ if $args.dismissible }} notice-dismissible{{ end }}">
        {{ $args.message }}
    </aside>
{{ end }}

The utility returns the initialized arguments separately from errors, warnings, and information about applied defaults. The caller decides how to report problems. Shortcodes have access to .Position, so a shortcode that validates its own .Params can point straight at the offending call in content:

{{ if $result.err }}
    {{ errorf "%s: %s" .Position (delimit $result.errmsg "; ") }}
{{ end }}

Hinode supports named and positional shortcode arguments, with positions declared in the definitions. Argument groups decide which arguments are required for each entry point. The shortcode receives its message through .Inner, while the underlying partial expects a message argument. Marking message with group: partial makes it required for partial calls only, so a shortcode call without it doesn’t fail validation.

Hinode’s args shortcode reads the same descriptions and permitted values to generate documentation tables, such as the arguments of the alert shortcode.

Reuse definitions and override where needed

As the component library grows, shared definitions become useful. Several components might accept the same tone argument, with the same permitted values and description. Hinode keeps reusable argument definitions in data/structures/_arguments.yml, while each component declares the arguments it accepts and any local overrides:

# data/structures/_arguments.yml
arguments:
  tone:
    type: select
    optional: true
    default: info
    options:
      values: [info, warning, success]
    comment: Visual style of the component.
# data/structures/notice.yml
arguments:
  message:
    type: string
    optional: false
  tone:
    default: warning
  dismissible:
    type: bool
    optional: true
    default: false

Here, the notice inherits the shared definition of tone but chooses its own default. Other components can reuse the same argument without making that choice.

One lesson from overrides is that merging definitions needs more thought than simply merging maps. If a component changes an argument from a numeric type to a string selection, an inherited numeric range or default may become invalid. Hinode clears inherited value-resolution fields and constraints when a local definition changes the type.

There are also two different precedence rules to keep clear. A component’s definition overrides the shared definition. At runtime, an explicitly supplied value takes precedence over a default. Keeping those rules predictable makes reuse much easier.

Validate structured arguments recursively

Structured arguments add another dimension. Consider a heading with a title and alignment. Hinode defines such shapes in data/structures/_types.yml:

types:
  notice-heading:
    title:
      type: string
      optional: true
    align:
      type: select
      optional: true
      default: start
      options:
        values: [start, center, end]

Type members draw on the same shared definitions as component arguments. A member with an empty value, such as a bare align:, inherits its definition from _arguments.yml. Hinode’s own heading type is mostly a list of such names.

The notice can then declare an optional heading argument in data/structures/notice.yml:

heading:
  type: notice-heading
  optional: true

This entry belongs under the file’s arguments key, alongside message, tone, and dismissible.

These definitions can reference other structured types, including lists of objects. In Hinode, for example, a list of locations can contain a list of instructions within each location. Validation follows those references recursively.

Checking only that heading is a map would miss an invalid alignment or an unexpected member. Recursive validation checks the individual members, applies their defaults, and reports problems using paths such as heading.align. Those paths are much more useful than a general “invalid heading” message.

Distinguish missing values from explicit values

Defaults brought another important lesson: an absent value and a false value require different handling.

A caller who passes dismissible: false has made a choice. The same applies to 0, or an empty string where the declared type permits it. The usual fallback idioms don’t all respect that choice:

{{/* An explicit false becomes true */}}
{{ $dismissible := or .dismissible true }}

{{/* An explicit 0 becomes 3; the default function keeps false, but treats 0 and "" as unset */}}
{{ $columns := .columns | default 3 }}

The same applies to if and with, which treat false, 0, and "" as empty.

Hugo’s isset function checks whether a key exists rather than whether its value is truthy. It doesn’t settle the question on its own, though: a key that is present with a null value counts as set. This matters especially for CMS-generated data, where untouched fields are often present with null values. Hinode therefore treats absent and null values alike as eligible for defaulting, while explicitly supplied false, 0, and "" values go through validation.

One more isset caveat: its key lookup is case-sensitive, whereas field access is not. Hugo lowercases page parameter keys, so isset .Params "showToc" returns false even when .Params.showToc returns true.

For configuration-backed defaults, the resolution order is: caller input, the configured site parameter, then the static default. An explicit false in the site configuration must survive that lookup.

Cache the schema

Finally, there is a useful opportunity for caching. The argument definitions usually stay the same across many component calls; the supplied values change each time.

Hinode separates schema compilation from input validation. It caches the compiled schema with partialCached, and each invocation then validates its own input against that schema. One detail is easy to miss: partialCached keys its cache on the partial name and the variant arguments only, never on the context. Every input that changes the schema must therefore be passed as a variant. In Hinode, those are the structure name plus two Hinode-specific inputs: a Bookshop component and a child structure.

Test argument behavior independently

I also found it useful to test the argument system independently of rendered HTML. Declarative cases provide inputs, a Hugo test layout writes the results as JSON, and golden files capture the expected behavior. This makes changes to defaults, overrides, casting, and nested validation visible without relying solely on whether an example site builds.

When the approach pays off

There is a maintenance cost to introducing this infrastructure. For a handful of simple shortcodes, local checks may be enough. Across a component library, I find the shared definitions valuable: they give validation, initialization, and documentation a common foundation.

The partial-development guide shows how Hinode applies these conventions, and the mod-utils documentation describes the current API.

I’d be interested to hear how other Hugo theme and module authors keep argument validation, defaults, and documentation aligned, particularly when components share arguments or accept nested data.

Impressive write-up, and I can relate to the testing part: I recently set up something similar for the Relearn theme, a set of small sites that each permute one feature, with the rendered output compared against golden files and any unexpected warning failing the build.

For the arguments themselves I went the opposite way: no schema. Each shortcode is a thin wrapper that maps its parameters to a dict and passes it to a partial of the same name, which applies its defaults with plain default calls. The documentation tables are written by hand.

Looking back, type validation was never an issue. Parameter names and allowed values change quite often. What I end up writing is migration instructions: a warning in the code that links to the release notes telling you what to do. Almost every warning the shortcodes emit is of that kind. Wrong values mostly fall back to something sensible instead of failing the build.

So my honest take is that a typed, inheritable, recursively validated schema in Go templates solves a problem I haven’t had and haven’t heard about from users. It comes with its own cost: it needs caching, tests of its own and explaining to contributors.

Thanks, and that’s a fair challenge. You’re right that type errors are rare in hand-written content; that’s not what made this worthwhile for us. Two things did. First, Hinode’s blocks are editable in CloudCannon’s visual editor, which sends every untouched field as null and every number as a float. Plain default calls can’t tell those apart from explicit values, and they swallow an explicit 0 or "". Second, the schema covers the migration warnings you describe: an argument marked deprecated with an alternative produces that warning without per-shortcode code. It also flags misspelled arguments, which Hugo otherwise ignores silently.

The cost is real, though. It needed caching, its own test suite, and a staged rollout. For a single theme without a CMS layer, I’d probably choose your approach too. I’d be curious how your permutation sites are set up; failing the build on any unexpected warning sounds like a nice guard.

The CMS layer explains it, thanks.

On the setup: my test suite lives in its own repository, hugo-theme-relearn-infra, so nothing of it is shipped to theme users. It’s a small Node runner around Hugo. You can read more about the details in the repo’s README.

A site is a handful of pages around one feature. A case names a site and an environment, and can vary a configuration axis, where each value is just a config directory. The URL case for example builds the same content with every combination of relative, absolute and ugly URLs and stores one result per combination. Each build is checked in three layers: it exits without unexpected warnings, it generates exactly the expected set of files, and every file matches the stored output. A testing environment switches off everything that makes the output differ between builds, like asset hashes and random ids, so a plain comparison is enough.

The warning guard is a text file of substrings. A WARN or ERROR that matches none of them fails the run. Each entry is an accepted defect and gets deleted once its issue is fixed, so the same warning fails again if it comes back.

Full disclosure: I vibe-coded this together with Claude Code, taking me a few days to get right. The concept was the difficult part, coding was done entirely by Claude. It’s definitely not perfect, but works and meets my requirements.

I had put off automated tests for years because I didn’t want to write and maintain the machinery around them, and with an agent that part became cheap. What stayed with me is deciding what is worth testing and reading the diff after each baseline update, because that diff is the actual test result - although both can also be done by an agent.

Thanks for the detail. The warning allowlist is the part I’ll borrow first: we have warnings-first deprecations in flight and nothing currently stops a new warning from slipping in next to them. And agreed on the diff being the real test result; that’s been my experience with the goldens here too.