Part I · Getting started · Chapter 3
Rendering output
How a manifest becomes files on disk — choosing a serialiser by filename, and what the render directory is for.
Rendering is two steps that happen in order: the manifest is evaluated to a JavaScript object, then each top-level key of that object is written to a file.
The command
npx spellcraft generate manifest.jsonnet
Output lands in render/, relative to where you run the command.
How a filename picks its serialiser
Each key is matched against a set of regular-expression handlers, and the first match wins. SpellCraft ships five:
| Pattern | Output |
|---|---|
.*?\.json$ | JSON.stringify(content, null, 4) |
.*?\.yaml$ | YAML, four-space indent |
.*?\.yml$ | YAML, four-space indent |
.*?\.md$ | verbatim |
.*?\.txt$ | verbatim |
A filename that matches nothing is written verbatim, so a manifest can emit arbitrary text — a shell script, an .ini, an HCL file no plugin claims — by simply naming the key.
"main.tf.json" needs no Terraform-specific handling for a simpler reason than the fallback: it ends in .json, so the JSON handler above already claims it, and Terraform reads plain JSON.
Plugins add their own handlers through fileTypeHandlers in their metadata. The terraform node of @c6fc/spellcraft-plugins registers one for .tf, which is what makes the section below possible.
Verbatim files
A verbatim handler writes a string through untouched — real newlines, no quotes. A handler that serialises does the opposite, so the same string in a .json key comes out as a quoted JSON string:
{
"README.md": "# Generated by SpellCraft. Do not edit.\n", // # Generated by...
"notes.json": "hello", // "hello"
}
Verbatim is also the default, so deploy.sh and app.ini behave like README.md above without anything claiming their extensions.
A non-string value in a verbatim slot is serialised as JSON rather than becoming [object Object] — it has to become text somehow.
Carrying existing HCL forward
Terraform reads every .tf and .tf.json in a directory as one module, so hand-written HCL only has to land in render/ beside the generated JSON. Jsonnet’s importstr reads a file as a raw string at evaluation time, and the .tf handler writes it through unchanged:
{
"legacy.tf": importstr "./hcl/networking.tf",
"main.tf.json": {
output: {
summary: { value: "${local.network_name} ${var.cidr}" },
},
},
}
The two files are one module to Terraform: the generated JSON above references a local and a variable declared in the carried-forward HCL, and resolves normally. Adoption can start with a directory of existing HCL and move declarations into Jsonnet one at a time.
Two limits worth knowing. importstr takes a literal path — it cannot be built from an expression or looped over a directory, so each file needs its own manifest key.
And the imported string is written through as-is; interpolating it is not supported. Applying Jsonnet’s % operator — (importstr "./hcl/networking.tf") % { cidr: "10.0.0.0/16" } — makes the file’s own contents the format string, so every % in it, including any inside a comment, is read as a conversion. It fails with an error that names neither the file nor the character.
Subdirectories
A key may name a subdirectory, which is created for you:
{
"modules/network/main.tf.json": { resource: {} },
"policies/s3-read.json": { Version: "2012-10-17" },
}
The one rule is that the key must resolve inside the render directory. A key containing .. that escapes it is refused by name, and nothing is written or cleaned when that happens.
The render directory
render/ is build output. SpellCraft owns it, rewrites it on every run, and deletes exactly what the previous run wrote before writing again — so renaming something does not leave a stale old.tf.json behind for terraform apply to find. Removals are reported, not silent.
Treat it as disposable and reproducible: everything in it should come from a render, and nothing you want to keep should live there. If a file needs to be in the directory, manifest it; if it needs to survive, put it somewhere else. The generators add render/ to .gitignore for you.
Construct the frame with cleanBeforeRender: false to turn cleaning off, or point renderPath somewhere else entirely. See the SpellFrame API.
Rendering from code
The CLI is a thin wrapper. Anything it does, your own script can do:
import { SpellFrame } from '@c6fc/spellcraft';
const frame = new SpellFrame({ renderPath: './out' });
await frame.init();
await frame.render('manifest.jsonnet');
frame.write();
render() returns the evaluated object, so you can inspect or transform it before write() ever touches the disk. Note write() is synchronous and returns the frame — there is nothing to await.