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.
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.
// 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:
node --test tests/content-images.test.mjsOutput when everything passes:
✔ all front matter image references resolve to existing files in assets/ (10ms)Output when something’s broken:
✗ 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.jpgIntegrating with the rest of your tests
Add it to package.json:
"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:
// 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.