← all posts · hugo

Validating Hugo front matter with node:test

A lightweight, zero-dependency test that walks your Hugo content tree and catches broken image paths before they reach production.

01 May 2026 · 3 min read · Stephen Masters hugotestingdevops

The silent failure problem

Hugo doesn’t error on a missing image in front matter. If image: /images/articles/2025/foo/hero.jpg refers to a file that doesn’t exist, the build succeeds, the template gets nil back from resources.Get, and the page renders without a hero image. No warning. No clue.

On a site with dozens of articles and hundreds of image references, a single mistyped path is easy to miss. It might go live, or it might sit there broken until someone notices the blank space in a browser.


The fix: a one-file test

Node 24 includes a built-in test runner — node:test — that needs no framework, no config, and no additional dependencies. A single file can walk the entire content tree and fail fast on any broken reference.

javascript
1234567891011121314151617181920212223242526272829303132333435363738394041
// tests/content-images.test.mjs
import { test }        from 'node:test';
import assert          from 'node:assert/strict';
import { readFile }    from 'node:fs/promises';
import { existsSync }  from 'node:fs';
import { glob }        from 'node:fs/promises';
import { join, resolve } from 'node:path';

const ROOT      = resolve(import.meta.dirname, '..');
const ASSETS    = join(ROOT, 'assets');
const CONTENT   = join(ROOT, 'content');

function extractPaths(yaml) {
  const paths = [];
  // image: /images/articles/...
  const image = yaml.match(/^image:\s*(.+)$/m);
  if (image) paths.push(image[1].trim());
  // thumbnail:
  //   url: /images/articles/...
  const thumb = yaml.match(/^\s+url:\s*(.+)$/m);
  if (thumb) paths.push(thumb[1].trim());
  return paths;
}

test('all front matter image references resolve to existing files in assets/', async () => {
  const files = await Array.fromAsync(glob('articles/**/*.md', { cwd: CONTENT }));
  const broken = [];

  for (const rel of files) {
    const src = await readFile(join(CONTENT, rel), 'utf8');
    const match = src.match(/^---\n([\s\S]*?)\n---/);
    if (!match) continue;

    for (const ref of extractPaths(match[1])) {
      const abs = join(ASSETS, ref.replace(/^\//, ''));
      if (!existsSync(abs)) broken.push(`${rel}: ${ref}`);
    }
  }

  assert.deepEqual(broken, [], `Broken image references:\n${broken.join('\n')}`);
});

Run it:

bash
1
node --test tests/content-images.test.mjs

Output when everything passes:

text
1
✔ all front matter image references resolve to existing files in assets/ (10ms)

Output when something’s broken:

text
123
✗ all front matter image references resolve to existing files in assets/
  AssertionError: Broken image references:
  articles/2025/canal-des-deux-mers/2025-09-05_cdm_day_05/index.md: /images/articles/2025/cdm/cdm_day_05/hero.jpg

Integrating with the rest of your tests

Add it to package.json:

json
123
"scripts": {
  "test:content-images": "node --test tests/content-images.test.mjs"
}

If you’re using Playwright, exclude it from Playwright’s discovery — it’s a node:test file, not a Playwright spec, and Playwright will try to run it as one if it matches the filename pattern:

typescript
12345
// playwright.config.ts
export default defineConfig({
  testIgnore: ['**/content-images.test.mjs'],
  // …
});

Run order: this test needs no server and no build, so it fits alongside ESLint and Stylelint in the fast, server-free check stage — run it before the Playwright tests that require a running dev server.


Extending it

The same pattern extends to any front-matter field that references a file. GPX tracks, thumbnail images, og:image overrides — add a regex for each field and a file-existence check. The test stays fast regardless of how many fields you add, because it’s just filesystem lookups, not HTTP requests or Hugo builds.

For a site with structured YAML front matter, you could replace the regex extraction with a proper YAML parser (js-yaml or yaml), but the regex approach covers the common simple cases without any extra dependency.

SM
Stephen Masters

Software developer and architect. I build systems for places that move energy, commodities, and money around. I keep a bike-packing journal at velostevie.com.