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.