The fifty document layouts in the Crixaa template gallery are generated, not drawn. Each comes out of a declarative spec — elements, positions, a map of preview values — and the generator's interesting property is not what it produces but what it refuses to.
The half-written directory
The first version did the obvious thing: empty the output directory, loop over the specs, write each bundle as it is built. Correct on the happy path, and it ran that way for a while.
Then a run failed partway. The old bundles were already gone, deleted before the loop started,
and what survived was however many new ones the loop had reached. git status was a wall of
changes with no line between the good and the bad, and the way out was to check out a
directory that held work worth keeping.
One spec was wrong. All fifty layouts were damaged.
Build in memory, commit at the end
The restructure is small, and it is the whole point:
let failed = false
const outputs = []
for (const tpl of TEMPLATES) {
const elements = tpl.build()
// ...each check prints the problem and sets `failed = true`
outputs.push({ slug: tpl.slug, payload })
}
if (failed) {
console.error('\nGeneration failed — nothing written, bundles left as they were.')
process.exit(1)
}
commit(OUT_DIR)
Two decisions there are easy to conflate. It does not stop at the first bad spec — it checks all fifty and prints every problem, so you fix them in one pass. And it writes nothing, until all fifty pass. "Keep going" and "write what worked" sound like the same instinct and are opposites: the first saves an afternoon, the second costs the directory.
commit itself is not atomic — a process killed between the unlink and the write still leaves
a mess. What the structure buys is that the failures which actually happen never reach the
filesystem.
Every referenced field needs a sample value
Elements refer to variables as {{ invoice_number }}, and tables pull rows from a dataKey.
Alongside the layout, each spec carries a map of preview values the gallery page renders with.
If a layout references a field that map does not contain, nothing errors. The canvas draws the field name in a chip instead — right in the editor, where a chip means the value arrives later; wrong on a landing page, where the visitor gets a wireframe of field names instead of a document.
So collect every key the layout references and require a value:
const referenced = collectVariables(elements)
const missing = referenced.filter((key) => tpl.values[key] === undefined)
if (missing.length > 0) {
console.error(` ✗ ${tpl.slug}: no preview value for ${missing.join(', ')}`)
failed = true
}
The keys come from walking the built elements, not a list declared in the spec. A declared list
drifts: rename gstin to supplier_gstin and the declaration still names the old key, so
the check passes while the document renders wrong.
Text that lands on its neighbour
The second check exists because of one layout: a marksheet that printed the word "Technology" from a branch field straight across its address line. Nothing had overflowed — each element's content fitted the box drawn for it, so the overflow warning stayed silent. The two boxes' contents simply landed in the same place.
Comparing element boxes does not find this. The first attempt did, and flagged most of the catalogue falsely: a right-aligned rupee amount and its left-aligned label span the same rectangle while their glyphs sit at opposite ends.
So model the ink instead. Resolve each element's preview values, wrap the text as the box will wrap it, and produce one rectangle per rendered line — placed by the element's alignment, only as tall as the glyphs. Two elements collide when those rectangles intersect by more than a couple of points.
Widths are estimated from an average advance per em, because a build script has no font engine, so the output is candidates rather than proof. The thresholds favour silence: a check that cries wolf is one people learn to skip.
Name the box, and the overlap
Both catch bugs that produce a file which parses, loads and renders. That is the class worth building machinery for — not the crash, which announces itself, but the valid artefact that is quietly wrong and ships.
Which is why the failure output has to be actionable. "Validation failed" says there is a problem somewhere in fifty templates; what the author needs is the element and the geometry:
✗ diploma-marksheet: text collides —
el_addr:L1 × el_branch:L0 (18.4×7.2pt)
Now they know which box, which line of it, and that the overlap is about 18pt wide — enough to tell a real collision from a rounding artefact, and how far the box has to shrink. An error naming the file but not the element makes the author redo the checker's search.
The property worth copying
If you write a generator of any kind — templates, config, migrations, SDKs — the ordering is the design: validate everything, report everything, then write all of it or none of it.
The checks here are specific to documents; yours will differ. The property should not. After a failed run the tree should look exactly as it did before you started, so the only thing left to fix is the input.