Hugo Template Development Skill
Purpose
This skill enforces proper Hugo template development practices, including mandatory runtime testing to catch errors that static builds miss.
Critical Testing Requirement
Hugo's npx hugo --quiet only validates template syntax, not runtime execution.
Template errors like accessing undefined fields, nil values, or incorrect type assertions only appear when Hugo actually renders pages. You MUST test templates by running the server.
Mandatory Testing Protocol
For ANY Hugo Template Change
After modifying files in layouts/, layouts/partials/, or layouts/shortcodes/:
Step 1: Start Hugo server in the background and capture output
rm -f /tmp/hugo-1315.log
npx hugo server --port 1315 >/tmp/hugo-1315.log 2>&1 &
sleep 5
head -50 /tmp/hugo-1315.log
This keeps the server running for the next steps while still showing startup output.
Success criteria:
- No
error calling partialmessages - No
can't evaluate fielderrors - No
template: ... failedmessages - Server shows "Web Server is available at http://localhost:1315/"
If errors appear: Fix the template and repeat Step 1 before proceeding.
Step 2: Verify the page renders
curl -s -o /dev/null -w "%{http_code}" http://localhost:1315/PATH/TO/PAGE/
Expected: HTTP 200 status code
Step 3: Browser testing (if MCP browser tools available)
If mcp__claude-in-chrome__* tools are available, use them for visual inspection:
# Navigate and screenshot
mcp__claude-in-chrome__navigate({ url: "http://localhost:1315/PATH/", tabId: ... })
mcp__claude-in-chrome__computer({ action: "screenshot", tabId: ... })
# Check for JavaScript errors
mcp__claude-in-chrome__read_console_messages({ tabId: ..., onlyErrors: true })
This catches runtime JavaScript errors that template changes may introduce.
Step 4: Stop the test server
pkill -f "hugo server --port 1315"
Quick Test Command
Use this one-liner to test and get immediate feedback:
rm -f /tmp/hugo-1315.log
npx hugo server --port 1315 >/tmp/hugo-1315.log 2>&1 &
sleep 5
grep -E "(error|Error|ERROR|fail|FAIL)" /tmp/hugo-1315.log | head -20
pkill -f "hugo server --port 1315" 2>/dev/null
If output is empty, no errors were detected.
Preparing template changes for PR review
The repo's preview workflow (.github/workflows/pr-preview.yml) deploys the
whole built site to a stable staging URL
(https://test2.docs.influxdata.com/pr-preview/pr-<N>/) on every push — a
hosted preview that replaces "check out my branch and run npx hugo server"
for reviewers. It builds with --environment production, so it reflects
production behavior (minification, fingerprinted/SRI JS, real analytics).
Subdirectory-baseURL product detection is handled by
layouts/partials/base-path-offset.html, not a per-environment config.
When you change layouts/, assets/, or data/, list the pages the
reviewer should focus on in the PR body — this doesn't affect what deploys
(the whole site always does), but it adds deep links to the sticky preview
comment so reviewers don't have to navigate manually. The URL extractor
(.github/scripts/parse-pr-urls.js) matches:
- Production URLs (
https://docs.influxdata.com/<path>) - Localhost URLs (
http://localhost:1313/<path>) - Bare paths with a known product namespace from
data/products.yml(/influxdb3/...,/telegraf/..., etc.)
URLs inside fenced code blocks are stripped before extraction — list them as bare paths or markdown links, not inside backtick fences. Pair each URL with an "Expected" column (DOM element, attribute value, copy) so the reviewer knows what to verify rather than guessing.
For wider behavioral coverage (interactive UI, JS errors, navigation), prefer the cypress-e2e-testing skill. The preview-pages mechanism is the right tool for visual / structural verification — exactly the cases where Cypress is overkill or doesn't cover what changed.
Autodiscovery coherence guard (when touching Markdown-alternate paths)
If your template change affects <link rel="alternate" type="text/markdown">,
the /sitemap-md.xml layout, the /llms.txt template, or any of the inputs
to scripts/lib/corpus-paths.js (notably data/products.yml), run after the
full build:
npx hugo --quiet && yarn build:md && yarn build:llms-full && yarn check:md-coherence
check:md-coherence runs two coherence checks: head-link → .md file
existence, and Hugo /llms.txt ↔ getCorpusPaths() agreement. Catches drift
between Hugo template logic and the JS derivation from products.yml.
See DOCS-TESTING.md "Autodiscovery coherence guard" for details.
Validating structured data (JSON-LD)
The layouts/partials/header/*-jsonld.html partials emit schema.org JSON-LD
(Organization, TechArticle, SoftwareApplication, FAQPage). When you add
or change one:
Use the Schema Markup Validator (https://validator.schema.org), NOT the
Google Rich Results Test.
The Rich Results Test only reports types eligible for a visual search enhancement. Most JSON-LD this repo emits is not eligible, so the Rich Results Test reports "no items detected" even for valid markup — a false negative that looks like failure:
| Emitted type | Rich Results Test | Why |
|---|---|---|
Organization |
Not reported | Feeds the knowledge graph / entity resolution, never a rich result |
TechArticle |
Not reported | Google's Article rich result fires only for Article/NewsArticle/BlogPosting |
SoftwareApplication |
Not reported | Google retired the general software-app rich result |
FAQPage |
Reported | One of the few eligible types here |
validator.schema.org validates every schema.org type regardless of
rich-result eligibility — that's what confirms the node is well-formed.
Validation steps:
Structural (local, scriptable): parse the emitted block to prove it's valid JSON and schema-shaped. The minifier emits
type=application/ld+json(unquoted) — match the attribute loosely:python3 - <<'EOF' import re, json html = open('public/index.html', encoding='utf-8', errors='replace').read() for b in re.findall(r'<script type=["']?application/ld\+json["']?>(.*?)</script>', html, re.S): j = json.loads(b) # raises on malformed JSON print('OK', j.get('@type')) EOFSchema (manual, reviewer): paste a deployed preview URL into
validator.schema.org; expect 0 errors. Note this in the PR description and do not ask reviewers to use the Rich Results Test for non-FAQPagenodes.Regression (Cypress): assertions depend on scope. Page-scoped nodes (
TechArticle/SoftwareApplication) — assert presence where they belong and absence where they don't (over-emission guard). Global nodes (Organization, emitted site-wide with a stable@id) — assert exactly one per page class, which catches both omission and duplicates. See the cypress-e2e-testing skill, "Testing structured data (JSON-LD)".
Common Hugo Template Errors
1. Accessing Keys with Hyphens or Dynamic Names
Hugo's dot notation only works for keys that are valid Go identifiers
(letters, digits, underscores). This repo's data dir is article_data
(underscore), so .Site.Data.article_data works. But a hyphenated key — or a
key held in a variable — must use index:
Wrong (hyphen breaks dot notation; dataKey is a variable):
{{ .Site.Data.my-data.influxdb }}
{{ .Site.Data.article_data.dataKey }}
Correct:
{{ index .Site.Data "my-data" "influxdb" }}
{{ index .Site.Data "article_data" $dataKey }}
2. Nil Field Access
Wrong:
{{ range $articles }}
{{ .path }} {{/* Fails if item is nil or wrong type */}}
{{ end }}
Correct:
{{ range $articles }}
{{ if . }}
{{ with index . "path" }}
{{ . }}
{{ end }}
{{ end }}
{{ end }}
3. Type Assertion on Interface{}
Wrong:
{{ range $data }}
{{ .fields.menuName }}
{{ end }}
Correct:
{{ range $data }}
{{ if isset . "fields" }}
{{ $fields := index . "fields" }}
{{ if isset $fields "menuName" }}
{{ index $fields "menuName" }}
{{ end }}
{{ end }}
{{ end }}
4. Empty Map vs Nil Check
Problem: Hugo's {{ if . }} passes for empty maps {}:
{{/* This doesn't catch empty maps */}}
{{ if $data }}
{{ .field }} {{/* Still fails if $data is {} */}}
{{ end }}
Solution: Check for specific keys:
{{ if and $data (isset $data "field") }}
{{ index $data "field" }}
{{ end }}
Hugo Data Access Patterns
Safe Nested Access
{{/* Build up access with nil checks at each level */}}
{{ $articleDataRoot := index .Site.Data "article_data" }}
{{ if $articleDataRoot }}
{{ $influxdbData := index $articleDataRoot "influxdb" }}
{{ if $influxdbData }}
{{ $productData := index $influxdbData $dataKey }}
{{ if $productData }}
{{ with $productData.articles }}
{{/* Safe to use . here */}}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
Iterating Over Data Safely
{{ range $idx, $item := $articles }}
{{/* Declare variables with defaults */}}
{{ $path := "" }}
{{ $name := "" }}
{{/* Safely extract values */}}
{{ if isset $item "path" }}
{{ $path = index $item "path" }}
{{ end }}
{{ if $path }}
{{/* Now safe to use $path */}}
{{ end }}
{{ end }}
File Organization
Layouts Directory Structure
layouts/
├── _default/ # Default templates
├── partials/ # Reusable template fragments
│ └── api/ # API-specific partials
├── shortcodes/ # Content shortcodes
└── TYPE/ # Type-specific templates (api/, etc.)
└── single.html # Single page template
Partial Naming
- Use descriptive names:
api/sidebar-nav.html, notnav.html - Group related partials in subdirectories
- Include comments at the top describing purpose and required context
No Magic Values in Template Logic
Principle: Templates operate on data and stay ignorant of the values in that data. A product name, version segment, or data/products.yml key must never appear as a string literal in template logic. Nobody should have to edit a template because a product was renamed or added.
Why this rule exists
header/coveo-meta-data.html and header/search-attributes.html both answered the same question — does this page document the current version of its product? — and each answered it with its own hardcoded list of version segments. The lists drifted. explorer and controller made it into the Algolia list but not the Coveo list, so InfluxDB 3 Explorer and Telegraf Controller were indexed as current by one search system and as stale by the other. Neither list was wrong on its face. The duplication was.
What counts as a magic value
| Pattern | Example |
|---|---|
| A list of product names or version segments | {{ $alwaysLatest := slice "cloud" "core" "enterprise" }} |
| A hardcoded comparison that branches | {{ if eq $product "platform" }} |
| An exclusion list | {{ if not (in (slice "chronograf" "kapacitor") $product) }} |
| A key inferred from the URL | {{ findRE "[^/]+.*?" .RelPermalink }} to build a products key |
Find them with:
grep -rnE '(slice|in |eq |ne )[^}]*"(core|enterprise|cloud|clustered|explorer|controller|platform|resources|influxdb|telegraf|chronograf|kapacitor|flux)' layouts/ --exclude=AGENTS.md
Not every hit is a violation. String literals in class names, URLs, and display text are fine. The rule is about branching on product identity.
Fix 1: Move the fact into products.yml
Name the field after the fact, not the product, and choose the default so only
the exceptions need the field.
Before (layouts/partials/footer/search.html):
{{ $productPathData := findRE "[^/]+.*?" .RelPermalink }}
{{ $product := index $productPathData 0 }}
{{ $version := index $productPathData 1 }}
{{ $fluxSupported := slice "influxdb" "enterprise_influxdb" }}
{{ $influxdbFluxSupport := slice "v1" "v2" "cloud" }}
{{ $includeFlux := and (in $fluxSupported $product) (in $influxdbFluxSupport $version) }}
{{ $includeResources := not (in (slice "cloud-serverless" "cloud-dedicated" "clustered" "core" "enterprise" "explorer") $version) }}
After:
{{ $ctx := partial "product/get-context.html" . }}
{{/*
Both flags come from data/products.yml so adding a product never requires
editing this template.
*/}}
{{ $includeFlux := $ctx.data.supports_flux | default false }}
{{ $includeResources := $ctx.data.search_includes_resources | default true }}
data/products.yml:
influxdb:
supports_flux: true
influxdb3_core:
search_includes_resources: false
Fix 2: Resolve the product from the page, not the path
layouts/partials/product/get-data.html and
layouts/partials/product/get-context.html read the page's cascade product
param. Every product section declares product and version by cascade in its
section _index.md, so the key is stated rather than guessed.
{{ $productData := partial "product/get-data.html" . }}
{{ $ctx := partial "product/get-context.html" . }}
{{ $ctx.key }} {{/* "influxdb3_cloud_dedicated" */}}
{{ $ctx.data }} {{/* the products.yml entry */}}
{{ $ctx.product }} {{/* first path segment, for path-scoped rules */}}
Parsing .RelPermalink gets the key wrong under /influxdb3/, where the path
segment is influxdb3 but the keys are influxdb3_core, influxdb3_cloud, and
so on. It also breaks under a subpath-mounted baseURL, where the PR preview's
/pr-preview/pr-N/ prefix becomes the "product."
Fix 3: Extract a shared decision into one partial
When two templates need the same answer, give them one partial to call.
layouts/partials/product/is-latest.html is the worked example: both search
templates now call it, so they can't disagree about which pages are current.
{{ $isLatest := partial "product/is-latest.html" . }}
The one exception
A value that must match an external system rather than a product fact stays as
it is. The Algolia search tag in header/search-attributes.html is path-derived
because Algolia indexed every record under the crawled URL, and changing the tag
would orphan those records. Comment any such case in the template so the next
reader doesn't "fix" it.
Separation of Concerns: Templates vs TypeScript
Principle: Hugo templates handle structure and data binding. TypeScript handles behavior and interactivity.
What Goes Where
| Concern | Location | Example |
|---|---|---|
| HTML structure | layouts/**/*.html |
Navigation markup, tab containers |
| Data binding | layouts/**/*.html |
{{ .Title }}, {{ range .Data }} |
| Static styling | assets/styles/**/*.scss |
Layout, colors, typography |
| User interaction | assets/js/components/*.ts |
Click handlers, scroll behavior |
| State management | assets/js/components/*.ts |
Active tabs, collapsed sections |
| DOM manipulation | assets/js/components/*.ts |
Show/hide, class toggling |
Anti-Pattern: Inline JavaScript in Templates
Wrong - JavaScript mixed with template:
{{/* DON'T DO THIS */}}
<nav class="api-nav">
{{ range $articles }}
<button .id }}')">{{ .name }}</button>
{{ end }}
</nav>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('is-open');
}
</script>
Correct - Clean separation:
Template (layouts/partials/api/sidebar-nav.html):
<nav class="api-nav" data-component="api-nav">
{{ range $articles }}
<button class="api-nav-group-header" aria-expanded="false">
{{ .name }}
</button>
<ul class="api-nav-group-items">
{{/* items */}}
</ul>
{{ end }}
</nav>
TypeScript (assets/js/components/api-nav.ts):
interface ApiNavOptions {
component: HTMLElement;
}
export default function initApiNav({ component }: ApiNavOptions): void {
const headers = component.querySelectorAll('.api-nav-group-header');
headers.forEach((header) => {
header.addEventListener('click', () => {
const isOpen = header.classList.toggle('is-open');
header.setAttribute('aria-expanded', String(isOpen));
header.nextElementSibling?.classList.toggle('is-open', isOpen);
});
});
}
Register in main.js:
import initApiNav from './components/api-nav.js';
const componentRegistry = {
'api-nav': initApiNav,
// ... other components
};
Data Passing Pattern
Pass Hugo data to TypeScript via data-* attributes:
Template:
<div
data-component="api-toc"
data-headings="{{ .headings | jsonify | safeHTMLAttr }}"
data-scroll-offset="80"
>
</div>
TypeScript:
interface TocOptions {
component: HTMLElement;
}
interface TocData {
headings: string[];
scrollOffset: number;
}
function parseData(component: HTMLElement): TocData {
const headingsRaw = component.dataset.headings;
const headings = headingsRaw ? JSON.parse(headingsRaw) : [];
const scrollOffset = parseInt(component.dataset.scrollOffset || '0', 10);
return { headings, scrollOffset };
}
export default function initApiToc({ component }: TocOptions): void {
const data = parseData(component);
// Use data.headings and data.scrollOffset
}
Minimal Inline Scripts (Exception)
The only acceptable inline scripts are minimal initialization that MUST run before component registration:
{{/* Acceptable: Critical path, no logic, runs immediately */}}
<script>
document.documentElement.dataset.theme =
localStorage.getItem('theme') || 'light';
</script>
Everything else belongs in assets/js/.
File Organization for Components
assets/
├── js/
│ ├── main.js # Entry point, component registry
│ ├── components/
│ │ └── api-toc.ts # API table of contents behavior
│ └── utils/
│ └── dom-helpers.ts # Shared DOM utilities
└── styles/
└── layouts/
├── _api-layout.scss # API page layout (3-column, sidebar, TOC)
└── _api-operations.scss # Operation rendering (methods, params, responses)
TypeScript Component Checklist
When creating a new interactive feature:
- Create TypeScript file in
assets/js/components/ - Define interface for component options
- Export default initializer function
- Register in
main.jscomponentRegistry - Add
data-componentattribute to HTML element - Pass data via
data-*attributes (not inline JS) - NO inline
<script>tags in templates
Debugging Templates
Enable Verbose Mode
npx hugo server --port 1315 --verbose 2>&1 | head -100
Print Variables for Debugging
{{/* Temporary debugging - REMOVE before committing */}}
<pre>{{ printf "%#v" $myVariable }}</pre>
Check Data File Loading
# Verify data files exist and are valid YAML
cat data/article_data/influxdb3/core/api/articles.yml | head -20
Integration with CI/CD
Pre-commit Hook (Recommended)
Add to .lefthook.yml or pre-commit configuration:
pre-commit:
commands:
hugo-template-test:
glob: "layouts/**/*.html"
run: |
timeout 20 npx hugo server --port 1315 2>&1 | grep -E "error|Error" && exit 1 || exit 0
pkill -f "hugo server --port 1315" 2>/dev/null
GitHub Actions Workflow
- name: Test Hugo templates
run: |
npx hugo server --port 1315 &
sleep 10
curl -f http://localhost:1315/ || exit 1
pkill -f hugo
Quick Reference
| Action | Command |
|---|---|
| Test templates (runtime) | npx hugo server --port 1315 2>&1 | head -50 |
| Build only (insufficient) | npx hugo --quiet |
| Check specific page | curl -s -o /dev/null -w "%{http_code}" http://localhost:1315/path/ |
| Stop test server | pkill -f "hugo server --port 1315" |
| Debug data access | <pre>{{ printf "%#v" $var }}</pre> |
Remember
- Never trust
npx hugo --quietalone - it only checks syntax - Always run the server to test template changes
- Check error output first before declaring success
- Use
issetandindexfor safe data access - Hyphenated keys require
indexfunction - dot notation fails - No product names in template logic - put the fact in
data/products.ymland resolve the product with theproduct/get-context.htmlpartial
Related Resources
api-docs/README.md: API documentation workflow, tags.yml format, overlays, generation pipeline- cypress-e2e-testing skill: E2E testing of UI components and pages
- docs-cli-workflow skill: Creating/editing documentation content
- ts-component-dev agent: TypeScript component behavior and interactivity
- ui-testing agent: Cypress E2E testing for UI components