@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.
@@ -6,11 +6,14 @@ const HEADER = `// Auto-generated by @edgekits/astro-i18n (edgekits-i18n CLI)
6
6
  // `FALLBACK_<NAMESPACE>` identifier, so its file name is restricted to what is valid in
7
7
  // all three: segments of [a-z0-9_], joined by single dots (`app.billing`).
8
8
  export const NAMESPACE_NAME = /^[a-z0-9][a-z0-9_]*(\.[a-z0-9][a-z0-9_]*)*$/;
9
+ // `fallbacks.generated.ts` exports FALLBACK_LOCALE (the default locale's code) beside one
10
+ // FALLBACK_<NAMESPACE> per namespace: a namespace `locale` would export it twice.
11
+ const RESERVED_NAMESPACES = new Set(['locale']);
9
12
  /**
10
13
  * Throws, naming the offender, when a locale folder is not one of the config's
11
- * `languages`, a namespace file name does not match NAMESPACE_NAME, or two namespaces
12
- * compile to the same fallback identifier (`app.billing` and `app__billing`). Run on the
13
- * collected tree before anything is generated or pushed.
14
+ * `languages`, a namespace file name does not match NAMESPACE_NAME or is reserved
15
+ * (`locale`), or two namespaces compile to the same fallback identifier (`app.billing` and
16
+ * `app__billing`). Run on the collected tree before anything is generated or pushed.
14
17
  */
15
18
  export function assertValidNames(collected, languages) {
16
19
  const configured = Object.keys(languages);
@@ -24,6 +27,11 @@ export function assertValidNames(collected, languages) {
24
27
  throw new Error(`Namespace "${ns}" (${locale}/${ns}.json) must match ${NAMESPACE_NAME}: ` +
25
28
  'segments of lowercase letters, digits and underscores, joined by single dots.');
26
29
  }
30
+ if (RESERVED_NAMESPACES.has(ns)) {
31
+ throw new Error(`Namespace "${ns}" (${locale}/${ns}.json) is reserved: its fallback would be exported as ` +
32
+ `${fallbackIdentifier(ns)}, which fallbacks.generated.ts already exports (the default locale's code). ` +
33
+ 'Rename the file.');
34
+ }
27
35
  const identifier = fallbackIdentifier(ns);
28
36
  const other = identifiers.get(identifier);
29
37
  if (other !== undefined) {
@@ -34,6 +42,43 @@ export function assertValidNames(collected, languages) {
34
42
  }
35
43
  }
36
44
  }
45
+ function nullValueError(keyPath, file) {
46
+ return new Error(`Key "${keyPath}" (${file}) is null: it would override the default locale's text with null. ` +
47
+ 'Remove the key to fall back, or give it a value.');
48
+ }
49
+ // The first null in `value`, as a key path below `keyPath` (`a.b`, `list[2]`), or null.
50
+ function findNull(value, keyPath) {
51
+ if (value === null)
52
+ return keyPath;
53
+ if (Array.isArray(value)) {
54
+ for (let i = 0; i < value.length; i++) {
55
+ const found = findNull(value[i], `${keyPath}[${i}]`);
56
+ if (found !== null)
57
+ return found;
58
+ }
59
+ }
60
+ else if (isTree(value)) {
61
+ for (const [key, child] of Object.entries(value)) {
62
+ const found = findNull(child, `${keyPath}.${key}`);
63
+ if (found !== null)
64
+ return found;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Throws, naming the key path and the file, on the first `null` in any locale: merged over
71
+ * the compiled fallback, a null replaces the default locale's text.
72
+ */
73
+ export function assertNoNullValues(collected) {
74
+ for (const [locale, namespaces] of Object.entries(collected)) {
75
+ for (const [ns, json] of Object.entries(namespaces)) {
76
+ const found = findNull(json, ns);
77
+ if (found !== null)
78
+ throw nullValueError(found, `${locale}/${ns}.json`);
79
+ }
80
+ }
81
+ }
37
82
  /**
38
83
  * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
39
84
  * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
@@ -77,39 +122,93 @@ function jsonKind(value) {
77
122
  return 'array';
78
123
  return typeof value;
79
124
  }
125
+ // A scalar leaf's TS type: numbers and booleans as such, everything else `string`.
126
+ function scalarType(value) {
127
+ if (typeof value === 'number')
128
+ return 'number';
129
+ if (typeof value === 'boolean')
130
+ return 'boolean';
131
+ return 'string';
132
+ }
80
133
  /**
81
- * The TS type of one JSON value. Objects become object types; arrays become `string[]`
82
- * (strings, other scalars, or empty) or `Array<…>` typed from their first element
83
- * (objects, nested arrays); every other leaf stays `string`. `keyPath` and `file` name the
84
- * value in the error a mixed array raises.
134
+ * Throws, naming the key path (with element indexes) and the file, on what the schema
135
+ * cannot type: a null, or an array that mixes kinds of elements (strings and objects).
85
136
  */
86
- function generateSchemaTs(value, indent, keyPath, file) {
137
+ function assertTypeable(value, keyPath, file) {
138
+ if (value === null)
139
+ throw nullValueError(keyPath, file);
87
140
  if (Array.isArray(value)) {
88
- if (value.length === 0)
89
- return 'string[]';
141
+ const nullAt = value.indexOf(null);
142
+ if (nullAt !== -1)
143
+ throw nullValueError(`${keyPath}[${nullAt}]`, file);
90
144
  const kinds = [...new Set(value.map(jsonKind))];
91
145
  if (kinds.length > 1) {
92
146
  throw new Error(`Key "${keyPath}" (${file}) is an array that mixes ${kinds.join(', ')} elements: ` +
93
147
  'I18n.Schema cannot type it. Use one kind of element per array.');
94
148
  }
95
- const first = value[0];
96
- if (!isTree(first) && !Array.isArray(first))
97
- return 'string[]';
98
- return `Array<${generateSchemaTs(first, indent, `${keyPath}[0]`, file)}>`;
149
+ value.forEach((element, i) => assertTypeable(element, `${keyPath}[${i}]`, file));
150
+ }
151
+ else if (isTree(value)) {
152
+ for (const [key, child] of Object.entries(value))
153
+ assertTypeable(child, `${keyPath}.${key}`, file);
99
154
  }
100
- if (!isTree(value))
101
- return 'string';
102
- const entries = Object.entries(value);
103
- if (entries.length === 0)
155
+ }
156
+ /**
157
+ * The TS type of the values found at one place in the tree (one value, or the same key
158
+ * across the elements of an array), checked by assertTypeable first. Objects merge into one
159
+ * object type, arrays into one array type, and the distinct kinds that remain form a union
160
+ * (`string | number` for a key that is a string in one element and a number in another).
161
+ */
162
+ function typeOfValues(values, indent) {
163
+ const objects = values.filter(isTree);
164
+ const arrays = values.filter(Array.isArray);
165
+ const scalars = [...new Set(values.filter((value) => !isTree(value) && !Array.isArray(value)).map(scalarType))];
166
+ return [
167
+ ...(objects.length > 0 ? [objectType(objects, indent)] : []),
168
+ ...(arrays.length > 0 ? [arrayType(arrays, indent)] : []),
169
+ ...scalars,
170
+ ].join(' | ');
171
+ }
172
+ /**
173
+ * One object type for a list of objects (one object, or every element of an array): each
174
+ * key in first-seen order, required when every object has it, optional (`?:`) otherwise,
175
+ * typed by typeOfValues over the objects that have it.
176
+ */
177
+ function objectType(objects, indent) {
178
+ const keys = [...new Set(objects.flatMap((object) => Object.keys(object)))];
179
+ if (keys.length === 0)
104
180
  return '{}';
105
181
  const pad = ' '.repeat(indent);
106
182
  const padInner = ' '.repeat(indent + 2);
107
- const lines = entries.map(([key, child]) => `${padInner}${JSON.stringify(key)}: ${generateSchemaTs(child, indent + 2, `${keyPath}.${key}`, file)}`);
183
+ const lines = keys.map((key) => {
184
+ const present = objects.filter((object) => Object.hasOwn(object, key));
185
+ const optional = present.length < objects.length ? '?' : '';
186
+ return `${padInner}${JSON.stringify(key)}${optional}: ${typeOfValues(present.map((object) => object[key]), indent + 2)}`;
187
+ });
108
188
  return `{\n${lines.join(';\n')};\n${pad}}`;
109
189
  }
190
+ /**
191
+ * One array type for a list of arrays (one array, or the same key's arrays across the
192
+ * elements of an outer one): the elements of all of them typed together. `string[]` when
193
+ * they are all empty, `<scalar>[]` for one scalar kind, `Array<…>` otherwise.
194
+ */
195
+ function arrayType(arrays, indent) {
196
+ const elements = arrays.flat(1);
197
+ if (elements.length === 0)
198
+ return 'string[]';
199
+ const elementType = typeOfValues(elements, indent);
200
+ return elements.every((element) => !isTree(element) && !Array.isArray(element)) && !elementType.includes('|')
201
+ ? `${elementType}[]`
202
+ : `Array<${elementType}>`;
203
+ }
204
+ /** The TS type of one namespace's JSON (see typeOfValues). */
205
+ function generateSchemaTs(value, indent, keyPath, file) {
206
+ assertTypeable(value, keyPath, file);
207
+ return typeOfValues([value], indent);
208
+ }
110
209
  /**
111
210
  * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
112
- * Throws, naming the key, when an array in it mixes kinds of elements.
211
+ * Throws, naming the key, when an array in it mixes kinds of elements or a value is null.
113
212
  */
114
213
  export function buildTypesContent(collected, defaultLocale) {
115
214
  const source = schemaSourceLocale(collected, defaultLocale);
@@ -169,3 +268,55 @@ export function buildFallbacksContent(collected, defaultLocale) {
169
268
  }
170
269
  return lines.join('\n');
171
270
  }
271
+ // Leaf key paths in `tree` that `base` lacks: a key missing from `base` counts once, with
272
+ // whatever it holds; arrays are leaves.
273
+ function extraKeyPaths(tree, base, prefix = '') {
274
+ const paths = [];
275
+ for (const [key, child] of Object.entries(tree)) {
276
+ const keyPath = prefix ? `${prefix}.${key}` : key;
277
+ if (!Object.hasOwn(base, key)) {
278
+ paths.push(keyPath);
279
+ }
280
+ else {
281
+ const baseChild = base[key];
282
+ if (isTree(child) && isTree(baseChild))
283
+ paths.push(...extraKeyPaths(child, baseChild, keyPath));
284
+ }
285
+ }
286
+ return paths;
287
+ }
288
+ /**
289
+ * Warnings about what I18n.Schema and the fallbacks will not cover, compared with the
290
+ * default locale: a namespace no default-locale file has, and keys a locale's file has
291
+ * that the default locale's lacks (a count per file, the first three paths). Empty when
292
+ * the default locale has no translations (the CLI warns about that on its own).
293
+ */
294
+ export function translationWarnings(collected, defaultLocale) {
295
+ const base = collected[defaultLocale];
296
+ if (!base)
297
+ return [];
298
+ const orphans = new Map();
299
+ const extraKeys = [];
300
+ for (const [locale, namespaces] of Object.entries(collected)) {
301
+ if (locale === defaultLocale)
302
+ continue;
303
+ for (const [ns, tree] of Object.entries(namespaces)) {
304
+ const baseTree = base[ns];
305
+ if (baseTree === undefined) {
306
+ orphans.set(ns, [...(orphans.get(ns) ?? []), locale]);
307
+ continue;
308
+ }
309
+ const extra = isTree(tree) && isTree(baseTree) ? extraKeyPaths(tree, baseTree) : [];
310
+ if (extra.length > 0) {
311
+ const shownPaths = extra.slice(0, 3).join(', ') + (extra.length > 3 ? ', …' : '');
312
+ extraKeys.push(`${locale}/${ns}.json has ${extra.length} ${extra.length === 1 ? 'key' : 'keys'} that ` +
313
+ `${defaultLocale}/${ns}.json lacks (not in I18n.Schema): ${shownPaths}`);
314
+ }
315
+ }
316
+ }
317
+ return [
318
+ ...[...orphans].map(([ns, locales]) => `Namespace "${ns}" exists only in ${locales.join(', ')} (no ${defaultLocale}/${ns}.json): ` +
319
+ 'it is not in I18n.Schema and has no fallback.'),
320
+ ...extraKeys,
321
+ ];
322
+ }
@@ -1,10 +1,12 @@
1
1
  import type { CollectedData } from './generate.ts';
2
2
  /**
3
- * Per-namespace content hashes and the change diff that drives the cache purge.
4
- * Pure apart from hashing. Same algorithm as the site's `scripts/bundle-translations.ts`,
5
- * so an existing `.i18n-hashes.json` stays a valid baseline.
3
+ * Per-namespace content hashes, the change diff that drives the cache purge, and the
4
+ * baseline file. Pure apart from hashing. The site's `scripts/bundle-translations.ts`
5
+ * algorithm, with keys sorted by code point instead of `localeCompare` (same order for
6
+ * lowercase ASCII keys; mixed-case keys hash differently once, ADR-0034).
6
7
  */
7
8
  export type HashMap = Record<string, string>;
9
+ export type BaselineFile = Record<string, HashMap>;
8
10
  export declare function stableStringify(value: unknown): string;
9
11
  export declare function computeHash(value: unknown): string;
10
12
  /**
@@ -15,5 +17,23 @@ export declare function diffHashes(collected: CollectedData, previousHashes: Has
15
17
  currentHashes: HashMap;
16
18
  changedKeys: string[];
17
19
  };
20
+ /** KV keys of "<locale>:<namespace>" pairs (the removed ones, for the bulk delete). */
21
+ export declare function kvKeysFor(hashKeys: readonly string[], keyPrefix: string): string[];
18
22
  /** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
19
23
  export declare function purgeUrlsFor(changedKeys: readonly string[], keyPrefix: string): string[];
24
+ /** Pairs in `previous` that `current` no longer has: translation files removed since. */
25
+ export declare function removedKeys(previous: HashMap, current: HashMap): string[];
26
+ /**
27
+ * The baseline of KV namespace `kvId` in a parsed `.i18n-hashes.json` (`undefined`: no
28
+ * file). A file in the old flat shape is the baseline of `flatOwner`, the top-level
29
+ * TRANSLATIONS id (0.2.x wrote it, and had no `--env`); another `kvId` gets none (`{}`).
30
+ * Without a `flatOwner` it is `kvId`'s. With no `kvId` (bundle, which reads no wrangler
31
+ * config) the only baseline the file holds, or `undefined` when it holds several.
32
+ */
33
+ export declare function readBaseline(file: unknown, kvId?: string, flatOwner?: string): HashMap | undefined;
34
+ /**
35
+ * The file with `kvId`'s baseline replaced. An old flat file stays the baseline of its
36
+ * owner (`flatOwner`, see readBaseline): `{ [kvId]: hashes }` when that is `kvId`,
37
+ * `{ [flatOwner]: flat, [kvId]: hashes }` otherwise.
38
+ */
39
+ export declare function writeBaseline(file: unknown, kvId: string, hashes: HashMap, flatOwner?: string): BaselineFile;
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
- import { formatTranslationCacheUrl } from "../key-format.js";
2
+ import { formatTranslationCacheUrl, formatTranslationKvKey } from "../key-format.js";
3
3
  export function stableStringify(value) {
4
4
  if (value === null || typeof value !== 'object') {
5
5
  return JSON.stringify(value);
@@ -7,7 +7,8 @@ export function stableStringify(value) {
7
7
  if (Array.isArray(value)) {
8
8
  return '[' + value.map(stableStringify).join(',') + ']';
9
9
  }
10
- const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
10
+ // Code point order: the same on every machine, whatever its ICU data and locale.
11
+ const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
11
12
  return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',') + '}';
12
13
  }
13
14
  export function computeHash(value) {
@@ -33,6 +34,13 @@ export function diffHashes(collected, previousHashes) {
33
34
  }
34
35
  return { currentHashes, changedKeys };
35
36
  }
37
+ /** KV keys of "<locale>:<namespace>" pairs (the removed ones, for the bulk delete). */
38
+ export function kvKeysFor(hashKeys, keyPrefix) {
39
+ return hashKeys.map((hashKey) => {
40
+ const [locale, ns] = hashKey.split(':');
41
+ return formatTranslationKvKey(keyPrefix, locale, ns);
42
+ });
43
+ }
36
44
  /** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
37
45
  export function purgeUrlsFor(changedKeys, keyPrefix) {
38
46
  return changedKeys.map((hashKey) => {
@@ -40,3 +48,49 @@ export function purgeUrlsFor(changedKeys, keyPrefix) {
40
48
  return formatTranslationCacheUrl(keyPrefix, locale, ns);
41
49
  });
42
50
  }
51
+ /** Pairs in `previous` that `current` no longer has: translation files removed since. */
52
+ export function removedKeys(previous, current) {
53
+ return Object.keys(previous).filter((key) => !Object.hasOwn(current, key));
54
+ }
55
+ function isRecord(value) {
56
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
57
+ }
58
+ // The pre-0.3 shape: one flat baseline, every value a hash string.
59
+ function isFlatBaseline(file) {
60
+ const values = Object.values(file);
61
+ return values.length > 0 && values.every((value) => typeof value === 'string');
62
+ }
63
+ /**
64
+ * The baseline of KV namespace `kvId` in a parsed `.i18n-hashes.json` (`undefined`: no
65
+ * file). A file in the old flat shape is the baseline of `flatOwner`, the top-level
66
+ * TRANSLATIONS id (0.2.x wrote it, and had no `--env`); another `kvId` gets none (`{}`).
67
+ * Without a `flatOwner` it is `kvId`'s. With no `kvId` (bundle, which reads no wrangler
68
+ * config) the only baseline the file holds, or `undefined` when it holds several.
69
+ */
70
+ export function readBaseline(file, kvId, flatOwner) {
71
+ if (!isRecord(file))
72
+ return {};
73
+ if (isFlatBaseline(file))
74
+ return kvId === undefined || kvId === (flatOwner ?? kvId) ? file : {};
75
+ const baselines = file;
76
+ if (kvId !== undefined)
77
+ return baselines[kvId] ?? {};
78
+ const ids = Object.keys(baselines);
79
+ if (ids.length > 1)
80
+ return undefined;
81
+ return ids.length === 1 ? baselines[ids[0]] : {};
82
+ }
83
+ /**
84
+ * The file with `kvId`'s baseline replaced. An old flat file stays the baseline of its
85
+ * owner (`flatOwner`, see readBaseline): `{ [kvId]: hashes }` when that is `kvId`,
86
+ * `{ [flatOwner]: flat, [kvId]: hashes }` otherwise.
87
+ */
88
+ export function writeBaseline(file, kvId, hashes, flatOwner) {
89
+ if (!isRecord(file))
90
+ return { [kvId]: hashes };
91
+ if (isFlatBaseline(file)) {
92
+ const owner = flatOwner ?? kvId;
93
+ return owner === kvId ? { [kvId]: hashes } : { [owner]: file, [kvId]: hashes };
94
+ }
95
+ return { ...file, [kvId]: hashes };
96
+ }