Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

VerificationLesson

Content validation

A rule written in a config file feels enforced, but it protects nothing until some code checks data against it. Give a Nuxt Content collection a strict schema, add a file with a misspelt field, and the build still passes. Terrarium closes that gap with a check of its own.

Know Zod's safeParse already? Skip to In Terrarium.

Outside the box · General

The concept

Zod is a TypeScript library for describing the shape of data in code. That description is a schema: z.object({ title: z.string() }) describes an object whose title is a string. TypeScript can infer a type from a schema, but types vanish when the code runs, so checking real data, such as a file read from disk, takes a call to the schema.

schema.parse(data) checks data and throws on failure. schema.safeParse(data) never throws: it returns { success: true, data } or { success: false, error }, and error.issues lists every problem with the path of the field it concerns. A .strict() object's ban on unknown fields, an enum's list of allowed values and a .refine check all take effect only inside one of these calls.

Other tools read a schema for other jobs. Nuxt Content converts each collection's schema to JSON Schema and uses that to choose the columns of the collection's table and to generate its TypeScript types. Its docs say schemas enforce data consistency, but its build never calls parse or safeParse on a file's data. A value outside an enum goes into the table as written, a field the schema doesn't declare lands in the catch-all meta column, and a .refine, which JSON Schema can't express, is dropped. What the schema does change is the column type: a z.date() field becomes a date column that keeps the day and loses the time.

So the check has to be a step you add: load each schema, parse every file it covers, and fail on any issue.

Full referenceZod 3 docs: .safeParse

Looking inside

In Terrarium

Many schemas in the Tenant manifests are strict. The Blog's pingbacks schema, for one, is .strict() and allows only the Blog's in fromPersona. Neither rule would stop pnpm build. How a manifest becomes collections is in Content collections, and how the build fills their tables is in Content database.

scripts/gate/validate-content-schema.ts runs the schemas instead. It calls the same loadManifests() and expand() from shared/expand.ts that content.config.ts calls: each collection it checks carries the very schema that built its table. Then it reads every file the collection covers, the frontmatter of a Markdown page or the whole of a YAML file, and parses it:

scripts/gate/validate-content-schema.ts ↗111–142as of
111  for (const col of cols) {
112    const schema = col.schema
113    if (!schema) continue // page Collection with no additional schema — nothing authored to check
⋯    // …
134      const res = schema.safeParse(data)
135      if (!res.success) {
136        messages.push(
137          ...res.error.issues.map((i) => {
138            const where = i.path.join('.')
139            return where ? `${where}: ${i.message}` : i.message
140          }),
141        )
142      }

It lists every failure with its collection key and file, and exits with an error if there is any. A file it can't parse at all is a failure too. pnpm validate:content runs it, then other checks, among them scripts/gate/validate-content-refs.ts, which checks what no single 's schema can see, such as a field that names another Document missing from its Space.

validate:content is a step of the , the part of the that runs on every change, before the build. It needs no build, and also runs alone.

Why it's built this way

  • Nuxt Content doesn't parse, so a Gate step does. When this script was written, an out-of-enum Persona and an extra field on a strict Pingback both passed a clean build. So a Floor step parses every Document, and pnpm validate:content checks a content edit in seconds, without a build. The cost: each file is read twice. scripts/gate/validate-content-schema.ts.
  • The same schemas, not a copy. A separate list of schemas for checking would drift from the ones that build the tables. So the script reuses the manifest expansion that content.config.ts runs. The cost: it sees only collections declared in a manifest. scripts/gate/validate-content-schema.ts.

What to watch for

  • The finding is tied to a version. It holds for the @nuxt/content version Terrarium installs at this revision. Re-check it after an upgrade: a release that starts parsing content at build time would make this step a second check of the same thing.
  • A file that fails to parse vanishes from the build. Nuxt Content logs a warning, skips a file whose YAML or frontmatter it can't read, and the build goes on. Run pnpm validate:content, which reports that file instead of skipping it.
  • Only the fields you declare are checked. A page's built-in fields, such as title and body, come from Nuxt Content and aren't checked here, and a page Collection with no schema of its own is skipped.
  • A valid shape can still point nowhere. A field holding another Document's slug passes its schema as any string. That's the reference check's job, and it covers only the Tenants it was written for.

Try it in your project

Add a field your schema doesn't declare to one content file and run your build. If it passes, add a script that loads each collection's schema, calls safeParse on every file it covers, and exits non-zero on any failure. Run it in CI before the build.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/verification/content-validation