@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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `I18n` class - the single public surface of i18nkit's core. A consumer constructs one
|
|
3
|
+
* instance from their locale config and authors + resolves every translation through it, so the
|
|
4
|
+
* instance's locale union `L` (inferred from the config) flows into every method and enforces
|
|
5
|
+
* 100% coverage at each call site.
|
|
6
|
+
*
|
|
7
|
+
* The class is a thin, config-bound facade over the pure functions in the sibling modules
|
|
8
|
+
* (`catalog`, `translate`, `detect`, `routing`), which stay independently testable and
|
|
9
|
+
* framework-free.
|
|
10
|
+
*/
|
|
11
|
+
import * as catalog from "./catalog.js";
|
|
12
|
+
import * as detect from "./detect.js";
|
|
13
|
+
import * as routing from "./routing.js";
|
|
14
|
+
import * as text from "./translate.js";
|
|
15
|
+
/** The default cookie name when the config does not override it. */
|
|
16
|
+
const DEFAULT_COOKIE = "locale";
|
|
17
|
+
/**
|
|
18
|
+
* A type-safe i18n instance bound to one locale set. Create it once and export it:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* export const i18n = new I18n({
|
|
22
|
+
* locales: { en: { label: "English" }, nl: { label: "Nederlands", htmlLang: "nl", locale: "nl-NL" } },
|
|
23
|
+
* default: "en",
|
|
24
|
+
* });
|
|
25
|
+
* export type Locale = keyof typeof i18n.locales; // "en" | "nl"
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @typeParam L - the locale union, inferred from the constructor's `locales` keys.
|
|
29
|
+
*/
|
|
30
|
+
export class I18n {
|
|
31
|
+
/** Every configured locale, fully resolved, keyed by code. `keyof this.locales` is the locale union. */
|
|
32
|
+
locales;
|
|
33
|
+
/** Every configured locale in declaration order - the order the picker lists them. */
|
|
34
|
+
list;
|
|
35
|
+
/** The fallback locale for detection/resolution (unknown cookie, unmatched `Accept-Language`). */
|
|
36
|
+
default;
|
|
37
|
+
/** The cookie name the picker writes and locale detection reads. */
|
|
38
|
+
cookie;
|
|
39
|
+
/** The absolute site origin for {@link hreflangAlternates}, or undefined if not configured. */
|
|
40
|
+
origin;
|
|
41
|
+
/** Path prefixes that are never localized. */
|
|
42
|
+
nonLocalizedPrefixes;
|
|
43
|
+
/** The URL scheme for the default locale (`"prefix-except-default"` or `"prefix-all"`). */
|
|
44
|
+
strategy;
|
|
45
|
+
/** Every locale code, cached for {@link uniform}. */
|
|
46
|
+
#codes;
|
|
47
|
+
/** The routing config passed to every routing helper. */
|
|
48
|
+
#routing;
|
|
49
|
+
/**
|
|
50
|
+
* Build an i18n instance.
|
|
51
|
+
*
|
|
52
|
+
* @param config - the locale set and options (see {@link I18nConfig}).
|
|
53
|
+
* @throws Error when `locales` is empty, `default` is not one of the configured locales, or two
|
|
54
|
+
* locales resolve to the same case-insensitive `htmlLang`.
|
|
55
|
+
*/
|
|
56
|
+
constructor(config) {
|
|
57
|
+
const codes = Object.keys(config.locales);
|
|
58
|
+
if (codes.length === 0) {
|
|
59
|
+
throw new Error("I18n: `locales` must declare at least one locale.");
|
|
60
|
+
}
|
|
61
|
+
if (!codes.includes(config.default)) {
|
|
62
|
+
throw new Error(`I18n: default locale "${config.default}" is not one of the configured locales (${codes.join(", ")}).`);
|
|
63
|
+
}
|
|
64
|
+
this.list = codes.map((code) => {
|
|
65
|
+
const info = config.locales[code];
|
|
66
|
+
const htmlLang = info.htmlLang ?? code;
|
|
67
|
+
return { code, label: info.label, htmlLang, locale: info.locale ?? htmlLang };
|
|
68
|
+
});
|
|
69
|
+
// URL routing matches locale segments case-insensitively (`/en-gb` resolves to `en-GB`), so
|
|
70
|
+
// two locales whose htmlLang differ only by case would share one URL prefix and leave one
|
|
71
|
+
// locale unreachable by URL. Fail fast at construction rather than fail silently at runtime.
|
|
72
|
+
const seenHtmlLang = new Map();
|
|
73
|
+
for (const entry of this.list) {
|
|
74
|
+
const key = entry.htmlLang.toLowerCase();
|
|
75
|
+
const clash = seenHtmlLang.get(key);
|
|
76
|
+
if (clash !== undefined) {
|
|
77
|
+
throw new Error(`I18n: locales "${clash}" and "${entry.code}" both resolve to htmlLang "${key}" (case-insensitive); each locale needs a distinct htmlLang.`);
|
|
78
|
+
}
|
|
79
|
+
seenHtmlLang.set(key, entry.code);
|
|
80
|
+
}
|
|
81
|
+
this.locales = Object.fromEntries(this.list.map((entry) => [entry.code, entry]));
|
|
82
|
+
this.default = config.default;
|
|
83
|
+
this.cookie = config.cookie ?? DEFAULT_COOKIE;
|
|
84
|
+
// Strip trailing slashes so a caller passing `origin: "https://x.com/"` does not yield a
|
|
85
|
+
// double slash in canonical/hreflang URLs (`https://x.com//pricing`), which harms SEO.
|
|
86
|
+
this.origin = config.origin?.replace(/\/+$/, "");
|
|
87
|
+
this.nonLocalizedPrefixes = config.nonLocalizedPrefixes ?? [];
|
|
88
|
+
this.strategy = config.strategy ?? "prefix-except-default";
|
|
89
|
+
this.#codes = codes;
|
|
90
|
+
this.#routing = {
|
|
91
|
+
locales: this.list,
|
|
92
|
+
default: this.default,
|
|
93
|
+
nonLocalizedPrefixes: this.nonLocalizedPrefixes,
|
|
94
|
+
strategy: this.strategy,
|
|
95
|
+
origin: this.origin,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// --- Authoring (type-level coverage enforcement) --------------------------------------------
|
|
99
|
+
/**
|
|
100
|
+
* Define a co-located catalog of copy. Every entry must carry a translation for every locale
|
|
101
|
+
* in `L`, or it is a compile error; the returned object keeps its precise literal type so a
|
|
102
|
+
* {@link Translator} can infer each parameterized entry's arguments. This is an identity
|
|
103
|
+
* function - all the work is in the type constraint.
|
|
104
|
+
*
|
|
105
|
+
* @typeParam C - the catalog literal, constrained to {@link LanguageTextCatalog}.
|
|
106
|
+
* @param catalog - the catalog object.
|
|
107
|
+
* @returns the same object, precisely typed.
|
|
108
|
+
*/
|
|
109
|
+
defineTextCatalog(catalog) {
|
|
110
|
+
return catalog;
|
|
111
|
+
}
|
|
112
|
+
defineText(text) {
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Build a {@link LanguageText} whose value is identical in every locale (a brand word, a
|
|
117
|
+
* symbol). Coverage is complete by construction.
|
|
118
|
+
*
|
|
119
|
+
* @param value - the string used for every locale.
|
|
120
|
+
* @returns a {@link LanguageText} for `L`.
|
|
121
|
+
*/
|
|
122
|
+
uniform(value) {
|
|
123
|
+
return catalog.uniform(value, this.#codes);
|
|
124
|
+
}
|
|
125
|
+
// --- Resolution -----------------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Build a {@link Translator} bound to `locale` - the ergonomic path: bind once, resolve many.
|
|
128
|
+
*
|
|
129
|
+
* @param locale - the locale to bind.
|
|
130
|
+
* @returns a translator for that locale.
|
|
131
|
+
*/
|
|
132
|
+
translator(locale) {
|
|
133
|
+
return text.createTranslator(locale);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve one static text for a locale without binding a {@link Translator}. For parameterized
|
|
137
|
+
* text or repeated use, prefer {@link translator}.
|
|
138
|
+
*
|
|
139
|
+
* @param textValue - the {@link LanguageText} to resolve.
|
|
140
|
+
* @param locale - the locale to resolve against.
|
|
141
|
+
* @returns the string for that locale.
|
|
142
|
+
*/
|
|
143
|
+
translate(textValue, locale) {
|
|
144
|
+
return text.translate(textValue, locale);
|
|
145
|
+
}
|
|
146
|
+
// --- Detection ------------------------------------------------------------------------------
|
|
147
|
+
/**
|
|
148
|
+
* Narrow an untrusted value (e.g. a cookie) to a valid locale, falling back to {@link default}.
|
|
149
|
+
*
|
|
150
|
+
* @param raw - the candidate value.
|
|
151
|
+
* @returns a guaranteed-valid locale.
|
|
152
|
+
*/
|
|
153
|
+
resolveLocale(raw) {
|
|
154
|
+
return detect.resolveLocale(raw, this.list, this.default);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Pick the best locale from an HTTP `Accept-Language` header, falling back to {@link default}.
|
|
158
|
+
*
|
|
159
|
+
* @param header - the raw header value (or null/undefined).
|
|
160
|
+
* @returns the best supported locale.
|
|
161
|
+
*/
|
|
162
|
+
matchAcceptLanguage(header) {
|
|
163
|
+
return detect.matchAcceptLanguage(header, this.list, this.default);
|
|
164
|
+
}
|
|
165
|
+
// --- Locale metadata ------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* The BCP-47 `<html lang>` subtag for a locale (e.g. `"nl"`).
|
|
168
|
+
*
|
|
169
|
+
* @param locale - the locale.
|
|
170
|
+
* @returns its `htmlLang`.
|
|
171
|
+
*/
|
|
172
|
+
htmlLangFor(locale) {
|
|
173
|
+
return this.locales[locale]?.htmlLang ?? locale;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The full BCP-47 `Intl` formatting locale for a locale (e.g. `"nl-NL"`), for
|
|
177
|
+
* `new Intl.DateTimeFormat(i18n.intlLocaleFor(locale))`.
|
|
178
|
+
*
|
|
179
|
+
* @param locale - the locale.
|
|
180
|
+
* @returns its `Intl` locale.
|
|
181
|
+
*/
|
|
182
|
+
intlLocaleFor(locale) {
|
|
183
|
+
return this.locales[locale]?.locale ?? this.htmlLangFor(locale);
|
|
184
|
+
}
|
|
185
|
+
// --- Routing (config-bound wrappers over the routing module) --------------------------------
|
|
186
|
+
/**
|
|
187
|
+
* The URL prefix for a locale: `""` for the default locale, `"/<htmlLang>"` otherwise.
|
|
188
|
+
*
|
|
189
|
+
* @param locale - the locale.
|
|
190
|
+
* @returns the leading URL segment.
|
|
191
|
+
*/
|
|
192
|
+
prefixFor(locale) {
|
|
193
|
+
return routing.prefixFor(this.#routing, locale);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The locale a URL path segment denotes (via `htmlLang`), or null.
|
|
197
|
+
*
|
|
198
|
+
* @param segment - the first path segment, without slashes.
|
|
199
|
+
* @returns the locale, or null.
|
|
200
|
+
*/
|
|
201
|
+
localeForSegment(segment) {
|
|
202
|
+
return routing.localeForSegment(this.#routing, segment);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Whether a pathname takes a locale prefix (i.e. is not under {@link nonLocalizedPrefixes}).
|
|
206
|
+
*
|
|
207
|
+
* @param pathname - the URL pathname.
|
|
208
|
+
* @returns true when the path is localized.
|
|
209
|
+
*/
|
|
210
|
+
isLocalizedPath(pathname) {
|
|
211
|
+
return routing.isLocalizedPath(this.#routing, pathname);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Localize an internal href for a locale (`/pricing` -> `/nl/pricing`).
|
|
215
|
+
*
|
|
216
|
+
* @param href - the link target.
|
|
217
|
+
* @param locale - the active locale.
|
|
218
|
+
* @returns the localized href.
|
|
219
|
+
*/
|
|
220
|
+
localizeHref(href, locale) {
|
|
221
|
+
return routing.localizeHref(this.#routing, href, locale);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Rewrite a pathname to another locale (used by the picker to switch pages).
|
|
225
|
+
*
|
|
226
|
+
* @param pathname - the current pathname.
|
|
227
|
+
* @param next - the locale to switch to.
|
|
228
|
+
* @returns the pathname in the target locale.
|
|
229
|
+
*/
|
|
230
|
+
switchLocalePath(pathname, next) {
|
|
231
|
+
return routing.switchLocalePath(this.#routing, pathname, next);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Strip any locale prefix from a pathname (`/nl/x` -> `/x`).
|
|
235
|
+
*
|
|
236
|
+
* @param pathname - the current pathname.
|
|
237
|
+
* @returns the unprefixed pathname.
|
|
238
|
+
*/
|
|
239
|
+
stripLocalePrefix(pathname) {
|
|
240
|
+
return routing.stripLocalePrefix(this.#routing, pathname);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Canonical + hreflang alternates for a page (requires `origin` in the config). A non-localized
|
|
244
|
+
* path yields a single canonical with only an `x-default`.
|
|
245
|
+
*
|
|
246
|
+
* @param path - the pathname (bare or already-locale-prefixed; it is reduced to the bare form).
|
|
247
|
+
* @param locale - the active locale.
|
|
248
|
+
* @returns the `{ canonical, languages }` alternates object.
|
|
249
|
+
* @throws Error when the config has no `origin`.
|
|
250
|
+
*/
|
|
251
|
+
hreflangAlternates(path, locale) {
|
|
252
|
+
return routing.hreflangAlternates(this.#routing, path, locale);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the i18nkit core (the `.` entry). The star is the {@link I18n} class - a
|
|
3
|
+
* consumer constructs one instance and reaches every feature through it. The mapped-type
|
|
4
|
+
* vocabulary is exported for annotations, and the two locale-free resolvers (`createTranslator`,
|
|
5
|
+
* `translate`) are exported as escape hatches for resolving a text you already hold without an
|
|
6
|
+
* instance. Detection and routing are reached as `I18n` methods, not standalone, so there is one
|
|
7
|
+
* obvious way to do each.
|
|
8
|
+
*/
|
|
9
|
+
export { I18n, type LocaleOf } from "./i18n.js";
|
|
10
|
+
export { createTranslator, translate } from "./translate.js";
|
|
11
|
+
export type { I18nConfig, LanguageText, LanguageTextCatalog, LanguageTextFn, LocaleInfo, ResolvedLocale, Translator, } from "./types.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the i18nkit core (the `.` entry). The star is the {@link I18n} class - a
|
|
3
|
+
* consumer constructs one instance and reaches every feature through it. The mapped-type
|
|
4
|
+
* vocabulary is exported for annotations, and the two locale-free resolvers (`createTranslator`,
|
|
5
|
+
* `translate`) are exported as escape hatches for resolving a text you already hold without an
|
|
6
|
+
* instance. Detection and routing are reached as `I18n` methods, not standalone, so there is one
|
|
7
|
+
* obvious way to do each.
|
|
8
|
+
*/
|
|
9
|
+
export { I18n } from "./i18n.js";
|
|
10
|
+
export { createTranslator, translate } from "./translate.js";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, config-driven URL-locale routing, ported from the SwiftGuard i18n core and generalized
|
|
3
|
+
* over any locale set. The URL scheme is "as-needed": the default locale lives at bare URLs
|
|
4
|
+
* (`/pricing`) and every other locale under its `htmlLang` prefix (`/nl/pricing`); the default
|
|
5
|
+
* locale's own prefix never appears. Dependency-free (imports only `./types.js`) so it can be
|
|
6
|
+
* bundled into edge middleware. Reached as instance methods (`i18n.localizeHref(...)` etc.).
|
|
7
|
+
*/
|
|
8
|
+
import type { ResolvedLocale, RoutingStrategy } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* The routing configuration a routing function needs - a structural subset of an {@link I18n}
|
|
11
|
+
* instance, so the instance can pass itself. Kept separate from the full config so the routing
|
|
12
|
+
* functions stay usable in isolation.
|
|
13
|
+
*
|
|
14
|
+
* @typeParam L - the locale union.
|
|
15
|
+
*/
|
|
16
|
+
export interface RoutingConfig<L extends string> {
|
|
17
|
+
/** Every resolved locale, for `htmlLang` <-> code mapping. */
|
|
18
|
+
readonly locales: readonly ResolvedLocale<L>[];
|
|
19
|
+
/** The default locale (served at bare URLs, no prefix). */
|
|
20
|
+
readonly default: L;
|
|
21
|
+
/** Path prefixes that are never localized (e.g. `["/api"]`). */
|
|
22
|
+
readonly nonLocalizedPrefixes: readonly string[];
|
|
23
|
+
/** The URL scheme for the default locale; see {@link RoutingStrategy}. */
|
|
24
|
+
readonly strategy: RoutingStrategy;
|
|
25
|
+
/** Absolute site origin for {@link hreflangAlternates} (e.g. `"https://example.com"`). */
|
|
26
|
+
readonly origin?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether a pathname belongs to the localized zone, i.e. is not under any
|
|
30
|
+
* {@link RoutingConfig.nonLocalizedPrefixes} entry. Segment-aware: `/api` and `/api/x` are
|
|
31
|
+
* non-localized, `/apis` is localized.
|
|
32
|
+
*
|
|
33
|
+
* @param config - the routing config.
|
|
34
|
+
* @param pathname - the URL pathname (no query/hash).
|
|
35
|
+
* @returns true when the path takes a locale prefix.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isLocalizedPath<L extends string>(config: RoutingConfig<L>, pathname: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Map a URL path segment to the locale it denotes (via each locale's `htmlLang`, e.g. `"nl"`),
|
|
40
|
+
* or null when the segment is not a locale.
|
|
41
|
+
*
|
|
42
|
+
* @param config - the routing config.
|
|
43
|
+
* @param segment - the first path segment, without slashes.
|
|
44
|
+
* @returns the matching locale code, or null.
|
|
45
|
+
*/
|
|
46
|
+
export declare function localeForSegment<L extends string>(config: RoutingConfig<L>, segment: string): L | null;
|
|
47
|
+
/**
|
|
48
|
+
* The URL prefix for a locale: `"/<htmlLang>"` (e.g. `"/nl"`), or `""` for the default locale under
|
|
49
|
+
* the `"prefix-except-default"` strategy (bare URLs). Under `"prefix-all"` the default locale is
|
|
50
|
+
* prefixed too, so this never returns `""`.
|
|
51
|
+
*
|
|
52
|
+
* The `htmlLang` is lowercased for the URL, so a region/script-subtag locale (`htmlLang: "en-GB"`)
|
|
53
|
+
* yields the conventional lowercase prefix `"/en-gb"`, matching the case-insensitive segment parsing
|
|
54
|
+
* in {@link localeForSegment} (it round-trips). The document `<html lang>` value keeps its configured
|
|
55
|
+
* BCP-47 casing - read that via `htmlLangFor`, not from the URL.
|
|
56
|
+
*
|
|
57
|
+
* @param config - the routing config.
|
|
58
|
+
* @param locale - the locale.
|
|
59
|
+
* @returns the leading URL segment (lowercased), or `""`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function prefixFor<L extends string>(config: RoutingConfig<L>, locale: L): string;
|
|
62
|
+
/**
|
|
63
|
+
* Localize an internal href for a locale: `/pricing` -> `/nl/pricing` when `nl` is active. Passes
|
|
64
|
+
* through unchanged: the default locale, hrefs that are not site-absolute (`mailto:`, `#hash`,
|
|
65
|
+
* external, protocol-relative `//`), non-localized targets, and hrefs that already carry a locale
|
|
66
|
+
* prefix. The prefix is inserted before the first `?` or `#`, and `"/"` becomes the bare prefix
|
|
67
|
+
* (`"/nl"`, never `"/nl/"`).
|
|
68
|
+
*
|
|
69
|
+
* @param config - the routing config.
|
|
70
|
+
* @param href - the link target as written at the call site.
|
|
71
|
+
* @param locale - the active locale.
|
|
72
|
+
* @returns the (possibly prefixed) href.
|
|
73
|
+
*/
|
|
74
|
+
export declare function localizeHref<L extends string>(config: RoutingConfig<L>, href: string, locale: L): string;
|
|
75
|
+
/**
|
|
76
|
+
* Rewrite a pathname to another locale's URL: strips any existing locale segment and applies the
|
|
77
|
+
* target locale's prefix. Used by the language picker to navigate between `/nl/pricing` and
|
|
78
|
+
* `/pricing`.
|
|
79
|
+
*
|
|
80
|
+
* @param config - the routing config.
|
|
81
|
+
* @param pathname - the current URL pathname.
|
|
82
|
+
* @param next - the locale to switch to.
|
|
83
|
+
* @returns the pathname for the same page in the target locale.
|
|
84
|
+
*/
|
|
85
|
+
export declare function switchLocalePath<L extends string>(config: RoutingConfig<L>, pathname: string, next: L): string;
|
|
86
|
+
/**
|
|
87
|
+
* Strip any locale prefix from a pathname (`/nl/dashboard` -> `/dashboard`, `/nl` -> `/`). Use
|
|
88
|
+
* before comparing a live pathname against bare hrefs (e.g. nav active-state highlighting), or as
|
|
89
|
+
* the primitive {@link switchLocalePath} builds on. Strips regardless of strategy - unlike
|
|
90
|
+
* "switch to the default locale", which under `"prefix-all"` would re-apply the default's prefix.
|
|
91
|
+
*
|
|
92
|
+
* @param config - the routing config.
|
|
93
|
+
* @param pathname - the current URL pathname.
|
|
94
|
+
* @returns the pathname without its locale prefix.
|
|
95
|
+
*/
|
|
96
|
+
export declare function stripLocalePrefix<L extends string>(config: RoutingConfig<L>, pathname: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Canonical + hreflang alternates for a page, shaped to plug straight into a framework's metadata
|
|
99
|
+
* (typed structurally so this module stays framework-free). The canonical is the active locale's
|
|
100
|
+
* own URL; the `languages` map lists every locale by `htmlLang` plus `x-default` pointing at the
|
|
101
|
+
* bare (default-locale) URL. Adding a locale extends every page's hreflang automatically.
|
|
102
|
+
*
|
|
103
|
+
* @param config - the routing config; its `origin` must be set.
|
|
104
|
+
* @param path - the page's bare (unprefixed) pathname, e.g. `"/pricing"`.
|
|
105
|
+
* @param locale - the active locale (decides the canonical).
|
|
106
|
+
* @returns the `{ canonical, languages }` alternates object.
|
|
107
|
+
* @throws Error when the config has no `origin`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function hreflangAlternates<L extends string>(config: RoutingConfig<L>, path: string, locale: L): {
|
|
110
|
+
canonical: string;
|
|
111
|
+
languages: Record<string, string>;
|
|
112
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Look up a locale's `htmlLang`, falling back to the code itself when unknown.
|
|
3
|
+
*
|
|
4
|
+
* @param config - the routing config.
|
|
5
|
+
* @param locale - the locale to look up.
|
|
6
|
+
* @returns the locale's `htmlLang` subtag.
|
|
7
|
+
*/
|
|
8
|
+
function htmlLangOf(config, locale) {
|
|
9
|
+
return config.locales.find((info) => info.code === locale)?.htmlLang ?? locale;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Whether a pathname belongs to the localized zone, i.e. is not under any
|
|
13
|
+
* {@link RoutingConfig.nonLocalizedPrefixes} entry. Segment-aware: `/api` and `/api/x` are
|
|
14
|
+
* non-localized, `/apis` is localized.
|
|
15
|
+
*
|
|
16
|
+
* @param config - the routing config.
|
|
17
|
+
* @param pathname - the URL pathname (no query/hash).
|
|
18
|
+
* @returns true when the path takes a locale prefix.
|
|
19
|
+
*/
|
|
20
|
+
export function isLocalizedPath(config, pathname) {
|
|
21
|
+
return !config.nonLocalizedPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Map a URL path segment to the locale it denotes (via each locale's `htmlLang`, e.g. `"nl"`),
|
|
25
|
+
* or null when the segment is not a locale.
|
|
26
|
+
*
|
|
27
|
+
* @param config - the routing config.
|
|
28
|
+
* @param segment - the first path segment, without slashes.
|
|
29
|
+
* @returns the matching locale code, or null.
|
|
30
|
+
*/
|
|
31
|
+
export function localeForSegment(config, segment) {
|
|
32
|
+
// Compare case-insensitively so a lowercased URL (`/en-gb/x`) still matches a region-subtag
|
|
33
|
+
// locale (`htmlLang: "en-GB"`), mirroring how `matchAcceptLanguage` lowercases both sides.
|
|
34
|
+
const lower = segment.toLowerCase();
|
|
35
|
+
return config.locales.find((info) => info.htmlLang.toLowerCase() === lower)?.code ?? null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The URL prefix for a locale: `"/<htmlLang>"` (e.g. `"/nl"`), or `""` for the default locale under
|
|
39
|
+
* the `"prefix-except-default"` strategy (bare URLs). Under `"prefix-all"` the default locale is
|
|
40
|
+
* prefixed too, so this never returns `""`.
|
|
41
|
+
*
|
|
42
|
+
* The `htmlLang` is lowercased for the URL, so a region/script-subtag locale (`htmlLang: "en-GB"`)
|
|
43
|
+
* yields the conventional lowercase prefix `"/en-gb"`, matching the case-insensitive segment parsing
|
|
44
|
+
* in {@link localeForSegment} (it round-trips). The document `<html lang>` value keeps its configured
|
|
45
|
+
* BCP-47 casing - read that via `htmlLangFor`, not from the URL.
|
|
46
|
+
*
|
|
47
|
+
* @param config - the routing config.
|
|
48
|
+
* @param locale - the locale.
|
|
49
|
+
* @returns the leading URL segment (lowercased), or `""`.
|
|
50
|
+
*/
|
|
51
|
+
export function prefixFor(config, locale) {
|
|
52
|
+
const prefixed = config.strategy === "prefix-all" || locale !== config.default;
|
|
53
|
+
return prefixed ? `/${htmlLangOf(config, locale).toLowerCase()}` : "";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Localize an internal href for a locale: `/pricing` -> `/nl/pricing` when `nl` is active. Passes
|
|
57
|
+
* through unchanged: the default locale, hrefs that are not site-absolute (`mailto:`, `#hash`,
|
|
58
|
+
* external, protocol-relative `//`), non-localized targets, and hrefs that already carry a locale
|
|
59
|
+
* prefix. The prefix is inserted before the first `?` or `#`, and `"/"` becomes the bare prefix
|
|
60
|
+
* (`"/nl"`, never `"/nl/"`).
|
|
61
|
+
*
|
|
62
|
+
* @param config - the routing config.
|
|
63
|
+
* @param href - the link target as written at the call site.
|
|
64
|
+
* @param locale - the active locale.
|
|
65
|
+
* @returns the (possibly prefixed) href.
|
|
66
|
+
*/
|
|
67
|
+
export function localizeHref(config, href, locale) {
|
|
68
|
+
const prefix = prefixFor(config, locale);
|
|
69
|
+
if (!prefix || !href.startsWith("/") || href.startsWith("//")) {
|
|
70
|
+
return href;
|
|
71
|
+
}
|
|
72
|
+
const queryIdx = href.indexOf("?");
|
|
73
|
+
const hashIdx = href.indexOf("#");
|
|
74
|
+
const stopIdx = queryIdx === -1 ? hashIdx : hashIdx === -1 ? queryIdx : Math.min(queryIdx, hashIdx);
|
|
75
|
+
const path = stopIdx === -1 ? href : href.slice(0, stopIdx);
|
|
76
|
+
const rest = stopIdx === -1 ? "" : href.slice(stopIdx);
|
|
77
|
+
if (!isLocalizedPath(config, path)) {
|
|
78
|
+
return href;
|
|
79
|
+
}
|
|
80
|
+
// `path` is guaranteed "/"-prefixed and non-empty here, so split("/")[1] is always present.
|
|
81
|
+
if (localeForSegment(config, path.split("/")[1])) {
|
|
82
|
+
return href;
|
|
83
|
+
}
|
|
84
|
+
return path === "/" ? `${prefix}${rest}` : `${prefix}${path}${rest}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Rewrite a pathname to another locale's URL: strips any existing locale segment and applies the
|
|
88
|
+
* target locale's prefix. Used by the language picker to navigate between `/nl/pricing` and
|
|
89
|
+
* `/pricing`.
|
|
90
|
+
*
|
|
91
|
+
* @param config - the routing config.
|
|
92
|
+
* @param pathname - the current URL pathname.
|
|
93
|
+
* @param next - the locale to switch to.
|
|
94
|
+
* @returns the pathname for the same page in the target locale.
|
|
95
|
+
*/
|
|
96
|
+
export function switchLocalePath(config, pathname, next) {
|
|
97
|
+
const stripped = stripLocalePrefix(config, pathname);
|
|
98
|
+
const prefix = prefixFor(config, next);
|
|
99
|
+
// A non-localized path (e.g. /api/*) never takes a locale prefix - mirror localizeHref, so
|
|
100
|
+
// the picker does not navigate to a route that does not exist.
|
|
101
|
+
if (!prefix || !isLocalizedPath(config, stripped)) {
|
|
102
|
+
return stripped;
|
|
103
|
+
}
|
|
104
|
+
return stripped === "/" ? prefix : `${prefix}${stripped}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Strip any locale prefix from a pathname (`/nl/dashboard` -> `/dashboard`, `/nl` -> `/`). Use
|
|
108
|
+
* before comparing a live pathname against bare hrefs (e.g. nav active-state highlighting), or as
|
|
109
|
+
* the primitive {@link switchLocalePath} builds on. Strips regardless of strategy - unlike
|
|
110
|
+
* "switch to the default locale", which under `"prefix-all"` would re-apply the default's prefix.
|
|
111
|
+
*
|
|
112
|
+
* @param config - the routing config.
|
|
113
|
+
* @param pathname - the current URL pathname.
|
|
114
|
+
* @returns the pathname without its locale prefix.
|
|
115
|
+
*/
|
|
116
|
+
export function stripLocalePrefix(config, pathname) {
|
|
117
|
+
const segment = pathname.split("/")[1] ?? "";
|
|
118
|
+
return localeForSegment(config, segment) ? pathname.slice(segment.length + 1) || "/" : pathname;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Canonical + hreflang alternates for a page, shaped to plug straight into a framework's metadata
|
|
122
|
+
* (typed structurally so this module stays framework-free). The canonical is the active locale's
|
|
123
|
+
* own URL; the `languages` map lists every locale by `htmlLang` plus `x-default` pointing at the
|
|
124
|
+
* bare (default-locale) URL. Adding a locale extends every page's hreflang automatically.
|
|
125
|
+
*
|
|
126
|
+
* @param config - the routing config; its `origin` must be set.
|
|
127
|
+
* @param path - the page's bare (unprefixed) pathname, e.g. `"/pricing"`.
|
|
128
|
+
* @param locale - the active locale (decides the canonical).
|
|
129
|
+
* @returns the `{ canonical, languages }` alternates object.
|
|
130
|
+
* @throws Error when the config has no `origin`.
|
|
131
|
+
*/
|
|
132
|
+
export function hreflangAlternates(config, path, locale) {
|
|
133
|
+
const { origin } = config;
|
|
134
|
+
if (!origin) {
|
|
135
|
+
throw new Error("hreflangAlternates requires `origin` to be set in the I18n config (e.g. new I18n({ ..., origin: 'https://example.com' })).");
|
|
136
|
+
}
|
|
137
|
+
// Tolerate a caller passing the already-prefixed pathname (routers often hand you `/nl/x`):
|
|
138
|
+
// reduce it to the bare form so alternates are built from a single canonical path.
|
|
139
|
+
const bare = stripLocalePrefix(config, path);
|
|
140
|
+
// A non-localized path has a single real URL - do not fabricate per-locale alternates for it.
|
|
141
|
+
if (!isLocalizedPath(config, bare)) {
|
|
142
|
+
const url = `${origin}${bare}`;
|
|
143
|
+
return { canonical: url, languages: { "x-default": url } };
|
|
144
|
+
}
|
|
145
|
+
const urlFor = (code) => {
|
|
146
|
+
const prefix = prefixFor(config, code);
|
|
147
|
+
// At the root, a prefixed locale is its own bare URL (`/nl`, not `/nl/`); only a locale
|
|
148
|
+
// with no prefix keeps the bare `/`.
|
|
149
|
+
return `${origin}${prefix}${bare === "/" && prefix ? "" : bare}`;
|
|
150
|
+
};
|
|
151
|
+
const languages = {};
|
|
152
|
+
for (const info of config.locales) {
|
|
153
|
+
languages[info.htmlLang] = urlFor(info.code);
|
|
154
|
+
}
|
|
155
|
+
languages["x-default"] = urlFor(config.default);
|
|
156
|
+
return { canonical: urlFor(locale), languages };
|
|
157
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resolver: turns a {@link LanguageText}/{@link LanguageTextFn} into a plain string for a
|
|
3
|
+
* given locale. Kept framework-free (types-only import) so the identical call shape works on the
|
|
4
|
+
* server, in the browser, and at the edge.
|
|
5
|
+
*/
|
|
6
|
+
import type { LanguageText, Translator } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Build a {@link Translator} bound to a single locale. The returned function reads the entry for
|
|
9
|
+
* `locale` and, if it is a builder function, applies the arguments. Usually reached through
|
|
10
|
+
* `i18n.translator(locale)` (which pins the locale to the instance's union) rather than directly.
|
|
11
|
+
*
|
|
12
|
+
* @typeParam L - the locale union the text objects are keyed by.
|
|
13
|
+
* @param locale - the locale every returned call resolves against.
|
|
14
|
+
* @returns the overloaded translator function.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createTranslator<L extends string>(locale: L): Translator<L>;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a single static text without building a bound {@link Translator} - handy for one-off
|
|
19
|
+
* lookups. For parameterized text or repeated resolution, prefer `i18n.translator(locale)`.
|
|
20
|
+
*
|
|
21
|
+
* @typeParam L - the locale union the text object is keyed by.
|
|
22
|
+
* @param text - the {@link LanguageText} to resolve.
|
|
23
|
+
* @param locale - the locale to resolve against.
|
|
24
|
+
* @returns the string for that locale.
|
|
25
|
+
*/
|
|
26
|
+
export declare function translate<L extends string>(text: LanguageText<L>, locale: L): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a {@link Translator} bound to a single locale. The returned function reads the entry for
|
|
3
|
+
* `locale` and, if it is a builder function, applies the arguments. Usually reached through
|
|
4
|
+
* `i18n.translator(locale)` (which pins the locale to the instance's union) rather than directly.
|
|
5
|
+
*
|
|
6
|
+
* @typeParam L - the locale union the text objects are keyed by.
|
|
7
|
+
* @param locale - the locale every returned call resolves against.
|
|
8
|
+
* @returns the overloaded translator function.
|
|
9
|
+
*/
|
|
10
|
+
export function createTranslator(locale) {
|
|
11
|
+
function boundTranslate(text, ...args) {
|
|
12
|
+
const value = text[locale];
|
|
13
|
+
return typeof value === "function" ? value(...args) : value;
|
|
14
|
+
}
|
|
15
|
+
return boundTranslate;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a single static text without building a bound {@link Translator} - handy for one-off
|
|
19
|
+
* lookups. For parameterized text or repeated resolution, prefer `i18n.translator(locale)`.
|
|
20
|
+
*
|
|
21
|
+
* @typeParam L - the locale union the text object is keyed by.
|
|
22
|
+
* @param text - the {@link LanguageText} to resolve.
|
|
23
|
+
* @param locale - the locale to resolve against.
|
|
24
|
+
* @returns the string for that locale.
|
|
25
|
+
*/
|
|
26
|
+
export function translate(text, locale) {
|
|
27
|
+
return text[locale];
|
|
28
|
+
}
|