Skip to main content
T3 fan-out · product / content / i18n — three chained fan-outs (read-all → translate-all → write-all) with jq transpose zipping paths back to texts between stages. The masterclass file for collections.

The job

« Can we have the docs in French? » is weeks of copy-paste, or it’s this file: glob finds every markdown file, the texts are read and translated in parallel (rate-limited so the provider doesn’t choke), and the mirror tree appears under i18n/fr/ with the original paths preserved.

The shape

The file

t3-localization-factory.nika.yaml
nika: v1
workflow: localization-factory
description: "glob docs → parallel read → parallel translate → mirror tree"

model: mistral/mistral-large    # EU model for EU locales · pick yours

vars:
  lang: "fr"
  source_glob: "./docs/**/*.md"

tasks:
  - id: files
    invoke:
      tool: "nika:glob"
      args:
        pattern: "${{ vars.source_glob }}"
        exclude: ["**/node_modules/**"]

  - id: texts
    depends_on: [files]
    for_each: ${{ tasks.files.output }}
    max_parallel: 8
    invoke:
      tool: "nika:read"
      args: { path: "${{ item }}" }

  - id: pairs
    depends_on: [files, texts]
    invoke:
      tool: "nika:jq"
      args:
        input: ["${{ tasks.files.output }}", "${{ tasks.texts.output }}"]
        expression: "transpose | map({path: .[0], text: .[1]})"

  - id: translated
    depends_on: [pairs]
    for_each: ${{ tasks.pairs.output }}
    max_parallel: 3                    # rate-limit the provider
    fail_fast: false                   # finish the batch · a failed file yields null at its index
    on_error:
      recover: null                    # null keeps the transpose zip aligned (order preserved)
    infer:
      prompt: |
        Translate to ${{ vars.lang }} · keep markdown structure, code blocks
        untouched, and the original tone ·
        ${{ item.text }}

  - id: bundle
    depends_on: [pairs, translated]
    invoke:
      tool: "nika:jq"
      args:
        input: ["${{ tasks.pairs.output }}", "${{ tasks.translated.output }}"]
        expression: "transpose | map(select(.[1] != null)) | map({path: .[0].path, text: .[1]})"

  - id: mirror
    depends_on: [bundle]
    for_each: ${{ tasks.bundle.output }}
    max_parallel: 8
    invoke:
      tool: "nika:write"
      args:
        path: "./i18n/${{ vars.lang }}/${{ item.path }}"
        content: "${{ item.text }}"
        create_dirs: true

outputs:
  files:
    value: ${{ tasks.files.output }}
    type: array
    description: "Every source file that was mirrored"

How it works

1

The filesystem is the collection

nika:glob returns the file list at runtime — add a doc tomorrow, it’s in the next run. exclude: keeps node_modules out.
2

transpose zips parallel arrays

Two fan-outs produce two aligned arrays (paths, texts). The jq transpose | map({path: .[0], text: .[1]}) zip turns them into [{path, text}] — and ${{ item.path }} works, because loop-locals are full objects.
3

The mirror tree comes out the other end

The final fan-out writes ./i18n/fr/<original path> with create_dirs: true. Path templating is plain ${{ }} interpolation inside a string.

Constructs you just used

ConstructWhereReference
nika:glob + excludefilesBuiltins
chained for_each stagestextstranslatedmirrorWorkflows
jq transpose zippairs · bundleBuiltins
${{ item.path }} object localsmirrorBindings

Make it yours

  • All your locales: wrap the lang in a list and run per locale — or lift vars.lang to a typed required input and call the workflow per language.
  • Glossary discipline: interpolate your terminology table into the translate prompt so product names never drift.
  • Only what changed: replace glob with exec: git diff --name-only and translate the delta (PR review fan-out starts the same way).

Next · Config drift sentinel

RFC 7396 merge-patch + RFC 6902 diff — jq decides, the model only explains.