# Hugo Template Dev

> Hugo template development for InfluxData docs-v2, enforcing build and runtime testing to catch template errors that build-only validation misses. Use when creating or editing Hugo layouts, partials, or shortcodes, debugging template errors, or accessing site data in templates.

- Skill: `influxdata/hugo-template-dev` (Agent Skill)
- Install (CLI): `npx skillmds@latest add influxdata/hugo-template-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/influxdata/hugo-template-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: influxdata (https://skillmd.com/u/influxdata)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/influxdata/hugo-template-dev

---


# 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**

```bash
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 partial` messages
- No `can't evaluate field` errors
- No `template: ... failed` messages
- 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**

```bash
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**

```bash
pkill -f "hugo server --port 1315"
```

### Quick Test Command

Use this one-liner to test and get immediate feedback:

```bash
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:

```bash
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:**

1. **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:

   ```bash
   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'))
   EOF
   ```

2. **Schema (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-`FAQPage` nodes.

3. **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):**

```go
{{ .Site.Data.my-data.influxdb }}
{{ .Site.Data.article_data.dataKey }}
```

**Correct:**

```go
{{ index .Site.Data "my-data" "influxdb" }}
{{ index .Site.Data "article_data" $dataKey }}
```

### 2. Nil Field Access

**Wrong:**

```go
{{ range $articles }}
  {{ .path }}  {{/* Fails if item is nil or wrong type */}}
{{ end }}
```

**Correct:**

```go
{{ range $articles }}
  {{ if . }}
    {{ with index . "path" }}
      {{ . }}
    {{ end }}
  {{ end }}
{{ end }}
```

### 3. Type Assertion on Interface{}

**Wrong:**

```go
{{ range $data }}
  {{ .fields.menuName }}
{{ end }}
```

**Correct:**

```go
{{ 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 `{}`:

```go
{{/* This doesn't catch empty maps */}}
{{ if $data }}
  {{ .field }}  {{/* Still fails if $data is {} */}}
{{ end }}
```

**Solution:** Check for specific keys:

```go
{{ if and $data (isset $data "field") }}
  {{ index $data "field" }}
{{ end }}
```

## Hugo Data Access Patterns

### Safe Nested Access

```go
{{/* 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

```go
{{ 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`, not `nav.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:

```bash
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`):

```go
{{ $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:**

```go
{{ $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`:

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

```go
{{ $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.

```go
{{ $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:**

```html
{{/* DON'T DO THIS */}}
<nav class="api-nav">
  {{ range $articles }}
    <button onclick="toggleSection('{{ .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`):

```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`):

```typescript
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`:

```javascript
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:

```html
<div
  data-component="api-toc"
  data-headings="{{ .headings | jsonify | safeHTMLAttr }}"
  data-scroll-offset="80"
>
</div>
```

TypeScript:

```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:

```html
{{/* 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:

1. [ ] Create TypeScript file in `assets/js/components/`
2. [ ] Define interface for component options
3. [ ] Export default initializer function
4. [ ] Register in `main.js` componentRegistry
5. [ ] Add `data-component` attribute to HTML element
6. [ ] Pass data via `data-*` attributes (not inline JS)
7. [ ] **NO inline `<script>` tags in templates**

## Debugging Templates

### Enable Verbose Mode

```bash
npx hugo server --port 1315 --verbose 2>&1 | head -100
```

### Print Variables for Debugging

```go
{{/* Temporary debugging - REMOVE before committing */}}
<pre>{{ printf "%#v" $myVariable }}</pre>
```

### Check Data File Loading

```bash
# 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:

```yaml
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

```yaml
- 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

1. **Never trust `npx hugo --quiet` alone** - it only checks syntax
2. **Always run the server** to test template changes
3. **Check error output first** before declaring success
4. **Use `isset` and `index`** for safe data access
5. **Hyphenated keys require `index` function** - dot notation fails
6. **No product names in template logic** - put the fact in `data/products.yml`
   and resolve the product with the `product/get-context.html` partial

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

