@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/README.md +285 -0
- package/dist/i18n/catalog.d.ts +18 -0
- package/dist/i18n/catalog.js +17 -0
- package/dist/i18n/detect.d.ts +34 -0
- package/dist/i18n/detect.js +62 -0
- package/dist/i18n/i18n.d.ts +178 -0
- package/dist/i18n/i18n.js +254 -0
- package/dist/i18n/index.d.ts +11 -0
- package/dist/i18n/index.js +10 -0
- package/dist/i18n/routing.d.ts +112 -0
- package/dist/i18n/routing.js +157 -0
- package/dist/i18n/translate.d.ts +26 -0
- package/dist/i18n/translate.js +28 -0
- package/dist/i18n/types.d.ts +155 -0
- package/dist/i18n/types.js +11 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/react/LanguagePicker.d.ts +21 -0
- package/dist/react/LanguagePicker.js +70 -0
- package/dist/react/LocaleLink.d.ts +21 -0
- package/dist/react/LocaleLink.js +23 -0
- package/dist/react/flags.d.ts +21 -0
- package/dist/react/flags.js +348 -0
- package/dist/react/index.d.ts +10 -0
- package/dist/react/index.js +10 -0
- package/dist/react/provider.d.ts +58 -0
- package/dist/react/provider.js +94 -0
- package/dist/react/styles.css +150 -0
- package/package.json +77 -0
- package/skills/i18nkit-add-locale/SKILL.md +182 -0
- package/skills/i18nkit-add-locale/reference/translation-rules.md +207 -0
- package/skills/i18nkit-add-locale/scripts/scan.sh +148 -0
- package/skills/i18nkit-sweep/SKILL.md +208 -0
- package/skills/i18nkit-sweep/reference/rules.md +174 -0
- package/skills/i18nkit-sweep/scripts/find_candidates.sh +99 -0
|
@@ -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 (`·` -> `·`, `’` -> `'`).
|
|
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. =="
|