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.