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.object in content.config.ts.
  • Node.js with the yaml and gray-matter packages, or Python with ruamel.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.

Front matter keys and their type inconsistencies Bars for the most common keys across 3,140 files, each split by type. title is always a string. date is mostly a date with 94 strings. weight is mostly a number with 18 booleans. author is 410 strings and 88 arrays. lastmod is mostly a date with 12 strings. The mixed types are highlighted as conversion work. Keys by type across 3,140 files (red = inconsistent) title date weight lastmod author _build 6 with no equivalent The small red segments are where hand-written rules and manual review go
Inconsistencies are small in count and large in effort — the inventory finds them before they become build errors.

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.

Conversion pipeline with validation and review outputs 3,140 source files pass through parse, the declarative field map and type coercion. The output is validated with the destination Zod schema. The script produces converted files, a redirects file from collected aliases, and a summary report with counts per rule, 6 manual items and 23 validation errors to fix before re-running. Parse, map, coerce, validate — then review a summary 3,140 .md Hugo front matter field map + type coercion Zod schema same as the site converted files redirects.json (1,203 aliases) report: 6 manual · 23 errors Re-run until the report shows no errors; the whole run takes 4 seconds
Because the script is fast and idempotent, fixing the source and re-running beats patching output by hand.

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.

Errors left behind by hand versus scripted conversion Two bars normalised per 1,000 files. Hand conversion of a 200-file pilot left 155 inconsistencies per 1,000 files, discovered later by the build. Scripted conversion of 3,140 files left zero, because every file was validated against the destination schema. Inconsistencies left per 1,000 files converted By hand (pilot) 155 Scripted + schema 0 31 issues in 200 hand-converted files vs 0 in 3,140 scripted ones
The schema check, not the script alone, is what drove the residue to zero.

Measured Impact

MeasureHand conversion (pilot, 200 files)Scripted conversion (3,140 files)
Time~6 hours1 day to write + 4 s per run
Inconsistencies left in output31 (found later by the build)0 (schema-validated)
Aliases carried into redirects74 of 811,203 of 1,203
Review effortline-by-line diffsmap + 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.