How typed datasets, resolvers that throw, and a prebuild QA gate keep 200 pages honest.
What is a dataset page?
A dataset here is a JSON file describing pages: each entry holds the slug, title, search intent, and data hooks — references like “words from these categories” or “speaking rows from unit 3, lessons 1–2”. A resolver is the function that turns those hooks into real curriculum rows at build time. A QA gate is a script that fails the build when a dataset breaks the rules. Authors write data; the engine decides what ships.
The five families
Page datasets live in src/data/seo/*.json, one file per family:
| File | Family | Pages render under |
|---|---|---|
vocab-topics.json |
Vocabulary topics | /vocabulary/<slug>/ |
phrase-pages.json |
Phrase topics | /phrases/<slug>/ |
grammar-pages.json |
Grammar topics | /grammar/<slug>/ |
hindi-bridges.json |
Hindi bridge rows | /hindi-to-marathi/<slug>/ |
english-paths.json |
English-first rows | /english-to-marathi/<slug>/ |
Each entry carries a status of draft or published — only published pages render. Drafts are how half-finished ideas wait without leaking onto the site.
Resolvers that throw
Real code from src/lib/seo.ts (verbatim, trimmed to the word resolver):
learn-marathi/src/lib/seo.tsexport function wordsFor(page: SeoPage): Word[] { const words: Word[] = []; if (page.categories) words.push(...vocab.filter((w) => page.categories!.includes(w.category))); for (const id of page.wordIds ?? []) { const w = vocab.find((x) => x.id === id); if (!w) throw new Error(`seo/lib: unknown word id "${id}" on page "${page.slug}"`); if (!words.includes(w)) words.push(w); } return words;}What. Resolves a page's word hooks against the synced vocabulary; unknown ids throw at build time.
Why. A typo'd word-id would otherwise render an empty section nobody notices. Throwing turns drift into a build failure you fix the same day.
Tradeoff. Authors can't preview a page with placeholder references — every hook must resolve before anything renders. The alternative (skipping bad refs silently) guarantees the one skipped section is the one users needed.
Three sibling resolvers work the same way: speakingFor pulls lesson-script rows by unit and lesson number, grammarNotesFor verifies every picked lesson resolved exactly one row (count mismatch throws), and bridgesForPage pulls Hindi-bridge rows by unit. The rule is absolute: every word, phrase, or grammar row on a generated page traces to vocab.json or units.json, or it is page-level prose. Nothing in between.
The QA gate
Resolvers catch drift. The validator catches everything else. Real thresholds from tools/validate-content.mjs (verbatim):
learn-marathi/tools/validate-content.mjsconst MIN = { vocabulary: { words: 10 }, phrases: { phrases: 8 }, 'hindi-bridge': { bridges: 5 }, 'english-path': { sentences: 5 }, grammar: { notes: 1 },};What. Minimum resolved-content counts per family — below the bar, a page must not be published.
Why. Numbers enforce what taste can't: extend an existing page instead of shipping a thin one. The gate runs in `prebuild`, so unvalidated pages can never ship.
Tradeoff. Thresholds are blunt — a brilliant 9-word page still waits. The alternative (reviewer judgment per page) doesn't scale to 60 dataset pages and counting.
The same script also fails on schema violations, duplicate slugs/titles/descriptions, slug collisions with the curated taxonomy, broken internal links, Latin script in Marathi answer fields (and Devanagari in English fields), and published pages with no practice items. It warns — without failing — on near-duplicate intents and draft debt. And because package.json wires it into prebuild (node ./tools/validate-content.mjs && node ./tools/build-search-index.mjs), none of this is advisory: a red dataset stops the deploy.
The alternative it beat
The alternative was trusting the datasets — write JSON carefully, review carefully, ship. Careful works until the 40th page, then a renamed category silently empties three pages and nobody notices for months because the pages still render. They render empty. The throw-at-build layer exists because the failure mode of data-driven sites isn’t crashing — it’s confidently displaying nothing.