@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
package/README.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# i18nkit
|
|
2
|
+
|
|
3
|
+
**Universal, 100% type-safe i18n for TypeScript sites.** Declare your locales once and the compiler
|
|
4
|
+
guarantees every string is translated into every language - add a locale and TypeScript lights up at
|
|
5
|
+
every translation you still owe.
|
|
6
|
+
|
|
7
|
+
- **Type-safe by construction** - a translation is an object with one entry per locale. Miss one and it is a compile error, not a runtime `undefined`.
|
|
8
|
+
- **Framework-agnostic core** - zero dependencies, no `next`, no `react`. Runs on the server, in the browser, and at the edge.
|
|
9
|
+
- **Optional React layer** - a provider, hooks, an accessible `<LanguagePicker>`, and a locale-aware `<LocaleLink>` (decoupled from any router).
|
|
10
|
+
- **Parameterized text** - per-locale builder functions keep word order correct in every language.
|
|
11
|
+
- **Locale detection & URL routing** included - `Accept-Language` matching, cookie resolution, and "default at bare URL, others under `/nl/…`" helpers.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// Add "de" to your locales...
|
|
15
|
+
const i18n = new I18n({
|
|
16
|
+
locales: { en: { label: "English" }, nl: { label: "Nederlands" }, de: { label: "Deutsch" } },
|
|
17
|
+
default: "en",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// ...and every catalog missing a German string is now a compile error:
|
|
21
|
+
i18n.defineTextCatalog({
|
|
22
|
+
title: { en: "Customers", nl: "Klanten" },
|
|
23
|
+
// ~~~~~ Property 'de' is missing
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @daanvandenbergh/i18nkit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`react` / `react-dom` are optional peer dependencies - you only need them if you use the `/react` entry.
|
|
36
|
+
|
|
37
|
+
## Install the Claude Code skill
|
|
38
|
+
|
|
39
|
+
i18nkit ships an agent skill, **`i18nkit-sweep`**, that sweeps your source for user-facing strings
|
|
40
|
+
that bypass the type-safe system (and, if you use `@daanvandenbergh/blogkit`, checks every post has a
|
|
41
|
+
translation and its own localized hero for each locale). It is report-only by default; run it with `/i18nkit-sweep`.
|
|
42
|
+
|
|
43
|
+
Wire it into Claude Code with a **symlink**, so it tracks the installed package version - an
|
|
44
|
+
`npm update` keeps the skill current, with no copy to re-sync:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
mkdir -p .claude/skills
|
|
48
|
+
# from your project root (assumes .claude/skills/ and node_modules/ share this root):
|
|
49
|
+
ln -s ../../node_modules/@daanvandenbergh/i18nkit/skills/i18nkit-sweep .claude/skills/i18nkit-sweep
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If your layout differs, point the link at an absolute target resolved by node:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ln -s "$(dirname "$(node -p "require.resolve('@daanvandenbergh/i18nkit/package.json')")")/skills/i18nkit-sweep" \
|
|
56
|
+
"$(pwd)/.claude/skills/i18nkit-sweep"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Verify with `ls .claude/skills/i18nkit-sweep/SKILL.md`, then invoke `/i18nkit-sweep` in Claude Code.
|
|
60
|
+
(The target exists once you have run `npm install`.) Developing against a local checkout of this repo?
|
|
61
|
+
Link the source folder instead: `ln -s ../../skills/i18nkit-sweep .claude/skills/i18nkit-sweep`.
|
|
62
|
+
|
|
63
|
+
## Set up once
|
|
64
|
+
|
|
65
|
+
Create your locale set in one place and export the instance (and its `Locale` type):
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// app/i18n.ts
|
|
69
|
+
import { I18n } from "@daanvandenbergh/i18nkit";
|
|
70
|
+
|
|
71
|
+
export const i18n = new I18n({
|
|
72
|
+
locales: {
|
|
73
|
+
en: { label: "English", htmlLang: "en", locale: "en-GB" },
|
|
74
|
+
nl: { label: "Nederlands", htmlLang: "nl", locale: "nl-NL" },
|
|
75
|
+
},
|
|
76
|
+
default: "en",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export type Locale = keyof typeof i18n.locales; // "en" | "nl"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`htmlLang` (for `<html lang>` / URL prefixes) and `locale` (for `Intl` formatting) each default to the
|
|
83
|
+
locale's key, so a BCP-47-keyed config can be as short as `en: { label: "English" }`.
|
|
84
|
+
|
|
85
|
+
## Write translations
|
|
86
|
+
|
|
87
|
+
Author catalogs through the instance. Every entry must cover every locale, checked at the call site:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// app/customers/text.ts
|
|
91
|
+
import { i18n } from "../i18n";
|
|
92
|
+
|
|
93
|
+
export const TX = i18n.defineTextCatalog({
|
|
94
|
+
title: { en: "Customers", nl: "Klanten" },
|
|
95
|
+
// Parameterized text: a builder per locale keeps word order native to each language.
|
|
96
|
+
deleted: { en: (name: string) => `${name} deleted`, nl: (name: string) => `${name} verwijderd` },
|
|
97
|
+
range: {
|
|
98
|
+
en: (from: number, to: number, total: number) => `Showing ${from}-${to} of ${total}`,
|
|
99
|
+
nl: (from: number, to: number, total: number) => `${from}-${to} van ${total} weergegeven`,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Resolve to strings
|
|
105
|
+
|
|
106
|
+
Bind a translator to a locale and call it:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const translate = i18n.translator("nl");
|
|
110
|
+
|
|
111
|
+
translate(TX.title); // "Klanten"
|
|
112
|
+
translate(TX.deleted, "Ada"); // "Ada verwijderd"
|
|
113
|
+
translate(TX.range, 1, 20, 137); // "1-20 van 137 weergegeven"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For a one-off, skip the binding: `i18n.translate(TX.title, "nl")`. For a string that is the same in
|
|
117
|
+
every language, `i18n.uniform("Acme")` builds a fully-covered entry. For a standalone parameterized
|
|
118
|
+
string, `i18n.defineText({ en: (n) => …, nl: (n) => … })` enforces coverage and infers the arguments.
|
|
119
|
+
|
|
120
|
+
## Detect the locale (server / SSR)
|
|
121
|
+
|
|
122
|
+
The core is framework-agnostic: feed it whatever your framework gives you.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// Anywhere you have the request: a cookie value and/or the Accept-Language header.
|
|
126
|
+
// A stored cookie choice wins; a first-time visitor (no cookie) gets their browser preference.
|
|
127
|
+
// Both helpers fall back to `default` internally, so gate on the cookie's presence - don't
|
|
128
|
+
// chain them with `??`/`||` (resolveLocale never returns nullish, so the fallback is dead code).
|
|
129
|
+
const locale = cookieValue
|
|
130
|
+
? i18n.resolveLocale(cookieValue) // honor the stored choice
|
|
131
|
+
: i18n.matchAcceptLanguage(acceptLanguageHeader); // else the visitor's preference
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
For example, in a Next.js Server Component:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { cookies, headers } from "next/headers";
|
|
138
|
+
import { i18n } from "@/app/i18n";
|
|
139
|
+
|
|
140
|
+
export async function activeLocale() {
|
|
141
|
+
const cookie = (await cookies()).get(i18n.cookie)?.value;
|
|
142
|
+
const accept = (await headers()).get("accept-language");
|
|
143
|
+
return cookie ? i18n.resolveLocale(cookie) : i18n.matchAcceptLanguage(accept);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## React
|
|
148
|
+
|
|
149
|
+
Wrap your app in the provider, passing the resolved locale and what to do when it changes (the provider
|
|
150
|
+
writes the locale cookie for you first, then calls `onChange` - do navigation/refresh there):
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
// app/providers.tsx
|
|
154
|
+
"use client";
|
|
155
|
+
import { I18nProvider } from "@daanvandenbergh/i18nkit/react";
|
|
156
|
+
import "@daanvandenbergh/i18nkit/styles.css"; // for <LanguagePicker>
|
|
157
|
+
import { i18n } from "./i18n";
|
|
158
|
+
|
|
159
|
+
export function Providers({ locale, children }) {
|
|
160
|
+
// e.g. in Next.js: const router = useRouter(); onChange={() => router.refresh()}
|
|
161
|
+
return (
|
|
162
|
+
<I18nProvider i18n={i18n} locale={locale} onChange={() => location.reload()}>
|
|
163
|
+
{children}
|
|
164
|
+
</I18nProvider>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Read and switch the locale with the hooks, and drop in the picker:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
"use client";
|
|
173
|
+
import { useTranslator, LanguagePicker } from "@daanvandenbergh/i18nkit/react";
|
|
174
|
+
import { TX } from "./customers/text";
|
|
175
|
+
|
|
176
|
+
export function Header() {
|
|
177
|
+
const translate = useTranslator();
|
|
178
|
+
return (
|
|
179
|
+
<header>
|
|
180
|
+
<h1>{translate(TX.title)}</h1>
|
|
181
|
+
<LanguagePicker />
|
|
182
|
+
</header>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Hooks: `useTranslator()` (a translator bound to the active locale), `useLocale()`, `useSetLocale()`,
|
|
188
|
+
`useI18n()`. Pass your `Locale` union as a type argument for a precise return, e.g. `useLocale<Locale>()`.
|
|
189
|
+
|
|
190
|
+
`<LanguagePicker>` is self-contained (open state, click-away, Escape, full ARIA) and shows the endonym
|
|
191
|
+
labels by default. Want flags? Pass the built-in `localeFlag` renderer - a set of ~38 inline-SVG flags in
|
|
192
|
+
one flat, rounded style, keyed by locale so `en`, `pt-BR`, `de-CH` all resolve correctly (unknown locales
|
|
193
|
+
just show their label, never a broken image):
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
import { LanguagePicker, localeFlag } from "@daanvandenbergh/i18nkit/react";
|
|
197
|
+
|
|
198
|
+
<LanguagePicker renderFlag={localeFlag} />;
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Or supply your own with `renderFlag={(loc) => <MyFlag code={loc} />}`. Style it entirely through the
|
|
202
|
+
`--i18nkit-*` CSS custom properties on the shipped stylesheet.
|
|
203
|
+
|
|
204
|
+
`<LocaleLink href="/pricing">` renders `/nl/pricing` when `nl` is active. It renders a plain `<a>` by
|
|
205
|
+
default, or pass your framework's link via `as`: `<LocaleLink href="/pricing" as={NextLink} />`.
|
|
206
|
+
|
|
207
|
+
## URL routing
|
|
208
|
+
|
|
209
|
+
Config-driven helpers for the "default locale at bare URLs, others under `/<htmlLang>/…`" scheme
|
|
210
|
+
(configure `nonLocalizedPrefixes` and `origin` on the instance):
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
i18n.prefixFor("nl"); // "/nl" ("" for the default)
|
|
214
|
+
i18n.localizeHref("/pricing", "nl"); // "/nl/pricing"
|
|
215
|
+
i18n.localizeHref("/pricing", "en"); // "/pricing" (default = bare)
|
|
216
|
+
i18n.switchLocalePath("/nl/pricing", "en"); // "/pricing"
|
|
217
|
+
i18n.stripLocalePrefix("/nl/pricing"); // "/pricing"
|
|
218
|
+
i18n.hreflangAlternates("/pricing", "nl"); // { canonical, languages: { en, nl, "x-default" } }
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Pass `strategy: "prefix-all"` to prefix every locale, including the default -
|
|
222
|
+
`i18n.localizeHref("/pricing", "en")` then returns `"/en/pricing"`, so no locale lives at a bare URL.
|
|
223
|
+
The default, `"prefix-except-default"`, keeps the default locale at bare URLs as shown above.
|
|
224
|
+
|
|
225
|
+
**URL casing.** If a locale's `htmlLang` carries a region or script subtag (`en-GB`, `pt-BR`,
|
|
226
|
+
`zh-Hant`), the generated URL prefix is lowercased - `htmlLang: "en-GB"` produces `/en-gb/pricing`,
|
|
227
|
+
the conventional casing for URLs - while `htmlLangFor(locale)` still returns `"en-GB"` for the
|
|
228
|
+
`<html lang>` attribute and hreflang keys. Segment parsing (`localeForSegment`, `stripLocalePrefix`)
|
|
229
|
+
is case-insensitive, so an inbound `/en-GB/…` or `/en-gb/…` both resolve. Plain lowercase primary
|
|
230
|
+
subtags (`en`, `nl`, `de`) are unaffected.
|
|
231
|
+
|
|
232
|
+
### Middleware
|
|
233
|
+
|
|
234
|
+
i18nkit stays out of your request pipeline: the helpers above are framework-agnostic, so you write
|
|
235
|
+
the redirect in your framework's own middleware and compose it from the instance. Detection is just
|
|
236
|
+
`resolveLocale` (cookie) and `matchAcceptLanguage` (header). Under `"prefix-all"` a bare URL maps to
|
|
237
|
+
no locale, so middleware **must** redirect it to a prefix or the site 404s on `/`:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
// Next.js middleware.ts - prefix-all
|
|
241
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
242
|
+
import { i18n } from "./i18n";
|
|
243
|
+
|
|
244
|
+
export function middleware(req: NextRequest) {
|
|
245
|
+
const { pathname } = req.nextUrl;
|
|
246
|
+
if (!i18n.isLocalizedPath(pathname)) return NextResponse.next(); // /api/* -> pass through
|
|
247
|
+
if (i18n.localeForSegment(pathname.split("/")[1] ?? "")) return NextResponse.next(); // /en/x -> serve
|
|
248
|
+
// bare URL: detect (cookie, else Accept-Language) and redirect to that locale's prefix
|
|
249
|
+
const cookie = req.cookies.get(i18n.cookie)?.value;
|
|
250
|
+
const locale = cookie
|
|
251
|
+
? i18n.resolveLocale(cookie)
|
|
252
|
+
: i18n.matchAcceptLanguage(req.headers.get("accept-language"));
|
|
253
|
+
const url = req.nextUrl.clone();
|
|
254
|
+
url.pathname = i18n.localizeHref(pathname, locale);
|
|
255
|
+
return NextResponse.redirect(url);
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Under `"prefix-except-default"` the same shape applies, minus the redirect - a bare URL is already the
|
|
260
|
+
default locale's real page, so you serve it as-is (optionally reading the locale for SSR).
|
|
261
|
+
|
|
262
|
+
## API reference
|
|
263
|
+
|
|
264
|
+
Everything hangs off the `I18n` instance.
|
|
265
|
+
|
|
266
|
+
| Member | Purpose |
|
|
267
|
+
| --- | --- |
|
|
268
|
+
| `new I18n({ locales, default, cookie?, origin?, nonLocalizedPrefixes?, strategy? })` | Create the instance; infers the locale union from `locales`. `strategy` picks the default-locale URL scheme (`"prefix-except-default"` default, or `"prefix-all"`). |
|
|
269
|
+
| `.locales` / `.list` / `.default` / `.cookie` | Resolved locale map / ordered array / default locale / cookie name. |
|
|
270
|
+
| `.defineTextCatalog(catalog)` | Author a catalog; every entry must cover every locale. |
|
|
271
|
+
| `.defineText(text)` | Author one static or parameterized text with full coverage. |
|
|
272
|
+
| `.uniform(value)` | A text that is the same string in every locale. |
|
|
273
|
+
| `.translator(locale)` | A `Translator` bound to `locale`. |
|
|
274
|
+
| `.translate(text, locale)` | Resolve one static text (no binding). |
|
|
275
|
+
| `.resolveLocale(raw)` / `.matchAcceptLanguage(header)` | Detect the locale from a cookie value / `Accept-Language`. |
|
|
276
|
+
| `.htmlLangFor(locale)` / `.intlLocaleFor(locale)` | BCP-47 `<html lang>` subtag / full `Intl` locale. |
|
|
277
|
+
| `.prefixFor` / `.localizeHref` / `.switchLocalePath` / `.stripLocalePrefix` / `.localeForSegment` / `.isLocalizedPath` / `.hreflangAlternates` | URL routing helpers. |
|
|
278
|
+
|
|
279
|
+
`@daanvandenbergh/i18nkit/react`: `I18nProvider`, `useI18n`, `useLocale`, `useTranslator`, `useSetLocale`,
|
|
280
|
+
`LanguagePicker`, `localeFlag`, `Flag`, `LocaleLink`. `@daanvandenbergh/i18nkit/styles.css`: the picker stylesheet.
|
|
281
|
+
|
|
282
|
+
## Type-safety notes
|
|
283
|
+
|
|
284
|
+
- **A missing translation is always a compile error.** This is the guarantee: grow your locale set and every incomplete `defineTextCatalog` / `defineText` fails to compile.
|
|
285
|
+
- **Caveat:** a *typo'd, unknown* locale key (e.g. `de` when your set is `en`/`nl`) is silently accepted inside a `define*` call - TypeScript's generic constraints don't run excess-property checks. Missing keys - the case that matters for coverage - are always caught.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog construction helpers. The type-level machinery that enforces per-locale coverage lives
|
|
3
|
+
* in {@link import("./types.js").LanguageTextCatalog} and is applied by the `I18n` instance's
|
|
4
|
+
* `defineTextCatalog`/`defineText` methods; this file holds the one runtime helper that builds a
|
|
5
|
+
* text object, {@link uniform}.
|
|
6
|
+
*/
|
|
7
|
+
import type { LanguageText } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Build a {@link LanguageText} whose value is identical in every locale - for a brand word or a
|
|
10
|
+
* symbol that is the same across languages (e.g. `"Acme"`, `"←"`). Reached as `i18n.uniform(value)`,
|
|
11
|
+
* which supplies the instance's locale codes.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam L - the locale union to cover.
|
|
14
|
+
* @param value - the string to use for every locale.
|
|
15
|
+
* @param codes - every locale code in `L`.
|
|
16
|
+
* @returns a {@link LanguageText} carrying `value` under each code.
|
|
17
|
+
*/
|
|
18
|
+
export declare function uniform<L extends string>(value: string, codes: readonly L[]): LanguageText<L>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a {@link LanguageText} whose value is identical in every locale - for a brand word or a
|
|
3
|
+
* symbol that is the same across languages (e.g. `"Acme"`, `"←"`). Reached as `i18n.uniform(value)`,
|
|
4
|
+
* which supplies the instance's locale codes.
|
|
5
|
+
*
|
|
6
|
+
* @typeParam L - the locale union to cover.
|
|
7
|
+
* @param value - the string to use for every locale.
|
|
8
|
+
* @param codes - every locale code in `L`.
|
|
9
|
+
* @returns a {@link LanguageText} carrying `value` under each code.
|
|
10
|
+
*/
|
|
11
|
+
export function uniform(value, codes) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const code of codes) {
|
|
14
|
+
out[code] = value;
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure locale detection, config-driven and framework-free: narrow an untrusted value (a cookie or
|
|
3
|
+
* `[lang]` segment) to a real locale, and pick the best locale from an HTTP `Accept-Language`
|
|
4
|
+
* header. Reached as `i18n.resolveLocale(...)` / `i18n.matchAcceptLanguage(...)`, but exported
|
|
5
|
+
* standalone too so edge middleware can call them without constructing an instance.
|
|
6
|
+
*/
|
|
7
|
+
import type { ResolvedLocale } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Narrow an untrusted value (a cookie value, or a locale code from anywhere) to a known locale,
|
|
10
|
+
* falling back to `fallback` when it is missing or unrecognized. Matches on the locale `code`
|
|
11
|
+
* (exact); for matching a URL segment against a locale's `htmlLang`, use `localeForSegment` in
|
|
12
|
+
* the routing module instead.
|
|
13
|
+
*
|
|
14
|
+
* @typeParam L - the locale union.
|
|
15
|
+
* @param raw - the candidate value, e.g. from a cookie.
|
|
16
|
+
* @param locales - every resolved locale of the instance.
|
|
17
|
+
* @param fallback - the locale to return when `raw` is missing or unknown.
|
|
18
|
+
* @returns a guaranteed-valid locale.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveLocale<L extends string>(raw: string | undefined, locales: readonly ResolvedLocale<L>[], fallback: L): L;
|
|
21
|
+
/**
|
|
22
|
+
* Detect the best-matching locale from an HTTP `Accept-Language` header, so a first-time visitor
|
|
23
|
+
* with no cookie lands in their own language. Tags are ranked by their `q` weight (default 1) and
|
|
24
|
+
* matched on the primary subtag against each locale's `htmlLang` (so `en-GB` and `en-US` both
|
|
25
|
+
* match `en`). Falls back to `fallback` when the header is absent or names only unsupported
|
|
26
|
+
* languages.
|
|
27
|
+
*
|
|
28
|
+
* @typeParam L - the locale union.
|
|
29
|
+
* @param header - the raw `Accept-Language` header value (or null/undefined).
|
|
30
|
+
* @param locales - every resolved locale of the instance.
|
|
31
|
+
* @param fallback - the locale to return when nothing matches.
|
|
32
|
+
* @returns the best supported locale.
|
|
33
|
+
*/
|
|
34
|
+
export declare function matchAcceptLanguage<L extends string>(header: string | null | undefined, locales: readonly ResolvedLocale<L>[], fallback: L): L;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow an untrusted value (a cookie value, or a locale code from anywhere) to a known locale,
|
|
3
|
+
* falling back to `fallback` when it is missing or unrecognized. Matches on the locale `code`
|
|
4
|
+
* (exact); for matching a URL segment against a locale's `htmlLang`, use `localeForSegment` in
|
|
5
|
+
* the routing module instead.
|
|
6
|
+
*
|
|
7
|
+
* @typeParam L - the locale union.
|
|
8
|
+
* @param raw - the candidate value, e.g. from a cookie.
|
|
9
|
+
* @param locales - every resolved locale of the instance.
|
|
10
|
+
* @param fallback - the locale to return when `raw` is missing or unknown.
|
|
11
|
+
* @returns a guaranteed-valid locale.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveLocale(raw, locales, fallback) {
|
|
14
|
+
return locales.some((info) => info.code === raw) ? raw : fallback;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Detect the best-matching locale from an HTTP `Accept-Language` header, so a first-time visitor
|
|
18
|
+
* with no cookie lands in their own language. Tags are ranked by their `q` weight (default 1) and
|
|
19
|
+
* matched on the primary subtag against each locale's `htmlLang` (so `en-GB` and `en-US` both
|
|
20
|
+
* match `en`). Falls back to `fallback` when the header is absent or names only unsupported
|
|
21
|
+
* languages.
|
|
22
|
+
*
|
|
23
|
+
* @typeParam L - the locale union.
|
|
24
|
+
* @param header - the raw `Accept-Language` header value (or null/undefined).
|
|
25
|
+
* @param locales - every resolved locale of the instance.
|
|
26
|
+
* @param fallback - the locale to return when nothing matches.
|
|
27
|
+
* @returns the best supported locale.
|
|
28
|
+
*/
|
|
29
|
+
export function matchAcceptLanguage(header, locales, fallback) {
|
|
30
|
+
if (!header) {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
const ranked = header
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((part) => {
|
|
36
|
+
const [tag, ...params] = part.trim().split(";");
|
|
37
|
+
// Parameter names are case-insensitive (RFC 7231), so accept `q=` and `Q=`.
|
|
38
|
+
const qParam = params.find((p) => p.trim().toLowerCase().startsWith("q="));
|
|
39
|
+
const q = qParam ? Number.parseFloat(qParam.trim().slice(2)) : 1;
|
|
40
|
+
const full = tag.trim().toLowerCase();
|
|
41
|
+
// Clamp to the RFC 7231 range [0,1]: a malformed `q>1` (`q=1.5`, `q=1e3`) must not
|
|
42
|
+
// outrank a legitimate `q=1`, and a negative q clamps to 0 (dropped by the filter
|
|
43
|
+
// below). An unparseable weight (`q=abc` -> NaN) still drops the tag.
|
|
44
|
+
return { full, primary: full.split("-")[0], q: Number.isNaN(q) ? 0 : Math.min(Math.max(q, 0), 1) };
|
|
45
|
+
})
|
|
46
|
+
.filter((entry) => entry.primary && entry.q > 0)
|
|
47
|
+
.sort((a, b) => b.q - a.q);
|
|
48
|
+
for (const { full, primary } of ranked) {
|
|
49
|
+
// Prefer an exact tag match, so `en-GB` beats a bare `en` locale and `zh-Hant` is kept
|
|
50
|
+
// distinct from `zh-Hans`; only then fall back to matching on the primary subtag (so a
|
|
51
|
+
// bare `en` request still finds an `en-GB` locale).
|
|
52
|
+
const exact = locales.find((info) => info.htmlLang.toLowerCase() === full);
|
|
53
|
+
if (exact) {
|
|
54
|
+
return exact.code;
|
|
55
|
+
}
|
|
56
|
+
const primaryHit = locales.find((info) => info.htmlLang.toLowerCase().split("-")[0] === primary);
|
|
57
|
+
if (primaryHit) {
|
|
58
|
+
return primaryHit.code;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { I18nConfig, LanguageText, LanguageTextCatalog, LanguageTextFn, ResolvedLocale, RoutingStrategy, Translator } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Recover the locale union of an {@link I18n} instance type, for annotating helpers that receive
|
|
4
|
+
* one: `function greet(i18n: I18n<Locale>, l: LocaleOf<typeof i18n>) {}`. In app code the simpler
|
|
5
|
+
* `type Locale = keyof typeof i18n.locales` is usually enough.
|
|
6
|
+
*
|
|
7
|
+
* @typeParam T - an `I18n<L>` instance type.
|
|
8
|
+
*/
|
|
9
|
+
export type LocaleOf<T> = T extends I18n<infer L> ? L : never;
|
|
10
|
+
/**
|
|
11
|
+
* A type-safe i18n instance bound to one locale set. Create it once and export it:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* export const i18n = new I18n({
|
|
15
|
+
* locales: { en: { label: "English" }, nl: { label: "Nederlands", htmlLang: "nl", locale: "nl-NL" } },
|
|
16
|
+
* default: "en",
|
|
17
|
+
* });
|
|
18
|
+
* export type Locale = keyof typeof i18n.locales; // "en" | "nl"
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @typeParam L - the locale union, inferred from the constructor's `locales` keys.
|
|
22
|
+
*/
|
|
23
|
+
export declare class I18n<L extends string> {
|
|
24
|
+
#private;
|
|
25
|
+
/** Every configured locale, fully resolved, keyed by code. `keyof this.locales` is the locale union. */
|
|
26
|
+
readonly locales: Record<L, ResolvedLocale<L>>;
|
|
27
|
+
/** Every configured locale in declaration order - the order the picker lists them. */
|
|
28
|
+
readonly list: readonly ResolvedLocale<L>[];
|
|
29
|
+
/** The fallback locale for detection/resolution (unknown cookie, unmatched `Accept-Language`). */
|
|
30
|
+
readonly default: L;
|
|
31
|
+
/** The cookie name the picker writes and locale detection reads. */
|
|
32
|
+
readonly cookie: string;
|
|
33
|
+
/** The absolute site origin for {@link hreflangAlternates}, or undefined if not configured. */
|
|
34
|
+
readonly origin: string | undefined;
|
|
35
|
+
/** Path prefixes that are never localized. */
|
|
36
|
+
readonly nonLocalizedPrefixes: readonly string[];
|
|
37
|
+
/** The URL scheme for the default locale (`"prefix-except-default"` or `"prefix-all"`). */
|
|
38
|
+
readonly strategy: RoutingStrategy;
|
|
39
|
+
/**
|
|
40
|
+
* Build an i18n instance.
|
|
41
|
+
*
|
|
42
|
+
* @param config - the locale set and options (see {@link I18nConfig}).
|
|
43
|
+
* @throws Error when `locales` is empty, `default` is not one of the configured locales, or two
|
|
44
|
+
* locales resolve to the same case-insensitive `htmlLang`.
|
|
45
|
+
*/
|
|
46
|
+
constructor(config: I18nConfig<L>);
|
|
47
|
+
/**
|
|
48
|
+
* Define a co-located catalog of copy. Every entry must carry a translation for every locale
|
|
49
|
+
* in `L`, or it is a compile error; the returned object keeps its precise literal type so a
|
|
50
|
+
* {@link Translator} can infer each parameterized entry's arguments. This is an identity
|
|
51
|
+
* function - all the work is in the type constraint.
|
|
52
|
+
*
|
|
53
|
+
* @typeParam C - the catalog literal, constrained to {@link LanguageTextCatalog}.
|
|
54
|
+
* @param catalog - the catalog object.
|
|
55
|
+
* @returns the same object, precisely typed.
|
|
56
|
+
*/
|
|
57
|
+
defineTextCatalog<const C extends LanguageTextCatalog<L>>(catalog: C): C;
|
|
58
|
+
/**
|
|
59
|
+
* Define one standalone text - a static per-locale map, or a per-locale builder for
|
|
60
|
+
* parameterized copy. Every locale in `L` must be present or it is a compile error. Identity
|
|
61
|
+
* at runtime; the overloads preserve literal/argument types.
|
|
62
|
+
*
|
|
63
|
+
* @param text - the {@link LanguageText} or {@link LanguageTextFn}.
|
|
64
|
+
* @returns the same object, precisely typed.
|
|
65
|
+
*/
|
|
66
|
+
defineText<const T extends LanguageText<L>>(text: T): T;
|
|
67
|
+
defineText<A extends readonly unknown[]>(text: LanguageTextFn<L, A>): LanguageTextFn<L, A>;
|
|
68
|
+
/**
|
|
69
|
+
* Build a {@link LanguageText} whose value is identical in every locale (a brand word, a
|
|
70
|
+
* symbol). Coverage is complete by construction.
|
|
71
|
+
*
|
|
72
|
+
* @param value - the string used for every locale.
|
|
73
|
+
* @returns a {@link LanguageText} for `L`.
|
|
74
|
+
*/
|
|
75
|
+
uniform(value: string): LanguageText<L>;
|
|
76
|
+
/**
|
|
77
|
+
* Build a {@link Translator} bound to `locale` - the ergonomic path: bind once, resolve many.
|
|
78
|
+
*
|
|
79
|
+
* @param locale - the locale to bind.
|
|
80
|
+
* @returns a translator for that locale.
|
|
81
|
+
*/
|
|
82
|
+
translator(locale: L): Translator<L>;
|
|
83
|
+
/**
|
|
84
|
+
* Resolve one static text for a locale without binding a {@link Translator}. For parameterized
|
|
85
|
+
* text or repeated use, prefer {@link translator}.
|
|
86
|
+
*
|
|
87
|
+
* @param textValue - the {@link LanguageText} to resolve.
|
|
88
|
+
* @param locale - the locale to resolve against.
|
|
89
|
+
* @returns the string for that locale.
|
|
90
|
+
*/
|
|
91
|
+
translate(textValue: LanguageText<L>, locale: L): string;
|
|
92
|
+
/**
|
|
93
|
+
* Narrow an untrusted value (e.g. a cookie) to a valid locale, falling back to {@link default}.
|
|
94
|
+
*
|
|
95
|
+
* @param raw - the candidate value.
|
|
96
|
+
* @returns a guaranteed-valid locale.
|
|
97
|
+
*/
|
|
98
|
+
resolveLocale(raw: string | undefined): L;
|
|
99
|
+
/**
|
|
100
|
+
* Pick the best locale from an HTTP `Accept-Language` header, falling back to {@link default}.
|
|
101
|
+
*
|
|
102
|
+
* @param header - the raw header value (or null/undefined).
|
|
103
|
+
* @returns the best supported locale.
|
|
104
|
+
*/
|
|
105
|
+
matchAcceptLanguage(header: string | null | undefined): L;
|
|
106
|
+
/**
|
|
107
|
+
* The BCP-47 `<html lang>` subtag for a locale (e.g. `"nl"`).
|
|
108
|
+
*
|
|
109
|
+
* @param locale - the locale.
|
|
110
|
+
* @returns its `htmlLang`.
|
|
111
|
+
*/
|
|
112
|
+
htmlLangFor(locale: L): string;
|
|
113
|
+
/**
|
|
114
|
+
* The full BCP-47 `Intl` formatting locale for a locale (e.g. `"nl-NL"`), for
|
|
115
|
+
* `new Intl.DateTimeFormat(i18n.intlLocaleFor(locale))`.
|
|
116
|
+
*
|
|
117
|
+
* @param locale - the locale.
|
|
118
|
+
* @returns its `Intl` locale.
|
|
119
|
+
*/
|
|
120
|
+
intlLocaleFor(locale: L): string;
|
|
121
|
+
/**
|
|
122
|
+
* The URL prefix for a locale: `""` for the default locale, `"/<htmlLang>"` otherwise.
|
|
123
|
+
*
|
|
124
|
+
* @param locale - the locale.
|
|
125
|
+
* @returns the leading URL segment.
|
|
126
|
+
*/
|
|
127
|
+
prefixFor(locale: L): string;
|
|
128
|
+
/**
|
|
129
|
+
* The locale a URL path segment denotes (via `htmlLang`), or null.
|
|
130
|
+
*
|
|
131
|
+
* @param segment - the first path segment, without slashes.
|
|
132
|
+
* @returns the locale, or null.
|
|
133
|
+
*/
|
|
134
|
+
localeForSegment(segment: string): L | null;
|
|
135
|
+
/**
|
|
136
|
+
* Whether a pathname takes a locale prefix (i.e. is not under {@link nonLocalizedPrefixes}).
|
|
137
|
+
*
|
|
138
|
+
* @param pathname - the URL pathname.
|
|
139
|
+
* @returns true when the path is localized.
|
|
140
|
+
*/
|
|
141
|
+
isLocalizedPath(pathname: string): boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Localize an internal href for a locale (`/pricing` -> `/nl/pricing`).
|
|
144
|
+
*
|
|
145
|
+
* @param href - the link target.
|
|
146
|
+
* @param locale - the active locale.
|
|
147
|
+
* @returns the localized href.
|
|
148
|
+
*/
|
|
149
|
+
localizeHref(href: string, locale: L): string;
|
|
150
|
+
/**
|
|
151
|
+
* Rewrite a pathname to another locale (used by the picker to switch pages).
|
|
152
|
+
*
|
|
153
|
+
* @param pathname - the current pathname.
|
|
154
|
+
* @param next - the locale to switch to.
|
|
155
|
+
* @returns the pathname in the target locale.
|
|
156
|
+
*/
|
|
157
|
+
switchLocalePath(pathname: string, next: L): string;
|
|
158
|
+
/**
|
|
159
|
+
* Strip any locale prefix from a pathname (`/nl/x` -> `/x`).
|
|
160
|
+
*
|
|
161
|
+
* @param pathname - the current pathname.
|
|
162
|
+
* @returns the unprefixed pathname.
|
|
163
|
+
*/
|
|
164
|
+
stripLocalePrefix(pathname: string): string;
|
|
165
|
+
/**
|
|
166
|
+
* Canonical + hreflang alternates for a page (requires `origin` in the config). A non-localized
|
|
167
|
+
* path yields a single canonical with only an `x-default`.
|
|
168
|
+
*
|
|
169
|
+
* @param path - the pathname (bare or already-locale-prefixed; it is reduced to the bare form).
|
|
170
|
+
* @param locale - the active locale.
|
|
171
|
+
* @returns the `{ canonical, languages }` alternates object.
|
|
172
|
+
* @throws Error when the config has no `origin`.
|
|
173
|
+
*/
|
|
174
|
+
hreflangAlternates(path: string, locale: L): {
|
|
175
|
+
canonical: string;
|
|
176
|
+
languages: Record<string, string>;
|
|
177
|
+
};
|
|
178
|
+
}
|