@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 CHANGED
@@ -1,15 +1,20 @@
1
1
  # @vouchington/localization-compiler
2
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.
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 shards must be a JSON array with one compact message per line (`id` first, sorted).
8
- `compile` rejects any other layout. Line tools never parse the shard as a JSON document:
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/common.json --message '{"id":"common.ok","consumers":["web"],"descriptor":null,"translations":{"en-US":"OK"}}'
12
- vouchington-localization remove --file localization/catalog/common.json --id common.ok
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,3 @@
1
+ import { type LocalizationCatalog } from '@vouchington/localization';
2
+ export declare function exportCatalogCsv(catalog: LocalizationCatalog): string;
3
+ export declare function importCatalogCsv(csv: string, expectedRevision?: string): LocalizationCatalog;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import { type LocalizationCatalog } from '@vouchington/localization';
2
+ export declare function validateLocalizationCatalog(catalog: LocalizationCatalog): void;
3
+ export declare function sortedCatalog(catalog: LocalizationCatalog): LocalizationCatalog;
@@ -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
+ }
@@ -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
- const path = requiredPath(args, '--file');
8
- mkdirSync(dirname(path), { recursive: true });
9
- writeFileSync(path, upsertCatalogLine(readShard(path), required(args, '--message')));
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
- if (command === 'remove') {
13
- const path = requiredPath(args, '--file');
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
- if (command === 'git-merge') {
18
- const [ancestor, ours, theirs] = args;
19
- if (ancestor === undefined || ours === undefined || theirs === undefined) {
20
- throw new TypeError(shardUsage());
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> --message <json>',
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
- const path = join(directory, name);
48
- try {
49
- writeFileSync(path, serializeCatalogShard(messagesFromUnknownText(readFileSync(path, 'utf8'))));
50
- }
51
- catch (error) {
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 index = args.indexOf(flag);
59
- const value = index === -1 ? undefined : args[index + 1];
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, writeJsonCatalog } from './compile.mjs';
6
- import { exportLocalizationCsv, importLocalizationCsv } from './csv.mjs';
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.messages, required(rest, '--output'), loaded.tags));
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 = exportLocalizationCsv((await loadCatalogDirectory(required(rest, '--source'))).messages);
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
- writeJsonCatalog(importLocalizationCsv(await readFile(required(rest, '--input'), 'utf8')), resolve(output, 'imported.json'));
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 {
@@ -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(messages: readonly CatalogMessage[], outputPath: string, tags?: EditorialTags): string;
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, writeFileSync } from 'node:fs';
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, serializeCatalogShard, } from '@vouchington/localization';
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
- export function compileLocalizationSqlite(messages, outputPath, tags = {}) {
11
- validateCatalogMessages(messages);
12
- const revision = catalogRevision(messages);
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
- insertMessages(database, messages);
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
- export function writeJsonCatalog(messages, path) {
34
- writeFileSync(path, serializeCatalogShard(messages));
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 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) => compareCodePoints(left.id, right.id))) {
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);
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 CatalogMessage } from '@vouchington/localization';
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
- messages: CatalogMessage[];
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 { validateCatalogMessages } from './validate.mjs';
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
- const messages = [];
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
- messages.push(...parseCatalogShardText(text));
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 byId = new Map();
6
+ const byAlias = new Map();
7
7
  for (const row of rows) {
8
- const current = byId.get(row.id) ?? {
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
- byId.set(row.id, current);
13
+ byAlias.set(row.alias, current);
14
14
  }
15
15
  const messages = {};
16
- for (const id of [...byId.keys()].toSorted(compareCodePoints)) {
17
- const entry = byId.get(id);
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[id] = leafForTranslation(entry.descriptor, value);
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 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 < ?`;
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 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 < ?`);
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;
@@ -1,2 +1,2 @@
1
- import { type CatalogMessage } from '@vouchington/localization';
2
- export declare function catalogRevision(messages: readonly CatalogMessage[]): string;
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 { serializeCatalogMessages } from '@vouchington/localization';
3
- export function catalogRevision(messages) {
4
- return createHash('sha256').update(serializeCatalogMessages(messages)).digest('hex');
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 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";
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 messages (
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
- message_id TEXT NOT NULL,
14
+ copy_id TEXT NOT NULL,
15
15
  value_json TEXT NOT NULL,
16
- PRIMARY KEY (locale, message_id),
17
- FOREIGN KEY (message_id) REFERENCES messages(id)
16
+ PRIMARY KEY (locale, copy_id),
17
+ FOREIGN KEY (copy_id) REFERENCES copies(id)
18
18
  );
19
- CREATE TABLE consumer_membership (
19
+ CREATE TABLE consumer_aliases (
20
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)
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
- message_id TEXT NOT NULL,
34
+ copy_id TEXT NOT NULL,
27
35
  tag TEXT NOT NULL,
28
- PRIMARY KEY (message_id, tag),
29
- FOREIGN KEY (message_id) REFERENCES messages(id)
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, message_id);
32
- CREATE INDEX membership_by_consumer_id ON consumer_membership (consumer, message_id);
33
- CREATE INDEX messages_by_id ON messages (id);
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.2",
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.1"
39
+ "@vouchington/localization": "^0.1.0"
40
40
  },
41
41
  "engines": {
42
42
  "node": ">=24.0.0"