Core Concepts

Templating

Netsuke renders MiniJinja inside YAML values. The structure stays as data; templating fills in strings, expands entries with foreach, and gates them with when — all before the build graph exists.

Why Jinja inside YAML

Netsuke validates YAML before rendering templates. That keeps the manifest legible, keeps the structure explicit, and still leaves room for repetition, conditions, and reusable helpers. The goal is not to hide the build graph — it is to keep repeated YAML short enough to tolerate.

YAML first

Structure is checked before templating renders commands. Structural mistakes fail early, with YAML line numbers.

Entries, not branches

foreach and when select manifest entries at plan time. Build-time branching belongs in recipe commands, not the manifest.

Reusable helpers

Declared macros, filters, and the standard library cover common path, file, and environment tasks.

YAML foundations

A Netsukefile is a YAML mapping. Netsuke rejects duplicate keys, unknown fields, and missing required fields; netsuke_version and targets are required. Quote version numbers and any value that begins with {{ — an unquoted { has meaning in YAML. Use | for a multi-line string that preserves line breaks, or >- to fold lines into one string.

Netsukefile
netsuke_version: "1.0.0"

vars:
  greeting: Hello
  recipients:
    - Netsuke
    - Ninja

targets:
  - name: greeting.txt
    command: >-
      printf '%s\n'
      "{{ greeting }}, {{ recipients | first }}!"
      > {{ outs }}

defaults:
  - greeting.txt

How rendering works

MiniJinja renders string fields. Put an expression inside {{ ... }}, read values declared under vars by name, pass a value through a filter with |, call a function with parentheses, and apply a test with is.

Rules that keep manifests honest

  • Undefined values are errors, not silent empty strings.
  • Only {{ ins }} and {{ outs }} are automatically quoted for the host shell; other rendered values are not.
  • Structural statements such as {% for %} cannot reshape the YAML document. Use foreach, when, and declared macros instead.

Order of operations

  1. Parse and validate the YAML document.
  2. Evaluate foreach and when to select and clone entries.
  3. Deserialize the typed manifest and render every string field.
  4. Build the graph and hand a static plan to Ninja.

The control keys run before typed deserialization, so they shape which entries exist — nothing about them survives into the build.

Repeating with foreach

foreach takes a direct Jinja expression — no {{ ... }} braces — that evaluates to a sequence: a list declared under vars, or a discovery call such as glob('src/*.c'). Netsuke clones the entry once per element, exposing item (the element) and index (its position) to every string field and to when. It works on targets and on top-level actions alike.

vars:
  designs:
    - hero
    - logo

targets:
  - foreach: designs
    name: "build/{{ item }}.png"
    rule: rasterise
    sources: "design/svg/{{ item }}.svg"

  - foreach: glob('pages/*.md')
    name: "site/{{ item | basename | with_suffix('.html') }}"
    rule: page
    sources: "{{ item }}"

Glob over source files only. Templates expand before anything is built, so a glob over generated outputs sees an empty directory on a clean checkout.

Selecting with when

when also takes a direct expression. If it evaluates false, the entry simply does not exist in the plan. On a foreach entry it runs once per clone with item and index in scope, so a single template can both expand and filter. Complementary branches pair naturally with command_available(...).

targets:
  - when: command_available('magick')
    name: "build/hero.png"
    rule: magick_convert
    sources: "design/hero.svg"

  - when: not command_available('magick')
    name: "build/hero.png"
    rule: inkscape_convert
    sources: "design/hero.svg"

when selects manifest entries at plan time; it is not build-time branching. A condition that must be re-evaluated during the build belongs in the recipe command or script.

Macros

Declared macros are the manifest's unit of reuse: a signature naming the macro and its parameters, and a body template rendered wherever the macro is called. Because they are declared as data rather than written as {% macro %} statements, the YAML document stays a document.

Netsukefile
macros:
  - signature: "obj_name(src)"
    body: |
      {{ src | basename | with_suffix('.o') }}

targets:
  - foreach: glob('src/*.c')
    name: "build/{{ obj_name(item) }}"
    rule: "compile"
    sources: "{{ item }}"

Common Patterns

Recipes

Platform-specific sources with `when`

Use when on repeated entries to include targets only when the current variables match the platform you want.

targets:
  - when: target_os == 'linux'
    name: "sys/linux.o"
    rule: "compile"
    sources: "src/linux_utils.c"

  - when: target_os == 'macos'
    name: "sys/macos.o"
    rule: "compile"
    sources: "src/macos_utils.c"

Keep conditions coarse-grained. Avoid selecting on individual compiler versions unless absolutely necessary.