nika: v1
workflow:
id: localization-factory
description: "glob docs β parallel read β parallel translate β mirror tree"
model: ollama/qwen3.5:4b # local default Β· swap for mistral/mistral-large (EU model for EU locales)
const:
lang: "fr"
# The committed rehearsal tree. Point this at ./docs (or wherever yours
# lives) and change `permits.fs.read` to match, in the same edit.
source_root: "./examples/fixtures/docs"
permits:
tools: ["nika:glob", "nika:jq", "nika:read", "nika:write"]
fs:
# `nika:glob` opens the ROOT of its pattern, so the bound has to cover
# the directory AND everything under it β `**` does both (it matches the
# dir itself and any descendant at any depth). A bound naming only the
# `.md` files refuses the glob before it lists anything:
# `NIKA-SEC-004 Β· ./examples/β¦/docs resolves outside permits.fs.read`.
read: ["./examples/fixtures/docs/**"]
# The mirror is as deep as the source tree, so this one earns its `**`:
# every locale lands under ./i18n/ and nothing lands outside it. Naming
# the locale (`./i18n/fr/**`) would be tighter but would have to be
# re-edited for every value of `const.lang` β the bound is the tree the
# factory owns.
write: ["./i18n/**"]
tasks:
# ββ the collection is discovered, never declared ββββββββββββββββββββ
files:
invoke:
tool: "nika:glob"
args:
pattern: "${{ const.source_root }}/**/*.md"
exclude: ["**/node_modules/**"]
# ββ stage 1 Β· read every file, 8 at a time βββββββββββββββββββββββββ
texts:
with:
files: ${{ tasks.files.output }}
for_each: ${{ with.files }}
max_parallel: 8 # local disk Β· the only cost is file handles
invoke:
tool: "nika:read"
args: { path: "${{ item }}" }
# ββ the zip Β· path + text, and the source root comes off βββββββββββ
# `texts` is an array in the SAME ORDER as `files`, so transpose pairs
# them index-for-index. `ltrimstr` makes each path relative to the source
# root β that stripped path is what the mirror tree is built from.
pairs:
with:
files: ${{ tasks.files.output }}
texts: ${{ tasks.texts.output }}
invoke:
tool: "nika:jq"
args:
input: ["${{ with.files }}", "${{ with.texts }}"]
expression: >-
transpose
| map({ path: (.[0] | ltrimstr("${{ const.source_root }}/")), text: .[1] })
# ββ stage 2 Β· translate every file, 3 at a time ββββββββββββββββββββ
translated:
with:
pairs: ${{ tasks.pairs.output }}
for_each: ${{ with.pairs }}
max_parallel: 3 # rate-limit the provider, not the disk
fail_fast: false # finish the batch Β· one bad file is not a batch failure
on_error:
recover: null # null holds the index open so the zip below stays aligned
infer:
max_tokens: 4000 # a doc page, not a book Β· the cost ceiling is a real number
prompt: |
Translate to ${{ const.lang }} Β· keep the markdown structure, leave
code blocks untouched, and keep the original tone Β·
${{ item.text }}
# ββ the fan-in Β· drop the files that failed, keep their paths ββββββ
# Second transpose, same law: `translated` is index-aligned with `pairs`
# because `recover: null` filled the failures in place. `select(.[1] !=
# null)` is where a failed translation leaves the batch β by VALUE, after
# the fact, not by aborting the fan-out.
bundle:
with:
pairs: ${{ tasks.pairs.output }}
translated: ${{ tasks.translated.output }}
invoke:
tool: "nika:jq"
args:
input: ["${{ with.pairs }}", "${{ with.translated }}"]
expression: >-
transpose
| map(select(.[1] != null))
| map({ path: .[0].path, text: .[1] })
# ββ stage 3 Β· write the mirror βββββββββββββββββββββββββββββββββββββ
mirror:
with:
bundle: ${{ tasks.bundle.output }}
for_each: ${{ with.bundle }}
max_parallel: 8
invoke:
tool: "nika:write"
args:
path: "./i18n/${{ const.lang }}/${{ item.path }}"
content: "${{ item.text }}"
create_dirs: true # the mirror's subdirectories do not exist yet
outputs:
translated:
value: ${{ tasks.bundle.output }}
description: "Every file that made it through, as {path, text} Β· the mirror's manifest"