@vouchington/localization-compiler 0.0.2 → 0.1.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/README.md +12 -7
- package/dist/catalog-csv.d.mts +3 -0
- package/dist/catalog-csv.mjs +55 -0
- package/dist/catalog.d.mts +3 -0
- package/dist/catalog.mjs +71 -0
- package/dist/cli-shard.mjs +61 -40
- package/dist/cli.mjs +15 -5
- package/dist/compile.d.mts +2 -3
- package/dist/compile.mjs +30 -27
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/load.d.mts +4 -2
- package/dist/load.mjs +66 -19
- package/dist/resolve.mjs +27 -20
- package/dist/revision.d.mts +2 -2
- package/dist/revision.mjs +4 -3
- package/dist/schema.d.mts +1 -1
- package/dist/schema.mjs +22 -14
- package/dist/table-cli.d.mts +5 -0
- package/dist/table-cli.mjs +82 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# @vouchington/localization-compiler
|
|
2
2
|
|
|
3
|
-
Node-only compiler for `@vouchington/localization`. It validates
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Node-only compiler for `@vouchington/localization`. It validates row-based JSON catalogs, requires
|
|
4
|
+
complete `en-US` with sparse partial locales, emits an immutable read-only SQLite artifact, and
|
|
5
|
+
exposes the same consumer/locale/selector resolver used by application CLIs.
|
|
6
6
|
|
|
7
|
-
Catalog
|
|
8
|
-
`
|
|
7
|
+
Catalog source has three canonical tables: `copies.json` (`{ id, descriptor }`), `aliases.json`
|
|
8
|
+
(`{ consumer, alias, copyId }`), and `translations/<locale>.json` (`{ id, value }`). A generated
|
|
9
|
+
`routes.json` table maps `{ consumer, selectorId, alias }`, allowing route selectors to return the
|
|
10
|
+
existing alias-keyed v1 payload without tying copy ids to source locations. Full plural and
|
|
11
|
+
select-plural translation values remain values in the locale table.
|
|
9
12
|
|
|
10
13
|
```bash
|
|
11
|
-
vouchington-localization upsert --file localization/catalog/
|
|
12
|
-
vouchington-localization
|
|
14
|
+
vouchington-localization upsert --file localization/catalog/copies.json --row '{"id":"copy.ok","descriptor":null}'
|
|
15
|
+
vouchington-localization upsert --file localization/catalog/aliases.json --row '{"consumer":"web","alias":"web.common.ok","copyId":"copy.ok"}'
|
|
16
|
+
vouchington-localization upsert --file localization/catalog/translations/en-US.json --row '{"id":"copy.ok","value":"OK"}'
|
|
17
|
+
vouchington-localization remove --file localization/catalog/aliases.json --id web.common.ok --consumer web
|
|
13
18
|
vouchington-localization format --source localization/catalog
|
|
14
19
|
```
|
|
15
20
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parseCsvRows, stringifyCsvRows } from '@vouchington/csv';
|
|
2
|
+
import { canonicalJson } from '@vouchington/localization';
|
|
3
|
+
import { sortedCatalog } from './catalog.mjs';
|
|
4
|
+
import { catalogRevision } from './revision.mjs';
|
|
5
|
+
const columns = [
|
|
6
|
+
'id',
|
|
7
|
+
'locale',
|
|
8
|
+
'descriptor_json',
|
|
9
|
+
'aliases_json',
|
|
10
|
+
'value_json',
|
|
11
|
+
'catalog_revision',
|
|
12
|
+
];
|
|
13
|
+
export function exportCatalogCsv(catalog) {
|
|
14
|
+
const source = sortedCatalog(catalog);
|
|
15
|
+
const revision = catalogRevision(source);
|
|
16
|
+
return stringifyCsvRows(Object.entries(source.translations).flatMap(([locale, rows]) => rows.map((row) => ({
|
|
17
|
+
id: row.id,
|
|
18
|
+
locale,
|
|
19
|
+
descriptor_json: canonicalJson(source.copies.find((copy) => copy.id === row.id).descriptor),
|
|
20
|
+
aliases_json: canonicalJson(source.aliases
|
|
21
|
+
.filter((alias) => alias.copyId === row.id)
|
|
22
|
+
.map(({ consumer, alias }) => ({ consumer, alias }))),
|
|
23
|
+
value_json: canonicalJson(row.value),
|
|
24
|
+
catalog_revision: revision,
|
|
25
|
+
}))), columns);
|
|
26
|
+
}
|
|
27
|
+
export function importCatalogCsv(csv, expectedRevision) {
|
|
28
|
+
const [header, ...rows] = parseCsvRows(csv);
|
|
29
|
+
if (header?.join(',') !== columns.join(','))
|
|
30
|
+
throw new TypeError('CSV header must match the localization interchange contract');
|
|
31
|
+
const copies = new Map();
|
|
32
|
+
const aliases = new Map();
|
|
33
|
+
const translations = {};
|
|
34
|
+
let revision;
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
const [id, locale, descriptor, aliasJson, value, current] = row;
|
|
37
|
+
if (revision !== undefined && revision !== current)
|
|
38
|
+
throw new TypeError('CSV rows must share a single catalog_revision');
|
|
39
|
+
revision = current;
|
|
40
|
+
copies.set(id, { id, descriptor: JSON.parse(descriptor) });
|
|
41
|
+
for (const alias of JSON.parse(aliasJson))
|
|
42
|
+
aliases.set(`${alias.consumer}\t${alias.alias}`, { ...alias, copyId: id });
|
|
43
|
+
(translations[locale] ??= []).push({ id, value: JSON.parse(value) });
|
|
44
|
+
}
|
|
45
|
+
const catalog = sortedCatalog({
|
|
46
|
+
copies: [...copies.values()],
|
|
47
|
+
aliases: [...aliases.values()],
|
|
48
|
+
translations: translations,
|
|
49
|
+
});
|
|
50
|
+
if (expectedRevision !== undefined && revision !== expectedRevision)
|
|
51
|
+
throw new TypeError('CSV catalog_revision does not match the source contract hash');
|
|
52
|
+
if (revision !== catalogRevision(catalog))
|
|
53
|
+
throw new TypeError('CSV catalog_revision does not match reconstructed catalog');
|
|
54
|
+
return catalog;
|
|
55
|
+
}
|
package/dist/catalog.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CANONICAL_SOURCE_LOCALE, ENGLISH_LOCALE_ALIAS, compareCodePoints, consumerAliasFromRecord, isMessageId, normalizeLocale, routeSelectorMembershipFromRecord, translationRowFromRecord, } from '@vouchington/localization';
|
|
2
|
+
import { validateCatalogMessages } from './validate.mjs';
|
|
3
|
+
export function validateLocalizationCatalog(catalog) {
|
|
4
|
+
const copies = new Map(catalog.copies.map((copy) => [copy.id, copy]));
|
|
5
|
+
if (copies.size !== catalog.copies.length)
|
|
6
|
+
throw new TypeError('Duplicate copy id');
|
|
7
|
+
const aliases = new Set();
|
|
8
|
+
for (const raw of catalog.aliases) {
|
|
9
|
+
const alias = consumerAliasFromRecord(raw);
|
|
10
|
+
if (!copies.has(alias.copyId))
|
|
11
|
+
throw new TypeError(`Alias "${alias.alias}" targets missing copy "${alias.copyId}"`);
|
|
12
|
+
const key = `${alias.consumer}\t${alias.alias}`;
|
|
13
|
+
if (aliases.has(key))
|
|
14
|
+
throw new TypeError(`Duplicate alias "${alias.alias}" for ${alias.consumer}`);
|
|
15
|
+
aliases.add(key);
|
|
16
|
+
}
|
|
17
|
+
const translated = new Map();
|
|
18
|
+
for (const [locale, rows] of Object.entries(catalog.translations)) {
|
|
19
|
+
const normalized = normalizeLocale(locale);
|
|
20
|
+
if (normalized === null || locale === ENGLISH_LOCALE_ALIAS || locale !== normalized) {
|
|
21
|
+
throw new TypeError(`Locale "${locale}" must be stored as ${normalized}`);
|
|
22
|
+
}
|
|
23
|
+
const values = new Map();
|
|
24
|
+
for (const raw of rows) {
|
|
25
|
+
const row = translationRowFromRecord(raw);
|
|
26
|
+
if (!copies.has(row.id))
|
|
27
|
+
throw new TypeError(`Translation targets missing copy "${row.id}"`);
|
|
28
|
+
if (values.has(row.id))
|
|
29
|
+
throw new TypeError(`Duplicate translation for "${row.id}" in ${locale}`);
|
|
30
|
+
values.set(row.id, row.value);
|
|
31
|
+
}
|
|
32
|
+
translated.set(locale, values);
|
|
33
|
+
}
|
|
34
|
+
const english = translated.get(CANONICAL_SOURCE_LOCALE);
|
|
35
|
+
for (const copy of copies.values()) {
|
|
36
|
+
if (!english?.has(copy.id))
|
|
37
|
+
throw new TypeError(`Copy "${copy.id}" is missing ${CANONICAL_SOURCE_LOCALE}`);
|
|
38
|
+
}
|
|
39
|
+
validateCatalogMessages([...copies.values()].map((copy) => ({
|
|
40
|
+
...copy,
|
|
41
|
+
consumers: ['web'],
|
|
42
|
+
translations: Object.fromEntries([...translated.entries()].flatMap(([locale, rows]) => rows.has(copy.id) ? [[locale, rows.get(copy.id)]] : [])),
|
|
43
|
+
})));
|
|
44
|
+
const membership = new Set();
|
|
45
|
+
for (const raw of catalog.routeMembership ?? []) {
|
|
46
|
+
const row = routeSelectorMembershipFromRecord(raw);
|
|
47
|
+
if (!aliases.has(`${row.consumer}\t${row.alias}`)) {
|
|
48
|
+
throw new TypeError(`Route selector "${row.selectorId}" targets missing alias "${row.alias}"`);
|
|
49
|
+
}
|
|
50
|
+
const key = `${row.consumer}\t${row.selectorId}\t${row.alias}`;
|
|
51
|
+
if (membership.has(key))
|
|
52
|
+
throw new TypeError(`Duplicate route membership "${row.selectorId}" → "${row.alias}"`);
|
|
53
|
+
membership.add(key);
|
|
54
|
+
}
|
|
55
|
+
for (const id of Object.keys(catalog.tags ?? {}))
|
|
56
|
+
if (!copies.has(id) || !isMessageId(id))
|
|
57
|
+
throw new TypeError(`Editorial tag target "${id}" is not in the catalog`);
|
|
58
|
+
}
|
|
59
|
+
export function sortedCatalog(catalog) {
|
|
60
|
+
validateLocalizationCatalog(catalog);
|
|
61
|
+
return {
|
|
62
|
+
copies: [...catalog.copies].toSorted((a, b) => compareCodePoints(a.id, b.id)),
|
|
63
|
+
aliases: [...catalog.aliases].toSorted((a, b) => compareCodePoints(`${a.consumer}\t${a.alias}`, `${b.consumer}\t${b.alias}`)),
|
|
64
|
+
translations: Object.fromEntries(Object.entries(catalog.translations).map(([locale, rows]) => [
|
|
65
|
+
locale,
|
|
66
|
+
[...rows].toSorted((a, b) => compareCodePoints(a.id, b.id)),
|
|
67
|
+
])),
|
|
68
|
+
routeMembership: [...(catalog.routeMembership ?? [])].toSorted((a, b) => compareCodePoints(`${a.consumer}\t${a.selectorId}\t${a.alias}`, `${b.consumer}\t${b.selectorId}\t${b.alias}`)),
|
|
69
|
+
tags: catalog.tags ?? {},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/cli-shard.mjs
CHANGED
|
@@ -1,66 +1,87 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
|
-
import { CatalogMergeConflict, mergeCatalogShards, parseCatalogShardText, removeCatalogLine, serializeCatalogShard, upsertCatalogLine, } from '@vouchington/localization';
|
|
3
|
+
import { CatalogMergeConflict, mergeCatalogShards, parseCatalogShardText, removeCatalogLine, serializeCatalogTable, serializeCatalogShard, upsertCatalogLine, } from '@vouchington/localization';
|
|
4
|
+
import { isTablePath, mergeTableFiles, readTable, removeTable, upsertTable } from './table-cli.mjs';
|
|
4
5
|
import { parseCatalogFile } from './validate.mjs';
|
|
5
6
|
export function runShardCli(command, args) {
|
|
6
|
-
if (command === 'upsert')
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
if (command === 'upsert')
|
|
8
|
+
return upsert(args);
|
|
9
|
+
if (command === 'remove')
|
|
10
|
+
return remove(args);
|
|
11
|
+
if (command === 'git-merge')
|
|
12
|
+
return merge(args);
|
|
13
|
+
if (command === 'format')
|
|
14
|
+
return formatCatalogDirectory(required(args, '--source'));
|
|
15
|
+
throw new TypeError(shardUsage());
|
|
16
|
+
}
|
|
17
|
+
function upsert(args) {
|
|
18
|
+
const path = requiredPath(args, '--file');
|
|
19
|
+
if (isTablePath(path))
|
|
20
|
+
return upsertTable(path, optional(args, '--row') ?? required(args, '--message'));
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
writeFileSync(path, upsertCatalogLine(readShard(path), required(args, '--message')));
|
|
23
|
+
}
|
|
24
|
+
function remove(args) {
|
|
25
|
+
const path = requiredPath(args, '--file');
|
|
26
|
+
if (isTablePath(path))
|
|
27
|
+
return removeTable(path, required(args, '--id'), optional(args, '--consumer'));
|
|
28
|
+
writeFileSync(path, removeCatalogLine(readFileSync(path, 'utf8'), required(args, '--id')));
|
|
29
|
+
}
|
|
30
|
+
function merge(args) {
|
|
31
|
+
const [ancestor, ours, theirs] = args;
|
|
32
|
+
if (ancestor === undefined || ours === undefined || theirs === undefined)
|
|
33
|
+
throw new TypeError(shardUsage());
|
|
34
|
+
if (isTablePath(ours)) {
|
|
35
|
+
mergeTableFiles(ancestor, ours, theirs);
|
|
10
36
|
return undefined;
|
|
11
37
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
writeFileSync(path, removeCatalogLine(readFileSync(path, 'utf8'), required(args, '--id')));
|
|
15
|
-
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(ours, mergeCatalogShards(readFileSync(ancestor, 'utf8'), readFileSync(ours, 'utf8'), readFileSync(theirs, 'utf8')));
|
|
16
40
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
try {
|
|
23
|
-
writeFileSync(ours, mergeCatalogShards(readFileSync(ancestor, 'utf8'), readFileSync(ours, 'utf8'), readFileSync(theirs, 'utf8')));
|
|
24
|
-
}
|
|
25
|
-
catch (error) {
|
|
26
|
-
if (error instanceof CatalogMergeConflict)
|
|
27
|
-
writeFileSync(ours, error.text);
|
|
28
|
-
throw error;
|
|
29
|
-
}
|
|
30
|
-
return undefined;
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error instanceof CatalogMergeConflict)
|
|
43
|
+
writeFileSync(ours, error.text);
|
|
44
|
+
throw error;
|
|
31
45
|
}
|
|
32
|
-
if (command === 'format')
|
|
33
|
-
return formatCatalogDirectory(required(args, '--source'));
|
|
34
|
-
throw new TypeError(shardUsage());
|
|
35
46
|
}
|
|
36
47
|
export function shardUsage() {
|
|
37
48
|
return [
|
|
38
|
-
'Usage: vouchington-localization upsert --file <file> --
|
|
39
|
-
'Usage: vouchington-localization remove --file <file> --id <id>',
|
|
49
|
+
'Usage: vouchington-localization upsert --file <file> --row <json>',
|
|
50
|
+
'Usage: vouchington-localization remove --file <file> --id <id> [--consumer <consumer>]',
|
|
40
51
|
'Usage: vouchington-localization git-merge <ancestor> <ours> <theirs>',
|
|
41
52
|
'Usage: vouchington-localization format --source <dir>',
|
|
42
53
|
].join('\n');
|
|
43
54
|
}
|
|
44
55
|
function formatCatalogDirectory(directory) {
|
|
45
56
|
const names = readdirSync(directory).filter((name) => name.endsWith('.json') && name !== 'tags.json');
|
|
46
|
-
for (const name of names)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
throw new TypeError(`${path}: ${error.message}`);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
57
|
+
for (const name of names)
|
|
58
|
+
formatFile(join(directory, name));
|
|
59
|
+
const translations = join(directory, 'translations');
|
|
60
|
+
if (existsSync(translations))
|
|
61
|
+
for (const name of readdirSync(translations).filter((name) => name.endsWith('.json')))
|
|
62
|
+
formatFile(join(translations, name));
|
|
55
63
|
return `${names.length} files`;
|
|
56
64
|
}
|
|
65
|
+
function formatFile(path) {
|
|
66
|
+
try {
|
|
67
|
+
if (isTablePath(path))
|
|
68
|
+
return writeFileSync(path, serializeCatalogTable(readTable(path)));
|
|
69
|
+
writeFileSync(path, serializeCatalogShard(messagesFromUnknownText(readFileSync(path, 'utf8'))));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new TypeError(`${path}: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
57
75
|
function required(args, flag) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
if (value === undefined || value.startsWith('--'))
|
|
76
|
+
const value = optional(args, flag);
|
|
77
|
+
if (value === undefined)
|
|
61
78
|
throw new TypeError(shardUsage());
|
|
62
79
|
return value;
|
|
63
80
|
}
|
|
81
|
+
function optional(args, flag) {
|
|
82
|
+
const value = args[args.indexOf(flag) + 1];
|
|
83
|
+
return value === undefined || value.startsWith('--') ? undefined : value;
|
|
84
|
+
}
|
|
64
85
|
function requiredPath(args, flag) {
|
|
65
86
|
return resolve(required(args, flag));
|
|
66
87
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { serializeLocalizationBatch } from '@vouchington/localization';
|
|
5
|
-
import { compileLocalizationSqlite
|
|
6
|
-
import {
|
|
5
|
+
import { compileLocalizationSqlite } from './compile.mjs';
|
|
6
|
+
import { exportCatalogCsv, importCatalogCsv } from './catalog-csv.mjs';
|
|
7
7
|
import { loadCatalogDirectory } from './load.mjs';
|
|
8
8
|
import { openLocalizationDatabase } from './open.mjs';
|
|
9
9
|
import { explainLocalizationPlan, resolveLocalizationBatch } from './resolve.mjs';
|
|
@@ -21,7 +21,7 @@ export async function runLocalizationCli(argv, write = console.log) {
|
|
|
21
21
|
}
|
|
22
22
|
if (command === 'compile') {
|
|
23
23
|
const loaded = await loadCatalogDirectory(required(rest, '--source'));
|
|
24
|
-
write(compileLocalizationSqlite(loaded.
|
|
24
|
+
write(compileLocalizationSqlite(loaded.catalog, required(rest, '--output')));
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
if (command === 'resolve') {
|
|
@@ -42,7 +42,7 @@ export async function runLocalizationCli(argv, write = console.log) {
|
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
if (command === 'csv-export') {
|
|
45
|
-
const csv =
|
|
45
|
+
const csv = exportCatalogCsv((await loadCatalogDirectory(required(rest, '--source'))).catalog);
|
|
46
46
|
const output = optional(rest, '--output');
|
|
47
47
|
if (output === undefined)
|
|
48
48
|
write(csv);
|
|
@@ -53,11 +53,21 @@ export async function runLocalizationCli(argv, write = console.log) {
|
|
|
53
53
|
if (command === 'csv-import') {
|
|
54
54
|
const output = required(rest, '--output');
|
|
55
55
|
mkdirSync(output, { recursive: true });
|
|
56
|
-
|
|
56
|
+
const catalog = importCatalogCsv(await readFile(required(rest, '--input'), 'utf8'));
|
|
57
|
+
writeCatalogSource(output, catalog);
|
|
57
58
|
return;
|
|
58
59
|
}
|
|
59
60
|
throw new TypeError(usage());
|
|
60
61
|
}
|
|
62
|
+
function writeCatalogSource(output, catalog) {
|
|
63
|
+
writeFileSync(resolve(output, 'imported.json'), JSON.stringify(catalog));
|
|
64
|
+
writeFileSync(resolve(output, 'copies.json'), JSON.stringify(catalog.copies));
|
|
65
|
+
writeFileSync(resolve(output, 'aliases.json'), JSON.stringify(catalog.aliases));
|
|
66
|
+
const translations = resolve(output, 'translations');
|
|
67
|
+
mkdirSync(translations, { recursive: true });
|
|
68
|
+
for (const [locale, rows] of Object.entries(catalog.translations))
|
|
69
|
+
writeFileSync(resolve(translations, `${locale}.json`), JSON.stringify(rows));
|
|
70
|
+
}
|
|
61
71
|
function withDatabase(path, run) {
|
|
62
72
|
const database = openLocalizationDatabase(path);
|
|
63
73
|
try {
|
package/dist/compile.d.mts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type CatalogMessage } from '@vouchington/localization';
|
|
1
|
+
import { type LocalizationCatalog, type CatalogMessage } from '@vouchington/localization';
|
|
2
2
|
import type { EditorialTags } from './load.mts';
|
|
3
|
-
export declare function compileLocalizationSqlite(
|
|
4
|
-
export declare function writeJsonCatalog(messages: readonly CatalogMessage[], path: string): void;
|
|
3
|
+
export declare function compileLocalizationSqlite(source: LocalizationCatalog | readonly CatalogMessage[], outputPath: string, tags?: EditorialTags): string;
|
package/dist/compile.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, renameSync, rmSync
|
|
1
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
-
import { canonicalJson, compareCodePoints, LOCALIZATION_WIRE_CONTRACT,
|
|
5
|
+
import { canonicalJson, catalogFromMessages, compareCodePoints, LOCALIZATION_WIRE_CONTRACT, } from '@vouchington/localization';
|
|
6
6
|
import { assertSqliteIntegrity } from './integrity.mjs';
|
|
7
7
|
import { catalogRevision } from './revision.mjs';
|
|
8
8
|
import { SQLITE_SCHEMA } from './schema.mjs';
|
|
9
9
|
import { validateCatalogMessages } from './validate.mjs';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
10
|
+
import { sortedCatalog } from './catalog.mjs';
|
|
11
|
+
export function compileLocalizationSqlite(source, outputPath, tags = {}) {
|
|
12
|
+
const catalog = isCatalog(source) ? source : { ...catalogFromMessages(source), tags };
|
|
13
|
+
if (!isCatalog(source))
|
|
14
|
+
validateCatalogMessages(source);
|
|
15
|
+
const normalized = sortedCatalog(catalog);
|
|
16
|
+
const revision = catalogRevision(normalized);
|
|
13
17
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
14
18
|
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'localization-'));
|
|
15
19
|
const temporary = join(temporaryDirectory, 'catalog.sqlite');
|
|
@@ -18,8 +22,7 @@ export function compileLocalizationSqlite(messages, outputPath, tags = {}) {
|
|
|
18
22
|
database.exec('PRAGMA journal_mode = OFF');
|
|
19
23
|
database.exec(SQLITE_SCHEMA);
|
|
20
24
|
insertMetadata(database, revision);
|
|
21
|
-
|
|
22
|
-
insertTags(database, tags);
|
|
25
|
+
insertCatalog(database, normalized);
|
|
23
26
|
database.exec('PRAGMA foreign_keys = ON');
|
|
24
27
|
assertSqliteIntegrity(database);
|
|
25
28
|
}
|
|
@@ -30,31 +33,31 @@ export function compileLocalizationSqlite(messages, outputPath, tags = {}) {
|
|
|
30
33
|
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
31
34
|
return revision;
|
|
32
35
|
}
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
function isCatalog(source) {
|
|
37
|
+
return !Array.isArray(source) || Object.hasOwn(source, 'copies');
|
|
35
38
|
}
|
|
36
39
|
function insertMetadata(database, revision) {
|
|
37
40
|
const insert = database.prepare('INSERT INTO metadata (key, value) VALUES (?, ?)');
|
|
38
41
|
insert.run('contract', LOCALIZATION_WIRE_CONTRACT);
|
|
39
42
|
insert.run('revision', revision);
|
|
40
43
|
}
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
const insertTranslation = database.prepare('INSERT INTO translations (locale,
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
for (const id of Object.keys(tags).toSorted(compareCodePoints)) {
|
|
57
|
-
for (const tag of tags[id])
|
|
58
|
-
insert.run(id, tag);
|
|
44
|
+
function insertCatalog(database, catalog) {
|
|
45
|
+
const insertCopy = database.prepare('INSERT INTO copies (id, descriptor_json) VALUES (?, ?)');
|
|
46
|
+
const insertTranslation = database.prepare('INSERT INTO translations (locale, copy_id, value_json) VALUES (?, ?, ?)');
|
|
47
|
+
const insertAlias = database.prepare('INSERT INTO consumer_aliases (consumer, alias, copy_id) VALUES (?, ?, ?)');
|
|
48
|
+
const insertRoute = database.prepare('INSERT INTO route_membership (consumer, selector_id, alias) VALUES (?, ?, ?)');
|
|
49
|
+
const insertTag = database.prepare('INSERT INTO editorial_tags (copy_id, tag) VALUES (?, ?)');
|
|
50
|
+
for (const copy of catalog.copies)
|
|
51
|
+
insertCopy.run(copy.id, canonicalJson(copy.descriptor));
|
|
52
|
+
for (const alias of catalog.aliases)
|
|
53
|
+
insertAlias.run(alias.consumer, alias.alias, alias.copyId);
|
|
54
|
+
for (const route of catalog.routeMembership)
|
|
55
|
+
insertRoute.run(route.consumer, route.selectorId, route.alias);
|
|
56
|
+
for (const [locale, rows] of Object.entries(catalog.translations)) {
|
|
57
|
+
for (const row of rows)
|
|
58
|
+
insertTranslation.run(locale, row.id, canonicalJson(row.value));
|
|
59
59
|
}
|
|
60
|
+
for (const id of Object.keys(catalog.tags).toSorted(compareCodePoints))
|
|
61
|
+
for (const tag of catalog.tags[id])
|
|
62
|
+
insertTag.run(id, tag);
|
|
60
63
|
}
|
package/dist/index.d.mts
CHANGED
|
@@ -5,5 +5,7 @@ export { openLocalizationDatabase, type LocalizationDatabase } from './open.mts'
|
|
|
5
5
|
export { explainLocalizationPlan, resolveLocalizationBatch } from './resolve.mts';
|
|
6
6
|
export { nativeLeafVariants, renderDotnetDescriptors, renderDotnetKeys, renderResx, renderSwiftDescriptors, renderSwiftKeys, renderSwiftStrings, } from './native.mts';
|
|
7
7
|
export { catalogRevision } from './revision.mts';
|
|
8
|
+
export { validateLocalizationCatalog, sortedCatalog } from './catalog.mts';
|
|
9
|
+
export { exportCatalogCsv, importCatalogCsv } from './catalog-csv.mts';
|
|
8
10
|
export { validateCatalogMessages, parseCatalogFile } from './validate.mts';
|
|
9
11
|
export { runLocalizationCli } from './cli.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -5,5 +5,7 @@ export { openLocalizationDatabase } from './open.mjs';
|
|
|
5
5
|
export { explainLocalizationPlan, resolveLocalizationBatch } from './resolve.mjs';
|
|
6
6
|
export { nativeLeafVariants, renderDotnetDescriptors, renderDotnetKeys, renderResx, renderSwiftDescriptors, renderSwiftKeys, renderSwiftStrings, } from './native.mjs';
|
|
7
7
|
export { catalogRevision } from './revision.mjs';
|
|
8
|
+
export { validateLocalizationCatalog, sortedCatalog } from './catalog.mjs';
|
|
9
|
+
export { exportCatalogCsv, importCatalogCsv } from './catalog-csv.mjs';
|
|
8
10
|
export { validateCatalogMessages, parseCatalogFile } from './validate.mjs';
|
|
9
11
|
export { runLocalizationCli } from './cli.mjs';
|
package/dist/load.d.mts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type LocalizationCatalog } from '@vouchington/localization';
|
|
2
2
|
export type EditorialTags = Readonly<Record<string, readonly string[]>>;
|
|
3
3
|
export declare function loadCatalogDirectory(directory: string): Promise<{
|
|
4
|
-
|
|
4
|
+
catalog: LocalizationCatalog;
|
|
5
|
+
/** @deprecated use catalog; retained for the 0.x compiler adapter. */
|
|
6
|
+
messages: never[];
|
|
5
7
|
tags: EditorialTags;
|
|
6
8
|
}>;
|
package/dist/load.mjs
CHANGED
|
@@ -1,28 +1,82 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { compareCodePoints, parseCatalogShardText, } from '@vouchington/localization';
|
|
4
|
-
import {
|
|
3
|
+
import { catalogFromMessages, compareCodePoints, parseCatalogShardText, } from '@vouchington/localization';
|
|
4
|
+
import { sortedCatalog } from './catalog.mjs';
|
|
5
5
|
export async function loadCatalogDirectory(directory) {
|
|
6
|
-
const names = (await readdir(directory))
|
|
7
|
-
.filter((name) => name.endsWith('.json'))
|
|
8
|
-
.toSorted(compareCodePoints);
|
|
6
|
+
const names = (await readdir(directory)).toSorted(compareCodePoints);
|
|
9
7
|
if (names.length === 0)
|
|
10
8
|
throw new TypeError(`No catalog JSON files in "${directory}"`);
|
|
11
|
-
|
|
9
|
+
if (!names.some((name) => name.endsWith('.json')))
|
|
10
|
+
throw new TypeError(`No catalog JSON files in "${directory}"`);
|
|
11
|
+
const copies = await optionalRows(directory, 'copies.json');
|
|
12
|
+
const aliases = await optionalRows(directory, 'aliases.json');
|
|
13
|
+
const routeMembership = (await optionalRows(directory, 'routes.json')) ?? [];
|
|
14
|
+
const translations = copies !== undefined && aliases !== undefined ? await translationRows(directory) : {};
|
|
12
15
|
let tags = {};
|
|
16
|
+
const legacy = [];
|
|
13
17
|
for (const name of names) {
|
|
18
|
+
if (!name.endsWith('.json'))
|
|
19
|
+
continue;
|
|
20
|
+
if (name === 'copies.json' || name === 'aliases.json' || name === 'routes.json')
|
|
21
|
+
continue;
|
|
14
22
|
const text = await readFile(join(directory, name), 'utf8');
|
|
15
23
|
if (name === 'tags.json') {
|
|
16
24
|
tags = parseTags(JSON.parse(text));
|
|
17
25
|
continue;
|
|
18
26
|
}
|
|
19
|
-
|
|
27
|
+
if (copies === undefined || aliases === undefined)
|
|
28
|
+
legacy.push(...parseCatalogShardText(text));
|
|
29
|
+
}
|
|
30
|
+
if (copies === undefined || aliases === undefined) {
|
|
31
|
+
if (legacy.length === 0)
|
|
32
|
+
throw new TypeError(`No catalog messages in "${directory}"`);
|
|
33
|
+
return {
|
|
34
|
+
catalog: sortedCatalog({ ...catalogFromMessages(legacy), tags }),
|
|
35
|
+
messages: legacy,
|
|
36
|
+
tags,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const catalog = sortedCatalog({
|
|
40
|
+
copies: copies,
|
|
41
|
+
aliases: aliases,
|
|
42
|
+
translations: translations,
|
|
43
|
+
routeMembership: routeMembership,
|
|
44
|
+
tags,
|
|
45
|
+
});
|
|
46
|
+
return { catalog, messages: [], tags };
|
|
47
|
+
}
|
|
48
|
+
async function translationRows(directory) {
|
|
49
|
+
const path = join(directory, 'translations');
|
|
50
|
+
let names;
|
|
51
|
+
try {
|
|
52
|
+
names = await readdir(path);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT')
|
|
56
|
+
throw new TypeError('Catalog is missing translations/');
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const translations = {};
|
|
60
|
+
for (const name of names.filter((name) => name.endsWith('.json')).toSorted(compareCodePoints)) {
|
|
61
|
+
const value = JSON.parse(await readFile(join(path, name), 'utf8'));
|
|
62
|
+
if (!Array.isArray(value))
|
|
63
|
+
throw new TypeError(`translations/${name} must be a JSON array`);
|
|
64
|
+
translations[name.slice(0, -5)] = value;
|
|
65
|
+
}
|
|
66
|
+
return translations;
|
|
67
|
+
}
|
|
68
|
+
async function optionalRows(directory, name) {
|
|
69
|
+
try {
|
|
70
|
+
const value = JSON.parse(await readFile(join(directory, name), 'utf8'));
|
|
71
|
+
if (!Array.isArray(value))
|
|
72
|
+
throw new TypeError(`${name} must be a JSON array`);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === 'ENOENT')
|
|
77
|
+
return undefined;
|
|
78
|
+
throw error;
|
|
20
79
|
}
|
|
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
80
|
}
|
|
27
81
|
function parseTags(value) {
|
|
28
82
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -35,10 +89,3 @@ function parseTags(value) {
|
|
|
35
89
|
return [id, [...new Set(tags)].toSorted(compareCodePoints)];
|
|
36
90
|
}));
|
|
37
91
|
}
|
|
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
|
-
}
|
package/dist/resolve.mjs
CHANGED
|
@@ -3,22 +3,22 @@ import { DEFAULT_TTL_SECONDS } from './schema.mjs';
|
|
|
3
3
|
export function resolveLocalizationBatch(database, request, options = {}) {
|
|
4
4
|
const normalized = normalizeLocalizationRequest(request, options.availableLocales, options.bounds);
|
|
5
5
|
const rows = loadRows(database, normalized.consumer, normalized.selectors);
|
|
6
|
-
const
|
|
6
|
+
const byAlias = new Map();
|
|
7
7
|
for (const row of rows) {
|
|
8
|
-
const current =
|
|
8
|
+
const current = byAlias.get(row.alias) ?? {
|
|
9
9
|
descriptor: parseDescriptor(JSON.parse(row.descriptor_json)),
|
|
10
10
|
translations: {},
|
|
11
11
|
};
|
|
12
12
|
current.translations[row.locale] = JSON.parse(row.value_json);
|
|
13
|
-
|
|
13
|
+
byAlias.set(row.alias, current);
|
|
14
14
|
}
|
|
15
15
|
const messages = {};
|
|
16
|
-
for (const
|
|
17
|
-
const entry =
|
|
16
|
+
for (const alias of [...byAlias.keys()].toSorted(compareCodePoints)) {
|
|
17
|
+
const entry = byAlias.get(alias);
|
|
18
18
|
const value = firstAvailableTranslation(normalized.locales, entry.translations);
|
|
19
19
|
if (value === undefined)
|
|
20
20
|
continue;
|
|
21
|
-
messages[
|
|
21
|
+
messages[alias] = leafForTranslation(entry.descriptor, value);
|
|
22
22
|
}
|
|
23
23
|
assertMessageCount(Object.keys(messages).length, options.bounds ?? DEFAULT_LOCALIZATION_BOUNDS);
|
|
24
24
|
const batch = createLocalizationBatch(database.revision, options.ttlSeconds ?? DEFAULT_TTL_SECONDS, messages);
|
|
@@ -27,8 +27,8 @@ export function resolveLocalizationBatch(database, request, options = {}) {
|
|
|
27
27
|
}
|
|
28
28
|
export function explainLocalizationPlan(database, selector) {
|
|
29
29
|
const sql = selector.kind === 'exact'
|
|
30
|
-
? `EXPLAIN QUERY PLAN SELECT
|
|
31
|
-
: `EXPLAIN QUERY PLAN SELECT
|
|
30
|
+
? `EXPLAIN QUERY PLAN SELECT a.alias FROM consumer_aliases a WHERE a.consumer = 'web' AND a.alias = ?`
|
|
31
|
+
: `EXPLAIN QUERY PLAN SELECT r.alias FROM route_membership r WHERE r.consumer = 'web' AND r.selector_id >= ? AND r.selector_id < ?`;
|
|
32
32
|
const statement = database.sqlite.prepare(sql);
|
|
33
33
|
const rows = selector.kind === 'exact'
|
|
34
34
|
? statement.all(selector.id)
|
|
@@ -36,21 +36,28 @@ export function explainLocalizationPlan(database, selector) {
|
|
|
36
36
|
return rows.map((row) => canonicalJson(row)).join('\n');
|
|
37
37
|
}
|
|
38
38
|
function loadRows(database, consumer, selectors) {
|
|
39
|
-
const exact = database.sqlite.prepare(`SELECT
|
|
40
|
-
FROM
|
|
41
|
-
JOIN
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
FROM
|
|
46
|
-
JOIN
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
const exact = database.sqlite.prepare(`SELECT DISTINCT a.alias, t.locale, c.descriptor_json, t.value_json
|
|
40
|
+
FROM consumer_aliases a JOIN copies c ON c.id = a.copy_id
|
|
41
|
+
JOIN translations t ON t.copy_id = c.id
|
|
42
|
+
WHERE a.consumer = ? AND a.alias = ?
|
|
43
|
+
UNION
|
|
44
|
+
SELECT a.alias, t.locale, c.descriptor_json, t.value_json
|
|
45
|
+
FROM route_membership r JOIN consumer_aliases a ON a.consumer = r.consumer AND a.alias = r.alias
|
|
46
|
+
JOIN copies c ON c.id = a.copy_id JOIN translations t ON t.copy_id = c.id
|
|
47
|
+
WHERE r.consumer = ? AND r.selector_id = ?`);
|
|
48
|
+
const prefix = database.sqlite.prepare(`SELECT DISTINCT a.alias, t.locale, c.descriptor_json, t.value_json
|
|
49
|
+
FROM route_membership r JOIN consumer_aliases a ON a.consumer = r.consumer AND a.alias = r.alias
|
|
50
|
+
JOIN copies c ON c.id = a.copy_id JOIN translations t ON t.copy_id = c.id
|
|
51
|
+
WHERE r.consumer = ? AND r.selector_id >= ? AND r.selector_id < ?
|
|
52
|
+
UNION
|
|
53
|
+
SELECT a.alias, t.locale, c.descriptor_json, t.value_json
|
|
54
|
+
FROM consumer_aliases a JOIN copies c ON c.id = a.copy_id JOIN translations t ON t.copy_id = c.id
|
|
55
|
+
WHERE a.consumer = ? AND a.alias >= ? AND a.alias < ?`);
|
|
49
56
|
const rows = [];
|
|
50
57
|
for (const selector of selectors) {
|
|
51
58
|
const found = selector.kind === 'exact'
|
|
52
|
-
? exact.all(consumer, selector.id)
|
|
53
|
-
: prefix.all(consumer, ...prefixRange(selector.prefix));
|
|
59
|
+
? exact.all(consumer, selector.id, consumer, selector.id)
|
|
60
|
+
: prefix.all(consumer, ...prefixRange(selector.prefix), consumer, ...prefixRange(selector.prefix));
|
|
54
61
|
rows.push(...found);
|
|
55
62
|
}
|
|
56
63
|
return rows;
|
package/dist/revision.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { type CatalogMessage } from '@vouchington/localization';
|
|
2
|
-
export declare function catalogRevision(
|
|
1
|
+
import { type CatalogMessage, type LocalizationCatalog } from '@vouchington/localization';
|
|
2
|
+
export declare function catalogRevision(source: LocalizationCatalog | readonly CatalogMessage[]): string;
|
package/dist/revision.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
export function catalogRevision(
|
|
4
|
-
|
|
2
|
+
import { canonicalJson, catalogFromMessages, } from '@vouchington/localization';
|
|
3
|
+
export function catalogRevision(source) {
|
|
4
|
+
const catalog = Array.isArray(source) ? catalogFromMessages(source) : source;
|
|
5
|
+
return createHash('sha256').update(canonicalJson(catalog)).digest('hex');
|
|
5
6
|
}
|
package/dist/schema.d.mts
CHANGED
|
@@ -1,3 +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
|
|
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 copies (\n id TEXT PRIMARY KEY NOT NULL,\n descriptor_json TEXT NOT NULL\n);\nCREATE TABLE translations (\n locale TEXT NOT NULL,\n copy_id TEXT NOT NULL,\n value_json TEXT NOT NULL,\n PRIMARY KEY (locale, copy_id),\n FOREIGN KEY (copy_id) REFERENCES copies(id)\n);\nCREATE TABLE consumer_aliases (\n consumer TEXT NOT NULL,\n alias TEXT NOT NULL,\n copy_id TEXT NOT NULL,\n PRIMARY KEY (consumer, alias),\n FOREIGN KEY (copy_id) REFERENCES copies(id)\n);\nCREATE TABLE route_membership (\n consumer TEXT NOT NULL,\n selector_id TEXT NOT NULL,\n alias TEXT NOT NULL,\n PRIMARY KEY (consumer, selector_id, alias),\n FOREIGN KEY (consumer, alias) REFERENCES consumer_aliases(consumer, alias)\n);\nCREATE TABLE editorial_tags (\n copy_id TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (copy_id, tag),\n FOREIGN KEY (copy_id) REFERENCES copies(id)\n);\nCREATE INDEX translations_by_locale_id ON translations (locale, copy_id);\nCREATE INDEX aliases_by_consumer_alias ON consumer_aliases (consumer, alias);\nCREATE INDEX routes_by_consumer_selector ON route_membership (consumer, selector_id);\n";
|
|
2
2
|
export declare const DEFAULT_SQLITE_CACHE_KB = 2048;
|
|
3
3
|
export declare const DEFAULT_TTL_SECONDS = 86400;
|
package/dist/schema.mjs
CHANGED
|
@@ -5,32 +5,40 @@ CREATE TABLE metadata (
|
|
|
5
5
|
key TEXT PRIMARY KEY NOT NULL CHECK (key IN ('contract', 'revision')),
|
|
6
6
|
value TEXT NOT NULL
|
|
7
7
|
);
|
|
8
|
-
CREATE TABLE
|
|
8
|
+
CREATE TABLE copies (
|
|
9
9
|
id TEXT PRIMARY KEY NOT NULL,
|
|
10
10
|
descriptor_json TEXT NOT NULL
|
|
11
11
|
);
|
|
12
12
|
CREATE TABLE translations (
|
|
13
13
|
locale TEXT NOT NULL,
|
|
14
|
-
|
|
14
|
+
copy_id TEXT NOT NULL,
|
|
15
15
|
value_json TEXT NOT NULL,
|
|
16
|
-
PRIMARY KEY (locale,
|
|
17
|
-
FOREIGN KEY (
|
|
16
|
+
PRIMARY KEY (locale, copy_id),
|
|
17
|
+
FOREIGN KEY (copy_id) REFERENCES copies(id)
|
|
18
18
|
);
|
|
19
|
-
CREATE TABLE
|
|
19
|
+
CREATE TABLE consumer_aliases (
|
|
20
20
|
consumer TEXT NOT NULL,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
alias TEXT NOT NULL,
|
|
22
|
+
copy_id TEXT NOT NULL,
|
|
23
|
+
PRIMARY KEY (consumer, alias),
|
|
24
|
+
FOREIGN KEY (copy_id) REFERENCES copies(id)
|
|
25
|
+
);
|
|
26
|
+
CREATE TABLE route_membership (
|
|
27
|
+
consumer TEXT NOT NULL,
|
|
28
|
+
selector_id TEXT NOT NULL,
|
|
29
|
+
alias TEXT NOT NULL,
|
|
30
|
+
PRIMARY KEY (consumer, selector_id, alias),
|
|
31
|
+
FOREIGN KEY (consumer, alias) REFERENCES consumer_aliases(consumer, alias)
|
|
24
32
|
);
|
|
25
33
|
CREATE TABLE editorial_tags (
|
|
26
|
-
|
|
34
|
+
copy_id TEXT NOT NULL,
|
|
27
35
|
tag TEXT NOT NULL,
|
|
28
|
-
PRIMARY KEY (
|
|
29
|
-
FOREIGN KEY (
|
|
36
|
+
PRIMARY KEY (copy_id, tag),
|
|
37
|
+
FOREIGN KEY (copy_id) REFERENCES copies(id)
|
|
30
38
|
);
|
|
31
|
-
CREATE INDEX translations_by_locale_id ON translations (locale,
|
|
32
|
-
CREATE INDEX
|
|
33
|
-
CREATE INDEX
|
|
39
|
+
CREATE INDEX translations_by_locale_id ON translations (locale, copy_id);
|
|
40
|
+
CREATE INDEX aliases_by_consumer_alias ON consumer_aliases (consumer, alias);
|
|
41
|
+
CREATE INDEX routes_by_consumer_selector ON route_membership (consumer, selector_id);
|
|
34
42
|
`;
|
|
35
43
|
export const DEFAULT_SQLITE_CACHE_KB = 2048;
|
|
36
44
|
export const DEFAULT_TTL_SECONDS = 86_400;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function isTablePath(path: string): boolean;
|
|
2
|
+
export declare function upsertTable(path: string, rowJson: string): undefined;
|
|
3
|
+
export declare function removeTable(path: string, id: string, consumer: string | undefined): undefined;
|
|
4
|
+
export declare function mergeTableFiles(ancestor: string, ours: string, theirs: string): void;
|
|
5
|
+
export declare function readTable(path: string): Record<string, unknown>[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { CatalogMergeConflict, canonicalJson, serializeCatalogTable, } from '@vouchington/localization';
|
|
4
|
+
const CONFLICT = Symbol('conflict');
|
|
5
|
+
export function isTablePath(path) {
|
|
6
|
+
return /(?:copies|aliases|routes)\.json$/.test(path) || /translations\/[^/]+\.json$/.test(path);
|
|
7
|
+
}
|
|
8
|
+
export function upsertTable(path, rowJson) {
|
|
9
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
10
|
+
const row = JSON.parse(rowJson);
|
|
11
|
+
const rows = readTable(path);
|
|
12
|
+
const key = tableKey(row);
|
|
13
|
+
writeFileSync(path, serializeCatalogTable([...rows.filter((current) => tableKey(current) !== key), row]));
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
export function removeTable(path, id, consumer) {
|
|
17
|
+
const rows = readTable(path);
|
|
18
|
+
const next = rows.filter((row) => !(row.id === id ||
|
|
19
|
+
(row.alias === id && (consumer === undefined || row.consumer === consumer))));
|
|
20
|
+
if (next.length === rows.length)
|
|
21
|
+
throw new TypeError(`Catalog table does not contain "${id}"`);
|
|
22
|
+
writeFileSync(path, serializeCatalogTable(next));
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function mergeTableFiles(ancestor, ours, theirs) {
|
|
26
|
+
const base = new Map(readTable(ancestor).map((row) => [tableKey(row), row]));
|
|
27
|
+
const left = new Map(readTable(ours).map((row) => [tableKey(row), row]));
|
|
28
|
+
const right = new Map(readTable(theirs).map((row) => [tableKey(row), row]));
|
|
29
|
+
const merged = [];
|
|
30
|
+
const conflicts = [];
|
|
31
|
+
for (const key of [...new Set([...base.keys(), ...left.keys(), ...right.keys()])].sort()) {
|
|
32
|
+
const value = mergeValue(base.get(key), left.get(key), right.get(key));
|
|
33
|
+
if (value === CONFLICT)
|
|
34
|
+
conflicts.push(key);
|
|
35
|
+
else if (value !== undefined)
|
|
36
|
+
merged.push(value);
|
|
37
|
+
}
|
|
38
|
+
if (conflicts.length > 0) {
|
|
39
|
+
const text = conflicts.map((key) => conflictText(left.get(key), right.get(key))).join('\n');
|
|
40
|
+
writeFileSync(ours, `${text}\n`);
|
|
41
|
+
throw new CatalogMergeConflict(conflicts, text);
|
|
42
|
+
}
|
|
43
|
+
writeFileSync(ours, serializeCatalogTable(merged));
|
|
44
|
+
}
|
|
45
|
+
export function readTable(path) {
|
|
46
|
+
if (!existsSync(path))
|
|
47
|
+
return [];
|
|
48
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
49
|
+
if (!Array.isArray(value))
|
|
50
|
+
throw new TypeError(`${path} must be a JSON array`);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
function mergeValue(base, ours, theirs) {
|
|
54
|
+
if (same(ours, theirs))
|
|
55
|
+
return ours;
|
|
56
|
+
if (same(ours, base))
|
|
57
|
+
return theirs;
|
|
58
|
+
if (same(theirs, base))
|
|
59
|
+
return ours;
|
|
60
|
+
return CONFLICT;
|
|
61
|
+
}
|
|
62
|
+
function conflictText(ours, theirs) {
|
|
63
|
+
return [
|
|
64
|
+
'<<<<<<< ours',
|
|
65
|
+
ours === undefined ? '' : canonicalJson(ours),
|
|
66
|
+
'=======',
|
|
67
|
+
theirs === undefined ? '' : canonicalJson(theirs),
|
|
68
|
+
'>>>>>>> theirs',
|
|
69
|
+
]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join('\n');
|
|
72
|
+
}
|
|
73
|
+
function same(left, right) {
|
|
74
|
+
if (left === undefined || right === undefined)
|
|
75
|
+
return left === right;
|
|
76
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
77
|
+
}
|
|
78
|
+
function tableKey(row) {
|
|
79
|
+
return ['consumer', 'selectorId', 'alias', 'id']
|
|
80
|
+
.map((key) => (typeof row[key] === 'string' ? row[key] : ''))
|
|
81
|
+
.join('\t');
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/localization-compiler",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Node compiler for localization JSON catalogs, immutable SQLite artifacts, and CLI resolution.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/localization-compiler#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@vouchington/csv": "^0.0.1",
|
|
39
|
-
"@vouchington/localization": "^0.0
|
|
39
|
+
"@vouchington/localization": "^0.1.0"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"node": ">=24.0.0"
|