@vouchington/localization-compiler 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +2 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +79 -0
- package/dist/compile.d.mts +4 -0
- package/dist/compile.mjs +60 -0
- package/dist/csv.d.mts +8 -0
- package/dist/csv.mjs +83 -0
- package/dist/index.d.mts +9 -0
- package/dist/index.mjs +9 -0
- package/dist/integrity.d.mts +2 -0
- package/dist/integrity.mjs +6 -0
- package/dist/load.d.mts +6 -0
- package/dist/load.mjs +44 -0
- package/dist/native.d.mts +10 -0
- package/dist/native.mjs +71 -0
- package/dist/open.d.mts +10 -0
- package/dist/open.mjs +33 -0
- package/dist/resolve.d.mts +13 -0
- package/dist/resolve.mjs +57 -0
- package/dist/revision.d.mts +2 -0
- package/dist/revision.mjs +5 -0
- package/dist/schema.d.mts +3 -0
- package/dist/schema.mjs +36 -0
- package/dist/test-helpers.d.mts +3 -0
- package/dist/test-helpers.mjs +49 -0
- package/dist/validate.d.mts +3 -0
- package/dist/validate.mjs +69 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Ong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN ANY ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @vouchington/localization-compiler
|
|
2
|
+
|
|
3
|
+
Node-only compiler for `@vouchington/localization`. It validates namespace-sharded JSON catalogs,
|
|
4
|
+
requires complete `en-US` with sparse partial locales, emits an immutable read-only SQLite
|
|
5
|
+
artifact, and exposes the same consumer/locale/selector resolver used by application CLIs.
|
|
6
|
+
|
|
7
|
+
CSV import/export is interchange only: never source of truth and never compiled directly to
|
|
8
|
+
SQLite. Native resource helpers emit strings, RESX, and typed key/descriptor files from the
|
|
9
|
+
same resolved catalog without product path assumptions.
|
package/dist/bin.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/bin.mjs
ADDED
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runLocalizationCli(argv: readonly string[], write?: (value: string) => void): Promise<void>;
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { serializeLocalizationBatch } from '@vouchington/localization';
|
|
5
|
+
import { compileLocalizationSqlite, writeJsonCatalog } from './compile.mjs';
|
|
6
|
+
import { exportLocalizationCsv, importLocalizationCsv } from './csv.mjs';
|
|
7
|
+
import { loadCatalogDirectory } from './load.mjs';
|
|
8
|
+
import { openLocalizationDatabase } from './open.mjs';
|
|
9
|
+
import { explainLocalizationPlan, resolveLocalizationBatch } from './resolve.mjs';
|
|
10
|
+
export async function runLocalizationCli(argv, write = console.log) {
|
|
11
|
+
const [command, ...rest] = argv;
|
|
12
|
+
if (command === 'compile') {
|
|
13
|
+
const loaded = await loadCatalogDirectory(required(rest, '--source'));
|
|
14
|
+
write(compileLocalizationSqlite(loaded.messages, required(rest, '--output'), loaded.tags));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (command === 'resolve') {
|
|
18
|
+
withDatabase(required(rest, '--db'), (database) => {
|
|
19
|
+
write(serializeLocalizationBatch(resolveLocalizationBatch(database, {
|
|
20
|
+
consumer: required(rest, '--consumer'),
|
|
21
|
+
locales: required(rest, '--locales').split(','),
|
|
22
|
+
selectors: required(rest, '--selectors').split(','),
|
|
23
|
+
})));
|
|
24
|
+
});
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (command === 'inspect') {
|
|
28
|
+
withDatabase(required(rest, '--db'), (database) => {
|
|
29
|
+
write(JSON.stringify({ contract: database.contract, revision: database.revision }, null, 2));
|
|
30
|
+
write(explainLocalizationPlan(database, { kind: 'prefix', prefix: 'nav' }));
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (command === 'csv-export') {
|
|
35
|
+
const csv = exportLocalizationCsv((await loadCatalogDirectory(required(rest, '--source'))).messages);
|
|
36
|
+
const output = optional(rest, '--output');
|
|
37
|
+
if (output === undefined)
|
|
38
|
+
write(csv);
|
|
39
|
+
else
|
|
40
|
+
writeFileSync(output, csv);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (command === 'csv-import') {
|
|
44
|
+
const output = required(rest, '--output');
|
|
45
|
+
mkdirSync(output, { recursive: true });
|
|
46
|
+
writeJsonCatalog(importLocalizationCsv(await readFile(required(rest, '--input'), 'utf8')), resolve(output, 'imported.json'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw new TypeError(usage());
|
|
50
|
+
}
|
|
51
|
+
function withDatabase(path, run) {
|
|
52
|
+
const database = openLocalizationDatabase(path);
|
|
53
|
+
try {
|
|
54
|
+
run(database);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
database.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function required(args, flag) {
|
|
61
|
+
const value = optional(args, flag);
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
throw new TypeError(usage());
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function optional(args, flag) {
|
|
67
|
+
const index = args.indexOf(flag);
|
|
68
|
+
const value = index === -1 ? undefined : args[index + 1];
|
|
69
|
+
return value === undefined || value.startsWith('--') ? undefined : value;
|
|
70
|
+
}
|
|
71
|
+
function usage() {
|
|
72
|
+
return [
|
|
73
|
+
'Usage: vouchington-localization compile --source <dir> --output <file>',
|
|
74
|
+
'Usage: vouchington-localization resolve --db <file> --consumer <name> --locales <list> --selectors <list>',
|
|
75
|
+
'Usage: vouchington-localization inspect --db <file>',
|
|
76
|
+
'Usage: vouchington-localization csv-export --source <dir> [--output <file>]',
|
|
77
|
+
'Usage: vouchington-localization csv-import --input <file> --output <dir>',
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type CatalogMessage } from '@vouchington/localization';
|
|
2
|
+
import type { EditorialTags } from './load.mts';
|
|
3
|
+
export declare function compileLocalizationSqlite(messages: readonly CatalogMessage[], outputPath: string, tags?: EditorialTags): string;
|
|
4
|
+
export declare function writeJsonCatalog(messages: readonly CatalogMessage[], path: string): void;
|
package/dist/compile.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { canonicalJson, compareCodePoints, LOCALIZATION_WIRE_CONTRACT, } from '@vouchington/localization';
|
|
6
|
+
import { assertSqliteIntegrity } from './integrity.mjs';
|
|
7
|
+
import { catalogRevision } from './revision.mjs';
|
|
8
|
+
import { SQLITE_SCHEMA } from './schema.mjs';
|
|
9
|
+
import { validateCatalogMessages } from './validate.mjs';
|
|
10
|
+
export function compileLocalizationSqlite(messages, outputPath, tags = {}) {
|
|
11
|
+
validateCatalogMessages(messages);
|
|
12
|
+
const revision = catalogRevision(messages);
|
|
13
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
14
|
+
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'localization-'));
|
|
15
|
+
const temporary = join(temporaryDirectory, 'catalog.sqlite');
|
|
16
|
+
const database = new DatabaseSync(temporary);
|
|
17
|
+
try {
|
|
18
|
+
database.exec('PRAGMA journal_mode = OFF');
|
|
19
|
+
database.exec(SQLITE_SCHEMA);
|
|
20
|
+
insertMetadata(database, revision);
|
|
21
|
+
insertMessages(database, messages);
|
|
22
|
+
insertTags(database, tags);
|
|
23
|
+
database.exec('PRAGMA foreign_keys = ON');
|
|
24
|
+
assertSqliteIntegrity(database);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
database.close();
|
|
28
|
+
}
|
|
29
|
+
renameSync(temporary, outputPath);
|
|
30
|
+
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
31
|
+
return revision;
|
|
32
|
+
}
|
|
33
|
+
export function writeJsonCatalog(messages, path) {
|
|
34
|
+
writeFileSync(path, `${JSON.stringify({ messages }, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
function insertMetadata(database, revision) {
|
|
37
|
+
const insert = database.prepare('INSERT INTO metadata (key, value) VALUES (?, ?)');
|
|
38
|
+
insert.run('contract', LOCALIZATION_WIRE_CONTRACT);
|
|
39
|
+
insert.run('revision', revision);
|
|
40
|
+
}
|
|
41
|
+
function insertMessages(database, messages) {
|
|
42
|
+
const insertMessage = database.prepare('INSERT INTO messages (id, descriptor_json) VALUES (?, ?)');
|
|
43
|
+
const insertTranslation = database.prepare('INSERT INTO translations (locale, message_id, value_json) VALUES (?, ?, ?)');
|
|
44
|
+
const insertConsumer = database.prepare('INSERT INTO consumer_membership (consumer, message_id) VALUES (?, ?)');
|
|
45
|
+
for (const message of [...messages].toSorted((left, right) => (left.id < right.id ? -1 : 1))) {
|
|
46
|
+
insertMessage.run(message.id, canonicalJson(message.descriptor));
|
|
47
|
+
for (const consumer of message.consumers)
|
|
48
|
+
insertConsumer.run(consumer, message.id);
|
|
49
|
+
for (const locale of Object.keys(message.translations).toSorted(compareCodePoints)) {
|
|
50
|
+
insertTranslation.run(locale, message.id, canonicalJson(message.translations[locale]));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function insertTags(database, tags) {
|
|
55
|
+
const insert = database.prepare('INSERT INTO editorial_tags (message_id, tag) VALUES (?, ?)');
|
|
56
|
+
for (const id of Object.keys(tags).toSorted(compareCodePoints)) {
|
|
57
|
+
for (const tag of tags[id])
|
|
58
|
+
insert.run(id, tag);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/csv.d.mts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CatalogMessage } from '@vouchington/localization';
|
|
2
|
+
declare const COLUMNS: readonly ['id', 'locale', 'consumers', 'descriptor_json', 'value_json', 'catalog_revision'];
|
|
3
|
+
export declare function exportLocalizationCsv(messages: readonly CatalogMessage[]): string;
|
|
4
|
+
export declare function importLocalizationCsv(csv: string, options?: {
|
|
5
|
+
expectedRevision?: string;
|
|
6
|
+
}): CatalogMessage[];
|
|
7
|
+
export declare function csvRecord(row: readonly string[]): Record<(typeof COLUMNS)[number], string>;
|
|
8
|
+
export {};
|
package/dist/csv.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { parseCsvRows, stringifyCsvRows } from '@vouchington/csv';
|
|
2
|
+
import { catalogMessageFromRecord, canonicalJson, compareCodePoints, } from '@vouchington/localization';
|
|
3
|
+
import { catalogRevision } from './revision.mjs';
|
|
4
|
+
import { validateCatalogMessages } from './validate.mjs';
|
|
5
|
+
const COLUMNS = [
|
|
6
|
+
'id',
|
|
7
|
+
'locale',
|
|
8
|
+
'consumers',
|
|
9
|
+
'descriptor_json',
|
|
10
|
+
'value_json',
|
|
11
|
+
'catalog_revision',
|
|
12
|
+
];
|
|
13
|
+
export function exportLocalizationCsv(messages) {
|
|
14
|
+
validateCatalogMessages(messages);
|
|
15
|
+
const revision = catalogRevision(messages);
|
|
16
|
+
const rows = messages.flatMap((message) => Object.keys(message.translations)
|
|
17
|
+
.toSorted(compareCodePoints)
|
|
18
|
+
.map((locale) => ({
|
|
19
|
+
id: message.id,
|
|
20
|
+
locale,
|
|
21
|
+
consumers: message.consumers.join('|'),
|
|
22
|
+
descriptor_json: canonicalJson(message.descriptor),
|
|
23
|
+
value_json: canonicalJson(message.translations[locale]),
|
|
24
|
+
catalog_revision: revision,
|
|
25
|
+
})));
|
|
26
|
+
return stringifyCsvRows(rows, COLUMNS);
|
|
27
|
+
}
|
|
28
|
+
export function importLocalizationCsv(csv, options = {}) {
|
|
29
|
+
const [header, ...rows] = parseCsvRows(csv);
|
|
30
|
+
if (header === undefined || header.join(',') !== COLUMNS.join(',')) {
|
|
31
|
+
throw new TypeError('CSV header must match the localization interchange contract');
|
|
32
|
+
}
|
|
33
|
+
if (rows.length === 0)
|
|
34
|
+
throw new TypeError('CSV has no data rows');
|
|
35
|
+
const drafts = new Map();
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
let revision;
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
const record = csvRecord(row);
|
|
40
|
+
if (revision !== undefined && record.catalog_revision !== revision) {
|
|
41
|
+
throw new TypeError('CSV rows must share a single catalog_revision');
|
|
42
|
+
}
|
|
43
|
+
revision = record.catalog_revision;
|
|
44
|
+
const key = `${record.id}\t${record.locale}`;
|
|
45
|
+
if (seen.has(key))
|
|
46
|
+
throw new TypeError(`Duplicate CSV row for "${record.id}" in ${record.locale}`);
|
|
47
|
+
seen.add(key);
|
|
48
|
+
const draft = drafts.get(record.id) ?? {
|
|
49
|
+
consumers: record.consumers,
|
|
50
|
+
descriptor_json: record.descriptor_json,
|
|
51
|
+
translations: {},
|
|
52
|
+
};
|
|
53
|
+
draft.translations[record.locale] = record.value_json;
|
|
54
|
+
drafts.set(record.id, draft);
|
|
55
|
+
}
|
|
56
|
+
const messages = [...drafts].map(([id, draft]) => catalogMessageFromRecord({
|
|
57
|
+
id,
|
|
58
|
+
consumers: draft.consumers.split('|'),
|
|
59
|
+
descriptor: JSON.parse(draft.descriptor_json),
|
|
60
|
+
translations: Object.fromEntries(Object.entries(draft.translations).map(([locale, value]) => [
|
|
61
|
+
locale,
|
|
62
|
+
JSON.parse(value),
|
|
63
|
+
])),
|
|
64
|
+
}));
|
|
65
|
+
if (options.expectedRevision !== undefined && revision !== options.expectedRevision) {
|
|
66
|
+
throw new TypeError('CSV catalog_revision does not match the source contract hash');
|
|
67
|
+
}
|
|
68
|
+
validateCatalogMessages(messages);
|
|
69
|
+
if (revision !== catalogRevision(messages)) {
|
|
70
|
+
throw new TypeError('CSV catalog_revision does not match reconstructed catalog');
|
|
71
|
+
}
|
|
72
|
+
return messages;
|
|
73
|
+
}
|
|
74
|
+
export function csvRecord(row) {
|
|
75
|
+
const record = {};
|
|
76
|
+
for (const [index, column] of COLUMNS.entries()) {
|
|
77
|
+
const value = row[index];
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
throw new TypeError(`CSV row is missing ${column}`);
|
|
80
|
+
record[column] = value;
|
|
81
|
+
}
|
|
82
|
+
return record;
|
|
83
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { compileLocalizationSqlite } from './compile.mts';
|
|
2
|
+
export { exportLocalizationCsv, importLocalizationCsv } from './csv.mts';
|
|
3
|
+
export { loadCatalogDirectory } from './load.mts';
|
|
4
|
+
export { openLocalizationDatabase, type LocalizationDatabase } from './open.mts';
|
|
5
|
+
export { explainLocalizationPlan, resolveLocalizationBatch } from './resolve.mts';
|
|
6
|
+
export { nativeLeafVariants, renderDotnetDescriptors, renderDotnetKeys, renderResx, renderSwiftDescriptors, renderSwiftKeys, renderSwiftStrings, } from './native.mts';
|
|
7
|
+
export { catalogRevision } from './revision.mts';
|
|
8
|
+
export { validateCatalogMessages, parseCatalogFile } from './validate.mts';
|
|
9
|
+
export { runLocalizationCli } from './cli.mts';
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { compileLocalizationSqlite } from './compile.mjs';
|
|
2
|
+
export { exportLocalizationCsv, importLocalizationCsv } from './csv.mjs';
|
|
3
|
+
export { loadCatalogDirectory } from './load.mjs';
|
|
4
|
+
export { openLocalizationDatabase } from './open.mjs';
|
|
5
|
+
export { explainLocalizationPlan, resolveLocalizationBatch } from './resolve.mjs';
|
|
6
|
+
export { nativeLeafVariants, renderDotnetDescriptors, renderDotnetKeys, renderResx, renderSwiftDescriptors, renderSwiftKeys, renderSwiftStrings, } from './native.mjs';
|
|
7
|
+
export { catalogRevision } from './revision.mjs';
|
|
8
|
+
export { validateCatalogMessages, parseCatalogFile } from './validate.mjs';
|
|
9
|
+
export { runLocalizationCli } from './cli.mjs';
|
package/dist/load.d.mts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type CatalogMessage } from '@vouchington/localization';
|
|
2
|
+
export type EditorialTags = Readonly<Record<string, readonly string[]>>;
|
|
3
|
+
export declare function loadCatalogDirectory(directory: string): Promise<{
|
|
4
|
+
messages: CatalogMessage[];
|
|
5
|
+
tags: EditorialTags;
|
|
6
|
+
}>;
|
package/dist/load.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { compareCodePoints } from '@vouchington/localization';
|
|
4
|
+
import { parseCatalogFile, validateCatalogMessages } from './validate.mjs';
|
|
5
|
+
export async function loadCatalogDirectory(directory) {
|
|
6
|
+
const names = (await readdir(directory))
|
|
7
|
+
.filter((name) => name.endsWith('.json'))
|
|
8
|
+
.toSorted(compareCodePoints);
|
|
9
|
+
if (names.length === 0)
|
|
10
|
+
throw new TypeError(`No catalog JSON files in "${directory}"`);
|
|
11
|
+
const messages = [];
|
|
12
|
+
let tags = {};
|
|
13
|
+
for (const name of names) {
|
|
14
|
+
const parsed = JSON.parse(await readFile(join(directory, name), 'utf8'));
|
|
15
|
+
if (name === 'tags.json') {
|
|
16
|
+
tags = parseTags(parsed);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
messages.push(...parseCatalogFile(parsed));
|
|
20
|
+
}
|
|
21
|
+
if (messages.length === 0)
|
|
22
|
+
throw new TypeError(`No catalog messages in "${directory}"`);
|
|
23
|
+
validateCatalogMessages(messages);
|
|
24
|
+
validateTagTargets(messages, tags);
|
|
25
|
+
return { messages, tags };
|
|
26
|
+
}
|
|
27
|
+
function parseTags(value) {
|
|
28
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
29
|
+
throw new TypeError('tags.json must be an object mapping message ids to tag arrays');
|
|
30
|
+
}
|
|
31
|
+
return Object.fromEntries(Object.entries(value).map(([id, tags]) => {
|
|
32
|
+
if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string' || tag.length === 0)) {
|
|
33
|
+
throw new TypeError(`Invalid editorial tags for "${id}"`);
|
|
34
|
+
}
|
|
35
|
+
return [id, [...new Set(tags)].toSorted(compareCodePoints)];
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
function validateTagTargets(messages, tags) {
|
|
39
|
+
const ids = new Set(messages.map((message) => message.id));
|
|
40
|
+
for (const id of Object.keys(tags)) {
|
|
41
|
+
if (!ids.has(id))
|
|
42
|
+
throw new TypeError(`Editorial tag target "${id}" is not in the catalog`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type LocalizationLeaf, type MessageDescriptor } from '@vouchington/localization';
|
|
2
|
+
export type NativeIdentifier = (id: string) => string;
|
|
3
|
+
export type NativeResourceEntry = readonly [string, string];
|
|
4
|
+
export declare function nativeLeafVariants(id: string, leaf: LocalizationLeaf): NativeResourceEntry[];
|
|
5
|
+
export declare function renderSwiftStrings(entries: readonly NativeResourceEntry[]): string;
|
|
6
|
+
export declare function renderResx(entries: readonly NativeResourceEntry[]): string;
|
|
7
|
+
export declare function renderSwiftKeys(ids: readonly string[], identifier: NativeIdentifier): string;
|
|
8
|
+
export declare function renderDotnetKeys(ids: readonly string[], identifier: NativeIdentifier): string;
|
|
9
|
+
export declare function renderSwiftDescriptors(entries: ReadonlyArray<readonly [string, MessageDescriptor]>, identifier: NativeIdentifier): string;
|
|
10
|
+
export declare function renderDotnetDescriptors(entries: ReadonlyArray<readonly [string, MessageDescriptor]>, identifier: NativeIdentifier): string;
|
package/dist/native.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { compareCodePoints, } from '@vouchington/localization';
|
|
2
|
+
export function nativeLeafVariants(id, leaf) {
|
|
3
|
+
if (typeof leaf === 'string')
|
|
4
|
+
return [[id, leaf]];
|
|
5
|
+
if (leaf.kind === 'plural') {
|
|
6
|
+
return [
|
|
7
|
+
[`${id}.__plural.one`, requireOne(id, leaf.forms)],
|
|
8
|
+
[`${id}.__plural.other`, leaf.forms.other],
|
|
9
|
+
];
|
|
10
|
+
}
|
|
11
|
+
return Object.entries(leaf.cases)
|
|
12
|
+
.toSorted(([left], [right]) => compareCodePoints(left, right))
|
|
13
|
+
.flatMap(([selected, forms]) => [
|
|
14
|
+
[`${id}.__select.${selected}.one`, requireOne(id, forms)],
|
|
15
|
+
[`${id}.__select.${selected}.other`, forms.other],
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
export function renderSwiftStrings(entries) {
|
|
19
|
+
return `// Generated localization strings. Do not edit.\n${entries
|
|
20
|
+
.map(([key, value]) => `"${key}" = "${escapeSwift(value)}";`)
|
|
21
|
+
.join('\n')}\n`;
|
|
22
|
+
}
|
|
23
|
+
export function renderResx(entries) {
|
|
24
|
+
const data = entries
|
|
25
|
+
.map(([key, value]) => ` <data name="${escapeXml(key)}" xml:space="preserve"><value>${escapeXml(value)}</value></data>`)
|
|
26
|
+
.join('\n');
|
|
27
|
+
return `<?xml version="1.0" encoding="utf-8"?>\n<root>\n${data}\n</root>\n`;
|
|
28
|
+
}
|
|
29
|
+
export function renderSwiftKeys(ids, identifier) {
|
|
30
|
+
const lines = ids.map((id) => ` public static let ${identifier(id)} = UiMessageKey(rawValue: "${id}")`);
|
|
31
|
+
return `// Generated localization keys. Do not edit.\npublic struct UiMessageKey: Hashable, Sendable {\n public let rawValue: String\n public init(rawValue: String) { self.rawValue = rawValue }\n${lines.join('\n')}\n}\n`;
|
|
32
|
+
}
|
|
33
|
+
export function renderDotnetKeys(ids, identifier) {
|
|
34
|
+
const lines = ids.map((id) => ` public static readonly UiMessageKey ${identifier(id)} = new("${id}");`);
|
|
35
|
+
return `// Generated localization keys. Do not edit.\n#nullable enable\nnamespace Localization;\n\npublic readonly record struct UiMessageKey(string Value)\n{\n${lines.join('\n')}\n}\n`;
|
|
36
|
+
}
|
|
37
|
+
export function renderSwiftDescriptors(entries, identifier) {
|
|
38
|
+
const values = entries.map(([id, descriptor]) => swiftDescriptorLine(id, descriptor, identifier));
|
|
39
|
+
return `// Generated localization descriptors. Do not edit.\npublic struct UiMessageDescriptor: Sendable {\n public enum Kind: Sendable { case plural, selectPlural }\n public let kind: Kind\n public let valueParameter: String\n public let selectParameter: String?\n public let numberParameters: [String]\n public let cases: [String]\n}\n\npublic let uiMessageDescriptors: [UiMessageKey: UiMessageDescriptor] = [\n${values.join(',\n')}\n]\n`;
|
|
40
|
+
}
|
|
41
|
+
export function renderDotnetDescriptors(entries, identifier) {
|
|
42
|
+
const values = entries.map(([id, descriptor]) => dotnetDescriptorLine(id, descriptor, identifier));
|
|
43
|
+
return `// Generated localization descriptors. Do not edit.\n#nullable enable\nnamespace Localization;\n\npublic sealed record UiMessageDescriptor(string Kind, string ValueParameter, string? SelectParameter, IReadOnlyList<string> NumberParameters, IReadOnlyList<string> Cases);\n\npublic static class UiMessageDescriptors\n{\n public static IReadOnlyDictionary<UiMessageKey, UiMessageDescriptor> All { get; } =\n new Dictionary<UiMessageKey, UiMessageDescriptor>\n {\n${values.join(',\n')}\n };\n}\n`;
|
|
44
|
+
}
|
|
45
|
+
function swiftDescriptorLine(id, descriptor, identifier) {
|
|
46
|
+
const select = descriptor.kind === 'select-plural' ? `"${descriptor.selectParameter}"` : 'nil';
|
|
47
|
+
const cases = descriptor.kind === 'select-plural'
|
|
48
|
+
? JSON.stringify([...descriptor.cases].toSorted(compareCodePoints))
|
|
49
|
+
: '[]';
|
|
50
|
+
return ` .${identifier(id)}: UiMessageDescriptor(kind: .${identifier(descriptor.kind)}, valueParameter: "${descriptor.valueParameter}", selectParameter: ${select}, numberParameters: ${JSON.stringify(descriptor.numberParameters ?? [])}, cases: ${cases})`;
|
|
51
|
+
}
|
|
52
|
+
function dotnetDescriptorLine(id, descriptor, identifier) {
|
|
53
|
+
const select = descriptor.kind === 'select-plural' ? `"${descriptor.selectParameter}"` : 'null';
|
|
54
|
+
const cases = descriptor.kind === 'select-plural' ? [...descriptor.cases].toSorted(compareCodePoints) : [];
|
|
55
|
+
return ` [UiMessageKey.${identifier(id)}] = new("${descriptor.kind}", "${descriptor.valueParameter}", ${select}, [${(descriptor.numberParameters ?? []).map((value) => `"${value}"`).join(', ')}], [${cases.map((value) => `"${value}"`).join(', ')}])`;
|
|
56
|
+
}
|
|
57
|
+
function requireOne(id, forms) {
|
|
58
|
+
if (forms.one === undefined)
|
|
59
|
+
throw new TypeError(`Native descriptor "${id}" must define a one form`);
|
|
60
|
+
return forms.one;
|
|
61
|
+
}
|
|
62
|
+
function escapeSwift(value) {
|
|
63
|
+
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
|
|
64
|
+
}
|
|
65
|
+
function escapeXml(value) {
|
|
66
|
+
return value
|
|
67
|
+
.replaceAll('&', '&')
|
|
68
|
+
.replaceAll('<', '<')
|
|
69
|
+
.replaceAll('>', '>')
|
|
70
|
+
.replaceAll('"', '"');
|
|
71
|
+
}
|
package/dist/open.d.mts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
export type LocalizationDatabase = {
|
|
3
|
+
readonly revision: string;
|
|
4
|
+
readonly contract: string;
|
|
5
|
+
readonly sqlite: DatabaseSync;
|
|
6
|
+
close(): void;
|
|
7
|
+
};
|
|
8
|
+
export declare function openLocalizationDatabase(path: string, options?: {
|
|
9
|
+
cacheKb?: number;
|
|
10
|
+
}): LocalizationDatabase;
|
package/dist/open.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { LOCALIZATION_WIRE_CONTRACT } from '@vouchington/localization';
|
|
3
|
+
import { assertSqliteIntegrity } from './integrity.mjs';
|
|
4
|
+
import { DEFAULT_SQLITE_CACHE_KB } from './schema.mjs';
|
|
5
|
+
export function openLocalizationDatabase(path, options = {}) {
|
|
6
|
+
const sqlite = new DatabaseSync(path, { readOnly: true });
|
|
7
|
+
sqlite.exec('PRAGMA query_only = ON');
|
|
8
|
+
sqlite.exec(`PRAGMA cache_size = -${options.cacheKb ?? DEFAULT_SQLITE_CACHE_KB}`);
|
|
9
|
+
sqlite.exec('PRAGMA mmap_size = 0');
|
|
10
|
+
assertSqliteIntegrity(sqlite);
|
|
11
|
+
const contract = readMetadata(sqlite, 'contract');
|
|
12
|
+
const revision = readMetadata(sqlite, 'revision');
|
|
13
|
+
if (contract !== LOCALIZATION_WIRE_CONTRACT) {
|
|
14
|
+
sqlite.close();
|
|
15
|
+
throw new Error(`Unsupported localization contract "${contract}"`);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
revision,
|
|
19
|
+
contract,
|
|
20
|
+
sqlite,
|
|
21
|
+
close() {
|
|
22
|
+
sqlite.close();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function readMetadata(database, key) {
|
|
27
|
+
const row = database.prepare('SELECT value FROM metadata WHERE key = ?').get(key);
|
|
28
|
+
if (row === undefined) {
|
|
29
|
+
database.close();
|
|
30
|
+
throw new Error(`Localization artifact is missing metadata "${key}"`);
|
|
31
|
+
}
|
|
32
|
+
return row.value;
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type LocalizationBounds, type LocalizationLeaf, type LocalizationRequest, type LocalizationSelector } from '@vouchington/localization';
|
|
2
|
+
import type { LocalizationDatabase } from './open.mts';
|
|
3
|
+
export declare function resolveLocalizationBatch(database: LocalizationDatabase, request: LocalizationRequest, options?: {
|
|
4
|
+
bounds?: LocalizationBounds;
|
|
5
|
+
ttlSeconds?: number;
|
|
6
|
+
availableLocales?: readonly string[];
|
|
7
|
+
}): Readonly<{
|
|
8
|
+
contract: import("@vouchington/localization").LocalizationWireContract;
|
|
9
|
+
revision: string;
|
|
10
|
+
ttlSeconds: number;
|
|
11
|
+
messages: Readonly<Record<string, LocalizationLeaf>>;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function explainLocalizationPlan(database: LocalizationDatabase, selector: LocalizationSelector): string;
|
package/dist/resolve.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DEFAULT_LOCALIZATION_BOUNDS, assertMessageCount, assertPayloadBytes, canonicalJson, compareCodePoints, createLocalizationBatch, firstAvailableTranslation, leafForTranslation, normalizeLocalizationRequest, parseDescriptor, prefixRange, serializeLocalizationBatch, } from '@vouchington/localization';
|
|
2
|
+
import { DEFAULT_TTL_SECONDS } from './schema.mjs';
|
|
3
|
+
export function resolveLocalizationBatch(database, request, options = {}) {
|
|
4
|
+
const normalized = normalizeLocalizationRequest(request, options.availableLocales, options.bounds);
|
|
5
|
+
const rows = loadRows(database, normalized.consumer, normalized.selectors);
|
|
6
|
+
const byId = new Map();
|
|
7
|
+
for (const row of rows) {
|
|
8
|
+
const current = byId.get(row.id) ?? {
|
|
9
|
+
descriptor: parseDescriptor(JSON.parse(row.descriptor_json)),
|
|
10
|
+
translations: {},
|
|
11
|
+
};
|
|
12
|
+
current.translations[row.locale] = JSON.parse(row.value_json);
|
|
13
|
+
byId.set(row.id, current);
|
|
14
|
+
}
|
|
15
|
+
const messages = {};
|
|
16
|
+
for (const id of [...byId.keys()].toSorted(compareCodePoints)) {
|
|
17
|
+
const entry = byId.get(id);
|
|
18
|
+
const value = firstAvailableTranslation(normalized.locales, entry.translations);
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
continue;
|
|
21
|
+
messages[id] = leafForTranslation(entry.descriptor, value);
|
|
22
|
+
}
|
|
23
|
+
assertMessageCount(Object.keys(messages).length, options.bounds ?? DEFAULT_LOCALIZATION_BOUNDS);
|
|
24
|
+
const batch = createLocalizationBatch(database.revision, options.ttlSeconds ?? DEFAULT_TTL_SECONDS, messages);
|
|
25
|
+
assertPayloadBytes(serializeLocalizationBatch(batch), options.bounds ?? DEFAULT_LOCALIZATION_BOUNDS);
|
|
26
|
+
return batch;
|
|
27
|
+
}
|
|
28
|
+
export function explainLocalizationPlan(database, selector) {
|
|
29
|
+
const sql = selector.kind === 'exact'
|
|
30
|
+
? `EXPLAIN QUERY PLAN SELECT m.id FROM consumer_membership c JOIN messages m ON m.id = c.message_id WHERE c.consumer = 'web' AND m.id = ?`
|
|
31
|
+
: `EXPLAIN QUERY PLAN SELECT m.id FROM consumer_membership c JOIN messages m ON m.id = c.message_id WHERE c.consumer = 'web' AND m.id >= ? AND m.id < ?`;
|
|
32
|
+
const statement = database.sqlite.prepare(sql);
|
|
33
|
+
const rows = selector.kind === 'exact'
|
|
34
|
+
? statement.all(selector.id)
|
|
35
|
+
: statement.all(...prefixRange(selector.prefix));
|
|
36
|
+
return rows.map((row) => canonicalJson(row)).join('\n');
|
|
37
|
+
}
|
|
38
|
+
function loadRows(database, consumer, selectors) {
|
|
39
|
+
const exact = database.sqlite.prepare(`SELECT m.id, t.locale, m.descriptor_json, t.value_json
|
|
40
|
+
FROM consumer_membership c
|
|
41
|
+
JOIN messages m ON m.id = c.message_id
|
|
42
|
+
JOIN translations t ON t.message_id = m.id
|
|
43
|
+
WHERE c.consumer = ? AND m.id = ?`);
|
|
44
|
+
const prefix = database.sqlite.prepare(`SELECT m.id, t.locale, m.descriptor_json, t.value_json
|
|
45
|
+
FROM consumer_membership c
|
|
46
|
+
JOIN messages m ON m.id = c.message_id
|
|
47
|
+
JOIN translations t ON t.message_id = m.id
|
|
48
|
+
WHERE c.consumer = ? AND m.id >= ? AND m.id < ?`);
|
|
49
|
+
const rows = [];
|
|
50
|
+
for (const selector of selectors) {
|
|
51
|
+
const found = selector.kind === 'exact'
|
|
52
|
+
? exact.all(consumer, selector.id)
|
|
53
|
+
: prefix.all(consumer, ...prefixRange(selector.prefix));
|
|
54
|
+
rows.push(...found);
|
|
55
|
+
}
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const SQLITE_SCHEMA = "\nPRAGMA encoding = 'UTF-8';\nPRAGMA foreign_keys = ON;\nCREATE TABLE metadata (\n key TEXT PRIMARY KEY NOT NULL CHECK (key IN ('contract', 'revision')),\n value TEXT NOT NULL\n);\nCREATE TABLE messages (\n id TEXT PRIMARY KEY NOT NULL,\n descriptor_json TEXT NOT NULL\n);\nCREATE TABLE translations (\n locale TEXT NOT NULL,\n message_id TEXT NOT NULL,\n value_json TEXT NOT NULL,\n PRIMARY KEY (locale, message_id),\n FOREIGN KEY (message_id) REFERENCES messages(id)\n);\nCREATE TABLE consumer_membership (\n consumer TEXT NOT NULL,\n message_id TEXT NOT NULL,\n PRIMARY KEY (consumer, message_id),\n FOREIGN KEY (message_id) REFERENCES messages(id)\n);\nCREATE TABLE editorial_tags (\n message_id TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (message_id, tag),\n FOREIGN KEY (message_id) REFERENCES messages(id)\n);\nCREATE INDEX translations_by_locale_id ON translations (locale, message_id);\nCREATE INDEX membership_by_consumer_id ON consumer_membership (consumer, message_id);\nCREATE INDEX messages_by_id ON messages (id);\n";
|
|
2
|
+
export declare const DEFAULT_SQLITE_CACHE_KB = 2048;
|
|
3
|
+
export declare const DEFAULT_TTL_SECONDS = 86400;
|
package/dist/schema.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const SQLITE_SCHEMA = `
|
|
2
|
+
PRAGMA encoding = 'UTF-8';
|
|
3
|
+
PRAGMA foreign_keys = ON;
|
|
4
|
+
CREATE TABLE metadata (
|
|
5
|
+
key TEXT PRIMARY KEY NOT NULL CHECK (key IN ('contract', 'revision')),
|
|
6
|
+
value TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
CREATE TABLE messages (
|
|
9
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
10
|
+
descriptor_json TEXT NOT NULL
|
|
11
|
+
);
|
|
12
|
+
CREATE TABLE translations (
|
|
13
|
+
locale TEXT NOT NULL,
|
|
14
|
+
message_id TEXT NOT NULL,
|
|
15
|
+
value_json TEXT NOT NULL,
|
|
16
|
+
PRIMARY KEY (locale, message_id),
|
|
17
|
+
FOREIGN KEY (message_id) REFERENCES messages(id)
|
|
18
|
+
);
|
|
19
|
+
CREATE TABLE consumer_membership (
|
|
20
|
+
consumer TEXT NOT NULL,
|
|
21
|
+
message_id TEXT NOT NULL,
|
|
22
|
+
PRIMARY KEY (consumer, message_id),
|
|
23
|
+
FOREIGN KEY (message_id) REFERENCES messages(id)
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE editorial_tags (
|
|
26
|
+
message_id TEXT NOT NULL,
|
|
27
|
+
tag TEXT NOT NULL,
|
|
28
|
+
PRIMARY KEY (message_id, tag),
|
|
29
|
+
FOREIGN KEY (message_id) REFERENCES messages(id)
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX translations_by_locale_id ON translations (locale, message_id);
|
|
32
|
+
CREATE INDEX membership_by_consumer_id ON consumer_membership (consumer, message_id);
|
|
33
|
+
CREATE INDEX messages_by_id ON messages (id);
|
|
34
|
+
`;
|
|
35
|
+
export const DEFAULT_SQLITE_CACHE_KB = 2048;
|
|
36
|
+
export const DEFAULT_TTL_SECONDS = 86_400;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export function sampleMessages() {
|
|
5
|
+
return [
|
|
6
|
+
{
|
|
7
|
+
id: 'nav.home',
|
|
8
|
+
descriptor: null,
|
|
9
|
+
consumers: ['web', 'swift'],
|
|
10
|
+
translations: { 'en-US': 'Home', es: 'Inicio' },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: 'common.save',
|
|
14
|
+
descriptor: null,
|
|
15
|
+
consumers: ['web'],
|
|
16
|
+
translations: { 'en-US': 'Save "{name}"' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'settings.count',
|
|
20
|
+
descriptor: { kind: 'plural', valueParameter: 'count' },
|
|
21
|
+
consumers: ['web', 'dotnet'],
|
|
22
|
+
translations: {
|
|
23
|
+
'en-US': { one: '{count} item', other: '{count} items' },
|
|
24
|
+
es: { one: '{count} artículo', other: '{count} artículos' },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'settings.ago',
|
|
29
|
+
descriptor: {
|
|
30
|
+
kind: 'select-plural',
|
|
31
|
+
valueParameter: 'value',
|
|
32
|
+
selectParameter: 'unit',
|
|
33
|
+
cases: ['day'],
|
|
34
|
+
},
|
|
35
|
+
consumers: ['email'],
|
|
36
|
+
translations: {
|
|
37
|
+
'en-US': { day: { one: '{value} day ago', other: '{value} days ago' } },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
export function writeCatalog(files) {
|
|
43
|
+
const directory = mkdtempSync(join(tmpdir(), 'catalog-'));
|
|
44
|
+
mkdirSync(directory, { recursive: true });
|
|
45
|
+
for (const [name, value] of Object.entries(files)) {
|
|
46
|
+
writeFileSync(join(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
|
47
|
+
}
|
|
48
|
+
return directory;
|
|
49
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CANONICAL_SOURCE_LOCALE, ENGLISH_LOCALE_ALIAS, catalogMessageFromRecord, compareCodePoints, descriptorSignature, isPluralForms, isSelectPluralCases, normalizeLocale, placeholdersIn, } from '@vouchington/localization';
|
|
2
|
+
export function validateCatalogMessages(messages) {
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
for (const message of messages) {
|
|
5
|
+
if (seen.has(message.id))
|
|
6
|
+
throw new TypeError(`Duplicate message id "${message.id}"`);
|
|
7
|
+
seen.add(message.id);
|
|
8
|
+
if (!Object.hasOwn(message.translations, CANONICAL_SOURCE_LOCALE)) {
|
|
9
|
+
throw new TypeError(`Message "${message.id}" is missing ${CANONICAL_SOURCE_LOCALE}`);
|
|
10
|
+
}
|
|
11
|
+
const canonical = message.translations[CANONICAL_SOURCE_LOCALE];
|
|
12
|
+
assertValueMatchesDescriptor(message.id, CANONICAL_SOURCE_LOCALE, message.descriptor, canonical);
|
|
13
|
+
const expected = placeholdersFor(canonical);
|
|
14
|
+
for (const [locale, value] of Object.entries(message.translations)) {
|
|
15
|
+
const normalized = normalizeLocale(locale);
|
|
16
|
+
if (normalized === null)
|
|
17
|
+
throw new TypeError(`Invalid locale "${locale}" on "${message.id}"`);
|
|
18
|
+
if (locale === ENGLISH_LOCALE_ALIAS || locale !== normalized) {
|
|
19
|
+
throw new TypeError(`Locale "${locale}" on "${message.id}" must be stored as ${normalized}`);
|
|
20
|
+
}
|
|
21
|
+
assertValueMatchesDescriptor(message.id, locale, message.descriptor, value);
|
|
22
|
+
if (message.descriptor?.kind === 'select-plural') {
|
|
23
|
+
assertSelectCases(message.id, locale, message.descriptor, value);
|
|
24
|
+
}
|
|
25
|
+
if (expected.join(',') !== placeholdersFor(value).join(',')) {
|
|
26
|
+
throw new TypeError(`Placeholder mismatch for "${message.id}" in ${locale}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function parseCatalogFile(value) {
|
|
32
|
+
const records = Array.isArray(value) ? value : isMessagesDocument(value) ? value.messages : null;
|
|
33
|
+
if (records === null)
|
|
34
|
+
throw new TypeError('Catalog file must be an array or { messages } document');
|
|
35
|
+
return records.map((record) => catalogMessageFromRecord(record));
|
|
36
|
+
}
|
|
37
|
+
function placeholdersFor(value) {
|
|
38
|
+
const names = typeof value === 'string'
|
|
39
|
+
? placeholdersIn(value)
|
|
40
|
+
: isPluralForms(value)
|
|
41
|
+
? Object.values(value).flatMap(placeholdersIn)
|
|
42
|
+
: Object.values(value).flatMap((forms) => Object.values(forms).flatMap(placeholdersIn));
|
|
43
|
+
return [...new Set(names)].toSorted(compareCodePoints);
|
|
44
|
+
}
|
|
45
|
+
function assertValueMatchesDescriptor(id, locale, descriptor, value) {
|
|
46
|
+
if (descriptor === null && typeof value !== 'string') {
|
|
47
|
+
throw new TypeError(`String message "${id}" has a non-string value in ${locale}`);
|
|
48
|
+
}
|
|
49
|
+
if (descriptor?.kind === 'plural' && !isPluralForms(value)) {
|
|
50
|
+
throw new TypeError(`Plural message "${id}" has invalid forms in ${locale}`);
|
|
51
|
+
}
|
|
52
|
+
if (descriptor?.kind === 'select-plural' && (typeof value === 'string' || isPluralForms(value))) {
|
|
53
|
+
throw new TypeError(`Select-plural message "${id}" has invalid cases in ${locale}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function assertSelectCases(id, locale, descriptor, value) {
|
|
57
|
+
if (typeof value === 'string' || isPluralForms(value) || !isSelectPluralCases(value)) {
|
|
58
|
+
throw new TypeError(`Select-plural message "${id}" has invalid cases in ${locale}`);
|
|
59
|
+
}
|
|
60
|
+
const actual = descriptorSignature({ ...descriptor, cases: Object.keys(value) });
|
|
61
|
+
if (descriptorSignature(descriptor) !== actual) {
|
|
62
|
+
throw new TypeError(`Select-plural cases mismatch for "${id}" in ${locale}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function isMessagesDocument(value) {
|
|
66
|
+
return (typeof value === 'object' &&
|
|
67
|
+
value !== null &&
|
|
68
|
+
Array.isArray(value.messages));
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vouchington/localization-compiler",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Node compiler for localization JSON catalogs, immutable SQLite artifacts, and CLI resolution.",
|
|
5
|
+
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/localization-compiler#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/vouchington/vouchington-platform/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Jonathan Ong",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/vouchington/vouchington-platform.git",
|
|
14
|
+
"directory": "packages/localization-compiler"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"vouchington-localization": "./dist/bin.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./dist/index.mjs",
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"import": "./dist/index.mjs",
|
|
31
|
+
"default": "./dist/index.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc --project tsconfig.build.json",
|
|
39
|
+
"prepack": "pnpm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@vouchington/csv": "^0.0.1",
|
|
43
|
+
"@vouchington/localization": "^0.0.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=24.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|