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:

PatternOutput
.*?\.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.

Disabling it

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.