@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.
@@ -0,0 +1,208 @@
1
+ ---
2
+ name: i18nkit-sweep
3
+ description: Full-project sweep that hunts down every user-facing string NOT wrapped in i18nkit's type-safe system (LanguageText / defineTextCatalog / a translator from useTranslator or i18n.translator) - so adding a locale stays a compile-checked guarantee instead of a manual hope. A team of parallel agents reads every UI file, reports each bare literal with file:line and category, and (only with --fix) wraps the confident ones through a co-located defineTextCatalog and verifies typecheck stays green. When the project uses @daanvandenbergh/blogkit it also verifies every post's per-slug folder has a body for every configured locale, and that each post's hero.js covers every locale so each language renders its own hero. Use this whenever the user wants to audit, enforce, or fix i18n / translation coverage, find or fix hardcoded / untranslated / non-translatable strings, make "all text support i18n" or "use LanguageText everywhere", verify nothing bypasses the translation system, get the codebase ready before adding a language, or asks "are all our strings translatable?" - even if they don't name the i18n module. It closes the gap for text that ALREADY exists; do NOT use it for translating supplied strings into another language, adding or configuring a locale in the I18n instance, or changing the default locale. Takes an optional area (e.g. components, "app/(dashboard)") to scope the sweep, or omit for all of src/. Report-only by default; pass --fix to also wrap the confident violations.
4
+ user-invokable: true
5
+ argument-hint: "[area] [--fix] e.g. components | \"app/(dashboard)\" --fix | (omit = report all of src/)"
6
+ ---
7
+
8
+ # i18nkit-sweep
9
+
10
+ Hunt every piece of user-facing text in the project's source that reaches the UI as a **bare
11
+ string** instead of going through an i18nkit translator. This is a **negative-space** search:
12
+ correct code resolves copy through a `LanguageText`/`LanguageTextCatalog` built with
13
+ `i18n.defineTextCatalog`; a violation is the *absence* of that - a literal that silently escapes the
14
+ compile-time coverage guarantee. i18nkit's guarantee is structural: the locale set is a union `L`
15
+ inferred from `new I18n({ locales })`, `LanguageText<L>` is a mapped type over `L`, so adding a
16
+ locale turns every un-translated entry into a compile error - but a bare literal has no per-locale
17
+ keys, so nothing forces it to keep up. Grep alone can't judge intent, so the sweep is a **team of
18
+ agents that read the actual files** against a precise ruleset. Thoroughness comes from covering
19
+ every file; correctness comes from judgment, not pattern-matching.
20
+
21
+ The bar is **exhaustive coverage with high precision**. A short, correct list beats a long, noisy
22
+ one - every reported violation must be a real one a staff engineer would agree with.
23
+
24
+ ## Inputs
25
+
26
+ Everything after `/i18nkit-sweep` is one raw string. Parse in prose:
27
+ 1. Extract `--fix` if present, then strip it. Its presence means **also fix**: after reporting, wrap
28
+ the confident violations and re-verify typecheck (Phase 3.4). **Default (no `--fix`):
29
+ report-only** - sweep and report, change no files.
30
+ 2. The remainder is the **area** to scope to (a path under the source root, e.g. `components` or
31
+ `app/(dashboard)`). Empty -> sweep **all of `src/`** (or the project's user-facing source root).
32
+
33
+ ## The authority
34
+
35
+ `reference/rules.md` (in this skill's own directory) is the adjudicator: exactly what counts as
36
+ user-facing text, the full exception list (dev-only `throw`s, JSON-LD data fields, endonyms, brand
37
+ name, non-copy attributes, mask glyphs...), the gray-zone calls, and the finding format. **Read it
38
+ now.** Let `<skill-dir>` be this skill's own directory (the folder containing this SKILL.md);
39
+ resolve it to an absolute path and pass `<skill-dir>/reference/rules.md` to every sweep agent - it
40
+ is what keeps precision high. (The skill is a drop-in: it carries its own rules and script, so
41
+ nothing here hardcodes a machine path.)
42
+
43
+ ## Phase 0 - Learn the locale set
44
+
45
+ Locate the consumer's `I18n` instance: `new I18n({ locales: {...}, default: "..." })` (conventionally
46
+ `app/i18n.ts`, often re-exported as `export const i18n` with `type Locale = keyof typeof i18n.locales`).
47
+ Read it to learn:
48
+ - the **supported locales** = the keys of `locales` (e.g. `en`, `nl`), and
49
+ - the **default locale** = the `default` field.
50
+
51
+ That set is what "if we shipped one more locale tomorrow" means concretely, and it is the locale
52
+ axis both the string sweep and the blogkit-parity step (Phase 3.5) enumerate. Endonyms live in
53
+ `locales[x].label` and are **not** copy (see rules.md).
54
+
55
+ ## Phase 1 - Scope and shard
56
+
57
+ 1. **Resolve the file set.** All `.ts`/`.tsx` under the target area, **excluding** `tests/`,
58
+ `*.test.*`, `*.test-d.*`, `*.d.ts`. Priority (Next.js App Router + React first; degrade
59
+ gracefully for other setups):
60
+ - **HIGH** (where copy lives): route files under `app/` - pages, `layout`, `error`,
61
+ `not-found`, and `generateMetadata` returns - and everything under `components/`.
62
+ - **IN SCOPE - server-generated user-facing copy**: email modules (transactional subjects /
63
+ headings / bodies) and product/catalog modules (titles/descriptions shown at checkout). Real
64
+ users see these, so bare copy there is a violation (`backend-copy`; see rules.md item 6). The
65
+ rest of server code is machine/dev strings (throws, logs, index names) = not copy.
66
+ - **EXEMPT**: the blog area when `@daanvandenbergh/blogkit` is used (article bodies and
67
+ front-matter-derived text are content, localized file-per-language; parity is checked
68
+ separately in Phase 3.5); and placeholder/demo `_data.ts`-style data (stand-in content, treated
69
+ like DB data).
70
+ - **SKIP**: the file that constructs the `I18n` instance (it *is* the config; its locale labels
71
+ and default are the exceptions), and i18nkit's own package files.
72
+ - **Non-Next / non-React project?** Drop the `app/`-specific names and treat it generically: sweep
73
+ all user-facing source under the area (default `src/`), same rules.
74
+ 2. **Get a heat-map.** Run the bundled lead-finder over the area:
75
+ ```bash
76
+ <skill-dir>/scripts/find_candidates.sh "<area or src>"
77
+ ```
78
+ It prints **SUSPECT FILES** (render JSX but touch no seam/translator - read these first) and
79
+ **LITERAL LEADS** (bare copy attributes, same-line JSX text, toast/error, metadata/JSON-LD).
80
+ Leads are starting points, **not verdicts** - every file still gets read in full.
81
+ 3. **Shard.** Split the file set into groups of ~8-12 files along directory boundaries so each agent
82
+ has coherent context. Aim for one agent per coherent sub-area. Keep concurrent agents to ~10-12;
83
+ run more in a second batch.
84
+
85
+ ## Phase 2 - Parallel sweep (the team)
86
+
87
+ Spawn one `general-purpose` agent per shard, **all in one message** so they run concurrently. Give
88
+ each this task (fill in the shard's files and the resolved rules path):
89
+
90
+ > You are one of several agents sweeping this project's source for i18nkit violations - user-facing
91
+ > text that reaches the UI as a bare string instead of resolving through an i18nkit translator
92
+ > (`useTranslator()` in React, or `i18n.translator(locale)` / `i18n.translate(text, locale)` in the
93
+ > core). **First read `<skill-dir>/reference/rules.md`** - it is the exact definition of a violation,
94
+ > the full exception list, and the finding format. It is easy to over-report; follow it precisely.
95
+ > The project's configured locales are `<list them>` (default `<default>`).
96
+ >
97
+ > Your files: `<list the shard's paths>`.
98
+ >
99
+ > For **every** file: read it **in full** (do not rely on grep excerpts - multi-line JSX text and
100
+ > metadata objects hide from grep). Judge each string against the one question in the rules: *"if we
101
+ > shipped one more locale tomorrow, would this exact string have to change?"* Flag every bare
102
+ > user-facing literal; ignore every exception (dev-only `throw`s, per-locale `defineTextCatalog`
103
+ > values, data/URLs/enums/ids, endonyms, brand name, mask glyphs, decorative `alt=""`, non-copy
104
+ > attributes). Pay special attention to any file that renders copy but imports no `useTranslator` /
105
+ > `i18n.translator` / `defineTextCatalog` and never calls a translator.
106
+ >
107
+ > Return findings in the exact block format from rules.md (file, line, snippet, category, why, fix,
108
+ > confidence). For every file with no violations, emit `<path>: clean` so coverage is provable. **Do
109
+ > not spawn sub-agents. Do not modify files.** Return only the findings + the clean list.
110
+
111
+ ## Phase 3 - Verify, then report
112
+
113
+ 1. **Collect** every agent's findings and clean-list. Confirm each shard's files were all accounted
114
+ for (found-or-clean) - if an agent skipped files, re-dispatch them. No file goes unread.
115
+ 2. **Verify the doubtful.** Re-check every `medium`/`low` confidence finding yourself by reading that
116
+ spot (or dispatch a single skeptical verifier agent for a batch): does it truly change under a new
117
+ locale, or is it an exception? Drop the false positives. A wrong finding is worse than a missing
118
+ one here.
119
+ 3. **Report.** Print, in the conversation:
120
+ - A one-line verdict: `N violations across M files (K files swept, area = ...)`.
121
+ - Violations grouped **by file**, each as the rules.md block, ordered high->low confidence.
122
+ - A short **counts-by-category** tally (jsx-text / attribute / toast-or-error / metadata /
123
+ json-ld / backend-copy).
124
+ - A **coverage line**: which areas were swept and which were intentionally skipped (so the user
125
+ knows nothing was silently dropped), plus the blogkit-parity result from 3.5.
126
+ Offer to save the report to `claude/reports/i18nkit-sweep.md` if the list is long.
127
+ 4. **Fix (only under `--fix`; skip this whole step by default).** Apply the fixes per rules.md ("How
128
+ a fix is applied") - extend the file's `defineTextCatalog`, wire a translator via the right seam,
129
+ replace the literal. Touch only the violating lines; do not restructure files. Guards:
130
+ - **Only auto-fix `high`-confidence findings, and only when wrapping yields a green entry.** A
131
+ bare literal is the *default*-locale text; a catalog entry needs **every** configured locale to
132
+ compile, and you must never invent the others. So: single-locale project -> wrap (lossless);
133
+ multi-locale with the other translations genuinely known -> fill all and wrap; otherwise list
134
+ the finding as **needs-translation** and leave it unchanged. Say clearly what you applied vs.
135
+ left, and list every `medium`/`low` finding as **needs-your-call**.
136
+ - **`backend-copy` is not a mechanical wrap** - it needs a locale source threaded in (no
137
+ request-scoped locale in a job/webhook). Do **not** auto-edit backend files; report them with
138
+ the design note from rules.md item 6 and let the user decide the seam.
139
+ After editing, run `npm run typecheck` (fallback `npx tsc --noEmit`) and report it green. If it
140
+ fails, fix the wrap or revert that one finding - never leave the tree non-compiling.
141
+ 5. **blogkit translation parity (always, even without `--fix`).** This is the one place a new locale
142
+ is **not** compile-checked, so verify it directly - mostly a file check, no agent.
143
+ 1. **Detect blogkit.** The project uses it if any of: `@daanvandenbergh/blogkit` appears in the
144
+ consumer's `package.json` (`dependencies`/`devDependencies`), a source file imports it, or a
145
+ `new Blog({...})` instance exists. If **none** hold, **skip this whole step** and say so in the
146
+ coverage line ("blogkit not detected - blog parity skipped").
147
+ 2. **If present, read the `Blog` config** for `contentDir` (a required field, conventionally
148
+ `./blog`), the `locales[].code` list, `defaultLocale`, and `extension` (the post file
149
+ extension, default `.mdx`). Cross-check that locale set against the `I18n` locales from Phase 0;
150
+ if they diverge, report the divergence as its own finding. Each post is a **folder**
151
+ `<contentDir>/<slug>/` holding one `<locale>.mdx` body per language - the default is
152
+ `<slug>/<defaultLocale>.mdx` (e.g. `en.mdx`; a neutral `<slug>/post.mdx` is the fallback name),
153
+ each translation `<slug>/<code>.mdx` (same folder), plus one `<slug>/hero.js`. blogkit has **no
154
+ silent fallback**, so a missing file is a real gap.
155
+ 3. **Post parity - a pure file check.** Set `CONTENT_DIR` and `DEF` (the default locale) from the
156
+ config, replace `.mdx` with the configured `extension` if it is non-default, and **inline the
157
+ non-default locale codes as a literal list** (write `for l in nl de` - do **not** rely on an
158
+ unquoted `$langs` variable, which does not word-split under zsh):
159
+ ```bash
160
+ CONTENT_DIR="./blog"; DEF="en" # from the Blog config (contentDir, defaultLocale)
161
+ # find (not a bare glob) so an empty or non-post folder never aborts the loop under zsh's nomatch
162
+ find "$CONTENT_DIR" -mindepth 1 -maxdepth 1 -type d | while read -r dir; do
163
+ # a post needs a default-locale body: <default>.mdx, or the neutral post.mdx fallback
164
+ if [ ! -f "$dir/$DEF.mdx" ] && [ ! -f "$dir/post.mdx" ]; then
165
+ # no base body: any <locale>.mdx here is an orphan translation (base renamed/deleted)
166
+ find "$dir" -maxdepth 1 -name '*.mdx' -exec echo "ORPHAN: {}" \;
167
+ continue
168
+ fi
169
+ for l in nl de; do # <- the non-default locale codes, inline
170
+ [ -f "$dir/$l.mdx" ] || echo "MISSING: $dir/$l.mdx"
171
+ done
172
+ done
173
+ ```
174
+ 4. **Hero parity.** The hero is one `<slug>/hero.js` per post, `export default (locale) => ({
175
+ gradient, ...text[locale] })`, whose `text` map holds each language's `title`/`subtitle`; it is
176
+ rendered once per locale to `public/assets/blog/<slug>/hero.<code>.jpg`, and each body's
177
+ `image:` front-matter points at its **own** locale's JPEG (a hero bakes in that language's text,
178
+ so a shared JPEG is untranslated copy). Two checks:
179
+ - **Primary - `hero.js` locale coverage (robust, no path guessing).** For each post that has a
180
+ `hero.js` in the `(locale) => params` form, read its `text` map and confirm it has a key for
181
+ **every** configured locale. A configured locale missing from the map -> **hero locale gap**
182
+ (that language renders a blank/wrong hero). This is the signal blogkit's `hero_image.md`
183
+ defers to the sweep to catch project-wide. (A single-language plain-object `hero.js` on a
184
+ now-multi-locale blog is itself a `hero locale gap` - it needs converting to the map form.)
185
+ - **Secondary - rendered hero + `image:` reference (heuristic path).** By the Next.js
186
+ convention the JPEGs live at `public/assets/blog/<slug>/hero.<code>.jpg` (say the base dir is
187
+ a heuristic if the project serves static assets elsewhere). For each configured locale whose
188
+ body exists, flag a **broken hero reference** if its `hero.<code>.jpg` is missing, if the
189
+ body's `image:` path has no file under `public/`, or if the `image:` points at **another**
190
+ locale's JPEG (e.g. `<slug>/fr.mdx` -> `hero.en.jpg`). Check the base body too.
191
+ 5. **Report** each `MISSING:`, hero locale gap, broken hero reference, and orphan as a
192
+ **blogkit-parity gap** in the coverage section - a content-parity gap, not a translator
193
+ violation, and **never auto-fixed** here (writing/translating a post body, or generating a
194
+ localized hero, is the blog author's job - point to blogkit's own `skills/blogkit` write and
195
+ `hero_image.md` skills).
196
+
197
+ ## Notes
198
+
199
+ - **Why a team, not one pass:** a real UI has many text-bearing files; one agent reading all of them
200
+ loses focus and misses things. Sharded parallel reads keep each agent sharp and finish fast.
201
+ - **Portability:** the default fan-out uses the `Agent` tool. For a very large or repeated whole-repo
202
+ sweep the same shape maps onto a `Workflow` (finder -> per-shard sweep -> verify), but Agent
203
+ parallelism is enough for most repos.
204
+ - **Universal drop-in:** this skill makes no repo-specific assumptions - it resolves the locale set
205
+ from the project's own `I18n` config, reads its own bundled rules/script by relative path, and
206
+ degrades from Next/React-specific guidance to a generic `src/` sweep when the project is neither.
207
+ - **Re-runnable:** scope to an area you just edited (`/i18nkit-sweep app/(dashboard)/billing`) for a
208
+ fast focused check, or run bare before adding a locale for the full guarantee.
@@ -0,0 +1,174 @@
1
+ # i18nkit-sweep rules - what is a violation, what is not
2
+
3
+ This is the adjudicator for the sweep. A **violation** is any *user-facing text* in the project's
4
+ source that reaches the UI as a bare string instead of being resolved through an i18nkit
5
+ translator. The whole point of the system: the locale set is a union `L` (inferred from the keys of
6
+ `new I18n({ locales })`) and `LanguageText<L>` is a **mapped type over `L`**, so adding a locale
7
+ turns every un-translated entry into a compile error. A bare literal silently escapes that
8
+ guarantee - so it is a bug, always. There is no linter for this by design; this sweep is the
9
+ enforcement.
10
+
11
+ ## The one question that decides every case
12
+
13
+ > **If the project shipped one more locale tomorrow, would this exact string have to change?**
14
+
15
+ - **Yes** -> it is user-facing copy and MUST be a `LanguageText<L>` / `LanguageTextFn<L, A>` resolved
16
+ via a translator. If it is a bare literal, it is a **violation**.
17
+ - **No** (it is a name, URL, enum value, CSS class, id, dev-only error, log line, or other
18
+ machine/data string) -> not copy, **not a violation**. Do not flag it.
19
+
20
+ When genuinely unsure, keep the finding but mark `confidence: low` and say why - the aggregation
21
+ pass re-checks those. Precision matters: a flood of false positives is worse than a short, correct
22
+ list.
23
+
24
+ ## How correct code looks (so you recognize the absence of it)
25
+
26
+ - Copy lives in a co-located catalog built with the project's `I18n` instance:
27
+ `export const TX = i18n.defineTextCatalog({ title: { en: "Customers", nl: "Klanten" } });`
28
+ (import `i18n` from the consumer's config module, e.g. `import { i18n } from "@/i18n"`). Every
29
+ configured locale must be present or it does not compile. Parameterized entries are functions per
30
+ locale: `{ en: (n: string) => \`${n} deleted\`, nl: (n: string) => \`${n} verwijderd\` }`.
31
+ - **Core / server** (framework-agnostic): resolve the active locale yourself - from a cookie or the
32
+ `Accept-Language` header via `i18n.resolveLocale(...)` / `i18n.matchAcceptLanguage(...)` - then
33
+ bind a translator: `const translate = i18n.translator(locale);` and call `translate(TX.title)`.
34
+ A one-off resolve without binding is `i18n.translate(TX.title, locale)`. There is **no**
35
+ dedicated server helper; the locale is the caller's to resolve.
36
+ - **React**: `const translate = useTranslator();` (from `@daanvandenbergh/i18nkit/react`) returns a
37
+ translator already bound to the active locale from context (`I18nProvider`).
38
+ - In JSX: `{translate(TX.title)}`, `placeholder={translate(TX.ph)}`, `aria-label={translate(TX.close)}`,
39
+ `toast(translate(TX.saved))`, `setError(translate(TX.required))`. Interpolation:
40
+ `translate(TX.subtitle, orgName)`.
41
+ - A word that is deliberately identical in every locale (a brand token) is authored with
42
+ `i18n.uniform("...")` - a real `LanguageText<L>`, not a bare literal.
43
+ - Note any `.js` suffix on i18nkit / internal imports (NodeNext ESM) - it is correct, not a build
44
+ artifact. Never flag an import specifier.
45
+
46
+ **A UI file that renders copy but imports no seam (`useTranslator` / `i18n.translator` /
47
+ `defineTextCatalog`) and never calls a translator is the #1 place bare copy hides.** Read those
48
+ first (the finder's "SUSPECT FILES").
49
+
50
+ ## MUST be `LanguageText` (flag if bare) - the full list
51
+
52
+ 1. **JSX text children** - headings, paragraphs, button/link labels, list items, empty states,
53
+ badges, any words rendered between tags: `<h1>Customers</h1>`, `>Save changes<`.
54
+ 2. **User-facing attribute values**: `aria-label`, `alt` (non-empty, non-decorative), `placeholder`,
55
+ `title`. These are read aloud by screen readers or shown on hover - they are copy.
56
+ 3. **Toasts, validation messages, and any error string shown to a user**: `toast("Saved")`,
57
+ `setError("Name is required")`, an error message returned from a **server action** or an **API
58
+ route** that the UI renders.
59
+ 4. **Page SEO metadata**: `title`, `description`, and OpenGraph/Twitter fields, whether in a static
60
+ `export const metadata` or a `generateMetadata()` return.
61
+ 5. **Human-readable strings in JSON-LD** (`application/ld+json`): `name`, `description`, `headline`,
62
+ `serviceType`, FAQ question/answer text, etc.
63
+ 6. **Backend/server-generated copy that reaches a user** - transactional **email** subjects,
64
+ headings and body text, and **product/catalog** titles/descriptions shown at checkout. i18nkit's
65
+ type layer (`@daanvandenbergh/i18nkit`) is dependency-free and importable anywhere, so this copy
66
+ is in scope. Flag it as `category: backend-copy`. In the `fix`, note that a backend job/webhook
67
+ has **no request-scoped locale** (no cookie/headers), so wrapping also needs a **language source**
68
+ - the recipient's / org's locale passed in; say so.
69
+
70
+ ## NOT `LanguageText` (never flag) - the exceptions
71
+
72
+ - **The per-locale values inside a `defineTextCatalog` entry** (`en: "..."`, `nl: (n) => ...`).
73
+ Those literals *are* the translations - the correct end state, not a violation. (The finder
74
+ pre-filters locale-code-keyed lines; never re-flag them.)
75
+ - **Anything already resolved**: `{translate(TX.x)}`, `translate(TX.x, arg)`,
76
+ `i18n.translate(TX.x, locale)`, `i18n.uniform("...")`, or a variable holding an already-resolved
77
+ string (e.g. a server action returns `{ ok:false, error }` where `error` was already built with a
78
+ translator; the JSX that renders `result.error` is **correct**, not bare).
79
+ - **User / DB / content data**: a customer's real name, address, phone, invoice number, transcript.
80
+ Also placeholder/demo/sample data (`_data.ts`-style stand-in content, sample names): it is
81
+ stand-in content, not product copy, so it is exempt like real DB data.
82
+ - **The entire blog area when the project uses `@daanvandenbergh/blogkit`**: MDX article bodies
83
+ **and their front-matter-derived fields** (title, excerpt, author, tags) are content, localized
84
+ **file-per-language in a per-slug folder** by blogkit - each post is a folder `<contentDir>/<slug>/`
85
+ whose default body `<slug>/<defaultLocale>.mdx` gets a same-slug translation beside it (e.g.
86
+ `blog/<slug>/nl.mdx`), never wrapped in a translator. The post's `<slug>/hero.js` is likewise
87
+ **content/data, not a translator seam**: its per-locale `text` map (`en`/`nl` `title`/`subtitle`) is
88
+ hero copy localized file-style, so **do not flag its string literals as bare copy**. Do not flag
89
+ literals that are article content or come from a post's front-matter or `hero.js`. (The blog's own
90
+ page-shell chrome - a hardcoded nav label or empty state in a route/component - would still be copy,
91
+ but the blog area is content-dominated; treat it as the low-yield exception.) Translation
92
+ *completeness* - a missing locale body, or a locale absent from a `hero.js` `text` map - is not a
93
+ translator concern and is checked separately by the sweep's blogkit-parity step, not flagged here.
94
+ - **The brand / product name** rendered as text (e.g. the wordmark) - a proper noun, not translated.
95
+ If it must be a `LanguageText<L>` for a seam, the sanctioned form is `i18n.uniform("Brand")`.
96
+ - **Language endonyms** - a locale's own name, i.e. the `label` on each entry in the `I18n`
97
+ `locales` config, and any language-name chips (a language's own name is never translated).
98
+ - **Non-copy attributes**: `className`, `href`, `src`, `role`, `id`, `name`, `type`, `key`, `value`
99
+ (of `<option>`/inputs), `htmlFor`, `rel`, `target`, `autoComplete`, `data-*`, `viewBox`, and
100
+ **technical ARIA** (`aria-hidden`, `aria-expanded`, `aria-controls`, `aria-current`). `alt=""`
101
+ (decorative image) is correct - leave it.
102
+ - **Developer-only strings never rendered to an end user**: `throw new Error("...")` in server/lib
103
+ code that only gets logged or is mapped to a `LanguageText` at the action layer (e.g.
104
+ `"Authentication required"` -> caught and re-messaged), React hook invariants
105
+ (`throw new Error("useX must be used within <ProviderX>")`), assertions, and every `log.*(...)` /
106
+ `console.*(...)` message. (Distinguish these from backend copy that a user *does* see - emails,
107
+ product catalog - which IS in scope, item 6 above.)
108
+ - **Machine/config strings**: database index `name:` options, collection names, enum discriminants,
109
+ lookup keys, ids, URLs, currency/tax codes - data, not copy.
110
+ - **Non-linguistic tokens**: pure punctuation/symbol separators (`·`, `/`, `→`, `•`), the em-dash
111
+ fallback `"-"`, mask glyphs (`placeholder="••••••••"`), lone numbers, currency symbols, single
112
+ characters.
113
+ - **JSON-LD data fields**: `streetAddress`, `addressCountry`/`addressLocality`, `postalCode`,
114
+ `email`, `telephone`, `url`, ids, `availableLanguage: ["en","nl"]`, price/currency codes - these
115
+ are data, not copy. Only the human-readable fields (above) are.
116
+
117
+ ## Gray-zone judgment calls
118
+
119
+ - **`throw new Error("...")`**: violation *only if* the thrown message can reach the UI. Backend
120
+ throws (mapped to `LanguageText` at the action layer, or only logged) and hook/dev invariants are
121
+ **not** violations. A `throw` inside a **client** component whose message is shown in a toast/error
122
+ UI **is**. When you can't tell, `confidence: low`.
123
+ - **`<option value="lead">Lead</option>`**: the `value` attribute stays bare (it's a machine key);
124
+ the child text `Lead` is copy and must be `{translate(TX.statusLead)}`.
125
+ - **Rich text** (a sentence with an inline `<b>`/`<span>`/`<a>`): correct form is several
126
+ `LanguageText` fragments composed in JSX (`{translate(TX.a)}<b>{translate(TX.b)}</b>{translate(TX.c)}`).
127
+ That is **not** the forbidden concatenation. The forbidden thing is a resolved string glued to a
128
+ **raw literal** (`translate(TX.a) + " more text"`) or JSX text sitting next to a `{translate(...)}`.
129
+ - **`aria-label` with a value that is data, not copy** (e.g. `aria-label={customer.name}`): not a
130
+ violation - it's data. `aria-label="Close"` (a literal) is.
131
+ - **Proper-noun-adjacent tokens** - product **tier names** (`"Brand Starter/Pro/Scale"`), **place
132
+ names** in JSON-LD (`areaServed name: "Europe"`), and similar: these sit between brand/data and
133
+ copy. Lean toward treating them as brand/data (usually shipped untranslated); if you do flag them,
134
+ use `confidence: medium` with a one-line note - never `high`. Don't let them pad the list.
135
+ - **Test files, `.d.ts`, `.stories.*`, `.test-d.*`**: not user-facing; skip them.
136
+
137
+ ## Finding format (return exactly this per violation)
138
+
139
+ ```
140
+ - file: src/app/customers/CustomerList.tsx
141
+ line: 42
142
+ snippet: <h2>Recent activity</h2>
143
+ category: jsx-text # jsx-text | attribute | toast-or-error | metadata | json-ld | backend-copy
144
+ why: bare JSX heading rendered to the user; must resolve via translate(TX.*)
145
+ fix: add `recentActivity: { en: "Recent activity", nl: "..." }` to the file's TX catalog and render `{translate(TX.recentActivity)}`
146
+ confidence: high # high | medium | low
147
+ ```
148
+
149
+ Group nothing, sort nothing - just emit one block per violation. If a file is clean, say so
150
+ (`<path>: clean`) so the orchestrator knows it was actually read, not skipped.
151
+
152
+ ## How a fix is applied (only under `--fix`; the default run is report-only)
153
+
154
+ 1. Find or create the file's co-located `export const TX = i18n.defineTextCatalog({ ... });`
155
+ (import `i18n` from the consumer's config module).
156
+ 2. Add an entry. Static -> `{ en: "...", nl: "..." }`. Interpolated ->
157
+ `{ en: (x) => \`...${x}...\`, nl: (x) => \`...${x}...\` }`. Convert HTML entities to the literal
158
+ Unicode char (`&middot;` -> `·`, `&rsquo;` -> `'`).
159
+ 3. Ensure the call site has a translator: `const translate = i18n.translator(locale);` (core/server,
160
+ with the locale resolved by the caller) or `const translate = useTranslator();` (React, from
161
+ `@daanvandenbergh/i18nkit/react`).
162
+ 4. Replace the literal with `{translate(TX.key)}` (or `translate(TX.key, arg)` for interpolation, or
163
+ the attribute form `attr={translate(TX.key)}`).
164
+ 5. **Locale-safe wrapping (critical):** a bare source literal is (only) the **default**-locale text.
165
+ A `defineTextCatalog` entry must cover **every** configured locale or it will not compile - and
166
+ you must **never invent** the other locales' translations (a fake `nl` value silently ships wrong
167
+ copy and defeats the whole guarantee). So:
168
+ - If the project has a **single** configured locale, wrapping is lossless: apply it.
169
+ - If **multi-locale** and the other translations are genuinely known, fill them all and apply.
170
+ - Otherwise **do not auto-wrap**: list the finding as `needs-translation` (default-locale value
171
+ ready, other locales awaiting a human) and leave the literal unchanged.
172
+ 6. Never create per-language source-file copies; never hardcode one locale's value at a call site; a
173
+ `LanguageText` value is always a plain string, never JSX.
174
+ 7. After any applied wrap, `npm run typecheck` (fallback `npx tsc --noEmit`) must stay green.
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # find_candidates.sh - lead generator for the i18nkit-sweep.
4
+ #
5
+ # This is a HEAT-MAP, not a verdict. Every line it prints is a *candidate* that a
6
+ # sweep agent must confirm by reading the surrounding file against reference/rules.md.
7
+ # It errs toward over-reporting (grep can't see intent); the agent's judgment - not this
8
+ # script - decides what is a real violation. It exists so every run and every agent
9
+ # starts from the same lead list instead of re-inventing the greps.
10
+ #
11
+ # It is framework-agnostic: it greps for the shape of bare copy, not for any one project's
12
+ # layout. Point it at any user-facing source root.
13
+ #
14
+ # Usage: scripts/find_candidates.sh [path] (default: src)
15
+ #
16
+ # Two sections:
17
+ # 1. SUSPECT FILES - .tsx that render JSX but never reach an i18nkit translate seam
18
+ # (useTranslator / i18n.translator / defineTextCatalog / translate):
19
+ # a file rendering copy with no visible path to i18n.
20
+ # 2. LITERAL LEADS - specific bare-literal lines (copy attributes, same-line JSX text,
21
+ # toast/error strings), with obvious non-copy pre-filtered out.
22
+
23
+ set -euo pipefail
24
+ ROOT="${1:-src}"
25
+
26
+ # Two backends, one interface. Prefer ripgrep; fall back to POSIX grep when it is absent (rg is
27
+ # not always on PATH - e.g. when it is a shell alias the script's own shell never sees). The
28
+ # helpers take only (pattern[, root]) - no tool-specific flags leak to the call sites, so SECTION 1
29
+ # works identically under either backend. TSX_FILES lists matching *.tsx paths; SEARCH prints
30
+ # matching lines with file:line. tests / *.d.ts / *.test.* are always excluded.
31
+ if command -v rg >/dev/null 2>&1; then
32
+ # Positive globs FIRST (ripgrep is last-match-wins), then the exclusions - same .ts/.tsx
33
+ # universe the grep fallback restricts to, so the lead list does not depend on which backend
34
+ # is installed. Without the includes, rg would also scan .css/.js/.json/.md and diverge.
35
+ SEARCH() { rg -n --no-heading -g '*.ts' -g '*.tsx' -g '!**/tests/**' -g '!**/*.d.ts' -g '!**/*.test.*' "$@"; }
36
+ # Positive glob FIRST, exclusions after: ripgrep is last-match-wins, so `-g '*.tsx'` must
37
+ # precede the negatives or it would re-include test/.d.ts files they excluded.
38
+ TSX_FILES() { rg -l -g '*.tsx' -g '!**/tests/**' -g '!**/*.d.ts' -g '!**/*.test.*' -e "$1" "$2"; }
39
+ else
40
+ # POSIX grep fallback (BSD/GNU). Coarser exclusions. SECTION 1's patterns avoid \s / \b so both
41
+ # backends yield the identical suspect list; the 2a-2d lead patterns keep \b (best-effort here).
42
+ SEARCH() { grep -rnE --include='*.tsx' --include='*.ts' "$@" | grep -vE '/tests/|\.d\.ts|\.test\.'; }
43
+ TSX_FILES() { grep -rlE --include='*.tsx' -e "$1" "$2" | grep -vE '/tests/|\.d\.ts|\.test\.'; }
44
+ fi
45
+
46
+ # Drop noise common to every pass: catalog interiors (a `defineTextCatalog` entry's per-locale
47
+ # values ARE the copy, e.g. `en: "..."` / `nl: (n) => ...`), comment lines, and import specifiers.
48
+ # The locale-code-key filter is best-effort - a BCP-47-ish key (`en`, `nl`, `pt-BR`) followed by a
49
+ # colon is almost always a catalog translation value, not a bare literal. Over-dropping a rare lead
50
+ # is fine: agents read every file in full regardless.
51
+ DENOISE() { grep -vE '^[[:space:]]*[a-z]{2,3}(-[A-Za-z]{2,4})?[[:space:]]*:|^[[:space:]]*//|^[[:space:]]*\*|^[[:space:]]*/\*|import .* from'; }
52
+
53
+ echo "############################################################"
54
+ echo "# i18nkit-sweep candidate leads for: $ROOT"
55
+ echo "# These are LEADS ONLY - confirm each by reading the file."
56
+ echo "############################################################"
57
+
58
+ echo
59
+ echo "== SECTION 1: SUSPECT FILES (render JSX, no translate seam) =="
60
+ echo " A UI file that never reaches useTranslator/i18n.translator/translate(...) is where bare copy hides."
61
+ # Candidate render files: .tsx that contain JSX return. Then subtract any that touch a seam.
62
+ # POSIX ERE (no \s / \b) so the pattern is identical under ripgrep and grep.
63
+ comm -23 \
64
+ <(TSX_FILES 'return[[:space:]]*\(|=>[[:space:]]*\(|<[A-Za-z]' "$ROOT" 2>/dev/null | sort -u) \
65
+ <(TSX_FILES 'useTranslator|\.translator\(|defineTextCatalog|defineText|translate\(|LanguageText' "$ROOT" 2>/dev/null | sort -u) \
66
+ || true
67
+
68
+ echo
69
+ echo "== SECTION 2a: COPY ATTRIBUTES with a bare string value =="
70
+ echo " placeholder / title / alt / aria-label = \"...words...\" -> should be {translate(TX.x)}"
71
+ SEARCH -e '\b(placeholder|title|alt|aria-label)=["'\''`][^"'\''`]*[A-Za-z]{2,}' "$ROOT" 2>/dev/null \
72
+ | grep -vE 'alt=""' | DENOISE || true
73
+
74
+ echo
75
+ echo "== SECTION 2b: SAME-LINE JSX TEXT between tags =="
76
+ echo " >Some words< on one line -> should be >{translate(TX.x)}< (multi-line text: read the file)"
77
+ # Exclude ():;= inside the span: those are TS-generic / signature noise (Promise<void>,
78
+ # Readonly<{...}>), never short JSX copy. Sentences with . , ' ! ? still match.
79
+ SEARCH -e '>[^<>{}():;=]*[A-Za-z]{3,}[^<>{}():;=]*<' "$ROOT" 2>/dev/null \
80
+ | grep -vE '=>|https?:|import ' | DENOISE || true
81
+
82
+ echo
83
+ echo "== SECTION 2c: TOAST / ERROR / setError string literals =="
84
+ echo " toast(\"...\") / setError(\"...\") / new Error(\"...\") shown to a user -> translate(TX.x)"
85
+ echo " (backend throws & dev-invariant Errors are NOT violations - see rules.md)"
86
+ SEARCH -e '\b(toast|setError|setMessage|alert)\([[:space:]]*["'\''`][^"'\''`]*[A-Za-z]{2,}' "$ROOT" 2>/dev/null \
87
+ | DENOISE || true
88
+
89
+ echo
90
+ echo "== SECTION 2d: metadata / JSON-LD human-readable string literals =="
91
+ echo " title/description/name/... : \"words\" inside metadata or ld+json -> translate(TX.x)"
92
+ echo " (data fields - streetAddress, email, ids, lang codes - stay bare; see rules.md)"
93
+ # Keywords narrowed to metadata/JSON-LD-specific keys: generic `name`/`label`/`heading`
94
+ # match too much config (index options, demo data) to be useful as leads.
95
+ SEARCH -e '\b(title|description|siteName|ogTitle|ogDescription|twitterTitle|twitterDescription|headline|serviceType|tagline)[[:space:]]*:[[:space:]]*["'\''`][^"'\''`]*[A-Za-z]{2,}' "$ROOT" 2>/dev/null \
96
+ | DENOISE || true
97
+
98
+ echo
99
+ echo "== done. Confirm every lead by reading the file against reference/rules.md. =="