Jostraca code generation, made repeatable

Why Jostraca

Code generation has a cliff, and everybody who has shipped a generator has walked off it. The first run is a delight. The second run is where you discover that somebody fixed a bug in the generated code by hand, and your generator has just deleted the fix.

The second-run problem

Here is the cliff. A generator writes a config file, someone adds a line to it, and the generator runs again:

import { appendFileSync } from 'node:fs'
import { Jostraca, Project, File, Content } from 'jostraca'

const jostraca = Jostraca()

const run = (body) => jostraca.generate({ folder: './out' }, () => {
  Project({}, () => {
    File({ name: 'config.sh' }, () => Content(body))
  })
})

await run('PORT=8080\nHOST=localhost\n')
appendFileSync('./out/config.sh', 'DEBUG=1\n')
await run('PORT=9090\nHOST=localhost\n')

leaves

out/config.sh
  PORT=9090
  HOST=localhost

PORT moved, which is what the second run was for. DEBUG=1 is gone, which nobody asked for. There was no warning, no backup and no exit code to notice, because as far as the generator was concerned it did exactly what it was told.

The usual answers are all avoidance. Generate into a directory nobody may touch and re-export by hand. Generate once, check the output in, and never run the generator again. Split every file into a generated half and an editable half, and accept the ceremony. Each of these works, and each is a way of not solving the problem.

Two phases, so the tree is known first

A generate() call runs in two halves. The define phase executes your callback: every component call records a node in an in-memory tree, and nothing touches disk. The build phase then walks that tree and performs the file operations.

The split is what makes the rest possible. Because the whole intended output is known before the first byte is written, Jostraca can compare what it is about to write against what is already there, and against what it wrote last time. Turn on merge and the same script, unchanged but for one option, keeps the edit:

import { appendFileSync } from 'node:fs'
import { Jostraca, Project, File, Content } from 'jostraca'

const jostraca = Jostraca({
  existing: { txt: { write: true, merge: true } },
})

const run = (body) => jostraca.generate({ folder: './out' }, () => {
  Project({}, () => {
    File({ name: 'config.sh' }, () => Content(body))
  })
})

await run('PORT=8080\nHOST=localhost\n')
appendFileSync('./out/config.sh', 'DEBUG=1\n')
await run('PORT=9090\nHOST=localhost\n')

leaves

out/config.sh
  PORT=9090
  HOST=localhost
  DEBUG=1

That is a three-way merge, and the base is the previous generate, which Jostraca keeps under .jostraca/ beside the output. Anything in the file that is not in the base is somebody's edit; anything the generator changed since the base is the generator's. The two are combined the way a version control merge combines them, conflict markers, and all when they cannot both hold.

merge is one of 5 modes, and the modest ones earn their keep more often: preserve writes the new version and leaves the old bytes in a sibling file, present leaves the file alone and writes the new version beside it, diff returns an annotated two-way diff. The modes are checked in that order, so present needs write: false to take effect at all. A file carrying JOSTRACA_PROTECT is never overwritten under any of them. That is how a user takes a file away from the generator without negotiating with you.

Components, because you already have a language

The other half of the design is what a generator is written in. Template dialects start small and grow: first an interpolation, then a conditional, then a loop, then partials, then a way to call JavaScript because the dialect cannot express the thing you need. Every step is reasonable and the destination is a second language with worse tooling than the one you were using.

Jostraca's components are function calls. Nesting them mirrors the folders and files you want. Iteration, branching, parameters, and helper functions use the language you already know:

import { Jostraca, Project, File, Content, each } from 'jostraca'

const routes = [
  { name: 'health', method: 'get' },
  { name: 'login', method: 'post' },
]

const jostraca = Jostraca()

await jostraca.generate({ folder: './out' }, () => {
  Project({ folder: 'api' }, () => {
    each(routes, (route) => {
      File({ name: route.name + '.js' }, () => {
        Content("export const method = '" + route.method + "'\n")
      })
    })
  })
})

leaves

out/api/health.js
  export const method = 'get'
out/api/login.js
  export const method = 'post'

There is a substitution syntax, and it is deliberately small: $$path$$ reads a value from the model, and that is all it does. No conditionals, no loops, no expressions, no filters. Anything harder belongs in the code around it, where you can test it. Being unable to grow is the feature: a syntax that gains a conditional gains a loop next, and ends up a second language with worse tooling than the one you started in.

Templates that are still valid source

The other half of this matters more in practice than the argument about dialects, because you meet it every time you open the file. Where Jostraca does use a template file, the marker is allowed to sit inside the target language’s own comment syntax:

<!-- <[SLOT:head]> -->    valid HTML
// <[SLOT:head]>          valid Go, C, JavaScript
/* <[SLOT:head]> */      valid CSS
# <[SLOT:head]>           valid shell, Python, YAML
-- <[SLOT:head]>          valid SQL

So the template is a legal file of its own type. It opens, highlights, formats, and lints like anything else in the repository, and a browser will render the HTML one directly. Compare a file carrying {% for item in items %}: it is valid in neither language, so highlighting gives up or wants a plugin, the formatter cannot parse it, the linter cannot check it, and nothing can tell you what it produces short of rendering it.

This is why the substitution syntax is $$path$$ rather than something more expressive. It has to fit inside a string literal or a comment without breaking the file around it.

What it costs

A generator written this way is a program, so you have to write and run a program to see its output. A template file you can open and read has a practical advantage over a tree of function calls. Jostraca restores some of it with Fragment and Slot: an external template file with named regions you fill. A component tree is still code, and code is heavier to skim.

The bookkeeping is on disk. A .jostraca/ directory beside the output holds the previous generate and a log of what happened, which is what merge and unchanged-detection read. It is gitignored by default, and if you delete it, merges fall back to having no base.

And two implementations means two chances to be wrong. Jostraca answers that with a shared, language-neutral test corpus both stacks assert against, and a rule for the arguments: TypeScript is canonical, so when the two disagree, Go is the one that changes, even on the occasions when the Go code happened to be more correct.

When not to use it

If you scaffold a project once and never generate into it again, a template repository is simpler and you should use one. If your output is a single file with no structure, a string and writeFileSync will do. Jostraca starts paying for itself when the output is a tree, the tree is checked in, and the generator is going to run again next month.

Next: the tutorial builds a generator from nothing, the explanation argues the design in full, and the source holds both implementations and the shared corpus they are both tested against.