Why Jostraca
Regenerating a file can overwrite a manual edit. Jostraca provides existing-file modes that let you preserve the old file, review a new version, compare changes, or merge them against the previous output.
The second-run problem
This generator writes a config file, appends a manual edit, and 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.
You can keep generated files separate from handwritten code, or generate a scaffold once and maintain it by hand. Those approaches work when the separation fits your project. Existing-file modes handle cases where generated and manual changes share a file.
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, with conflict markers when both changes cannot be combined.
merge is one of 5 modes: 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. Use it to keep a file under manual control.
Components, because you already have a language
Template languages provide their own syntax for loops, conditionals, and reusable sections. Jostraca puts that logic in TypeScript or Go, so it uses the same debugger and test runner as the rest of your code.
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'
The substitution syntax reads model values with $$path$$.
Put loops, conditionals, and other transformations in the surrounding
code, where you can test them.
Templates that are still valid source
A template marker can 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
A template that uses these comment forms remains valid source. Its
editor and formatter can process it as HTML, Go, CSS, or SQL. A file
containing {% for item in items %} instead needs tooling that
understands that template language.
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.
Both implementations run a shared, language-neutral test corpus. TypeScript defines the API contract. If either implementation has a defect, correct TypeScript first where needed, then bring Go into parity. A defect is not a compatibility requirement.
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.