@edgekits/i18n-core 0.2.1 → 0.3.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.
@@ -1,6 +1,20 @@
1
1
  export declare const KV_BINDING = "TRANSLATIONS";
2
- export declare function getKvNamespaceId(wranglerConfigPath: string, binding: string): string;
3
- export declare function getWranglerVar(wranglerConfigPath: string, key: string): string | undefined;
2
+ /** What the CLI reads from the Worker's wrangler config, for one environment or the top level. */
3
+ export type WorkerSettings = {
4
+ kvId: string;
5
+ previewId?: string;
6
+ zoneId?: string;
7
+ otherEnvs: string[];
8
+ topLevelKvId?: string;
9
+ sameZoneScopes: string[];
10
+ };
11
+ /**
12
+ * The `binding` KV namespace and `CLOUDFLARE_ZONE_ID` of the top level, or of `env.<env>`
13
+ * when `env` is given: wrangler does not inherit `kv_namespaces` and `vars` into an
14
+ * environment, so nothing falls back to the top level. Exits, naming the file and the
15
+ * scope, when the environment or the binding (or its `id`) is missing.
16
+ */
17
+ export declare function readWorkerSettings(wranglerConfigPath: string, binding: string, env?: string): WorkerSettings;
4
18
  /**
5
19
  * The absolute path of the project's wrangler entry (`bin.wrangler` in its package.json),
6
20
  * resolved from `root` as the project itself would resolve it. `wrangler/package.json` is
@@ -5,7 +5,8 @@ import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
5
5
  // ---------------------------------------------------------------------------
6
6
  // Wrangler config reader
7
7
  // Parses the Worker's wrangler.jsonc (the path `--wrangler` names, ./wrangler.jsonc by
8
- // default) to extract KV namespace IDs and vars by key.
8
+ // default) for the translations binding and the zone id, at the top level or in one
9
+ // `env.<name>`.
9
10
  // Single source of truth — no hardcoded IDs or zone IDs in package.json.
10
11
  // ---------------------------------------------------------------------------
11
12
  export const KV_BINDING = 'TRANSLATIONS';
@@ -27,20 +28,72 @@ function readWranglerConfig(wranglerConfigPath) {
27
28
  }
28
29
  return config;
29
30
  }
30
- export function getKvNamespaceId(wranglerConfigPath, binding) {
31
+ function findBinding(scope, binding) {
32
+ const kvNamespaces = Array.isArray(scope?.kv_namespaces) ? scope.kv_namespaces : [];
33
+ return kvNamespaces.find((kv) => kv?.binding === binding);
34
+ }
35
+ /**
36
+ * The `binding` KV namespace and `CLOUDFLARE_ZONE_ID` of the top level, or of `env.<env>`
37
+ * when `env` is given: wrangler does not inherit `kv_namespaces` and `vars` into an
38
+ * environment, so nothing falls back to the top level. Exits, naming the file and the
39
+ * scope, when the environment or the binding (or its `id`) is missing.
40
+ */
41
+ export function readWorkerSettings(wranglerConfigPath, binding, env) {
31
42
  const config = readWranglerConfig(wranglerConfigPath);
32
- const kvNamespaces = config.kv_namespaces;
33
- const entry = kvNamespaces?.find((kv) => kv.binding === binding);
43
+ const envs = (config.env ?? {});
44
+ let scope = config;
45
+ let where = wranglerConfigPath;
46
+ if (env !== undefined) {
47
+ if (!Object.hasOwn(envs, env) || typeof envs[env] !== 'object' || envs[env] === null) {
48
+ console.error(`[i18n] Environment "${env}" not found in ${wranglerConfigPath} (no env.${env}).`);
49
+ process.exit(1);
50
+ }
51
+ scope = envs[env];
52
+ where = `env.${env} of ${wranglerConfigPath}`;
53
+ }
54
+ const entry = findBinding(scope, binding);
34
55
  if (!entry) {
35
- console.error(`[i18n] KV binding "${binding}" not found in ${wranglerConfigPath}`);
56
+ console.error(`[i18n] KV binding "${binding}" not found in ${where}` +
57
+ (env !== undefined ? ' (wrangler does not inherit kv_namespaces into an environment).' : ''));
58
+ process.exit(1);
59
+ }
60
+ if (typeof entry.id !== 'string') {
61
+ console.error(`[i18n] KV binding "${binding}" in ${where} has no id.`);
36
62
  process.exit(1);
37
63
  }
38
- return entry.id;
64
+ const zoneId = zoneIdOf(scope);
65
+ const otherEnvs = env === undefined
66
+ ? Object.keys(envs).filter((name) => {
67
+ const other = findBinding(envs[name], binding);
68
+ return other !== undefined && other.id !== entry.id;
69
+ })
70
+ : [];
71
+ const topLevel = findBinding(config, binding);
72
+ const scopes = [
73
+ ['the top level', config],
74
+ ...Object.keys(envs).map((name) => [`env.${name}`, envs[name]]),
75
+ ];
76
+ const sameZoneScopes = zoneId === undefined
77
+ ? []
78
+ : scopes
79
+ .filter(([name]) => name !== (env === undefined ? 'the top level' : `env.${env}`))
80
+ .filter(([, other]) => {
81
+ const otherEntry = findBinding(other, binding);
82
+ return otherEntry !== undefined && otherEntry.id !== entry.id && zoneIdOf(other) === zoneId;
83
+ })
84
+ .map(([name]) => name);
85
+ return {
86
+ kvId: entry.id,
87
+ previewId: typeof entry.preview_id === 'string' ? entry.preview_id : undefined,
88
+ zoneId,
89
+ otherEnvs,
90
+ topLevelKvId: typeof topLevel?.id === 'string' ? topLevel.id : undefined,
91
+ sameZoneScopes,
92
+ };
39
93
  }
40
- export function getWranglerVar(wranglerConfigPath, key) {
41
- const config = readWranglerConfig(wranglerConfigPath);
42
- const vars = config.vars;
43
- return vars?.[key];
94
+ function zoneIdOf(scope) {
95
+ const zoneId = scope?.vars?.CLOUDFLARE_ZONE_ID;
96
+ return typeof zoneId === 'string' && zoneId !== '' ? zoneId : undefined;
44
97
  }
45
98
  // ---------------------------------------------------------------------------
46
99
  // Wrangler binary
@@ -35,10 +35,10 @@ export function createI18n(config, generated, bindings) {
35
35
  }
36
36
  /**
37
37
  * Single source of truth for translation cache URL construction, shared with the CLI
38
- * (Purge API) through `key-format.ts`. The URL is static, not versioned: invalidation
39
- * is the explicit purge in `edgekits-i18n migrate`.
38
+ * (Purge API) through `key-format.ts`. The URL carries the entry format (`v2`), not a
39
+ * content version: invalidation is the explicit purge in `edgekits-i18n migrate`.
40
40
  *
41
- * Format: https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
41
+ * Format: https://<keyPrefix>/<encodeURIComponent(i18n:v2:<locale>:<namespace>)>
42
42
  */
43
43
  function buildTranslationCacheUrl(locale, ns) {
44
44
  return formatTranslationCacheUrl(config.keyPrefix, locale, ns);
@@ -3,6 +3,9 @@
3
3
  * - `base` provides known structure (fallback)
4
4
  * - `overrides` can provide partial values
5
5
  * - Both types are preserved and merged correctly
6
- * - `__proto__`, `constructor` and `prototype` keys in `overrides` are skipped
6
+ * - `__proto__` in `overrides` is skipped: JSON.parse creates it as an own key, so a KV
7
+ * value could carry one, and assigning it would replace the result's prototype.
8
+ * `constructor` and `prototype` are ordinary keys: assigning them on the fresh result
9
+ * object creates own properties and reaches no prototype.
7
10
  */
8
11
  export declare function deepMerge<A extends object, B extends Partial<A>>(base: A, overrides: B): A;
@@ -1,23 +1,24 @@
1
1
  function isObject(value) {
2
2
  return typeof value === 'object' && value !== null && !Array.isArray(value);
3
3
  }
4
- // Keys that would reach an object's prototype instead of its own properties. JSON.parse
5
- // creates `__proto__` as an own key, so a KV value could carry one.
6
- const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
7
4
  /**
8
5
  * Deep-merge two dictionaries.
9
6
  * - `base` provides known structure (fallback)
10
7
  * - `overrides` can provide partial values
11
8
  * - Both types are preserved and merged correctly
12
- * - `__proto__`, `constructor` and `prototype` keys in `overrides` are skipped
9
+ * - `__proto__` in `overrides` is skipped: JSON.parse creates it as an own key, so a KV
10
+ * value could carry one, and assigning it would replace the result's prototype.
11
+ * `constructor` and `prototype` are ordinary keys: assigning them on the fresh result
12
+ * object creates own properties and reaches no prototype.
13
13
  */
14
14
  export function deepMerge(base, overrides) {
15
15
  const result = { ...base };
16
16
  for (const key of Object.keys(overrides)) {
17
- if (UNSAFE_KEYS.has(key))
17
+ if (key === '__proto__')
18
18
  continue;
19
19
  const overrideValue = overrides[key];
20
- const baseValue = base[key];
20
+ // Own keys only: `base.constructor` would otherwise be the inherited `Object`.
21
+ const baseValue = Object.hasOwn(base, key) ? base[key] : undefined;
21
22
  if (overrideValue === undefined)
22
23
  continue;
23
24
  // For primitives or different types — override directly
package/dist/fetcher.js CHANGED
@@ -1,14 +1,31 @@
1
1
  import { assembleNamespace } from "./assemble.js";
2
2
  import { fallbackIdentifier } from "./key-format.js";
3
3
  const KV_JSON_OPTIONS = { type: 'json' };
4
+ // The most keys one KV bulk read accepts.
5
+ const KV_BATCH_LIMIT = 100;
6
+ // A value read from KV: a year, until `migrate` purges it. A key KV does not have (a locale
7
+ // without that namespace): a `null` marker for a minute, so a later `seed` shows without a purge.
8
+ const CACHE_CONTROL_VALUE = 'public, s-maxage=31536000, immutable';
9
+ const CACHE_CONTROL_MISSING = 'public, s-maxage=60';
10
+ // The fetcher's mark on its own entries. The cache URL sits on a hostname of the site's zone,
11
+ // so a response something else left at that URL is a miss, never read as translations.
12
+ // The value is the entry format, as in the URL (`i18n:v2:…`).
13
+ const ENTRY_MARKER = 'X-EdgeKits-I18n';
14
+ const ENTRY_FORMAT = 'v2';
4
15
  export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildTranslationKvKey, buildTranslationCacheRequest, }) {
5
16
  /**
6
17
  * Fetch translations from KV for a given locale and list of namespaces.
7
18
  *
8
- * Cache strategy — per-namespace static cache keys:
19
+ * Cache strategy — per-namespace cache keys, versioned by entry format:
9
20
  *
10
- * Each namespace is cached individually under a static (non-versioned) URL:
11
- * https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<ns>)>
21
+ * Each namespace is cached individually under
22
+ * https://<keyPrefix>/<encodeURIComponent(i18n:v2:<locale>:<ns>)>
23
+ *
24
+ * An entry holds what KV holds: the raw value of the namespace's key, or a `null`
25
+ * marker when KV has none (cached for 60 s). Never the merged namespace: the
26
+ * compiled fallback is merged on every read, so a fallback change reaches every
27
+ * locale on the next deploy (ADR-0033). Each entry carries `X-EdgeKits-I18n: v2`; a
28
+ * response at the URL without it is a miss, read again from KV and replaced.
12
29
  *
13
30
  * Cache invalidation is handled explicitly by the Cloudflare Purge API
14
31
  * in `edgekits-i18n migrate` — only namespaces whose content actually changed
@@ -19,19 +36,19 @@ export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildT
19
36
  * FALLBACK_* → return immediately. Zero KV reads.
20
37
  *
21
38
  * Partial or full cache miss:
22
- * Missing namespaces collected → single KV getAll() batch →
23
- * merge with FALLBACK_* → individual cache.put() per namespace → return.
39
+ * Missing namespaces collected → KV bulk reads of at most 100 keys, in
40
+ * parallel → merge with FALLBACK_* → individual cache.put() of the raw value
41
+ * per namespace → return.
24
42
  *
25
43
  * Every namespace — cached or freshly read — is assembled through
26
44
  * assembleNamespace(), so a cache HIT is merged with FALLBACK_* exactly like a
27
- * miss. An entry cached before a schema addition therefore degrades to
28
- * default-locale text for the keys it predates instead of yielding `undefined`
45
+ * miss: a newer fallback key shows as default-locale text instead of `undefined`
29
46
  * (ADR-0014).
30
47
  *
31
- * KV unavailable:
32
- * Falls back to FALLBACK_* compiled dictionaries (always available), and caches
33
- * nothing: only values read from KV are written to the cache.
34
- * If none were generated, returns empty objects per namespace — no crash.
48
+ * KV unavailable (per bulk read):
49
+ * That read's namespaces fall back to FALLBACK_* compiled dictionaries (always
50
+ * available), and none of them is cached: only values read from KV are written
51
+ * to the cache. If none were generated, returns empty objects — no crash.
35
52
  */
36
53
  async function fetchTranslations(locale, namespaces) {
37
54
  const useCache = bindings.cacheEnabled;
@@ -56,9 +73,11 @@ export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildT
56
73
  const cacheRequest = buildTranslationCacheRequest(locale, ns);
57
74
  try {
58
75
  const cached = await cache.match(cacheRequest);
59
- if (cached) {
76
+ if (cached?.headers.get(ENTRY_MARKER) === ENTRY_FORMAT) {
60
77
  return { ns, data: await cached.json(), hit: true };
61
78
  }
79
+ if (cached)
80
+ debug(`i18n cache entry for ${ns} lacks ${ENTRY_MARKER}: treated as a miss`);
62
81
  }
63
82
  catch (error) {
64
83
  debug(`i18n cache READ error for ${ns}`, error);
@@ -72,9 +91,8 @@ export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildT
72
91
  const missingNamespaces = [];
73
92
  for (const { ns, data, hit } of cacheResults) {
74
93
  if (hit) {
75
- // Cached entries are merged with FALLBACK_* too. A namespace cached before
76
- // a schema addition is missing the newer keys; without this merge it would
77
- // reach pages as-is and any read of a new key would throw (ADR-0014).
94
+ // The raw KV value (or a null marker: the fallback alone), merged with the
95
+ // current FALLBACK_* like a miss (ADR-0014, ADR-0033).
78
96
  ;
79
97
  finalData[ns] = assembleNamespace(data, getFallback(ns));
80
98
  }
@@ -93,19 +111,10 @@ export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildT
93
111
  miss: missingNamespaces.length,
94
112
  });
95
113
  // ---------------------------------------------------------------------------
96
- // 3. Batch KV read for missing namespaces only
114
+ // 3. Batched KV reads for missing namespaces only
97
115
  // ---------------------------------------------------------------------------
98
116
  const missingKvKeys = missingNamespaces.map((ns) => buildTranslationKvKey(locale, ns));
99
- let kvResults = new Map();
100
- let kvFailed = false;
101
- try {
102
- kvResults = (await bindings.kv.get(missingKvKeys, KV_JSON_OPTIONS));
103
- debug('i18n KV batch OK', { locale, missingNamespaces });
104
- }
105
- catch (error) {
106
- kvFailed = true;
107
- debug('i18n KV ERROR, using FALLBACK_* for missing namespaces', error);
108
- }
117
+ const { values: kvResults, failed: failedKvKeys } = await readKv(missingKvKeys);
109
118
  // ---------------------------------------------------------------------------
110
119
  // 4. Assemble missing namespaces + schedule individual cache writes
111
120
  // ---------------------------------------------------------------------------
@@ -113,32 +122,32 @@ export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildT
113
122
  for (let i = 0; i < missingNamespaces.length; i++) {
114
123
  const ns = missingNamespaces[i];
115
124
  const kvKey = missingKvKeys[i];
116
- // Use KV value if available, otherwise empty object (fallback will fill it)
117
- const kvValue = kvFailed ? {} : (kvResults.get(kvKey) ?? {});
125
+ const kvFailed = failedKvKeys.has(kvKey);
126
+ // The KV value, or null when KV has none or its read failed (the fallback fills it)
127
+ const kvValue = kvFailed ? null : (kvResults.get(kvKey) ?? null);
118
128
  // Merge with FALLBACK_* compiled dictionary if present
119
129
  const fallback = getFallback(ns);
120
- const nsData = assembleNamespace(kvValue, fallback);
121
130
  if (kvFailed && !fallback) {
122
131
  debug(`i18n namespace "${ns}" unavailable: KV failed and no fallback`);
123
132
  }
124
133
  ;
125
- finalData[ns] = nsData;
126
- // Only a value that came from KV is cached. After a KV failure nsData is the
127
- // compiled fallback alone (the default locale's text, whatever `locale` asked for),
128
- // and an immutable year-long entry would serve it until the next purge.
134
+ finalData[ns] = assembleNamespace(kvValue, fallback);
135
+ // Only what KV answered is cached. After a KV failure there is no answer: a null
136
+ // marker would hide the namespace's real value for a minute.
129
137
  if (kvFailed)
130
138
  continue;
131
- // Cache the resolved namespace individually with a long TTL.
132
- // Invalidation is handled explicitly via Cloudflare Purge API —
133
- // no TTL-based expiry needed here.
139
+ // Cache the raw KV value, never the merged namespace: the fallback is merged on
140
+ // every read. A value lives until the Purge API removes it, a missing key's null
141
+ // marker for a minute.
134
142
  const cacheRequest = buildTranslationCacheRequest(locale, ns);
135
- const response = new Response(JSON.stringify(nsData), {
143
+ const response = new Response(JSON.stringify(kvValue), {
136
144
  headers: {
137
145
  'Content-Type': 'application/json; charset=utf-8',
138
- 'Cache-Control': 'public, s-maxage=31536000, immutable',
146
+ 'Cache-Control': kvValue === null ? CACHE_CONTROL_MISSING : CACHE_CONTROL_VALUE,
147
+ [ENTRY_MARKER]: ENTRY_FORMAT,
139
148
  },
140
149
  });
141
- putPromises.push(cache.put(cacheRequest, response.clone()).catch((error) => {
150
+ putPromises.push(cache.put(cacheRequest, response).catch((error) => {
142
151
  const errorMsg = error instanceof Error ? error.message : String(error);
143
152
  debug(`i18n cache WRITE error for ${ns}`, errorMsg);
144
153
  }));
@@ -153,8 +162,8 @@ export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildT
153
162
  bindings.waitUntil(Promise.all(putPromises));
154
163
  debug('i18n cache PUT scheduled', { locale, missingNamespaces });
155
164
  }
156
- else if (kvFailed) {
157
- debug('i18n cache PUT skipped: KV failed, the fallbacks are not cached', { locale, missingNamespaces });
165
+ if (failedKvKeys.size > 0) {
166
+ debug('i18n cache PUT skipped where KV failed: the fallbacks are not cached', { locale, failedKvKeys });
158
167
  }
159
168
  return finalData;
160
169
  }
@@ -167,43 +176,48 @@ export function createFetchTranslations({ fallbacks: FALLBACKS, bindings, buildT
167
176
  */
168
177
  async function fetchAllFromKV(locale, namespaces) {
169
178
  const keys = namespaces.map((ns) => buildTranslationKvKey(locale, ns));
170
- let kvResults = new Map();
171
- let kvFailed = false;
172
- try {
173
- kvResults = (await bindings.kv.get(keys, KV_JSON_OPTIONS));
174
- }
175
- catch (error) {
176
- kvFailed = true;
177
- debug('i18n KV ERROR (cache bypassed), using FALLBACK_* for all namespaces', error);
178
- }
179
- if (kvFailed) {
180
- return buildFallbackOnly(namespaces);
181
- }
179
+ const { values: kvResults, failed: failedKvKeys } = await readKv(keys);
182
180
  const data = {};
183
181
  for (let i = 0; i < namespaces.length; i++) {
184
182
  const ns = namespaces[i];
185
- const kvValue = kvResults.get(keys[i]);
183
+ const key = keys[i];
184
+ // A namespace whose read failed gets its compiled fallback alone (a copy, as every
185
+ // assembled namespace is).
186
+ const kvValue = failedKvKeys.has(key) ? null : (kvResults.get(key) ?? null);
186
187
  data[ns] = assembleNamespace(kvValue, getFallback(ns));
187
188
  }
188
189
  return data;
189
190
  }
190
191
  /**
191
- * Look up the compiled FALLBACK_* dictionary for a namespace, if one was generated.
192
+ * Read `keys` from KV in bulk reads of at most KV_BATCH_LIMIT keys, in parallel. A read
193
+ * that throws marks its own keys failed; the other reads' values still arrive.
192
194
  */
193
- function getFallback(ns) {
194
- return FALLBACKS[fallbackIdentifier(String(ns))];
195
+ async function readKv(keys) {
196
+ const batches = [];
197
+ for (let i = 0; i < keys.length; i += KV_BATCH_LIMIT) {
198
+ batches.push(keys.slice(i, i + KV_BATCH_LIMIT));
199
+ }
200
+ const values = new Map();
201
+ const failed = new Set();
202
+ await Promise.all(batches.map(async (batch) => {
203
+ try {
204
+ for (const [key, value] of await bindings.kv.get(batch, KV_JSON_OPTIONS))
205
+ values.set(key, value);
206
+ debug('i18n KV batch OK', { keys: batch.length });
207
+ }
208
+ catch (error) {
209
+ for (const key of batch)
210
+ failed.add(key);
211
+ debug('i18n KV ERROR, using FALLBACK_* for this batch', error);
212
+ }
213
+ }));
214
+ return { values, failed };
195
215
  }
196
216
  /**
197
- * Build a payload using only FALLBACK_* compiled dictionaries.
198
- * Used when KV is completely unavailable.
217
+ * Look up the compiled FALLBACK_* dictionary for a namespace, if one was generated.
199
218
  */
200
- function buildFallbackOnly(namespaces) {
201
- const data = {};
202
- for (const ns of namespaces) {
203
- ;
204
- data[ns] = getFallback(ns) ?? {};
205
- }
206
- return data;
219
+ function getFallback(ns) {
220
+ return FALLBACKS[fallbackIdentifier(String(ns))];
207
221
  }
208
222
  // ---------------------------------------------------------------------------
209
223
  // Runtime helpers
package/dist/format.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare function fmt(template: string | null | undefined, values?: Templa
9
9
  /**
10
10
  * Simple two-form plural helper.
11
11
  * Good for English-like languages where "1" vs "other" is enough.
12
+ * Both forms interpolate `{count}`.
12
13
  */
13
14
  export declare function plural(count: number, singular: string, plural: string): string;
14
15
  /**
@@ -37,11 +38,20 @@ export type LocalizedPathOptions = {
37
38
  isLocale: (segment: string) => boolean;
38
39
  };
39
40
  /**
40
- * The path of a page in `locale`, for either routing mode, always with a trailing slash.
41
- * A leading locale segment is stripped first, so a prefixed `/de/about/`, a rewritten
42
- * `/en/about/` and a bare `/about/` all name the same page. 'prefix': `/<locale>/<rest>/`.
43
- * 'prefix-except-default': the same, except `/<rest>/` (`/` for the root) for the default
44
- * locale.
41
+ * True when the path's last segment ends in a file extension (`/rss.xml`, `/doc.pdf`).
42
+ * A path that ends in a slash is a page (`/rss.xml/` included).
43
+ */
44
+ export declare function isFilePath(pathname: string): boolean;
45
+ /**
46
+ * The path of a page in `locale`, for either routing mode: with a trailing slash, or
47
+ * without one for a file path (`isFilePath`). A leading locale segment is stripped first,
48
+ * so a prefixed `/de/about/`, a rewritten `/en/about/` and a bare `/about/` all name the
49
+ * same page. 'prefix': `/<locale>/<rest>/`. 'prefix-except-default': the same, except
50
+ * `/<rest>/` (`/` for the root) for the default locale. A `?query` and a `#hash` in the
51
+ * input are kept after the path. The path is rebuilt from its non-empty segments, read as
52
+ * the WHATWG parser reads an https path: `\` separates segments like `/`, and a tab or
53
+ * newline is dropped. So no result starts with `//` or `/\`, even for a decoded path
54
+ * (Astro's `originPathname` turns `%5C` into `\`); an encoded `%5C` stays one segment.
45
55
  */
46
56
  export declare function localizedPath(locale: string, pathname: string, options: LocalizedPathOptions): string;
47
57
  export declare function replaceLocaleInPath(currentPath: string, currentLocale: string, newLocale: string): string;
package/dist/format.js CHANGED
@@ -38,9 +38,10 @@ export function fmt(template, values) {
38
38
  /**
39
39
  * Simple two-form plural helper.
40
40
  * Good for English-like languages where "1" vs "other" is enough.
41
+ * Both forms interpolate `{count}`.
41
42
  */
42
43
  export function plural(count, singular, plural) {
43
- return count === 1 ? singular : fmt(plural, { count });
44
+ return fmt(count === 1 ? singular : plural, { count });
44
45
  }
45
46
  /**
46
47
  * Advanced plural helper using Intl.PluralRules + fmt().
@@ -64,20 +65,38 @@ export function pluralIcu(count, locale, patterns) {
64
65
  (isProd() ? '' : (console.warn('[i18n] pluralIcu(): missing "other" pattern'), ''));
65
66
  return fmt(template, { count });
66
67
  }
68
+ // An extension that starts with a letter, up to 11 characters (`.webmanifest`, the
69
+ // longest common one): `.xml`, `.md`; not `.0` (`/blog/v2.0` is a page).
70
+ const FILE_EXTENSION = /\.[a-z][a-z0-9]{0,10}$/i;
67
71
  /**
68
- * The path of a page in `locale`, for either routing mode, always with a trailing slash.
69
- * A leading locale segment is stripped first, so a prefixed `/de/about/`, a rewritten
70
- * `/en/about/` and a bare `/about/` all name the same page. 'prefix': `/<locale>/<rest>/`.
71
- * 'prefix-except-default': the same, except `/<rest>/` (`/` for the root) for the default
72
- * locale.
72
+ * True when the path's last segment ends in a file extension (`/rss.xml`, `/doc.pdf`).
73
+ * A path that ends in a slash is a page (`/rss.xml/` included).
74
+ */
75
+ export function isFilePath(pathname) {
76
+ const last = pathname.slice(pathname.lastIndexOf('/') + 1);
77
+ return FILE_EXTENSION.test(last);
78
+ }
79
+ /**
80
+ * The path of a page in `locale`, for either routing mode: with a trailing slash, or
81
+ * without one for a file path (`isFilePath`). A leading locale segment is stripped first,
82
+ * so a prefixed `/de/about/`, a rewritten `/en/about/` and a bare `/about/` all name the
83
+ * same page. 'prefix': `/<locale>/<rest>/`. 'prefix-except-default': the same, except
84
+ * `/<rest>/` (`/` for the root) for the default locale. A `?query` and a `#hash` in the
85
+ * input are kept after the path. The path is rebuilt from its non-empty segments, read as
86
+ * the WHATWG parser reads an https path: `\` separates segments like `/`, and a tab or
87
+ * newline is dropped. So no result starts with `//` or `/\`, even for a decoded path
88
+ * (Astro's `originPathname` turns `%5C` into `\`); an encoded `%5C` stays one segment.
73
89
  */
74
90
  export function localizedPath(locale, pathname, options) {
75
- const segments = pathname.split('/').filter(Boolean);
91
+ const suffixAt = pathname.search(/[?#]/);
92
+ const path = (suffixAt === -1 ? pathname : pathname.slice(0, suffixAt)).replace(/[\t\n\r]/g, '');
93
+ const suffix = suffixAt === -1 ? '' : pathname.slice(suffixAt);
94
+ const segments = path.split(/[/\\]/).filter(Boolean);
76
95
  if (segments.length > 0 && options.isLocale(segments[0]))
77
96
  segments.shift();
78
- const rest = segments.length > 0 ? `${segments.join('/')}/` : '';
97
+ const rest = segments.length > 0 ? `${segments.join('/')}${isFilePath(path) ? '' : '/'}` : '';
79
98
  const unprefixed = options.routing !== 'prefix' && locale === options.defaultLocale;
80
- return unprefixed ? `/${rest}` : `/${locale}/${rest}`;
99
+ return (unprefixed ? `/${rest}` : `/${locale}/${rest}`) + suffix;
81
100
  }
82
101
  // Used in the LanguageSwitcher and SeoHreflangs components. Prefix-only by construction:
83
102
  // a path without a locale prefix (the default locale in 'prefix-except-default') comes
@@ -95,9 +114,18 @@ export function getLangFromUrl(pathname) {
95
114
  return lang;
96
115
  }
97
116
  // Converts a URL locale code to an IETF BCP 47 language tag.
98
- // Used for the HTML lang attribute and hreflang values.
99
- // 'pt-br' → 'pt-BR', 'en-us' → 'en-US', 'en' → 'en'
117
+ // Used for the HTML lang attribute and hreflang values. After the language, each subtag
118
+ // is cased by its length: 2 letters (a region) upper, 4 letters (a script) title case,
119
+ // anything else (`419`) as is.
120
+ // 'pt-br' → 'pt-BR', 'zh-hant-tw' → 'zh-Hant-TW', 'en' → 'en'
100
121
  export function toIetfTag(locale) {
101
- const [lang, region] = locale.split('-');
102
- return region ? `${lang}-${region.toUpperCase()}` : locale;
122
+ const [lang = '', ...subtags] = locale.split('-');
123
+ const cased = subtags.map((subtag) => {
124
+ if (/^[a-z]{2}$/i.test(subtag))
125
+ return subtag.toUpperCase();
126
+ if (/^[a-z]{4}$/i.test(subtag))
127
+ return subtag[0].toUpperCase() + subtag.slice(1).toLowerCase();
128
+ return subtag;
129
+ });
130
+ return [lang, ...cased].join('-');
103
131
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { createI18n, type GeneratedModules, type I18nInstance, type InstanceLoca
2
2
  export { localesOf, type LocalesOf } from './config.ts';
3
3
  export { DEFAULT_GENERATED_DIR, DEFAULT_LOCALES_DIR, defineI18nConfig, type I18nBindings, type I18nConfig, type I18nLanguage, type I18nLocales, type I18nRouting, type Locale, type LocaleOf, type Namespace, type PickSchema, } from './types.ts';
4
4
  export type { Bindings, EdgeCache, FetcherInputs, TranslationsKv } from './fetcher.ts';
5
- export { fmt, getLangFromUrl, localizedPath, plural, pluralIcu, replaceLocaleInPath, toIetfTag, type LocalizedPathOptions, type PluralCategory, type PluralPatterns, } from './format.ts';
5
+ export { fmt, getLangFromUrl, isFilePath, localizedPath, plural, pluralIcu, replaceLocaleInPath, toIetfTag, type LocalizedPathOptions, type PluralCategory, type PluralPatterns, } from './format.ts';
6
6
  export { matchAcceptLanguage, parseAcceptLanguage, type AcceptLanguageEntry } from './accept-language.ts';
7
7
  export { mapCountryToLocale, resolveFallbackLocale, type FallbackInputs, type FallbackOptions, } from './locale-fallback.ts';
8
8
  export { MARKDOWN_PATH, markdownRedirectTarget, type MarkdownOptions } from './markdown-path.ts';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  export { createI18n, } from "./create-i18n.js";
4
4
  export { localesOf } from "./config.js";
5
5
  export { DEFAULT_GENERATED_DIR, DEFAULT_LOCALES_DIR, defineI18nConfig, } from "./types.js";
6
- export { fmt, getLangFromUrl, localizedPath, plural, pluralIcu, replaceLocaleInPath, toIetfTag, } from "./format.js";
6
+ export { fmt, getLangFromUrl, isFilePath, localizedPath, plural, pluralIcu, replaceLocaleInPath, toIetfTag, } from "./format.js";
7
7
  export { matchAcceptLanguage, parseAcceptLanguage } from "./accept-language.js";
8
8
  export { mapCountryToLocale, resolveFallbackLocale, } from "./locale-fallback.js";
9
9
  export { MARKDOWN_PATH, markdownRedirectTarget } from "./markdown-path.js";
@@ -8,9 +8,12 @@
8
8
  export function formatTranslationKvKey(keyPrefix, locale, ns) {
9
9
  return `${keyPrefix}:${ns}:${locale}`;
10
10
  }
11
- // Format: https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
11
+ // The cache entry format: `v2` entries hold the raw KV value (ADR-0033). 0.2.x wrote merged
12
+ // namespaces under the unversioned `i18n:<locale>:<namespace>`, which is never read again.
13
+ const CACHE_FORMAT = 'v2';
14
+ // Format: https://<keyPrefix>/<encodeURIComponent(i18n:v2:<locale>:<namespace>)>
12
15
  export function formatTranslationCacheUrl(keyPrefix, locale, ns) {
13
- const cacheId = `i18n:${locale}:${ns}`;
16
+ const cacheId = `i18n:${CACHE_FORMAT}:${locale}:${ns}`;
14
17
  return `https://${keyPrefix}/${encodeURIComponent(cacheId)}`;
15
18
  }
16
19
  // Format: FALLBACK_<NAMESPACE>, upper-cased, every `.` as `__` (`app.billing` ->
package/dist/types.d.ts CHANGED
@@ -28,7 +28,8 @@ export declare const DEFAULT_LOCALES_DIR = "locales";
28
28
  /** The locale keys of a config's `languages`: a literal union for a literal config. */
29
29
  export type LocaleOf<C extends I18nConfig> = Extract<keyof C['languages'], string>;
30
30
  /**
31
- * Validates and returns the config unchanged. Throws at config time on an empty
31
+ * Validates and returns the config unchanged. Throws at config time on a `keyPrefix` that
32
+ * is not a URL host as written, an empty
32
33
  * `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, a
33
34
  * `routing` other than the two modes (absent is valid), or a `countryToLocale` entry whose key is not an uppercase country
34
35
  * code or whose value is not one of `languages` (also a type error for a literal config).
package/dist/types.js CHANGED
@@ -7,8 +7,21 @@ const ROUTINGS = ['prefix', 'prefix-except-default'];
7
7
  export const DEFAULT_GENERATED_DIR = 'src/i18n';
8
8
  export const DEFAULT_LOCALES_DIR = 'locales';
9
9
  const COUNTRY_CODE = /^[A-Z]{2}$/;
10
+ // True when `https://<value>` parses with exactly `value` as its host: no scheme, path,
11
+ // port syntax error, space or upper case (the URL parser would change any of them).
12
+ function isOwnUrlHost(value) {
13
+ if (typeof value !== 'string' || value === '')
14
+ return false;
15
+ try {
16
+ return new URL(`https://${value}`).host === value;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
10
22
  /**
11
- * Validates and returns the config unchanged. Throws at config time on an empty
23
+ * Validates and returns the config unchanged. Throws at config time on a `keyPrefix` that
24
+ * is not a URL host as written, an empty
12
25
  * `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, a
13
26
  * `routing` other than the two modes (absent is valid), or a `countryToLocale` entry whose key is not an uppercase country
14
27
  * code or whose value is not one of `languages` (also a type error for a literal config).
@@ -24,6 +37,11 @@ export function defineI18nConfig(config) {
24
37
  throw new Error(`[@edgekits/i18n-core] \`languages\` key "${locale}" is not lowercase (use "${locale.toLowerCase()}").`);
25
38
  }
26
39
  }
40
+ if (!isOwnUrlHost(config.keyPrefix)) {
41
+ throw new Error(`[@edgekits/i18n-core] \`keyPrefix\` ${JSON.stringify(config.keyPrefix)} is not a URL host as written: ` +
42
+ 'it is the host of every edge-cache URL (https://<keyPrefix>/…). Use lowercase letters, digits, dots ' +
43
+ 'and hyphens, with no scheme or path ("example.com", "my-site").');
44
+ }
27
45
  if (!locales.includes(config.defaultLocale)) {
28
46
  throw new Error(`[@edgekits/i18n-core] \`defaultLocale\` "${config.defaultLocale}" is not one of \`languages\`: ${locales.join(', ')}.`);
29
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgekits/i18n-core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The EdgeKits edge-native i18n engine, free of any framework: translations in Workers KV, a per-namespace edge cache invalidated by the Purge API, typed keys, and the edgekits-i18n CLI.",
5
5
  "keywords": [
6
6
  "i18n",
@@ -22,7 +22,7 @@
22
22
  "url": "https://github.com/EdgeKits/i18n/issues"
23
23
  },
24
24
  "engines": {
25
- "node": ">=22"
25
+ "node": ">=22.23.4"
26
26
  },
27
27
  "exports": {
28
28
  ".": {