@edgekits/i18n-core 0.2.2 → 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.
- package/CHANGELOG.md +47 -0
- package/README.md +124 -38
- package/dist/assemble.d.ts +5 -0
- package/dist/assemble.js +6 -1
- package/dist/cli/collect.d.ts +13 -0
- package/dist/cli/collect.js +54 -0
- package/dist/cli/dev-vars.d.ts +8 -0
- package/dist/cli/dev-vars.js +13 -0
- package/dist/cli/generate.d.ts +16 -4
- package/dist/cli/generate.js +171 -20
- package/dist/cli/hashes.d.ts +23 -3
- package/dist/cli/hashes.js +56 -2
- package/dist/cli/index.js +344 -187
- package/dist/cli/wrangler.d.ts +16 -2
- package/dist/cli/wrangler.js +63 -10
- package/dist/create-i18n.js +3 -3
- package/dist/deep-merge.d.ts +4 -1
- package/dist/deep-merge.js +7 -6
- package/dist/fetcher.js +80 -66
- package/dist/format.d.ts +15 -5
- package/dist/format.js +41 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/key-format.js +5 -2
- package/dist/types.d.ts +2 -1
- package/dist/types.js +19 -1
- package/package.json +2 -2
package/dist/cli/wrangler.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
export declare const KV_BINDING = "TRANSLATIONS";
|
|
2
|
-
|
|
3
|
-
export
|
|
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
|
package/dist/cli/wrangler.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
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
|
package/dist/create-i18n.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/deep-merge.d.ts
CHANGED
|
@@ -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__
|
|
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;
|
package/dist/deep-merge.js
CHANGED
|
@@ -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__
|
|
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 (
|
|
17
|
+
if (key === '__proto__')
|
|
18
18
|
continue;
|
|
19
19
|
const overrideValue = overrides[key];
|
|
20
|
-
|
|
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
|
|
19
|
+
* Cache strategy — per-namespace cache keys, versioned by entry format:
|
|
9
20
|
*
|
|
10
|
-
* Each namespace is cached individually under
|
|
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 →
|
|
23
|
-
* merge with FALLBACK_* → individual cache.put()
|
|
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
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* If none were generated, returns empty objects
|
|
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
|
-
//
|
|
76
|
-
//
|
|
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.
|
|
114
|
+
// 3. Batched KV reads for missing namespaces only
|
|
97
115
|
// ---------------------------------------------------------------------------
|
|
98
116
|
const missingKvKeys = missingNamespaces.map((ns) => buildTranslationKvKey(locale, ns));
|
|
99
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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] =
|
|
126
|
-
// Only
|
|
127
|
-
//
|
|
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
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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(
|
|
143
|
+
const response = new Response(JSON.stringify(kvValue), {
|
|
136
144
|
headers: {
|
|
137
145
|
'Content-Type': 'application/json; charset=utf-8',
|
|
138
|
-
'Cache-Control':
|
|
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
|
|
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
|
-
|
|
157
|
-
debug('i18n cache PUT skipped
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
194
|
-
|
|
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
|
-
*
|
|
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
|
|
201
|
-
|
|
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
|
-
*
|
|
41
|
-
* A
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 :
|
|
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
|
-
*
|
|
69
|
-
* A
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
102
|
-
|
|
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";
|
package/dist/key-format.js
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
export function formatTranslationKvKey(keyPrefix, locale, ns) {
|
|
9
9
|
return `${keyPrefix}:${ns}:${locale}`;
|
|
10
10
|
}
|
|
11
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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
|
".": {
|