@daanvandenbergh/i18nkit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@daanvandenbergh/i18nkit",
3
+ "version": "1.0.0",
4
+ "description": "Type-safe i18n for TypeScript - miss a translation and it's a compile error. Framework-agnostic core, optional React layer, and a Claude Code skill that sweeps for untranslated strings.",
5
+ "type": "module",
6
+ "author": "Daan van den Bergh",
7
+ "keywords": [
8
+ "i18n",
9
+ "internationalization",
10
+ "localization",
11
+ "typescript",
12
+ "type-safe",
13
+ "react",
14
+ "translations"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/daanvandenbergh/i18nkit.git"
19
+ },
20
+ "homepage": "https://github.com/daanvandenbergh/i18nkit#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/daanvandenbergh/i18nkit/issues"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "sideEffects": [
27
+ "**/*.css"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./react": {
35
+ "types": "./dist/react/index.d.ts",
36
+ "default": "./dist/react/index.js"
37
+ },
38
+ "./styles.css": "./dist/react/styles.css",
39
+ "./package.json": "./package.json"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "skills"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json && cp src/react/styles.css dist/react/styles.css",
50
+ "test": "vitest run --passWithNoTests",
51
+ "test:watch": "vitest",
52
+ "typecheck": "tsc --noEmit",
53
+ "prepublishOnly": "npm run build"
54
+ },
55
+ "peerDependencies": {
56
+ "react": ">=18",
57
+ "react-dom": ">=18"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "react": {
61
+ "optional": true
62
+ },
63
+ "react-dom": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "devDependencies": {
68
+ "@types/react": "^19",
69
+ "@types/react-dom": "^19",
70
+ "@vitest/coverage-v8": "^3.2.7",
71
+ "jsdom": "^25",
72
+ "react": "^19",
73
+ "react-dom": "^19",
74
+ "typescript": "^5.6.0",
75
+ "vitest": "^3.0.0"
76
+ }
77
+ }
@@ -0,0 +1,182 @@
1
+ ---
2
+ name: i18nkit-add-locale
3
+ description: Add one new locale to a project already built on i18nkit and carry every translation through - the compiler-guided counterpart to i18nkit-sweep. Adding the code to `new I18n({ locales })` grows the locale union `L`, so `tsc` turns every `defineTextCatalog` / `defineText` / `LanguageText` entry still missing that locale into a compile error: an exhaustive, exact worklist of what to translate, handed to you by the compiler. This skill edits the I18n config, then translates every erroring entry - preserving interpolation params, `${...}` placeholders, split rich-text fragments, and leaving brand / `i18n.uniform` words untouched - fanning the work across parallel agents when there are many, and loops `tsc` to green. It also covers what the compiler cannot see: for `@daanvandenbergh/blogkit` it enumerates and creates the translated body beside the default in each post's per-slug folder (front-matter + body), extends the post's single `hero.js` i18n map with the new locale (its hero rendered via blogkit's hero skill), and it hunts hardcoded locale lists (static params, hreflang, sitemaps, middleware) that name codes literally. Use this whenever the user wants to add, support, or ship a new language or locale, translate the whole site / app into another language, internationalize into German / French / Spanish / Japanese / etc., launch in a new country or market, or asks "how do we add <language>?" - even if they never name i18nkit. It EXTENDS an already-wrapped codebase to a new locale; if bare untranslated strings might still exist, run i18nkit-sweep FIRST so the compiler can see them, then this. Takes the new locale code and an optional endonym label.
4
+ user-invokable: true
5
+ argument-hint: "<locale> [endonym] e.g. de | fr \"Français\" | pt-BR \"Português (Brasil)\""
6
+ ---
7
+
8
+ # i18nkit-add-locale
9
+
10
+ Extend a project already built on i18nkit to **one new locale**, end to end: add the locale to the
11
+ config, translate every catalog entry the compiler now flags, and close the two gaps the compiler
12
+ cannot see (blogkit posts, hardcoded locale lists). This is the **inverse of i18nkit-sweep**. The sweep
13
+ has no compiler signal - it hunts the *absence* of a translator and needs a team reading every file.
14
+ Adding a locale is the opposite: the moment the new code enters `new I18n({ locales })`, the locale
15
+ union `L` grows and `LanguageText<L>` is a mapped type over it, so `tsc` reports **every** un-covered
16
+ entry as a compile error. The worklist is exact and free; the skill's job is to fill it with **real**
17
+ translations and to not miss the handful of things that live outside the type system.
18
+
19
+ The bar is **100% coverage with shippable quality**: `tsc` green proves nothing is structurally
20
+ missing, and every translation both **carries the source's meaning and intent** (never a literal
21
+ word-for-word swap - see the principle below) and is **grammatically flawless native copy**, or is
22
+ explicitly flagged for a human - never a silent guess (a fabricated translation compiles green and
23
+ ships wrong copy, the exact failure the types exist to prevent).
24
+
25
+ **Translate for meaning, not word-for-word, in perfect grammar - this is non-negotiable and the
26
+ easiest thing to get wrong.** A literal rendering drags the source language's grammar and idiom into
27
+ the target and reads as broken to a native customer; the right translation is often *not* the direct
28
+ one (an idiom becomes a different idiom, a slogan is transcreated to land in the target culture, a
29
+ stiff construction is reshaped to how a native actually speaks). Read what surrounds each string - what
30
+ it is, who reads it, what the product means to convey to the customer - and translate that *intent*,
31
+ with 100% correct grammar (every agreement, gender, case, plural, accent). `reference/translation-rules.md`
32
+ carries the full principle; it is the first thing every translation agent must internalise.
33
+
34
+ ## The authority
35
+
36
+ `reference/translation-rules.md` (in this skill's own directory) is the adjudicator: the compiler-vs-
37
+ uncatchable split, how to edit the config, exactly how to translate each entry shape (static,
38
+ parameterized, split rich text) while preserving params / placeholders / structure, what to **never**
39
+ translate (`uniform`, brand, endonyms, data), when to flag `needs-review` instead of guessing, and the
40
+ blogkit + hardcoded-list rules. **Read it now.** Let `<skill-dir>` be this skill's own directory
41
+ (the folder holding this SKILL.md); resolve it to an absolute path and pass
42
+ `<skill-dir>/reference/translation-rules.md` to every translation agent - it is what keeps quality
43
+ high. (The skill is a drop-in: it carries its own rules and script, nothing here hardcodes a path.)
44
+
45
+ ## Inputs
46
+
47
+ Everything after `/i18nkit-add-locale` is one raw string. Parse in prose:
48
+ 1. The **first token is the new locale code** (`de`, `fr`, `pt-BR`, `ja`). Required. If it is missing,
49
+ ask which language to add rather than guessing.
50
+ 2. The **remainder, if any, is the endonym label** (`"Français"`, `"Português (Brasil)"`) - the
51
+ locale's own native name for the picker. If omitted, supply the correct native name yourself and
52
+ show it for confirmation in Phase 0 (get diacritics / script right).
53
+
54
+ ## Phase 0 - Locate the config and confirm the entry
55
+
56
+ Find the consumer's `I18n` instance: `new I18n({ locales: {...}, default: "..." })` (conventionally
57
+ `app/i18n.ts`, often `export const i18n` with `type Locale = keyof typeof i18n.locales`). Read it to
58
+ learn the **existing locales** (the keys of `locales`) and the **default**. Then decide the new entry:
59
+ - `label` - the endonym (from the argument, or the correct native name you supply).
60
+ - `htmlLang` - the BCP-47 document subtag / URL prefix (primary subtag `de`; a regional form `pt-BR`
61
+ only when the site distinguishes regional URLs). Omit when it equals the map key.
62
+ - `locale` - the full `Intl` locale for formatting (`de-DE`, `fr-FR`). Omit when `htmlLang` suffices.
63
+
64
+ Confirm the code is not already configured (if it is, this is a re-run - jump to Phase 2 to fill any
65
+ gaps). If the codebase may still contain **bare, unwrapped** strings, note that those are invisible to
66
+ this compiler-driven flow and recommend running **/i18nkit-sweep** first so every string is in a
67
+ catalog the new locale can force. State the resolved entry to the user before editing.
68
+
69
+ ## Phase 1 - Add the locale (turn on the compiler)
70
+
71
+ Make the **single** edit to the config's `locales` map: add the new entry, placed where it should
72
+ appear in the picker (insertion order is picker order). Do **not** change `default`. Do **not** edit
73
+ anything else yet - every downstream method reads the instance, so this one edit is what makes the new
74
+ locale exist and what makes `tsc` start reporting the worklist.
75
+
76
+ ## Phase 2 - Let the compiler enumerate the worklist
77
+
78
+ Run the consumer's typecheck - `npm run typecheck`, fallback `npx tsc --noEmit`. Every catalog entry
79
+ still missing the new locale now errors (typically `Property '<newcode>' is missing in type ...` at the
80
+ `i18n.defineTextCatalog(...)` / `defineText(...)` call site). **This list is the exhaustive worklist** -
81
+ group the errors **by file**. `i18n.uniform(...)` entries will **not** appear (they cover every locale
82
+ by construction); leave them alone. If the count is zero, every entry is already covered - skip to
83
+ Phase 4.
84
+
85
+ ## Phase 3 - Translate every entry (fan out when there are many)
86
+
87
+ Filling each entry is independent per file, so **shard the erroring files and translate in parallel** -
88
+ the meticulous, throw-compute-at-it half. For a handful of files, do it inline. For many, spawn one
89
+ `general-purpose` agent per shard of ~8-12 files, **all in one message** so they run concurrently
90
+ (keep to ~10-12 at once; run more in a second batch). Give each agent this task (fill in the shard and
91
+ the resolved rules path):
92
+
93
+ > You are translating this project's i18nkit catalogs into a new locale: **`<code>` (`<language
94
+ > name>`)**. **First read `<skill-dir>/reference/translation-rules.md`** - it is the exact rule for how
95
+ > to translate each entry shape and what to never touch. The existing locales are `<list them>`; read
96
+ > their values in each file first to match tone, register (formal vs. informal address), and voice.
97
+ >
98
+ > **Translate for meaning and intent, never word-for-word, in flawless native grammar - this is the
99
+ > whole point of the task.** A literal translation is the #1 way to ship wrong copy: it carries the
100
+ > source grammar and idiom into the target and reads as broken to a native customer. For every string,
101
+ > look at its context - what it is (button, heading, error, legal line, marketing promise), who reads
102
+ > it, and what the product means to convey to the customer - and translate that intent; the natural
103
+ > native phrasing often diverges from the direct one (idioms, slogans, register), and when it does,
104
+ > choose the natural one. Grammar must be 100% correct: every agreement, gender, case, plural, accent,
105
+ > diacritic. Hold each entry to a professional native writer's standard, not a dictionary's.
106
+ >
107
+ > Your files: `<list the shard's paths>`.
108
+ >
109
+ > For every `defineTextCatalog` / `defineText` / `LanguageText` / `LanguageTextFn` entry in these
110
+ > files, **add the single new key `<code>`** with a real translation. Preserve everything structural:
111
+ > for parameterized entries keep the exact parameter types and arity of the sibling locales and reuse
112
+ > every `${...}` value (reorder words for the target grammar, never the params); for split rich-text
113
+ > keep the fragment count; convert HTML entities to literal Unicode. **Never** touch `i18n.uniform(...)`
114
+ > entries (already covered), brand / product names, endonyms, or data values. Where a translation is
115
+ > genuinely uncertain (slogan, legal phrase, domain jargon, tight length constraint), still add your
116
+ > best draft **and** report it as `needs-review` with a one-line reason - never leave a blank, never
117
+ > confidently guess legal/brand copy. **Do not run the compiler, do not spawn sub-agents, do not edit
118
+ > anything but the new key in each entry.** Return, per file: the entries you translated, and the
119
+ > `needs-review` list with `file:line` + reason.
120
+
121
+ Collect every agent's result. Then **loop `tsc` to green yourself**: re-run the typecheck, and for any
122
+ remaining error fix that entry (a missed file, a parameterized signature that drifted, an entity that
123
+ broke a template). Green `tsc` = every catalog entry provably covers the new locale.
124
+
125
+ ## Phase 4 - Close the compiler-invisible gaps
126
+
127
+ Run the bundled scanner over the source root (it needs the new code, and the existing codes + blog
128
+ `contentDir` / `defaultLocale` / `extension` from the config if the defaults differ):
129
+
130
+ ```bash
131
+ EXISTING_CODES="en nl" CONTENT_DIR="./blog" DEFAULT_CODE="en" <skill-dir>/scripts/scan.sh <newcode> "<src or .>"
132
+ ```
133
+
134
+ It prints two lead sections - confirm each by reading, none is a verdict:
135
+ 1. **BLOGKIT POST WORKLIST** - each post is a `<contentDir>/<slug>/` folder; for each, the
136
+ `<slug>/<newcode>` body that must exist beside the default (`<slug>/<defaultLocale>` or the neutral
137
+ `<slug>/post`), and whether the post has a `hero.js`. If blogkit is in use: add the new locale to the
138
+ `Blog` config's `locales[]`, then create each listed body - **same slug**, front-matter human fields
139
+ + body translated per translation-rules.md, and extend the post's single `hero.js` `text` map with
140
+ the new locale (reusing its shared gradient), its per-locale JPEG rendered via blogkit's
141
+ `hero_image.md`. Posts are independent, so fan them out one-agent-per-post exactly like Phase 3 when
142
+ there are several; for long articles, hand the precise worklist to the author and point at blogkit's
143
+ own writing / hero skills rather than machine-translating a long body. Report any **hero locale gap**
144
+ or **broken hero reference**. If blogkit is not detected, say so and skip.
145
+ 2. **HARDCODED LOCALE-LIST LEADS** - lines in app code that name existing locale codes as literals
146
+ (`generateStaticParams`, hreflang / sitemap builders, middleware matchers, locale `switch`es).
147
+ These do not follow `L`, so the new locale is missing until they are updated. Add the new code, and
148
+ note where deriving the list from `i18n.list` would prevent the next recurrence.
149
+
150
+ Also note whether the new locale has a **built-in picker flag** (the React `localeFlag` helper): if not,
151
+ the picker shows just the label - fine, but say so, and mention a custom `renderFlag` can supply one.
152
+
153
+ ## Phase 5 - Verify and report
154
+
155
+ 1. **Prove it compiles.** Final `npm run typecheck` (fallback `npx tsc --noEmit`) must be green - state
156
+ it. If the project has a build, run it too. Never leave the tree non-compiling.
157
+ 2. **Report**, in the conversation, per `reference/translation-rules.md`'s "What done looks like":
158
+ - the **config diff** (the exact `locales` entry added);
159
+ - **`tsc` green** stated as the coverage proof, with the **translated-entries count**;
160
+ - the **`needs-review` list** - every flagged entry by `file:line` + reason, so a human reviews only
161
+ those;
162
+ - the **blogkit result** (posts created / handed off, hero locale gaps, or "not detected");
163
+ - the **compile-invisible coverage line**: hardcoded-list leads fixed or left as advice, and the
164
+ picker-flag note.
165
+ Offer to save a long report to `claude/reports/i18nkit-add-locale-<code>.md`.
166
+
167
+ ## Notes
168
+
169
+ - **Why compiler-driven, not a blind team:** unlike the sweep, the catalog worklist is exact and
170
+ provided by `tsc` - so the skill leans on the compiler for *what* to translate and spends its agents
171
+ on the part that is actually parallel and judgment-heavy (*translating* many entries / posts well).
172
+ Don't re-implement enumeration the compiler already does.
173
+ - **The blogkit half is the real risk.** It is the one place a new locale is not compile-checked and
174
+ has no runtime fallback, so a missed post is a live broken page - never let the green `tsc` lull you
175
+ into skipping Phase 4.
176
+ - **Portability:** the default fan-out uses the `Agent` tool; the same finder -> per-shard translate ->
177
+ verify shape maps onto a `Workflow` for a very large catalog, but Agent parallelism is enough for
178
+ most repos. The skill makes no repo-specific assumptions - it reads the locale set from the project's
179
+ own `I18n` config and its own bundled rules/script by relative path, and degrades from
180
+ Next/React-specific guidance (`generateStaticParams`, `app/i18n.ts`) to a generic `src/` project.
181
+ - **Re-runnable:** run it again for the same code to fill any gap left by later-added copy, or run
182
+ /i18nkit-sweep first to guarantee every string is wrapped before you add the locale.
@@ -0,0 +1,207 @@
1
+ # i18nkit-add-locale rules - what a correct translation is, and what the compiler can't see
2
+
3
+ This is the adjudicator for adding a locale. The job has two halves with very different guarantees,
4
+ and getting the split right is the whole skill:
5
+
6
+ 1. **Catalog entries are compiler-enforced.** Every user-facing string already lives in a
7
+ `LanguageText<L>` / `LanguageTextFn<L, A>` - a mapped type over the locale union `L`. Adding the
8
+ new code to `new I18n({ locales })` grows `L`, so `tsc` reports every entry still missing the new
9
+ key as a compile error. That error list **is** the exhaustive worklist: when `tsc` is green, every
10
+ catalog entry provably covers the new locale. You do not hunt for these - the compiler hands them
11
+ to you, exactly.
12
+ 2. **A few things the compiler cannot see** and that therefore need a direct check: `@daanvandenbergh/blogkit`
13
+ post files (content, localized file-per-language in a per-slug folder - one body per language plus a
14
+ per-post `hero.js` - with **no** silent fallback, so a missing file is a broken page), hardcoded
15
+ locale-code lists in app code (static params, hreflang, sitemaps, middleware matchers) that enumerate
16
+ locales literally instead of from `i18n.list`, and whether the new locale has a built-in picker flag.
17
+ None of these move `L`, so none of them error.
18
+
19
+ The bar is **a real, shippable, grammatically-flawless translation that carries the source's meaning
20
+ and intent** for every entry, with anything genuinely uncertain flagged for a human - never a silent
21
+ guess. A fabricated or sloppy translation is worse than a flagged gap: it compiles green and ships
22
+ wrong copy to real users, which is exactly the failure the type system exists to prevent. Translate
23
+ confidently where the language and the copy are unambiguous; flag where they are not.
24
+
25
+ ## Translate for meaning and intent - never word-for-word - in flawless native grammar
26
+
27
+ **This is the heart of the job and the easiest thing to get wrong - read it before you translate a
28
+ single string.** Do **not** translate literally. A word-for-word rendering of the source is the single
29
+ most common way to ship copy that is technically "translated" and still wrong: it drags the source
30
+ language's grammar, idiom, and word order into a language where they read as broken, foreign, or
31
+ unintentionally comic - and a native customer notices *instantly*, the same way you would notice a
32
+ clumsily-translated foreign product. Your task is not to swap words; it is to carry the **meaning and
33
+ intent** across so the result reads as though it were originally written in the target language, not
34
+ translated into it.
35
+
36
+ - **Read the context first - always.** Look at what surrounds each entry: is this string a button, a
37
+ heading, a tooltip, a validation error, a legal line, a marketing promise? Who reads it, in what
38
+ moment, and what is the product trying to make that customer understand, feel, or do here? The same
39
+ English word is translated differently in a three-word CTA than in an error message. You are bringing
40
+ the author's intent to the customer - translate the *purpose* of the string, not just its characters.
41
+ - **Prefer the phrasing a native speaker would actually use over the one closest to the source.** Very
42
+ often the right translation is **not** the direct one: an idiom becomes a different idiom (or plain
43
+ language), a slogan is rewritten to land in the target culture (this is transcreation, not
44
+ translation), a construction that is natural in the source reads stiff or wrong in the target and must
45
+ be reshaped. When the natural native phrasing diverges from the literal source, **choose the natural
46
+ one every time.** Faithful to meaning beats faithful to words.
47
+ - **Grammar must be 100% correct - there is no "good enough".** Every ending, agreement, gender, case,
48
+ article, plural, contraction, accent, and diacritic must be right. A single grammatical error is the
49
+ tell that a page was machine-translated and it corrodes trust in the entire product the way a typo on
50
+ the homepage does. Hold every entry to the standard of a professional native writer, not a dictionary.
51
+ Where correct grammar depends on a runtime value - the grammatical gender of an interpolated noun,
52
+ plural agreement with a count, a case ending that changes with the inserted word - the string shape
53
+ may not be able to stay correct for all inputs: treat that as a real linguistic problem, flag it
54
+ `needs-review`, and describe it, rather than shipping a form that is only sometimes grammatical.
55
+ - **When meaning can't be preserved faithfully without more context** - an ambiguous source string, a
56
+ term of art, a pun, a brand voice you cannot verify - translate to your best judgment and flag it
57
+ `needs-review` with the *specific* doubt. Meaning you are unsure of is exactly what a human confirms;
58
+ never paper over it with a confident literal guess.
59
+
60
+ The two requirements hold **together**: the source's meaning and intent must survive, **and** the result
61
+ must be grammatically flawless, natural native copy. A translation that satisfies only one of them is a
62
+ defect, not a draft - a beautiful literal sentence with wrong intent fails, and a correct-meaning
63
+ sentence with broken grammar fails.
64
+
65
+ ## Editing the I18n config (the switch that turns on the compiler)
66
+
67
+ Add one entry to the instance's `locales` map (conventionally in `app/i18n.ts`):
68
+
69
+ ```ts
70
+ locales: {
71
+ en: { label: "English" },
72
+ nl: { label: "Nederlands", htmlLang: "nl", locale: "nl-NL" },
73
+ de: { label: "Deutsch", htmlLang: "de", locale: "de-DE" }, // <- the new entry
74
+ },
75
+ ```
76
+
77
+ - **`label` is the endonym** - the locale's own native name, shown in the picker in every UI language
78
+ alike (`Deutsch`, `Français`, `Português (Brasil)`, `日本語`). It is **not** copy and is **not**
79
+ translated into the other UI languages. Get the native spelling right (correct diacritics, script).
80
+ - **`htmlLang`** is the BCP-47 document subtag and the URL prefix (`/de/...`). Use the primary subtag
81
+ (`de`, `fr`), or a primary-region form (`pt-BR`) only when the site deliberately distinguishes
82
+ regional variants in its URLs. It defaults to the map key, so it can be omitted when the key is
83
+ already the subtag you want.
84
+ - **`locale`** is the full BCP-47 `Intl` formatting locale (`de-DE`, `fr-FR`, `pt-BR`) used for
85
+ dates/numbers. Set it when the region matters for formatting; it defaults to `htmlLang`.
86
+ - **Insertion order is the picker order** - place the new entry where it should appear in the list.
87
+ - **Do not touch `default`.** Adding a locale never changes the fallback locale.
88
+
89
+ Add the entry in one place only. Every downstream method (`translator`, `localizeHref`,
90
+ `resolveLocale`, the picker, hreflang) reads the instance, so nothing else in well-built consumer code
91
+ needs a second edit for the locale to *exist* - the remaining work is filling in its translations.
92
+
93
+ ## Translating a catalog entry - preserve every structural token, translate the copy for meaning
94
+
95
+ This section is the mechanics; it sits **under** the meaning-and-grammar principle above, never against
96
+ it. "Preserve structure" means keep the code intact (params, `${...}`, fragments, object shape) - it
97
+ does **not** mean translate the words literally. Translate the copy for meaning and intent, in flawless
98
+ grammar, *while* keeping every structural token below exactly as it is.
99
+
100
+ You are adding **one new key** to an existing entry. Never remove or reword the other locales' values,
101
+ never reformat the object, never add or delete entries.
102
+
103
+ - **Static text** (`LanguageText`): add the new key with the translated string.
104
+ `{ en: "Save changes", nl: "Wijzigingen opslaan" }` -> add `de: "Änderungen speichern"`.
105
+ - **Parameterized text** (`LanguageTextFn`): every locale shares **one** argument tuple `A`. Add a
106
+ builder with the **same parameter types and arity** as the sibling locales - you may rename the
107
+ parameters, but never add, drop, or retype one, or `A` changes and it will not compile. Translate
108
+ the template body and **reorder the words freely for the target grammar** - that is the entire
109
+ reason this is a function per locale (each language keeps its own word order). Use **every**
110
+ interpolated value the siblings use; dropping one silently loses that data.
111
+ `{ en: (name: string) => \`Welcome back, ${name}\` }` -> add
112
+ `de: (name: string) => \`Willkommen zurück, ${name}\``. Keep every `${...}` expression byte-identical
113
+ (they are code, not words).
114
+ - **Split rich text** (a sentence deliberately broken into several `LanguageText` fragments composed
115
+ in JSX): translate **each fragment**, keeping the same number of fragments. The natural break point
116
+ may fall at a different word in the target language - that is fine; translate for meaning across the
117
+ fragments, do not force a word-for-word alignment that reads wrong.
118
+ - **HTML entities -> the literal Unicode char** (`&middot;` -> `·`, `&rsquo;` -> `'`, `&nbsp;` -> a real
119
+ non-breaking space), matching how the other locales are authored.
120
+ - **Match register and tone.** Read the existing locales first to gauge the voice (marketing vs.
121
+ formal, `du` vs. `Sie`, `tu` vs. `vous`) and keep the new locale consistent with it across the whole
122
+ catalog. Consistency of address form matters as much as literal accuracy.
123
+ - **Numbers, dates, currency** are formatted at runtime via `i18n.intlLocaleFor(locale)` - never bake
124
+ a formatted number or a currency symbol into the translated string.
125
+
126
+ ## Never translate these (leave them exactly as they are)
127
+
128
+ - **`i18n.uniform("...")` entries.** `uniform` generates the value for **every** locale code at
129
+ runtime, so the new locale is already covered - these never appear in the `tsc` error list. Do not
130
+ "add a key" to them; do not convert them.
131
+ - **Brand and product names, proper nouns, the wordmark.** A product name is not translated (`"Acme
132
+ Pro"` stays `"Acme Pro"`). If a name is genuinely rendered differently in the target market, that is
133
+ a `needs-review` call for a human, not a guess.
134
+ - **Machine/data values** that were never copy in the first place: enum keys, ids, URLs, CSS classes,
135
+ `<option value>` keys, currency/tax codes. These are not in the catalogs and do not concern you.
136
+ - **Existing locales' values.** You are only ever adding the new key.
137
+
138
+ ## Flag - do not silently translate - when
139
+
140
+ Add the new key with your best translation **and** list the entry as `needs-review` (with a one-line
141
+ reason) whenever the copy is: a marketing slogan or tagline where literal translation reads wrong, a
142
+ legally-loaded phrase (consent, liability, pricing terms), domain jargon or an industry term of art, a
143
+ UI string whose length constraint could break layout in the target language, or anything where meaning
144
+ is genuinely ambiguous without product context. Provide the draft so the human reviews rather than
145
+ writes from scratch, but make the uncertainty explicit. Never leave an entry blank to "compile later" -
146
+ a blank does not compile, and a fabricated confident translation of legal/brand copy is the one
147
+ outcome worse than a flagged draft.
148
+
149
+ ## blogkit post translation - the compiler-invisible content half
150
+
151
+ If the project uses `@daanvandenbergh/blogkit`, adding a locale to i18nkit does **not** create the
152
+ blog's translations - blogkit localizes **file-per-language inside a per-slug folder**, and it has
153
+ **no** silent fallback, so every post needs a body file for the new locale or that page is broken.
154
+
155
+ Each post is a **folder**: `<contentDir>/<slug>/` holds one `<locale><ext>` body per language (the
156
+ default is `<slug>/<defaultLocale><ext>`, e.g. `en.mdx`; a neutral `<slug>/post<ext>` is the fallback
157
+ name), plus a single `<slug>/hero.js` that renders every language's hero. (Extension defaults to `.mdx`.)
158
+
159
+ 1. **Add the new locale to the `Blog` config** (`locales[]` with its `code`/`label`) so blogkit serves
160
+ it, and cross-check that its locale set now matches the `I18n` locales - a divergence between the two
161
+ is its own finding.
162
+ 2. **For every post folder**, create the translated body `<contentDir>/<slug>/<newcode><ext>` beside the
163
+ default - **same slug** (routing depends on the folder name), translated:
164
+ - **Front-matter**: translate the human fields (`title`, `excerpt`/`description`, and `tags` only if
165
+ the site shows localized tag labels - otherwise leave tags as shared keys). Keep every front-matter
166
+ **key** and every non-copy value (dates, slugs, ids) unchanged.
167
+ - **Body**: translate prose. Leave code blocks, inline code, identifiers, URLs, and MDX component
168
+ names/props untouched. Keep the heading structure and any anchors.
169
+ - **Hero** (`<slug>/hero.js`): the hero is **not** a carried-over image path - it is one i18n-style
170
+ data file per post, `export default (locale) => ({ gradient, ...text[locale] })`, whose `text` map
171
+ holds each language's `title`/`subtitle`. Add the new locale's entry to that `text` map, **reusing
172
+ the post's single shared `gradient`** (never re-pick it) so the language renders with matching art.
173
+ A per-locale JPEG is then rendered to `public/assets/blog/<slug>/hero.<newcode>.jpg` and the new
174
+ `<newcode><ext>`'s `image:` front-matter pointed at **its own** file - never at another locale's
175
+ JPEG (each hero bakes in that language's text). Rendering runs headless Chrome, so hand it to
176
+ blogkit's `hero_image.md`; if a post's `hero.js` is still the single-language plain-object form,
177
+ converting it to the `(locale) => params` map is likewise blogkit's job. Flag a `hero locale gap`
178
+ if a post's `hero.js` (or its rendered JPEG) is missing the new locale, and a `broken hero
179
+ reference` if a translation's `image:` has no file under `public/` or points at another locale's JPEG.
180
+ 3. Translating full article bodies is content authoring - for anything beyond short posts, hand the
181
+ precise per-post worklist to the author and point at blogkit's own `skills/blogkit` writing skill and
182
+ its `hero_image.md` skill rather than machine-translating a long article silently. (If the project
183
+ annotates `hero.js` with `@satisfies {Record<Locale, …>}` and runs `checkJs`, `tsc` may also surface
184
+ a missing locale there - a bonus, not the mechanism you rely on.)
185
+
186
+ ## Hardcoded locale lists - the other compiler-invisible gap
187
+
188
+ Well-built consumer code enumerates locales from `i18n.list` / `i18n.locales`, so adding a locale flows
189
+ through automatically. A **literal** list of codes does not - it is the anti-pattern that leaves the new
190
+ locale half-wired. Look for and update: `generateStaticParams` returning a fixed `[{ locale: "en" }, ...]`,
191
+ hreflang/sitemap builders iterating a bare `["en","nl"]`, a middleware matcher listing locale prefixes,
192
+ or a `switch`/map keyed on locale codes. The bundled `scripts/scan.sh` surfaces these as leads; confirm
193
+ each by reading the file - the fix is either to add the new code or (better, noted as advice) to derive
194
+ the list from `i18n.list`.
195
+
196
+ ## What "done" looks like - the report
197
+
198
+ - **The config diff**: the exact `locales` entry added (code, label, htmlLang, locale).
199
+ - **`tsc` is green**: state it explicitly - that is the proof that every catalog entry now covers the
200
+ new locale. (Run the consumer's `npm run typecheck`, fallback `npx tsc --noEmit`.)
201
+ - **A translated-entries count** and a **`needs-review` list**: every entry translated, with the flagged
202
+ ones called out by `file:line` and reason, so a human reviews exactly those and not the whole set.
203
+ - **blogkit result**: posts created / handed off, plus any hero locale gap or broken hero reference; or
204
+ "blogkit not detected - skipped".
205
+ - **Compile-invisible coverage**: the hardcoded-list leads that were fixed or left as advice, and a
206
+ one-line note if the new locale has **no built-in picker flag** (the picker falls back to its label;
207
+ a custom `renderFlag` can supply one) - not a bug, just so nothing is silently missing.
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # scan.sh - the compile-invisible checks for i18nkit-add-locale.
4
+ #
5
+ # Adding a locale to `new I18n({ locales })` grows the union `L`, so `tsc` already hands you an
6
+ # exhaustive worklist of every catalog entry to translate. This script covers only the two things
7
+ # the compiler CANNOT see, because neither moves `L`:
8
+ # 1. BLOGKIT POST WORKLIST - for each post folder <slug>/, the <slug>/<newcode> body the new locale
9
+ # needs beside the default (@daanvandenbergh/blogkit localizes file-per-language inside a per-slug
10
+ # folder with NO fallback, so a missing file is a broken page), plus whether the post has a
11
+ # hero.js whose per-locale text map + rendered JPEG must gain the new locale.
12
+ # 2. HARDCODED LOCALE-LIST LEADS - app code that names locale codes as literals (generateStaticParams,
13
+ # hreflang/sitemap builders, middleware matchers, locale switches) instead of reading i18n.list,
14
+ # so the new locale is missing there until it is added by hand.
15
+ #
16
+ # Every line printed is a LEAD, not a verdict - confirm each by reading the file against
17
+ # reference/translation-rules.md. It errs toward over-reporting; the agent's judgment decides.
18
+ #
19
+ # Usage: scan.sh <new-locale-code> [source-root] (source-root default: src)
20
+ # Env: EXISTING_CODES="en nl" the currently-configured codes, for the hardcoded-list grep
21
+ # CONTENT_DIR="./blog" blogkit content root (from the Blog config; default ./blog)
22
+ # DEFAULT_CODE="en" blogkit default locale (names the base body <slug>/<default><ext>;
23
+ # from the Blog config; default en)
24
+ # EXT=".mdx" blogkit post extension (from the Blog config; default .mdx)
25
+ # CONFIG_FILE="app/i18n.ts" the I18n config file to exclude from leads (it is meant to
26
+ # name every code; excluding it drops the obvious false leads)
27
+
28
+ set -euo pipefail
29
+
30
+ NEWCODE="${1:-}"
31
+ ROOT="${2:-src}"
32
+ EXISTING_CODES="${EXISTING_CODES:-}"
33
+ CONTENT_DIR="${CONTENT_DIR:-./blog}"
34
+ DEFAULT_CODE="${DEFAULT_CODE:-en}"
35
+ EXT="${EXT:-.mdx}"
36
+ CONFIG_FILE="${CONFIG_FILE:-}"
37
+
38
+ if [ -z "$NEWCODE" ]; then
39
+ echo "usage: scan.sh <new-locale-code> [source-root] (EXISTING_CODES / CONTENT_DIR / DEFAULT_CODE / EXT / CONFIG_FILE via env)" >&2
40
+ exit 2
41
+ fi
42
+
43
+ echo "############################################################"
44
+ echo "# i18nkit-add-locale compile-invisible scan"
45
+ echo "# new locale : $NEWCODE"
46
+ echo "# source root: $ROOT"
47
+ echo "# blog dir : $CONTENT_DIR (default $DEFAULT_CODE, extension $EXT)"
48
+ echo "# These are LEADS ONLY - confirm each by reading the file."
49
+ echo "############################################################"
50
+
51
+ # ---------------------------------------------------------------------------
52
+ echo
53
+ echo "== SECTION 1: BLOGKIT POST WORKLIST (folder-per-slug, file-per-language, no fallback) =="
54
+ echo " Each post is a $CONTENT_DIR/<slug>/ folder with one <locale>$EXT body per language (the"
55
+ echo " default is <slug>/$DEFAULT_CODE$EXT, or the neutral <slug>/post$EXT). For each, the"
56
+ echo " <slug>/$NEWCODE$EXT that must exist. blogkit has no silent fallback, so a MISSING file"
57
+ echo " renders as a broken page."
58
+ if [ -d "$CONTENT_DIR" ]; then
59
+ found_post=0
60
+ for dir in "$CONTENT_DIR"/*/; do
61
+ [ -d "$dir" ] || continue
62
+ slug="$(basename "$dir")"
63
+ # The default-locale body names the post: prefer <default>$EXT, fall back to the neutral post$EXT.
64
+ if [ -f "$dir$DEFAULT_CODE$EXT" ]; then
65
+ base="$dir$DEFAULT_CODE$EXT"
66
+ elif [ -f "${dir}post$EXT" ]; then
67
+ base="${dir}post$EXT"
68
+ else
69
+ base=""
70
+ fi
71
+ if [ -z "$base" ]; then
72
+ # No default-locale body: any <locale>$EXT here is an orphan translation (base renamed/
73
+ # deleted). A folder with no bodies at all is not a post - stay quiet.
74
+ for tr in "$dir"*"$EXT"; do
75
+ [ -e "$tr" ] || continue
76
+ echo " ORPHAN : $tr (no $DEFAULT_CODE$EXT / post$EXT base body in $slug/ - stale)"
77
+ done
78
+ continue
79
+ fi
80
+ found_post=1
81
+ target="$dir$NEWCODE$EXT"
82
+ # The hero is one <slug>/hero.js exporting (locale)=>params with a per-locale `text` map; the new
83
+ # locale needs a `text` entry there AND a rendered public/.../hero.$NEWCODE.jpg (translation-rules.md).
84
+ if [ -f "${dir}hero.js" ]; then
85
+ hero=" [has hero.js - add \"$NEWCODE\" to its text map + render hero.$NEWCODE.jpg]"
86
+ else
87
+ hero=""
88
+ fi
89
+ if [ -f "$target" ]; then
90
+ echo " ok : $target$hero"
91
+ else
92
+ echo " MISSING : $target$hero"
93
+ fi
94
+ done
95
+ if [ "$found_post" -eq 0 ]; then
96
+ echo " (no <slug>/ post folders with a $DEFAULT_CODE$EXT / post$EXT body in $CONTENT_DIR - nothing to translate here)"
97
+ fi
98
+ else
99
+ echo " (no $CONTENT_DIR directory - blogkit not detected; skip this section unless the blog"
100
+ echo " content root lives elsewhere, then re-run with CONTENT_DIR set)"
101
+ fi
102
+
103
+ # ---------------------------------------------------------------------------
104
+ echo
105
+ echo "== SECTION 2: HARDCODED LOCALE-LIST LEADS =="
106
+ echo " Lines naming existing codes as string literals - these do NOT follow the locale union L,"
107
+ echo " so $NEWCODE is missing there until added (prefer deriving the list from i18n.list)."
108
+
109
+ if [ -z "$EXISTING_CODES" ]; then
110
+ echo " (set EXISTING_CODES=\"en nl ...\" to scan for hardcoded lists - skipped)"
111
+ else
112
+ # Build an alternation of the existing codes as QUOTED string literals ("en" / 'en'). Catalog
113
+ # keys are unquoted (`en:`), so searching for the quoted form naturally skips catalog interiors
114
+ # and matches array/param/enumeration usage - exactly the leads we want.
115
+ alt=""
116
+ for c in $EXISTING_CODES; do
117
+ [ -z "$alt" ] && alt="$c" || alt="$alt|$c"
118
+ done
119
+ PATTERN="[\"']($alt)[\"']"
120
+
121
+ # Two backends, one interface (mirrors find_candidates.sh): prefer ripgrep, fall back to POSIX
122
+ # grep. Restrict to the .ts/.tsx universe and exclude tests / .d.ts.
123
+ if command -v rg >/dev/null 2>&1; then
124
+ SEARCH() { rg -n --no-heading -g '*.ts' -g '*.tsx' -g '!**/tests/**' -g '!**/*.d.ts' -g '!**/*.test.*' "$@"; }
125
+ else
126
+ SEARCH() { grep -rnE --include='*.tsx' --include='*.ts' "$@" | grep -vE '/tests/|\.d\.ts|\.test\.'; }
127
+ fi
128
+
129
+ # Drop the config file (it is meant to name every code) and comment/import lines.
130
+ DENOISE() {
131
+ if [ -n "$CONFIG_FILE" ]; then
132
+ grep -vF "$CONFIG_FILE" | grep -vE '^[^:]*:[0-9]+:[[:space:]]*(//|\*|/\*)'
133
+ else
134
+ grep -vE '^[^:]*:[0-9]+:[[:space:]]*(//|\*|/\*)'
135
+ fi
136
+ }
137
+
138
+ leads="$(SEARCH -e "$PATTERN" "$ROOT" 2>/dev/null | DENOISE || true)"
139
+ if [ -n "$leads" ]; then
140
+ printf '%s\n' "$leads"
141
+ else
142
+ echo " (no hardcoded locale-code literals found - locales likely derive from i18n.list)"
143
+ fi
144
+ [ -z "$CONFIG_FILE" ] && echo " NOTE: pass CONFIG_FILE=<path to the I18n config> to drop its expected self-matches."
145
+ fi
146
+
147
+ echo
148
+ echo "== done. Confirm every lead by reading the file against reference/translation-rules.md. =="