@vouchington/localization 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,5 +5,12 @@ normalization (`en` aliases `en-US`), exact and terminal-prefix selector validat
5
5
  fallback, consumer membership, and deterministic catalog serialization. It does not load catalogs,
6
6
  open SQLite, or interpolate message text.
7
7
 
8
- `@vouchington/localization-compiler` compiles namespace-sharded JSON into an immutable SQLite
9
- artifact and resolves the same selectors locally.
8
+ Catalog shards on disk are a JSON array with **one compact message object per line**. `id` is the
9
+ first key so git and line editors can add, remove, or update a message without parsing the file.
10
+ `parseCatalogShardText` rejects pretty-printed JSON, `{ messages }` wrappers, and unsorted ids.
11
+
12
+ `@vouchington/localization-compiler` compiles those shards into an immutable SQLite artifact,
13
+ resolves the same selectors locally, and ships `upsert` / `remove` / `git-merge` for the line
14
+ format. `git-merge` is 3-way: union independent ids, then merge consumers, descriptor, and
15
+ each locale. Same-locale edits conflict and write `<<<<<<< ours` markers; adding `es` on one
16
+ side and `fr` on the other does not.
@@ -1,3 +1,7 @@
1
1
  import type { CatalogMessage } from './types.mts';
2
2
  export declare function serializeCatalogMessages(messages: readonly CatalogMessage[]): string;
3
+ export declare function serializeCatalogLine(message: CatalogMessage): string;
4
+ export declare function serializeCatalogShard(messages: readonly CatalogMessage[]): string;
5
+ export declare function serializeCatalogShardFromLines(lines: readonly string[]): string;
6
+ export declare function catalogLineId(line: string): string;
3
7
  export declare function catalogMessageFromRecord(value: unknown): CatalogMessage;
package/dist/catalog.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { uniqueConsumers } from './consumers.mjs';
2
+ import { compareCodePoints } from './compare.mjs';
2
3
  import { parseDescriptor } from './descriptors.mjs';
3
4
  import { canonicalJson } from './serialize.mjs';
4
5
  import { isMessageId } from './selectors.mjs';
6
+ const ID_PREFIX = '{"id":"';
5
7
  export function serializeCatalogMessages(messages) {
6
8
  return canonicalJson([...messages]
7
- .toSorted((left, right) => (left.id < right.id ? -1 : 1))
9
+ .toSorted((left, right) => compareCodePoints(left.id, right.id))
8
10
  .map((message) => ({
9
11
  consumers: uniqueConsumers(message.consumers),
10
12
  descriptor: message.descriptor,
@@ -12,6 +14,36 @@ export function serializeCatalogMessages(messages) {
12
14
  translations: message.translations,
13
15
  })));
14
16
  }
17
+ export function serializeCatalogLine(message) {
18
+ const normalized = catalogMessageFromRecord(message);
19
+ // id first for line edits; not canonicalJson key order (revision hashing uses that).
20
+ return `{"id":${canonicalJson(normalized.id)},"consumers":${canonicalJson(normalized.consumers)},"descriptor":${canonicalJson(normalized.descriptor)},"translations":${canonicalJson(normalized.translations)}}`;
21
+ }
22
+ export function serializeCatalogShard(messages) {
23
+ return serializeCatalogShardFromLines([...messages]
24
+ .toSorted((left, right) => compareCodePoints(left.id, right.id))
25
+ .map(serializeCatalogLine));
26
+ }
27
+ export function serializeCatalogShardFromLines(lines) {
28
+ if (lines.length === 0)
29
+ return '[]\n';
30
+ for (const line of lines) {
31
+ if (line.includes('\n') || line.includes('\r')) {
32
+ throw new TypeError('Catalog line must not contain newlines');
33
+ }
34
+ }
35
+ return `[\n${lines.map((line, index) => (index < lines.length - 1 ? `${line},` : line)).join('\n')}\n]\n`;
36
+ }
37
+ export function catalogLineId(line) {
38
+ const body = line.endsWith(',') ? line.slice(0, -1) : line;
39
+ if (!body.startsWith(ID_PREFIX)) {
40
+ throw new TypeError('Catalog line must start with {"id":');
41
+ }
42
+ const end = body.indexOf('"', ID_PREFIX.length);
43
+ if (end === -1)
44
+ throw new TypeError('Catalog line is missing a message id');
45
+ return body.slice(ID_PREFIX.length, end);
46
+ }
15
47
  export function catalogMessageFromRecord(value) {
16
48
  if (!isPlainObject(value) || typeof value.id !== 'string' || !isMessageId(value.id)) {
17
49
  throw new TypeError('Catalog message is missing a valid id');
@@ -2,5 +2,6 @@ import { type MessageDescriptor, type PluralForms, type TranslationValue } from
2
2
  export declare function isPluralForms(value: unknown): value is PluralForms;
3
3
  export declare function isSelectPluralCases(value: unknown): value is Readonly<Record<string, PluralForms>>;
4
4
  export declare function isTranslationValue(value: unknown): value is TranslationValue;
5
+ export declare function translationMatchesDescriptor(descriptor: MessageDescriptor | null, value: unknown): boolean;
5
6
  export declare function descriptorSignature(descriptor: MessageDescriptor): string;
6
7
  export declare function parseDescriptor(value: unknown): MessageDescriptor | null;
@@ -14,6 +14,15 @@ export function isSelectPluralCases(value) {
14
14
  export function isTranslationValue(value) {
15
15
  return typeof value === 'string' || isPluralForms(value) || isSelectPluralCases(value);
16
16
  }
17
+ export function translationMatchesDescriptor(descriptor, value) {
18
+ if (descriptor === null)
19
+ return typeof value === 'string';
20
+ if (descriptor.kind === 'plural')
21
+ return isPluralForms(value);
22
+ return (isSelectPluralCases(value) &&
23
+ descriptorSignature({ ...descriptor, cases: Object.keys(value) }) ===
24
+ descriptorSignature(descriptor));
25
+ }
17
26
  export function descriptorSignature(descriptor) {
18
27
  return JSON.stringify({
19
28
  kind: descriptor.kind,
package/dist/index.d.mts CHANGED
@@ -7,8 +7,11 @@ export { dedupeSelectors, isMessageId, parseSelector, prefixRange, selectorMatch
7
7
  export { assertMessageCount, assertPayloadBytes, normalizeLocalizationRequest } from './request.mts';
8
8
  export { firstAvailableTranslation, leafForTranslation, selectedIds } from './selection.mts';
9
9
  export { assertSamePlaceholders, placeholdersIn, uniquePlaceholders } from './placeholders.mts';
10
- export { descriptorSignature, isPluralForms, isSelectPluralCases, isTranslationValue, parseDescriptor, } from './descriptors.mts';
10
+ export { descriptorSignature, isPluralForms, isSelectPluralCases, isTranslationValue, parseDescriptor, translationMatchesDescriptor, } from './descriptors.mts';
11
11
  export { canonicalJson } from './serialize.mts';
12
12
  export { compareCodePoints } from './compare.mts';
13
- export { catalogMessageFromRecord, serializeCatalogMessages } from './catalog.mts';
13
+ export { catalogLineId, catalogMessageFromRecord, serializeCatalogLine, serializeCatalogMessages, serializeCatalogShard, serializeCatalogShardFromLines, } from './catalog.mts';
14
+ export { catalogShardLines, parseCatalogShardText } from './shard-text.mts';
15
+ export { removeCatalogLine, upsertCatalogLine } from './shard-edit.mts';
16
+ export { CatalogMergeConflict, mergeCatalogShards } from './shard-merge.mts';
14
17
  export { createLocalizationBatch, etagMatches, localizationEtag, serializeLocalizationBatch, } from './wire.mts';
package/dist/index.mjs CHANGED
@@ -6,8 +6,11 @@ export { dedupeSelectors, isMessageId, parseSelector, prefixRange, selectorMatch
6
6
  export { assertMessageCount, assertPayloadBytes, normalizeLocalizationRequest } from './request.mjs';
7
7
  export { firstAvailableTranslation, leafForTranslation, selectedIds } from './selection.mjs';
8
8
  export { assertSamePlaceholders, placeholdersIn, uniquePlaceholders } from './placeholders.mjs';
9
- export { descriptorSignature, isPluralForms, isSelectPluralCases, isTranslationValue, parseDescriptor, } from './descriptors.mjs';
9
+ export { descriptorSignature, isPluralForms, isSelectPluralCases, isTranslationValue, parseDescriptor, translationMatchesDescriptor, } from './descriptors.mjs';
10
10
  export { canonicalJson } from './serialize.mjs';
11
11
  export { compareCodePoints } from './compare.mjs';
12
- export { catalogMessageFromRecord, serializeCatalogMessages } from './catalog.mjs';
12
+ export { catalogLineId, catalogMessageFromRecord, serializeCatalogLine, serializeCatalogMessages, serializeCatalogShard, serializeCatalogShardFromLines, } from './catalog.mjs';
13
+ export { catalogShardLines, parseCatalogShardText } from './shard-text.mjs';
14
+ export { removeCatalogLine, upsertCatalogLine } from './shard-edit.mjs';
15
+ export { CatalogMergeConflict, mergeCatalogShards } from './shard-merge.mjs';
13
16
  export { createLocalizationBatch, etagMatches, localizationEtag, serializeLocalizationBatch, } from './wire.mjs';
@@ -0,0 +1,2 @@
1
+ export declare function upsertCatalogLine(shardText: string, messageJson: string): string;
2
+ export declare function removeCatalogLine(shardText: string, id: string): string;
@@ -0,0 +1,20 @@
1
+ import { catalogLineId, catalogMessageFromRecord, serializeCatalogLine, serializeCatalogShardFromLines, } from './catalog.mjs';
2
+ import { compareCodePoints } from './compare.mjs';
3
+ import { catalogShardLines } from './shard-text.mjs';
4
+ export function upsertCatalogLine(shardText, messageJson) {
5
+ const line = serializeCatalogLine(catalogMessageFromRecord(JSON.parse(messageJson)));
6
+ const id = catalogLineId(line);
7
+ const next = catalogShardLines(shardText).filter((current) => catalogLineId(current) !== id);
8
+ next.push(line);
9
+ return serializeCatalogShardFromLines(sortedLines(next));
10
+ }
11
+ export function removeCatalogLine(shardText, id) {
12
+ const lines = catalogShardLines(shardText);
13
+ const next = lines.filter((line) => catalogLineId(line) !== id);
14
+ if (next.length === lines.length)
15
+ throw new TypeError(`Catalog does not contain "${id}"`);
16
+ return serializeCatalogShardFromLines(sortedLines(next));
17
+ }
18
+ function sortedLines(lines) {
19
+ return [...lines].toSorted((left, right) => compareCodePoints(catalogLineId(left), catalogLineId(right)));
20
+ }
@@ -0,0 +1,6 @@
1
+ export declare class CatalogMergeConflict extends Error {
2
+ readonly ids: readonly string[];
3
+ readonly text: string;
4
+ constructor(ids: readonly string[], text: string);
5
+ }
6
+ export declare function mergeCatalogShards(ancestor: string, ours: string, theirs: string): string;
@@ -0,0 +1,147 @@
1
+ import { catalogLineId, catalogMessageFromRecord, serializeCatalogLine, serializeCatalogShardFromLines, } from './catalog.mjs';
2
+ import { compareCodePoints } from './compare.mjs';
3
+ import { uniqueConsumers } from './consumers.mjs';
4
+ import { canonicalJson } from './serialize.mjs';
5
+ import { catalogShardLines } from './shard-text.mjs';
6
+ import { translationMatchesDescriptor } from './descriptors.mjs';
7
+ import { LOCALIZATION_CONSUMERS } from './types.mjs';
8
+ const CONFLICT = Symbol('conflict');
9
+ export class CatalogMergeConflict extends Error {
10
+ ids;
11
+ text;
12
+ constructor(ids, text) {
13
+ super(`Catalog merge conflict for ${ids.map((id) => `"${id}"`).join(', ')}`);
14
+ this.name = 'CatalogMergeConflict';
15
+ this.ids = ids;
16
+ this.text = text;
17
+ }
18
+ }
19
+ export function mergeCatalogShards(ancestor, ours, theirs) {
20
+ const base = lineMap(ancestor);
21
+ const left = lineMap(ours);
22
+ const right = lineMap(theirs);
23
+ const ids = [...new Set([...base.keys(), ...left.keys(), ...right.keys()])].toSorted(compareCodePoints);
24
+ const lines = [];
25
+ const entries = [];
26
+ const conflicts = [];
27
+ for (const id of ids) {
28
+ const oursLine = left.get(id);
29
+ const theirsLine = right.get(id);
30
+ const kept = mergeLine(base.get(id), oursLine, theirsLine);
31
+ if (kept === false) {
32
+ conflicts.push(id);
33
+ entries.push({ ours: oursLine, theirs: theirsLine });
34
+ }
35
+ else if (kept !== undefined) {
36
+ lines.push(kept);
37
+ entries.push(kept);
38
+ }
39
+ }
40
+ if (conflicts.length > 0)
41
+ throw new CatalogMergeConflict(conflicts, serializeConflicted(entries));
42
+ return serializeCatalogShardFromLines(lines);
43
+ }
44
+ function lineMap(text) {
45
+ const map = new Map();
46
+ for (const line of catalogShardLines(text)) {
47
+ const id = catalogLineId(line);
48
+ if (map.has(id))
49
+ throw new TypeError(`Duplicate message id "${id}"`);
50
+ map.set(id, line);
51
+ }
52
+ return map;
53
+ }
54
+ function serializeConflicted(entries) {
55
+ const chunks = ['['];
56
+ for (const [index, entry] of entries.entries()) {
57
+ const suffix = index < entries.length - 1 ? ',' : '';
58
+ if (typeof entry === 'string') {
59
+ chunks.push(`${entry}${suffix}`);
60
+ continue;
61
+ }
62
+ chunks.push('<<<<<<< ours');
63
+ if (entry.ours !== undefined)
64
+ chunks.push(`${entry.ours}${suffix}`);
65
+ chunks.push('=======');
66
+ if (entry.theirs !== undefined)
67
+ chunks.push(`${entry.theirs}${suffix}`);
68
+ chunks.push('>>>>>>> theirs');
69
+ }
70
+ chunks.push(']');
71
+ return `${chunks.join('\n')}\n`;
72
+ }
73
+ function mergeLine(ancestor, ours, theirs) {
74
+ if (ours === theirs)
75
+ return ours;
76
+ if (ours === ancestor)
77
+ return theirs;
78
+ if (theirs === ancestor)
79
+ return ours;
80
+ if (ours === undefined || theirs === undefined)
81
+ return false;
82
+ return mergeEditedLines(ancestor, ours, theirs);
83
+ }
84
+ function mergeEditedLines(ancestor, ours, theirs) {
85
+ const base = ancestor === undefined ? emptyDraft(ours) : messageFromLine(ancestor);
86
+ const left = messageFromLine(ours);
87
+ const right = messageFromLine(theirs);
88
+ const consumers = mergeConsumers(base.consumers, left.consumers, right.consumers);
89
+ const descriptor = mergeValue(base.descriptor, left.descriptor, right.descriptor);
90
+ const translations = mergeTranslations(base.translations, left.translations, right.translations);
91
+ if (descriptor === CONFLICT || translations === CONFLICT)
92
+ return false;
93
+ if (!Object.values(translations).every((value) => translationMatchesDescriptor(descriptor, value))) {
94
+ return false;
95
+ }
96
+ try {
97
+ return serializeCatalogLine(catalogMessageFromRecord({ id: left.id, consumers, descriptor, translations }));
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ function emptyDraft(line) {
104
+ return { id: catalogLineId(line), descriptor: null, consumers: [], translations: {} };
105
+ }
106
+ function messageFromLine(line) {
107
+ return catalogMessageFromRecord(JSON.parse(line));
108
+ }
109
+ function mergeConsumers(ancestor, ours, theirs) {
110
+ const kept = [];
111
+ for (const consumer of LOCALIZATION_CONSUMERS) {
112
+ if (mergeValue(ancestor.includes(consumer), ours.includes(consumer), theirs.includes(consumer)) === true) {
113
+ kept.push(consumer);
114
+ }
115
+ }
116
+ return uniqueConsumers(kept);
117
+ }
118
+ function mergeTranslations(ancestor, ours, theirs) {
119
+ const locales = new Set([...Object.keys(ancestor), ...Object.keys(ours), ...Object.keys(theirs)]);
120
+ const translations = {};
121
+ for (const locale of [...locales].toSorted(compareCodePoints)) {
122
+ const value = mergeValue(ancestor[locale], ours[locale], theirs[locale]);
123
+ if (value === CONFLICT)
124
+ return CONFLICT;
125
+ if (value !== undefined)
126
+ translations[locale] = value;
127
+ }
128
+ return translations;
129
+ }
130
+ function mergeValue(ancestor, ours, theirs) {
131
+ if (same(ours, theirs))
132
+ return ours;
133
+ if (same(ours, ancestor))
134
+ return theirs;
135
+ if (same(theirs, ancestor))
136
+ return ours;
137
+ return CONFLICT;
138
+ }
139
+ function same(left, right) {
140
+ if (left === right)
141
+ return true;
142
+ if (left === undefined || right === undefined || left === null || right === null)
143
+ return false;
144
+ if (typeof left !== 'object' || typeof right !== 'object')
145
+ return false;
146
+ return canonicalJson(left) === canonicalJson(right);
147
+ }
@@ -0,0 +1,3 @@
1
+ import type { CatalogMessage } from './types.mts';
2
+ export declare function catalogShardLines(text: string): string[];
3
+ export declare function parseCatalogShardText(text: string): CatalogMessage[];
@@ -0,0 +1,48 @@
1
+ import { catalogLineId, catalogMessageFromRecord, serializeCatalogLine } from './catalog.mjs';
2
+ import { compareCodePoints } from './compare.mjs';
3
+ export function catalogShardLines(text) {
4
+ if (text === '[]\n' || text === '')
5
+ return [];
6
+ if (!text.startsWith('[\n') || !text.endsWith('\n]\n')) {
7
+ throw new TypeError(text.includes('\r')
8
+ ? 'Catalog shard must use LF line endings'
9
+ : 'Catalog shard must be a JSON array with one message per line');
10
+ }
11
+ const raw = text.slice(2, -3).split('\n');
12
+ if (raw.length === 1 && raw[0] === '') {
13
+ throw new TypeError('Empty catalog shard must be written []\n');
14
+ }
15
+ return raw.map((line, index) => {
16
+ const needsComma = index < raw.length - 1;
17
+ if (line.endsWith(',') !== needsComma) {
18
+ throw new TypeError('Catalog shard commas must appear on every line except the last');
19
+ }
20
+ const body = needsComma ? line.slice(0, -1) : line;
21
+ if (body.length === 0) {
22
+ throw new TypeError('Catalog shard messages must be exactly one line each');
23
+ }
24
+ catalogLineId(body);
25
+ return body;
26
+ });
27
+ }
28
+ export function parseCatalogShardText(text) {
29
+ const lines = catalogShardLines(text);
30
+ const messages = lines.map((line) => {
31
+ const message = catalogMessageFromRecord(JSON.parse(line));
32
+ const canonical = serializeCatalogLine(message);
33
+ if (canonical !== line) {
34
+ throw new TypeError(`Catalog line for "${message.id}" is not canonical; expected ${canonical}. Run format to rewrite.`);
35
+ }
36
+ return message;
37
+ });
38
+ for (let index = 1; index < messages.length; index++) {
39
+ const previous = messages[index - 1].id;
40
+ const current = messages[index].id;
41
+ if (previous === current)
42
+ throw new TypeError(`Duplicate message id "${current}"`);
43
+ if (compareCodePoints(previous, current) > 0) {
44
+ throw new TypeError('Catalog shard ids must be sorted');
45
+ }
46
+ }
47
+ return messages;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vouchington/localization",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Browser-safe localization catalog contracts, locale fallback, and selector validation.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/localization#readme",
6
6
  "bugs": {
@@ -31,11 +31,10 @@
31
31
  "publishConfig": {
32
32
  "access": "public"
33
33
  },
34
- "scripts": {
35
- "build": "tsc --project tsconfig.build.json",
36
- "prepack": "pnpm run build"
37
- },
38
34
  "engines": {
39
35
  "node": ">=24.0.0"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc --project tsconfig.build.json"
40
39
  }
41
- }
40
+ }