Converting Front Matter at Scale During Migration
Moving content between static site generators is mostly moving Markdown — and the Markdown body usually moves unchanged. What changes is the front matter: Hugo's weight becomes Astro's order, Jekyll's permalink becomes a route, Docusaurus's sidebar_position becomes Starlight's sidebar.order, dates written three different ways over five years need to be one type, and a dozen fields nobody remembers adding need a decision. On a few pages you edit by hand. On 3,000 pages, hand edits are slow, inconsistent and impossible to review.
This guide converts the front matter of 3,140 Hugo pages to an Astro content collection with a script: a declarative field map, type normalisation, validation against the destination schema, and a summary that makes the change reviewable. It is part of Migrating Between Static Site Generators and was used in Migrating from Hugo to Astro Without Breaking URLs.
Prerequisites
- The source content in Git, so every change is a diff.
- The destination schema written down — for Astro, the
z.objectincontent.config.ts. - Node.js with the
yamlandgray-matterpackages, or Python withruamel.yaml.
Step 1: Inventory the Fields
Before writing a map, list every key actually used, its frequency and the types it appears as. Real content is always messier than the documented schema:
// scripts/fm-inventory.mjs
import matter from 'gray-matter';
import { globSync, readFileSync } from 'node:fs';
const stats = {};
for (const f of globSync('content/**/*.md')) {
const { data } = matter(readFileSync(f, 'utf8'));
for (const [k, v] of Object.entries(data)) {
const t = Array.isArray(v) ? 'array' : v instanceof Date ? 'date' : typeof v;
(stats[k] ??= {})[t] = (stats[k][t] ?? 0) + 1;
}
}
console.table(Object.entries(stats).sort((a, b) => sum(b[1]) - sum(a[1])).map(([k, t]) => ({ key: k, ...t })));
key string date number boolean array object
title 3140
date 94 3046
weight 2211 18
draft 412
tags 1860
aliases 1203
description 2804
toc 960
author 410 88
lastmod 12 1510
_build 6
Three problems surfaced immediately: 94 dates stored as strings in five different formats, weight sometimes a boolean (a template hack from 2021), and author both a string and an array. Six pages used Hugo's _build options, which have no Astro equivalent.
Step 2: Write a Declarative Field Map
Express the conversion as data, so it can be reviewed like configuration rather than read as code:
// scripts/fm-map.mjs
export const MAP = {
title: { to: 'title' },
description: { to: 'description' },
date: { to: 'pubDate', type: 'date' },
lastmod: { to: 'updatedDate', type: 'date' },
weight: { to: 'order', type: 'int', ifInvalid: 'drop' },
draft: { to: 'draft', type: 'bool' },
tags: { to: 'tags', type: 'string[]', transform: (v) => v.map((t) => t.toLowerCase().trim()) },
author: { to: 'authors', type: 'string[]' }, // string → [string]
aliases: { to: null, collect: 'redirects' }, // moved out of front matter
toc: { to: 'tableOfContents', type: 'bool' },
_build: { to: null, manual: true }, // flag for a human
};
The script applies the map, normalises types (five date formats parsed with explicit patterns, never new Date(string) guessing), collects aliases into a redirect file rather than front matter, and flags anything marked manual or not in the map at all.
// scripts/fm-convert.mjs (core)
const out = {}, notes = [];
for (const [k, v] of Object.entries(data)) {
const rule = MAP[k];
if (!rule) { notes.push(`unknown key ${k}`); continue; }
if (rule.manual) { notes.push(`manual: ${k}`); continue; }
if (rule.collect) { collected[rule.collect].push(...[].concat(v).map((from) => ({ from, to: url }))); continue; }
const val = coerce(v, rule.type);
if (val === INVALID) { if (rule.ifInvalid === 'drop') { notes.push(`dropped ${k}=${v}`); continue; } throw new Error(`${file}: ${k}`); }
out[rule.to] = rule.transform ? rule.transform(val) : val;
}
Step 3: Validate Against the Destination Schema
The destination generator already defines what valid front matter is. Reuse it rather than re-implementing checks:
// scripts/fm-validate.ts — import the same Zod schema the Astro collection uses
import { docsSchema } from '../src/schema';
for (const [file, fm] of converted) {
const r = docsSchema.safeParse(fm);
if (!r.success) errors.push({ file, issues: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) });
}
Validation against the real schema found 23 problems the map missed: descriptions over the 160-character limit the schema enforced, 7 duplicate slugs from files that differed only in case, and tags with characters the site's tag URLs could not represent. Each was fixed in the source content or by a new rule, and the script re-run.
Step 4: Make the Change Reviewable
A pull request touching 3,140 files cannot be reviewed line by line. Review the map and the summary instead:
fm-convert summary
files processed 3140
title→title 3140
date→pubDate 3140 (94 parsed from strings: 61 'YYYY-MM-DD', 22 'DD/MM/YYYY', 11 'Month D, YYYY')
weight→order 2193 (18 boolean weights dropped)
author→authors 498 (410 wrapped string→array)
aliases→redirects.json 1203 entries from 614 files
manual review 6 (_build)
unknown keys 0
validation errors 0
Then spot-check a random sample: 20 files from each transformation category, reading the before and after side by side. Keep the conversion script in the repository until the migration is complete, so a late source-content fix can be re-converted rather than hand-edited in two places. The same approach applies to other constructs covered in Porting Shortcodes and Includes Between Generators.
Handling the Manual Cases
Every conversion has a residue that rules should not try to cover. Here it was six pages with Hugo _build options (pages built but not listed, or listed but not rendered) and a handful of pages whose weight had been abused to force ordering across sections. Writing clever rules for six files costs more than handling them by hand, and hides decisions inside code. Instead, the script emitted a checklist file with one line per manual item — file, key, value, and a suggested action — and a person worked through it in twenty minutes. Four _build pages became draft: false with unlisted: true in the new schema; two were genuinely obsolete and deleted, with redirects added.
Keep the manual list in the pull request description so reviewers see every decision a human made, next to the summary of everything the script did. That split — rules for the patterns, people for the exceptions, both visible — is what makes a bulk conversion trustworthy.
Measured Impact
| Measure | Hand conversion (pilot, 200 files) | Scripted conversion (3,140 files) |
|---|---|---|
| Time | ~6 hours | 1 day to write + 4 s per run |
| Inconsistencies left in output | 31 (found later by the build) | 0 (schema-validated) |
| Aliases carried into redirects | 74 of 81 | 1,203 of 1,203 |
| Review effort | line-by-line diffs | map + summary + 140 spot checks |
Pitfalls & Rollback
- Regex on YAML. Titles with colons, multi-line strings and quoted values break text replacement. Parse and re-serialise.
- Lossy YAML libraries. Default serialisers drop comments and reorder keys, producing enormous diffs. Use a comment-preserving document API.
- Guessing date formats.
new Date('03/04/2021')is ambiguous. Parse with explicit patterns and fail on anything else. - Leaving aliases in front matter. Most destination generators ignore Hugo's
aliases; collect them into the redirect system. - Converting while writers keep editing the source. Freeze content during the final run, or re-run the script right before cutover so late edits are carried across.
- Rollback: the conversion is a single commit produced by a script from untouched source; revert the commit, fix the map, re-run.
Conclusion
Front matter conversion is where migrations quietly accumulate errors, because it looks too simple to need tooling. An inventory of real key usage, a declarative field map, explicit type coercion, validation against the destination generator's own schema and a summary-based review turned 3,140 files of inconsistent Hugo front matter into schema-valid Astro content in a four-second, re-runnable script — with every alias preserved as a redirect.
FAQ
Why script front matter conversion instead of using find and replace?
Front matter is structured data. Regex replacements break on quoting, multi-line values, lists and YAML edge cases such as colons in titles. Parsing each file, transforming the data and writing it back with a YAML library is safer and can be validated.
How do I preserve formatting and comments in YAML?
Use a YAML library that keeps comments and key order, such as the yaml package's Document API in JavaScript or ruamel.yaml in Python. Most libraries drop comments by default, which creates noisy diffs.
What should the conversion validate?
Required fields present, correct types for dates and booleans, allowed values for enums such as layout or status, unique slugs, and no unknown fields left over. Validate against the destination generator's schema, for example an Astro content collection schema.
How do I review a change to thousands of files?
Generate a summary instead of reading every diff: counts of files per transformation, a list of files that needed manual rules, and validation errors. Then spot-check a sample of each category.
Related
- Parent: Migrating Between Static Site Generators — the migration playbook.
- Migrating from Hugo to Astro Without Breaking URLs — the migration this conversion served.
- Content Collections vs Eleventy Data Cascade — schema validation on the destination side.
- Migrating from MkDocs to Starlight — the same technique for a Python source.
- Migrating WordPress to a Static Site Generator — generating front matter from an API.