@edgekits/astro-i18n 0.1.8 → 0.2.1
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 +28 -0
- package/README.md +92 -28
- package/dist/integration/index.d.ts +25 -4
- package/dist/integration/index.js +35 -9
- package/dist/integration/vite-plugin.d.ts +1 -1
- package/dist/types.d.ts +7 -55
- package/dist/types.js +1 -44
- package/package.json +7 -17
- package/src/components/LanguageSwitcher.astro +89 -93
- package/src/config.entry.ts +5 -36
- package/src/constants.ts +5 -3
- package/src/cookie-storage.ts +19 -25
- package/src/fetcher.ts +3 -304
- package/src/format.ts +3 -107
- package/src/instance.ts +44 -0
- package/src/integration/index.ts +38 -9
- package/src/integration/vite-plugin.ts +1 -1
- package/src/middleware/i18n.ts +232 -114
- package/src/resolve-locale.ts +7 -39
- package/src/runtime.ts +2 -2
- package/src/schema.ts +3 -11
- package/src/translations-keys.ts +7 -38
- package/src/types.ts +16 -116
- package/dist/cli/generate.d.ts +0 -50
- package/dist/cli/generate.js +0 -160
- package/dist/cli/hashes.d.ts +0 -19
- package/dist/cli/hashes.js +0 -42
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -372
- package/dist/cli/key-prefix-check.d.ts +0 -6
- package/dist/cli/key-prefix-check.js +0 -27
- package/dist/cli/load-config.d.ts +0 -7
- package/dist/cli/load-config.js +0 -19
- package/dist/cli/purge.d.ts +0 -29
- package/dist/cli/purge.js +0 -94
- package/dist/cli/wrangler.d.ts +0 -10
- package/dist/cli/wrangler.js +0 -71
- package/dist/cli/zone.d.ts +0 -21
- package/dist/cli/zone.js +0 -42
- package/dist/key-format.d.ts +0 -8
- package/dist/key-format.js +0 -15
- package/src/accept-language.ts +0 -56
- package/src/assemble.ts +0 -29
- package/src/cli/generate.ts +0 -205
- package/src/cli/hashes.ts +0 -66
- package/src/cli/index.ts +0 -472
- package/src/cli/key-prefix-check.ts +0 -31
- package/src/cli/load-config.ts +0 -26
- package/src/cli/purge.ts +0 -122
- package/src/cli/wrangler.ts +0 -96
- package/src/cli/zone.ts +0 -64
- package/src/deep-merge.ts +0 -38
- package/src/key-format.ts +0 -17
- package/src/locale-fallback.ts +0 -58
- package/src/markdown-path.ts +0 -31
- package/src/parse-boolean.ts +0 -20
package/dist/cli/zone.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export type ZoneDeps = {
|
|
2
|
-
fetch?: (input: string, init: RequestInit) => Promise<Response>;
|
|
3
|
-
};
|
|
4
|
-
/**
|
|
5
|
-
* Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
|
|
6
|
-
* on the token. Never throws: any failure (non-2xx, network error, unexpected body)
|
|
7
|
-
* comes back as `{ error }`.
|
|
8
|
-
*
|
|
9
|
-
* `deps` replaces `fetch` in tests.
|
|
10
|
-
*/
|
|
11
|
-
export declare function readZoneName(zoneId: string, apiToken: string, deps?: ZoneDeps): Promise<{
|
|
12
|
-
name: string;
|
|
13
|
-
} | {
|
|
14
|
-
error: string;
|
|
15
|
-
}>;
|
|
16
|
-
/**
|
|
17
|
-
* Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
|
|
18
|
-
* when the zone name cannot be read. Advisory only: it returns nothing, and the purge
|
|
19
|
-
* runs the same either way.
|
|
20
|
-
*/
|
|
21
|
-
export declare function checkKeyPrefixZone(keyPrefix: string, zoneId: string, apiToken: string, deps?: ZoneDeps): Promise<void>;
|
package/dist/cli/zone.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { keyPrefixZoneWarning } from "./key-prefix-check.js";
|
|
2
|
-
/**
|
|
3
|
-
* Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
|
|
4
|
-
* on the token. Never throws: any failure (non-2xx, network error, unexpected body)
|
|
5
|
-
* comes back as `{ error }`.
|
|
6
|
-
*
|
|
7
|
-
* `deps` replaces `fetch` in tests.
|
|
8
|
-
*/
|
|
9
|
-
export async function readZoneName(zoneId, apiToken, deps = {}) {
|
|
10
|
-
const { fetch: fetchFn = fetch } = deps;
|
|
11
|
-
try {
|
|
12
|
-
const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}`, {
|
|
13
|
-
method: 'GET',
|
|
14
|
-
headers: { Authorization: `Bearer ${apiToken}` },
|
|
15
|
-
});
|
|
16
|
-
if (!response.ok)
|
|
17
|
-
return { error: `HTTP ${response.status}` };
|
|
18
|
-
const body = (await response.json());
|
|
19
|
-
const name = body?.result?.name;
|
|
20
|
-
if (typeof name !== 'string' || name.trim() === '')
|
|
21
|
-
return { error: 'no result.name in the response' };
|
|
22
|
-
return { name };
|
|
23
|
-
}
|
|
24
|
-
catch (err) {
|
|
25
|
-
return { error: err instanceof Error ? err.message : String(err) };
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
|
|
30
|
-
* when the zone name cannot be read. Advisory only: it returns nothing, and the purge
|
|
31
|
-
* runs the same either way.
|
|
32
|
-
*/
|
|
33
|
-
export async function checkKeyPrefixZone(keyPrefix, zoneId, apiToken, deps = {}) {
|
|
34
|
-
const zone = await readZoneName(zoneId, apiToken, deps);
|
|
35
|
-
if ('error' in zone) {
|
|
36
|
-
console.log(`[i18n] ℹ️ Could not read the zone name (${zone.error}), so the keyPrefix check was skipped (needs Zone › Zone › Read on the token).`);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
const warning = keyPrefixZoneWarning(keyPrefix, zone.name);
|
|
40
|
-
if (warning)
|
|
41
|
-
console.warn(warning);
|
|
42
|
-
}
|
package/dist/key-format.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The translation KV key and edge-cache URL formats, as pure functions of the key
|
|
3
|
-
* prefix. Shared by the runtime (`translations-keys.ts`, prefix from the virtual config)
|
|
4
|
-
* and the CLI (prefix from the loaded config), so the key a value is stored under and
|
|
5
|
-
* the URL a purge targets can never drift apart. Pinned by `test/i18n-keys.test.ts`.
|
|
6
|
-
*/
|
|
7
|
-
export declare function formatTranslationKvKey(keyPrefix: string, locale: string, ns: string): string;
|
|
8
|
-
export declare function formatTranslationCacheUrl(keyPrefix: string, locale: string, ns: string): string;
|
package/dist/key-format.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The translation KV key and edge-cache URL formats, as pure functions of the key
|
|
3
|
-
* prefix. Shared by the runtime (`translations-keys.ts`, prefix from the virtual config)
|
|
4
|
-
* and the CLI (prefix from the loaded config), so the key a value is stored under and
|
|
5
|
-
* the URL a purge targets can never drift apart. Pinned by `test/i18n-keys.test.ts`.
|
|
6
|
-
*/
|
|
7
|
-
// Format: <keyPrefix>:<namespace>:<locale>
|
|
8
|
-
export function formatTranslationKvKey(keyPrefix, locale, ns) {
|
|
9
|
-
return `${keyPrefix}:${ns}:${locale}`;
|
|
10
|
-
}
|
|
11
|
-
// Format: https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
|
|
12
|
-
export function formatTranslationCacheUrl(keyPrefix, locale, ns) {
|
|
13
|
-
const cacheId = `i18n:${locale}:${ns}`;
|
|
14
|
-
return `https://${keyPrefix}/${encodeURIComponent(cacheId)}`;
|
|
15
|
-
}
|
package/src/accept-language.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Accept-Language step of the middleware's fallback cascade, read from the header
|
|
3
|
-
* itself. Astro's own match is no substitute: it keeps only the tags that equal a
|
|
4
|
-
* configured locale before choosing, so a regional tag (`es-MX`) never reaches a
|
|
5
|
-
* language-part match. Pure: no Astro, no virtual module, no `cloudflare:`.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export type AcceptLanguageEntry = { tag: string; q: number }
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* The header's language ranges, highest `q` first (header order among equals). A missing
|
|
12
|
-
* or unparsable `q` is 1; `q=0` entries and the `*` wildcard are dropped.
|
|
13
|
-
*/
|
|
14
|
-
export function parseAcceptLanguage(header: string | null): AcceptLanguageEntry[] {
|
|
15
|
-
if (!header) return []
|
|
16
|
-
|
|
17
|
-
const entries: AcceptLanguageEntry[] = []
|
|
18
|
-
for (const part of header.split(',')) {
|
|
19
|
-
const [rawTag = '', ...params] = part.split(';')
|
|
20
|
-
const tag = rawTag.trim()
|
|
21
|
-
if (!tag || tag === '*') continue
|
|
22
|
-
|
|
23
|
-
let q = 1
|
|
24
|
-
for (const param of params) {
|
|
25
|
-
const [name, value] = param.split('=').map((s) => s.trim())
|
|
26
|
-
if (name?.toLowerCase() !== 'q' || value === undefined) continue
|
|
27
|
-
const parsed = Number.parseFloat(value)
|
|
28
|
-
if (!Number.isNaN(parsed)) q = parsed
|
|
29
|
-
}
|
|
30
|
-
if (q <= 0) continue
|
|
31
|
-
|
|
32
|
-
entries.push({ tag, q })
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Array.prototype.sort is stable: equal q keeps header order.
|
|
36
|
-
return entries.sort((a, b) => b.q - a.q)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The first supported locale the header names: for each tag in `q` order, the full tag
|
|
41
|
-
* (lowercased), then its language subtag (before the first `-` or `_`). Null when none.
|
|
42
|
-
*/
|
|
43
|
-
export function matchAcceptLanguage<L extends string>(
|
|
44
|
-
header: string | null,
|
|
45
|
-
parseLocale: (value: unknown) => L | null,
|
|
46
|
-
): L | null {
|
|
47
|
-
for (const { tag } of parseAcceptLanguage(header)) {
|
|
48
|
-
const lower = tag.toLowerCase()
|
|
49
|
-
const full = parseLocale(lower)
|
|
50
|
-
if (full) return full
|
|
51
|
-
|
|
52
|
-
const language = parseLocale(lower.split(/[-_]/)[0])
|
|
53
|
-
if (language) return language
|
|
54
|
-
}
|
|
55
|
-
return null
|
|
56
|
-
}
|
package/src/assemble.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { deepMerge } from './deep-merge'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Assemble a single translation namespace from a raw value and its compiled
|
|
5
|
-
* fallback dictionary.
|
|
6
|
-
*
|
|
7
|
-
* This is the ONLY place namespace assembly happens: `fetchTranslations` routes
|
|
8
|
-
* its cache-HIT branch, its cache-miss/KV branch and `fetchAllFromKV` through it,
|
|
9
|
-
* so a cached namespace and a freshly read one can never diverge in shape.
|
|
10
|
-
*
|
|
11
|
-
* Semantics (unchanged from the original miss path):
|
|
12
|
-
* - the fallback provides the full known key set,
|
|
13
|
-
* - the raw value (cached entry or KV read) overrides it wherever it has a value,
|
|
14
|
-
* - `null` / `undefined` are tolerated for either argument.
|
|
15
|
-
*
|
|
16
|
-
* Consequence for stale cache entries: a namespace cached before a schema addition
|
|
17
|
-
* degrades to default-locale text for the keys it predates instead of handing pages
|
|
18
|
-
* `undefined` (see ADR-0014 and the 2026-07-18 `/en/blog/` incident).
|
|
19
|
-
*
|
|
20
|
-
* Deliberately pure — no `cloudflare:workers` imports, so it is testable under
|
|
21
|
-
* plain Node (`npm test`).
|
|
22
|
-
*/
|
|
23
|
-
export function assembleNamespace(rawValue: unknown, fallback: unknown): object {
|
|
24
|
-
const raw = (rawValue as object | null | undefined) ?? {}
|
|
25
|
-
|
|
26
|
-
if (!fallback) return raw
|
|
27
|
-
|
|
28
|
-
return deepMerge(fallback as object, raw)
|
|
29
|
-
}
|
package/src/cli/generate.ts
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { formatTranslationKvKey } from '../key-format.ts'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The CLI's code generation, as pure functions of the collected locale tree: no
|
|
5
|
-
* filesystem, so the emitted text is testable. Formats are those of the site's
|
|
6
|
-
* `scripts/bundle-translations.ts`, header aside.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export type TranslationTree = Record<string, unknown>
|
|
10
|
-
|
|
11
|
-
// <locale> → <namespace> → the namespace's JSON
|
|
12
|
-
export type CollectedData = Record<string, Record<string, TranslationTree>>
|
|
13
|
-
|
|
14
|
-
export type KvBulkEntry = { key: string; value: string }
|
|
15
|
-
|
|
16
|
-
const HEADER = `// Auto-generated by @edgekits/astro-i18n (edgekits-i18n CLI)
|
|
17
|
-
// Do not edit manually.
|
|
18
|
-
`
|
|
19
|
-
|
|
20
|
-
// A namespace becomes part of a KV key, a cache URL and the `FALLBACK_<NAMESPACE>`
|
|
21
|
-
// identifier, so its file name is restricted to what is valid in all three.
|
|
22
|
-
export const NAMESPACE_NAME = /^[a-z0-9][a-z0-9_]*$/
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Throws, naming the offender, when a locale folder is not one of the config's
|
|
26
|
-
* `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
|
|
27
|
-
* collected tree before anything is generated or pushed.
|
|
28
|
-
*/
|
|
29
|
-
export function assertValidNames(collected: CollectedData, languages: Readonly<Record<string, unknown>>): void {
|
|
30
|
-
const configured = Object.keys(languages)
|
|
31
|
-
|
|
32
|
-
for (const [locale, namespaces] of Object.entries(collected)) {
|
|
33
|
-
if (!configured.includes(locale)) {
|
|
34
|
-
throw new Error(
|
|
35
|
-
`Locale folder "${locale}" is not one of \`languages\` in edgekits-i18n.config.ts: ${configured.join(', ')}.`,
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
for (const ns of Object.keys(namespaces)) {
|
|
39
|
-
if (!NAMESPACE_NAME.test(ns)) {
|
|
40
|
-
throw new Error(
|
|
41
|
-
`Namespace "${ns}" (${locale}/${ns}.json) must match ${NAMESPACE_NAME}: lowercase letters, digits and underscores.`,
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* A single-quoted TS string literal, escaped by JSON.stringify. For every name the
|
|
50
|
-
* validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
|
|
51
|
-
* existing generated files do not change; anything else comes out escaped, not injected.
|
|
52
|
-
*/
|
|
53
|
-
export function quoteString(value: string): string {
|
|
54
|
-
const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")
|
|
55
|
-
return `'${escaped}'`
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Locales that have at least one namespace, sorted. */
|
|
59
|
-
export function localesWithTranslations(collected: CollectedData): string[] {
|
|
60
|
-
return Object.keys(collected).sort()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The locale the schema and the namespace list are derived from: the default locale
|
|
65
|
-
* when it has translations, otherwise the first locale that does.
|
|
66
|
-
*/
|
|
67
|
-
export function schemaSourceLocale(collected: CollectedData, defaultLocale: string): string | undefined {
|
|
68
|
-
if (collected[defaultLocale]) return defaultLocale
|
|
69
|
-
return localesWithTranslations(collected)[0]
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** `wrangler kv bulk put` input: one entry per locale and namespace. */
|
|
73
|
-
export function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[] {
|
|
74
|
-
const bulkData: KvBulkEntry[] = []
|
|
75
|
-
|
|
76
|
-
for (const [locale, namespaces] of Object.entries(collected)) {
|
|
77
|
-
for (const [ns, json] of Object.entries(namespaces)) {
|
|
78
|
-
bulkData.push({ key: formatTranslationKvKey(keyPrefix, locale, ns), value: JSON.stringify(json) })
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return bulkData
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function isTree(value: unknown): value is TranslationTree {
|
|
86
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// The JSON kind of an array element; an array is typed only when all its elements share one.
|
|
90
|
-
function jsonKind(value: unknown): string {
|
|
91
|
-
if (value === null) return 'null'
|
|
92
|
-
if (Array.isArray(value)) return 'array'
|
|
93
|
-
return typeof value
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The TS type of one JSON value. Objects become object types; arrays become `string[]`
|
|
98
|
-
* (strings, other scalars, or empty) or `Array<…>` typed from their first element
|
|
99
|
-
* (objects, nested arrays); every other leaf stays `string`. `keyPath` and `file` name the
|
|
100
|
-
* value in the error a mixed array raises.
|
|
101
|
-
*/
|
|
102
|
-
function generateSchemaTs(value: unknown, indent: number, keyPath: string, file: string): string {
|
|
103
|
-
if (Array.isArray(value)) {
|
|
104
|
-
if (value.length === 0) return 'string[]'
|
|
105
|
-
|
|
106
|
-
const kinds = [...new Set(value.map(jsonKind))]
|
|
107
|
-
if (kinds.length > 1) {
|
|
108
|
-
throw new Error(
|
|
109
|
-
`Key "${keyPath}" (${file}) is an array that mixes ${kinds.join(', ')} elements: ` +
|
|
110
|
-
'I18n.Schema cannot type it. Use one kind of element per array.',
|
|
111
|
-
)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const first = value[0]
|
|
115
|
-
if (!isTree(first) && !Array.isArray(first)) return 'string[]'
|
|
116
|
-
return `Array<${generateSchemaTs(first, indent, `${keyPath}[0]`, file)}>`
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!isTree(value)) return 'string'
|
|
120
|
-
|
|
121
|
-
const entries = Object.entries(value)
|
|
122
|
-
if (entries.length === 0) return '{}'
|
|
123
|
-
|
|
124
|
-
const pad = ' '.repeat(indent)
|
|
125
|
-
const padInner = ' '.repeat(indent + 2)
|
|
126
|
-
const lines = entries.map(
|
|
127
|
-
([key, child]) =>
|
|
128
|
-
`${padInner}${JSON.stringify(key)}: ${generateSchemaTs(child, indent + 2, `${keyPath}.${key}`, file)}`,
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
return `{\n${lines.join(';\n')};\n${pad}}`
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
|
|
136
|
-
* Throws, naming the key, when an array in it mixes kinds of elements.
|
|
137
|
-
*/
|
|
138
|
-
export function buildTypesContent(collected: CollectedData, defaultLocale: string): string {
|
|
139
|
-
const source = schemaSourceLocale(collected, defaultLocale)
|
|
140
|
-
const schemaObject: TranslationTree = (source && collected[source]) || {}
|
|
141
|
-
|
|
142
|
-
const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
|
|
143
|
-
const valueTs = generateSchemaTs(value, 10, ns, `${source}/${ns}.json`)
|
|
144
|
-
return ` ${JSON.stringify(ns)}: ${valueTs}`
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
const interfaceBody =
|
|
148
|
-
namespaceLines.length > 0
|
|
149
|
-
? ` export interface Schema {\n${namespaceLines.join(';\n')};\n }`
|
|
150
|
-
: ` export interface Schema {}`
|
|
151
|
-
|
|
152
|
-
return `${HEADER}
|
|
153
|
-
declare global {
|
|
154
|
-
namespace I18n {
|
|
155
|
-
${interfaceBody}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export {}
|
|
160
|
-
`
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* `runtime-constants.ts`:
|
|
165
|
-
* - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
|
|
166
|
-
* - LOCALES_WITH_TRANSLATIONS -> from all collected locales
|
|
167
|
-
* - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
|
|
168
|
-
*/
|
|
169
|
-
export function buildRuntimeConstantsContent(collected: CollectedData, defaultLocale: string): string {
|
|
170
|
-
const source = schemaSourceLocale(collected, defaultLocale)
|
|
171
|
-
const namespaceKeys = Object.keys((source && collected[source]) || {}).sort()
|
|
172
|
-
const locales = localesWithTranslations(collected)
|
|
173
|
-
|
|
174
|
-
const fullyTranslatedLocales = locales.filter((locale) => {
|
|
175
|
-
const localeNamespaces = Object.keys(collected[locale] || {})
|
|
176
|
-
return namespaceKeys.every((ns) => localeNamespaces.includes(ns))
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
return `${HEADER}
|
|
180
|
-
export const NAMESPACE_KEYS = [${namespaceKeys.map(quoteString).join(', ')}] as [string, ...string[]];
|
|
181
|
-
|
|
182
|
-
export const LOCALES_WITH_TRANSLATIONS = [${locales.map(quoteString).join(', ')}] as const;
|
|
183
|
-
|
|
184
|
-
export const FULLY_TRANSLATED_LOCALES = [${fullyTranslatedLocales.map(quoteString).join(', ')}] as const;
|
|
185
|
-
`
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
|
|
190
|
-
* Null when the default locale has no translations (nothing to fall back to).
|
|
191
|
-
*/
|
|
192
|
-
export function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null {
|
|
193
|
-
const fallbackNamespaces = collected[defaultLocale]
|
|
194
|
-
if (!fallbackNamespaces) return null
|
|
195
|
-
|
|
196
|
-
const lines: string[] = [HEADER]
|
|
197
|
-
lines.push(`export const FALLBACK_LOCALE = ${JSON.stringify(defaultLocale)} as const;\n`)
|
|
198
|
-
|
|
199
|
-
for (const [ns, json] of Object.entries(fallbackNamespaces)) {
|
|
200
|
-
const constName = `FALLBACK_${ns.toUpperCase()}`
|
|
201
|
-
lines.push(`export const ${constName} = ${JSON.stringify(json, null, 2)} as const;\n`)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return lines.join('\n')
|
|
205
|
-
}
|
package/src/cli/hashes.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
import { formatTranslationCacheUrl } from '../key-format.ts'
|
|
4
|
-
import type { CollectedData } from './generate.ts'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Per-namespace content hashes and the change diff that drives the cache purge.
|
|
8
|
-
* Pure apart from hashing. Same algorithm as the site's `scripts/bundle-translations.ts`,
|
|
9
|
-
* so an existing `.i18n-hashes.json` stays a valid baseline.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
// Persisted hash map: "<locale>:<namespace>" → content hash
|
|
13
|
-
export type HashMap = Record<string, string>
|
|
14
|
-
|
|
15
|
-
export function stableStringify(value: unknown): string {
|
|
16
|
-
if (value === null || typeof value !== 'object') {
|
|
17
|
-
return JSON.stringify(value)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (Array.isArray(value)) {
|
|
21
|
-
return '[' + value.map(stableStringify).join(',') + ']'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b))
|
|
25
|
-
|
|
26
|
-
return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',') + '}'
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function computeHash(value: unknown): string {
|
|
30
|
-
const json = stableStringify(value)
|
|
31
|
-
return crypto.createHash('sha256').update(json).digest('hex').slice(0, 12)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Hash every collected namespace and list the "<locale>:<namespace>" pairs whose hash
|
|
36
|
-
* differs from the previous run (all of them when there is no previous run).
|
|
37
|
-
*/
|
|
38
|
-
export function diffHashes(
|
|
39
|
-
collected: CollectedData,
|
|
40
|
-
previousHashes: HashMap,
|
|
41
|
-
): { currentHashes: HashMap; changedKeys: string[] } {
|
|
42
|
-
const currentHashes: HashMap = {}
|
|
43
|
-
const changedKeys: string[] = []
|
|
44
|
-
|
|
45
|
-
for (const [locale, namespaces] of Object.entries(collected)) {
|
|
46
|
-
for (const [ns, json] of Object.entries(namespaces)) {
|
|
47
|
-
const hashKey = `${locale}:${ns}`
|
|
48
|
-
const hash = computeHash(json)
|
|
49
|
-
currentHashes[hashKey] = hash
|
|
50
|
-
|
|
51
|
-
if (previousHashes[hashKey] !== hash) {
|
|
52
|
-
changedKeys.push(hashKey)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { currentHashes, changedKeys }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
|
|
61
|
-
export function purgeUrlsFor(changedKeys: readonly string[], keyPrefix: string): string[] {
|
|
62
|
-
return changedKeys.map((hashKey) => {
|
|
63
|
-
const [locale, ns] = hashKey.split(':') as [string, string]
|
|
64
|
-
return formatTranslationCacheUrl(keyPrefix, locale, ns)
|
|
65
|
-
})
|
|
66
|
-
}
|