Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,23 @@ jobs:
# `check:specs` is the aggregate (json-strict + manifest + register),
# listed as ONE leg rather than three.
# Measured on this tree before enabling: `check:specs` PASSES (with
# warnings), `test:l10n` FAILS on source strings missing from
# l10n/en.json, e.g. "Write an audit-trail entry for every step"
# (src/views/settings/sections/FlowConfiguration.vue:29). A real
# pre-existing defect; the gate is what makes it visible.
# warnings), `test:l10n` FAILED on source strings missing from the English
# catalogue. That defect is now fixed and the leg PASSES: the gate was
# asserting frontend t() calls against l10n/en.json, a file no frontend
# code path reads, and now targets l10n/en.js.
#
# `test:l10n:parity` is listed as a SEPARATE leg from `test:l10n` because
# they answer different questions and fail for different reasons:
# test:l10n -> does en.js cover every t()/n() call in src/?
# test:l10n:parity -> does every FINISHED locale cover every en.js key?
# Without the second one, adding an English string silently leaves 17
# finished locales one key short and nothing notices — which is how en.js
# came to sit ~700 keys ahead of the locales in the first place. Locales
# still being translated are reported as a backlog and do not fail the
# build; empty values and wrong plural arity fail for every locale, because
# those render blank at runtime.
# Measured on this tree before enabling: PASSES — 17 locales at full
# key-for-key parity (2051 keys each), 19 in progress.
# `test` is NOT listed: "Frontend Tests (unit)" already runs it.
#
# `format` (prettier --check) is listed because the shared workflow has NO
Expand All @@ -324,7 +337,16 @@ jobs:
# renders the English source inside an otherwise translated form, silently.
# The fleet had 30,459 such strings, so this records the current count and
# fails only when it GROWS — burning it down stays an ordinary PR.
frontend-checks: '["check:specs", "test:l10n", "format", "check:schema-l10n", "check:l10n-js"]'
#
# `test:l10n:parity` is a SEPARATE leg from `test:l10n` because they answer
# different questions: `test:l10n` asks whether en.js covers every t()/n()
# call in src/, while `test:l10n:parity` asks whether every locale matches
# en.js key-for-key. One passing tells you nothing about the other.
#
# `check:l10n-js` is a THIRD question again: whether the generated browser
# catalogues (l10n/*.js) are in step with their .json sources. Both sides
# of this merge added one of these; neither replaces the other.
frontend-checks: '["check:specs", "test:l10n", "test:l10n:parity", "format", "check:schema-l10n", "check:l10n-js"]'
# ── Cost controls (see ConductionNL/.github#596, #599) ───────────────
#
# The fleet's CI was not slow, it was QUEUED. A Code Quality run does
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

/node_modules/
/docs/node_modules/

# Third-party hunspell dictionaries for the l10n audit spell pass. Fetched by
# `npm run l10n:fetchdicts`; tens of MB and licensed variously, so not vendored.
/scripts/l10n/dicts/
/website/.docusaurus/
/docs/build/
/docs/.docusaurus/
Expand Down Expand Up @@ -127,6 +131,9 @@ tests/e2e/test-results-ci/
tests/e2e/.mdm-seed.json
.hydra

# harvest.js writes its candidate list next to itself, one file per locale. It is
# scratch input for a translation pass, not something a locale commit should carry.
scripts/l10n/harvest-*.json
# Agent/test scratch and tool caches — generated, never source.
# Added by the 2026-08-25 fleet hygiene sweep (ADR-100 Decision 2).
.stale/
Expand Down
141 changes: 141 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# openregister — l10n

App id is **`openregister`**. Every user-visible string is wrapped:

```js
t('openregister', 'Some string')
n('openregister', 'object', 'objects', count)
n('openregister', '{count} object', '{count} objects', count, { count })
```

## Two catalogues, different consumers

| Files | Set | Read by |
| --- | --- | --- |
| `l10n/*.js` | **frontend** | `OC.L10N.register` → `t()` / `n()` |
| `l10n/*.json` | **backend** | PHP `IL10N` |

Not two renderings of one source — separate catalogues, separate consumers. **A `t()` call
in `.vue`/`.js` belongs in `en.js`, never in `en.json`.** Mixing them up is the bug that
once left `en.js` ~700 keys behind while every gate stayed green. There is no scanner for
the backend set; it is maintained by hand.

## Commands

| You want to… | Run |
| --- | --- |
| Check / view / edit a key | `node scripts/l10n-ai.js has\|get\|find\|add\|set\|rm\|rename` |
| Audit `en.js` vs `src/` (missing / unused / unwrapped) | `npm run check:l10n` |
| **CI gate** — does `en.js` cover every `t()`/`n()` call? | `npm run test:l10n` |
| **CI gate** — every locale complete and well-formed? | `npm run test:l10n:parity` |
| Extract new keys into `en.js` | `npm run test:l10n:write` |
| State of one locale | `npm run l10n:status -- <loc>` |
| Write a patch (gated, dry-run by default) | `npm run l10n:apply -- <loc> patch.json [--apply]` |
| Verify one locale before committing | `npm run l10n:selfcheck -- <loc>` |
| See what actually renders | `npm run l10n:runtime -- <loc>` |
| Prove the gates still refuse | `npm run l10n:gatetest -- <loc>` |

Never hand-edit `l10n/*.js` — 37 files that must stay in sync, no validation, and reading
one into context costs hundreds of tokens per call. **`apply.js` is the only writer.**

## Rules

**Every locale is key-for-key identical to `en.js`.** `test:l10n:parity` fails on a missing
key, an empty value or wrong plural arity, for every locale, with no exemption list and no
env override. Add an English string and you translate it or you unwrap it. Never add an
exemption to get a green build.

**An untranslatable string should not be wrapped at all.** An input placeholder or example
value (`sk-...`, `myapp`, `https://example.com/webhook`) gets unwrapped in `src/` *and*
deleted from all 37 bundles including `en.js`, in one commit. Deleting it from the locales
alone leaves `check:l10n` reporting it unused forever.

**A genuine cognate is written out and recorded.** `CSV`, `PDF`, `URL`, `Flows` in nl/de/da:
write the value so the locale keeps parity, and record why in `locales/<loc>.json` under
`"cognates"`. `apply.js` refuses an identical value without a record; parity fails on an
unjustified one *and* on a stale record whose value is no longer identical. Enforcement is
opt-in per locale, keyed on `locales/<loc>.json` existing — the gate prints which locales
are enforced and which are merely unreviewed, so a green run is not evidence of review.

Measure that split, never eyeball it: a key is untranslatable only if **no locale has ever
carried a value differing from it**. `node scripts/l10n-ai.js get <key>` answers it.
`value === key` without a record is the worst option available — absent falls back to
English and stays visibly untranslated, identical is indistinguishable from finished work
and so never gets revisited.

**An `n()` call's key is NEITHER source string.** It is `"_<singular>_::_<plural>_"` —
`pluralIdentifier` in `scripts/l10n/lib.js`. Storing forms under the bare singular renders
for `count === 1` and falls back to English everywhere else; that shipped in all 37 bundles
while passing every gate.

**Plural arrays match that locale's own `nplurals`, and are never copied between
languages.** An array shorter than the index the runtime asks for renders **blank** — the
one defect you cannot see by reading the file. Equal form counts do not mean equal
boundaries. **Read `docs/l10n-workflow.md` §7.1 before writing an array.**

At runtime the form index comes from the library's own `getPlural`, not the file's
`plural=` header: the header governs the arity gate, the library governs which element
renders. `npm run l10n:runtime -- <loc>` is the only check that catches a wrong boundary,
and **nothing** catches a wrong noun form.

**`{plural}` is banned** — in a key, in a value, and inside a translation call, including
the `? 's' : ''` spelling. Four gates enforce it; `l10n:gatetest` proves they refuse.
Use `n()`. Why: `docs/l10n-workflow.md` §7.4.

**A plural form must not also be a catalogue key.** `translatePlural` re-translates the form
it selects, so such a form renders the *other* key's value, invisibly. Parity fails on it.

**Never overwrite a real translation.** `l10n-ai.js` refuses without `--force`, `apply.js`
without `--allow-replace`. Trust the refusal; replace only what is genuinely wrong, and say
why in the commit.

**But a locale pass must grammatically audit the pre-existing values and fix what is bad.**
That rule guards against changes of taste, not against fixing grammar. No gate sees a
wrongly-inflected value: it is not empty, not identical to English, has the right arity, and
reads as finished work. Method in `docs/l10n-workflow.md` §6.9; what past passes found, and
how big to expect it to be, in `docs/l10n-audit-findings.md`.

**Adding a string:** `en` is required (identical to the key is correct — `en` *is* the
source), other locales optional, `--locales=` narrows. A new English string puts every
finished locale one key short, which parity treats as fatal — procedure in
`docs/l10n-workflow.md` §6.15.

**Commit one language at a time**, so a bad locale can be reverted alone.

## Gotchas

- **`clean:l10n` is a dry run on purpose.** It removes keys from all 37 files, and some
candidates are live UI prose nobody has wrapped yet. Cross-check against
`find:unwrapped`, then remove by hand, matching the whole quoted literal.
- **Locale files are not linted or formatted, by design.** `serializeJs` emits the exact
Nextcloud/Transifex layout; `.prettierignore` excludes `l10n/`.
- `l10n-ai.js rename` does not rewrite call sites — grep `src/` afterwards. `set` refuses
pluralized (array) keys.
- `find:unwrapped` is deliberately high-recall (~1500 candidates). Audit by hand; do not
tighten the heuristic until real strings are missed.
- **A `SKIP` or `NOTE` from `selfcheck` / `runtime-check` is not a bug to tighten away.**
See "Two traps in the verification scripts" in `scripts/l10n/README.md` first.
- `scripts/l10n/lib.js` is the **origin** copy; `openconnector` vendors it. Keep them in
sync — the only intended divergence is `DYNAMIC_KEYS`. openconnector is currently behind:
old `scripts/lib/l10n.js` path, plurals stored under the bare singular, and a
`check-l10n-parity.js` predating the arity, identical-value and finished-set gates.

## Known state

`npm run test:l10n` is **red at HEAD, and not because of l10n work**: a `development` merge
replaced the Dutch GDPR source terms with English ones and added flow strings, leaving 17
keys used in `src/` but missing from `en.js`. That is the §6.15 procedure and its own
commit. Everything else is green.

Do not trust key or locale counts written down anywhere — `npm run test:l10n:parity` prints
the current parity / enforced / unreviewed split, and that is the only number that cannot go
stale.

## Further reading

| Doc | What |
| --- | --- |
| `docs/l10n-workflow.md` | The runbook: the pass in order, every gate refusal, the traps catalogue, per-locale plural data (§7.1) |
| `scripts/l10n/README.md` | Tooling layout and what each script refuses |
| `docs/l10n-ui-translation.md` | The non-mechanical parts: register, button conventions, per-locale decisions |
| `docs/l10n-audit-findings.md` | What past audits actually found — defect rates, where to look first |
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata

Vrij en open source onder de EUPL-licentie.
]]></description>
<version>1.1.9-unstable.20260829214947</version>
<version>1.1.11-unstable.20260830083706</version>
<licence>EUPL-1.2</licence>
<author mail="info@conduction.nl" homepage="https://www.conduction.nl/">Conduction</author>
<namespace>OpenRegister</namespace>
Expand Down
5 changes: 2 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"php": "^8.3",
"adbario/php-dot-notation": "^3.3.0",
"bamarni/composer-bin-plugin": "^1.8",
"cweagans/composer-patches": "^1.7",
"cweagans/composer-patches": "^2.0",
"ddn/sapp": "dev-feat/chained-filter-text-replace#5c406e91254d6936f44372db35f1cc15e5a06c56",
"dompdf/dompdf": "^3.1",
"dragonmantank/cron-expression": "^3.3",
Expand All @@ -124,7 +124,7 @@
"symfony/uid": "^6.4",
"symfony/workflow": "^6.4",
"symfony/yaml": "^6.4 || ^7.0",
"theodo-group/llphant": "^0.9.3",
"theodo-group/llphant": "^1.0",
"twig/twig": "^3.27.0",
"web-token/jwt-library": "^4.1.9",
"webonyx/graphql-php": "^15.0",
Expand Down Expand Up @@ -163,7 +163,6 @@
}
},
"extra": {
"composer-exit-on-patch-failure": true,
"patches": {
"theodo-group/llphant": {
"Forward think:false + keep_alive:-1 to the Ollama API (qwen3 ~5x speedup, no idle-unload wedge) \u2014 drop when LLPhant adds a per-call keep_alive/think model option": "patches/llphant-ollama-think-keepalive.patch",
Expand Down
Loading
Loading