@_linked/translation 0.2.2

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.
Files changed (153) hide show
  1. package/README.md +102 -0
  2. package/lib/cjs/data/translation.json +8 -0
  3. package/lib/esm/archive.d.ts +133 -0
  4. package/lib/esm/archive.js +567 -0
  5. package/lib/esm/archive.js.map +1 -0
  6. package/lib/esm/authorization.d.ts +12 -0
  7. package/lib/esm/authorization.js +10 -0
  8. package/lib/esm/authorization.js.map +1 -0
  9. package/lib/esm/backend.d.ts +1 -0
  10. package/lib/esm/backend.js +3 -0
  11. package/lib/esm/backend.js.map +1 -0
  12. package/lib/esm/cdn-loader.d.ts +40 -0
  13. package/lib/esm/cdn-loader.js +200 -0
  14. package/lib/esm/cdn-loader.js.map +1 -0
  15. package/lib/esm/checks.d.ts +56 -0
  16. package/lib/esm/checks.js +288 -0
  17. package/lib/esm/checks.js.map +1 -0
  18. package/lib/esm/compile.d.ts +64 -0
  19. package/lib/esm/compile.js +99 -0
  20. package/lib/esm/compile.js.map +1 -0
  21. package/lib/esm/core/messages.d.ts +25 -0
  22. package/lib/esm/core/messages.js +76 -0
  23. package/lib/esm/core/messages.js.map +1 -0
  24. package/lib/esm/data/translation.json +8 -0
  25. package/lib/esm/discovery.d.ts +97 -0
  26. package/lib/esm/discovery.js +365 -0
  27. package/lib/esm/discovery.js.map +1 -0
  28. package/lib/esm/exchange.d.ts +100 -0
  29. package/lib/esm/exchange.js +223 -0
  30. package/lib/esm/exchange.js.map +1 -0
  31. package/lib/esm/export.d.ts +28 -0
  32. package/lib/esm/export.js +92 -0
  33. package/lib/esm/export.js.map +1 -0
  34. package/lib/esm/formats/csv.d.ts +8 -0
  35. package/lib/esm/formats/csv.js +397 -0
  36. package/lib/esm/formats/csv.js.map +1 -0
  37. package/lib/esm/formats/json.d.ts +30 -0
  38. package/lib/esm/formats/json.js +459 -0
  39. package/lib/esm/formats/json.js.map +1 -0
  40. package/lib/esm/formats/xliff.d.ts +12 -0
  41. package/lib/esm/formats/xliff.js +577 -0
  42. package/lib/esm/formats/xliff.js.map +1 -0
  43. package/lib/esm/import.d.ts +197 -0
  44. package/lib/esm/import.js +391 -0
  45. package/lib/esm/import.js.map +1 -0
  46. package/lib/esm/index.d.ts +42 -0
  47. package/lib/esm/index.js +34 -0
  48. package/lib/esm/index.js.map +1 -0
  49. package/lib/esm/key-sync-node.d.ts +29 -0
  50. package/lib/esm/key-sync-node.js +174 -0
  51. package/lib/esm/key-sync-node.js.map +1 -0
  52. package/lib/esm/key-sync.d.ts +46 -0
  53. package/lib/esm/key-sync.js +114 -0
  54. package/lib/esm/key-sync.js.map +1 -0
  55. package/lib/esm/key-version.d.ts +50 -0
  56. package/lib/esm/key-version.js +134 -0
  57. package/lib/esm/key-version.js.map +1 -0
  58. package/lib/esm/languages.d.ts +18 -0
  59. package/lib/esm/languages.js +74 -0
  60. package/lib/esm/languages.js.map +1 -0
  61. package/lib/esm/linked-discovery.d.ts +44 -0
  62. package/lib/esm/linked-discovery.js +267 -0
  63. package/lib/esm/linked-discovery.js.map +1 -0
  64. package/lib/esm/message-format.d.ts +41 -0
  65. package/lib/esm/message-format.js +133 -0
  66. package/lib/esm/message-format.js.map +1 -0
  67. package/lib/esm/mt.d.ts +111 -0
  68. package/lib/esm/mt.js +142 -0
  69. package/lib/esm/mt.js.map +1 -0
  70. package/lib/esm/ontologies/translation.d.ts +203 -0
  71. package/lib/esm/ontologies/translation.js +219 -0
  72. package/lib/esm/ontologies/translation.js.map +1 -0
  73. package/lib/esm/package.d.ts +4 -0
  74. package/lib/esm/package.js +11 -0
  75. package/lib/esm/package.js.map +1 -0
  76. package/lib/esm/react.d.ts +55 -0
  77. package/lib/esm/react.js +127 -0
  78. package/lib/esm/react.js.map +1 -0
  79. package/lib/esm/records.d.ts +151 -0
  80. package/lib/esm/records.js +2 -0
  81. package/lib/esm/records.js.map +1 -0
  82. package/lib/esm/release.d.ts +198 -0
  83. package/lib/esm/release.js +435 -0
  84. package/lib/esm/release.js.map +1 -0
  85. package/lib/esm/shape-catalog.d.ts +79 -0
  86. package/lib/esm/shape-catalog.js +118 -0
  87. package/lib/esm/shape-catalog.js.map +1 -0
  88. package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
  89. package/lib/esm/shapes/GlossaryTerm.js +94 -0
  90. package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
  91. package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
  92. package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
  93. package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
  94. package/lib/esm/shapes/TranslationKey.d.ts +92 -0
  95. package/lib/esm/shapes/TranslationKey.js +194 -0
  96. package/lib/esm/shapes/TranslationKey.js.map +1 -0
  97. package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
  98. package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
  99. package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
  100. package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
  101. package/lib/esm/shapes/TranslationLanguage.js +102 -0
  102. package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
  103. package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
  104. package/lib/esm/shapes/TranslationProvider.js +1376 -0
  105. package/lib/esm/shapes/TranslationProvider.js.map +1 -0
  106. package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
  107. package/lib/esm/shapes/TranslationRelease.js +149 -0
  108. package/lib/esm/shapes/TranslationRelease.js.map +1 -0
  109. package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
  110. package/lib/esm/shapes/TranslationRevision.js +159 -0
  111. package/lib/esm/shapes/TranslationRevision.js.map +1 -0
  112. package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
  113. package/lib/esm/shapes/TranslationUnit.js +111 -0
  114. package/lib/esm/shapes/TranslationUnit.js.map +1 -0
  115. package/package.json +134 -0
  116. package/src/archive.ts +894 -0
  117. package/src/authorization.ts +33 -0
  118. package/src/backend.ts +2 -0
  119. package/src/cdn-loader.ts +264 -0
  120. package/src/checks.ts +413 -0
  121. package/src/compile.ts +168 -0
  122. package/src/core/messages.ts +106 -0
  123. package/src/data/translation.json +8 -0
  124. package/src/discovery.ts +594 -0
  125. package/src/exchange.ts +428 -0
  126. package/src/export.ts +135 -0
  127. package/src/formats/csv.ts +459 -0
  128. package/src/formats/json.ts +587 -0
  129. package/src/formats/xliff.ts +708 -0
  130. package/src/import.ts +648 -0
  131. package/src/index.ts +207 -0
  132. package/src/key-sync-node.ts +269 -0
  133. package/src/key-sync.ts +168 -0
  134. package/src/key-version.ts +206 -0
  135. package/src/languages.ts +102 -0
  136. package/src/linked-discovery.ts +385 -0
  137. package/src/message-format.ts +214 -0
  138. package/src/mt.ts +248 -0
  139. package/src/ontologies/translation.ts +234 -0
  140. package/src/package.ts +19 -0
  141. package/src/react.tsx +250 -0
  142. package/src/records.ts +180 -0
  143. package/src/release.ts +726 -0
  144. package/src/shape-catalog.ts +198 -0
  145. package/src/shapes/GlossaryTerm.ts +61 -0
  146. package/src/shapes/TranslationInventoryOrigin.ts +131 -0
  147. package/src/shapes/TranslationKey.ts +174 -0
  148. package/src/shapes/TranslationKeyVersion.ts +77 -0
  149. package/src/shapes/TranslationLanguage.ts +51 -0
  150. package/src/shapes/TranslationProvider.ts +1755 -0
  151. package/src/shapes/TranslationRelease.ts +114 -0
  152. package/src/shapes/TranslationRevision.ts +105 -0
  153. package/src/shapes/TranslationUnit.ts +72 -0
@@ -0,0 +1,206 @@
1
+ import type { MessageFormat } from './core/messages.js';
2
+ import { analyzeMessage } from './message-format.js';
3
+
4
+ export interface TranslationKeyVersionContract {
5
+ sourceLanguage: string;
6
+ sourceText: string;
7
+ format: MessageFormat;
8
+ argumentSignature: string;
9
+ contractHash: string;
10
+ sourceHash: string;
11
+ }
12
+
13
+ export interface DerivedBackfillKeyVersion
14
+ extends TranslationKeyVersionContract {
15
+ versionId: string;
16
+ }
17
+
18
+ export interface TranslationKeyVersionLike {
19
+ sourceText: string;
20
+ format: MessageFormat;
21
+ argumentSignature: string;
22
+ }
23
+
24
+ export interface ExtractedTranslationKeyContract {
25
+ sourceText: string;
26
+ format: MessageFormat;
27
+ }
28
+
29
+ export interface KeyVersionDecision {
30
+ action: 'unchanged' | 'create-version' | 'retire';
31
+ reason:
32
+ | 'same-source-and-contract'
33
+ | 'source-changed'
34
+ | 'format-changed'
35
+ | 'argument-contract-changed'
36
+ | 'missing-from-source';
37
+ }
38
+
39
+ const bytesToHex = (bytes: Uint8Array): string =>
40
+ [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
41
+
42
+ const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
43
+ const BIGINT_FIVE = BigInt(5);
44
+ const BIGINT_EIGHT = BigInt(8);
45
+ const BIGINT_THIRTY_ONE = BigInt(31);
46
+ let lastUlidTime = -1;
47
+ let lastUlidRandom = new Uint8Array(10);
48
+
49
+ function encodeUlidTime(timestamp: number): string {
50
+ let value = BigInt(timestamp);
51
+ let encoded = '';
52
+ for (let index = 0; index < 10; index++) {
53
+ encoded = ULID_ALPHABET[Number(value & BIGINT_THIRTY_ONE)] + encoded;
54
+ value >>= BIGINT_FIVE;
55
+ }
56
+ return encoded;
57
+ }
58
+
59
+ function encodeUlidRandom(bytes: Uint8Array): string {
60
+ let value = BigInt(0);
61
+ for (const byte of bytes) value = (value << BIGINT_EIGHT) | BigInt(byte);
62
+ let encoded = '';
63
+ for (let index = 0; index < 16; index++) {
64
+ encoded = ULID_ALPHABET[Number(value & BIGINT_THIRTY_ONE)] + encoded;
65
+ value >>= BIGINT_FIVE;
66
+ }
67
+ return encoded;
68
+ }
69
+
70
+ function incrementRandom(bytes: Uint8Array): Uint8Array {
71
+ const next = new Uint8Array(bytes);
72
+ for (let index = next.length - 1; index >= 0; index--) {
73
+ next[index] = (next[index] + 1) & 0xff;
74
+ if (next[index] !== 0) return next;
75
+ }
76
+ throw new Error('ULID random component overflowed within one millisecond.');
77
+ }
78
+
79
+ /** Monotonic ULID for newly authored key versions. */
80
+ export function createMonotonicVersionId(
81
+ now = Date.now(),
82
+ randomBytes?: Uint8Array,
83
+ ): string {
84
+ if (!Number.isSafeInteger(now) || now < 0 || now > 0xffffffffffff) {
85
+ throw new Error('ULID timestamp is outside the supported 48-bit range.');
86
+ }
87
+ const timestamp = Math.max(now, lastUlidTime);
88
+ if (timestamp === lastUlidTime) {
89
+ lastUlidRandom = incrementRandom(lastUlidRandom);
90
+ } else {
91
+ const next = randomBytes ?? globalThis.crypto.getRandomValues(new Uint8Array(10));
92
+ if (next.length !== 10) {
93
+ throw new Error('ULID randomness must contain exactly 10 bytes.');
94
+ }
95
+ lastUlidTime = timestamp;
96
+ lastUlidRandom = new Uint8Array(next);
97
+ }
98
+ return `${encodeUlidTime(timestamp)}${encodeUlidRandom(lastUlidRandom)}`;
99
+ }
100
+
101
+ /** Portable SHA-256 used by both the browser-safe package and Node publishers. */
102
+ export async function sha256Hex(value: string): Promise<string> {
103
+ const digest = await globalThis.crypto.subtle.digest(
104
+ 'SHA-256',
105
+ new TextEncoder().encode(value),
106
+ );
107
+ return bytesToHex(new Uint8Array(digest));
108
+ }
109
+
110
+ export function canonicalLanguageTag(language: string): string {
111
+ const value = language.trim();
112
+ if (!value) throw new Error('sourceLanguage is required.');
113
+ try {
114
+ return Intl.getCanonicalLocales(value)[0] ?? value;
115
+ } catch {
116
+ throw new Error(`Invalid BCP-47 source language: ${language}`);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Stable runtime-argument contract. Object/argument ordering is canonical so
122
+ * extraction order and translator word order cannot change the hash.
123
+ */
124
+ export function createArgumentSignature(
125
+ sourceText: string,
126
+ format: MessageFormat,
127
+ ): string {
128
+ const analysis = analyzeMessage(sourceText, format);
129
+ if (!analysis.valid) {
130
+ throw new Error(
131
+ `Cannot version invalid ${format.toUpperCase()} source text: ${analysis.error}`,
132
+ );
133
+ }
134
+ return JSON.stringify(
135
+ analysis.arguments.map(({ name, kinds }) => [name, [...kinds].sort()]),
136
+ );
137
+ }
138
+
139
+ export function classifyKeyVersion(
140
+ current: TranslationKeyVersionLike | undefined,
141
+ extracted: ExtractedTranslationKeyContract | undefined,
142
+ ): KeyVersionDecision {
143
+ if (!extracted) {
144
+ return { action: 'retire', reason: 'missing-from-source' };
145
+ }
146
+ if (!current) {
147
+ return { action: 'create-version', reason: 'source-changed' };
148
+ }
149
+ if (current.format !== extracted.format) {
150
+ return { action: 'create-version', reason: 'format-changed' };
151
+ }
152
+ const nextSignature = createArgumentSignature(
153
+ extracted.sourceText,
154
+ extracted.format,
155
+ );
156
+ if (current.argumentSignature !== nextSignature) {
157
+ return {
158
+ action: 'create-version',
159
+ reason: 'argument-contract-changed',
160
+ };
161
+ }
162
+ if (current.sourceText !== extracted.sourceText) {
163
+ return { action: 'create-version', reason: 'source-changed' };
164
+ }
165
+ return { action: 'unchanged', reason: 'same-source-and-contract' };
166
+ }
167
+
168
+ export async function deriveTranslationKeyVersionContract(input: {
169
+ sourceLanguage: string;
170
+ sourceText: string;
171
+ format: MessageFormat;
172
+ }): Promise<TranslationKeyVersionContract> {
173
+ const sourceLanguage = canonicalLanguageTag(input.sourceLanguage);
174
+ const argumentSignature = createArgumentSignature(
175
+ input.sourceText,
176
+ input.format,
177
+ );
178
+ const [sourceHash, contractHash] = await Promise.all([
179
+ sha256Hex(JSON.stringify([sourceLanguage, input.sourceText])),
180
+ sha256Hex(JSON.stringify([input.format, argumentSignature])),
181
+ ]);
182
+ return {
183
+ sourceLanguage,
184
+ sourceText: input.sourceText,
185
+ format: input.format,
186
+ argumentSignature,
187
+ contractHash,
188
+ sourceHash,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Existing keys need a deterministic first version so a restarted migration
194
+ * cannot create a second node. Future authored versions use monotonic ULIDs.
195
+ */
196
+ export async function deriveBackfillKeyVersion(input: {
197
+ sourceLanguage: string;
198
+ sourceText: string;
199
+ format: MessageFormat;
200
+ }): Promise<DerivedBackfillKeyVersion> {
201
+ const contract = await deriveTranslationKeyVersionContract(input);
202
+ return {
203
+ ...contract,
204
+ versionId: `backfill-${contract.sourceHash.slice(0, 16)}-${contract.contractHash.slice(0, 16)}`,
205
+ };
206
+ }
@@ -0,0 +1,102 @@
1
+ /** Portable language metadata. Codes are open BCP-47 strings, never an enum. */
2
+ export interface TranslationLanguageDefinition {
3
+ id: string;
4
+ code: string;
5
+ nativeName: string;
6
+ englishName: string;
7
+ direction: 'ltr' | 'rtl';
8
+ parent?: string;
9
+ fallback?: string;
10
+ enabled: boolean;
11
+ supported: boolean;
12
+ }
13
+
14
+ export function canonicalLocale(code: string): string {
15
+ return (
16
+ Intl.getCanonicalLocales(code.trim())[0] ||
17
+ (() => {
18
+ throw new Error('Language code is required.');
19
+ })()
20
+ );
21
+ }
22
+
23
+ export function localeDirection(code: string): 'ltr' | 'rtl' {
24
+ const locale = new Intl.Locale(canonicalLocale(code));
25
+ const info = (locale as any).getTextInfo?.() ?? (locale as any).textInfo;
26
+ if (info?.direction) return info.direction;
27
+ // Script fallback for engines without Intl.Locale text information. This is
28
+ // a script property, not a closed list of supported languages.
29
+ return /^(Adlm|Arab|Hebr|Nkoo|Rohg|Samr|Syrc|Thaa)$/.test(
30
+ locale.maximize().script ?? ''
31
+ )
32
+ ? 'rtl'
33
+ : 'ltr';
34
+ }
35
+
36
+ export function defineLanguage(
37
+ code: string,
38
+ input: Partial<TranslationLanguageDefinition> = {}
39
+ ): TranslationLanguageDefinition {
40
+ code = canonicalLocale(code);
41
+ const base = new Intl.Locale(code).language;
42
+ const name = (locale: string) =>
43
+ new Intl.DisplayNames([locale], { type: 'language' }).of(code) ?? code;
44
+ return {
45
+ id: input.id ?? `https://id.linked.cm/language/${code}`,
46
+ code,
47
+ nativeName: input.nativeName || name(code),
48
+ englishName: input.englishName || name('en'),
49
+ direction: input.direction ?? localeDirection(code),
50
+ ...(base !== code ? { parent: base } : {}),
51
+ ...input,
52
+ enabled: input.enabled ?? true,
53
+ supported: input.supported ?? true,
54
+ };
55
+ }
56
+
57
+ /** Ordered explicit fallback chain, ending at the source; safe against cycles. */
58
+ export function languageFallbacks(
59
+ code: string,
60
+ source: string,
61
+ definitions: readonly TranslationLanguageDefinition[] = []
62
+ ): string[] {
63
+ const byCode = new Map(
64
+ definitions.map((language) => [language.code, language])
65
+ );
66
+ const chain: string[] = [];
67
+ let current: string | undefined = canonicalLocale(code);
68
+ while (current && !chain.includes(current) && current !== source) {
69
+ chain.push(current);
70
+ current = byCode.get(current)?.fallback;
71
+ }
72
+ if (!chain.includes(source)) chain.push(source);
73
+ return chain;
74
+ }
75
+
76
+ export function validateLanguageDefinitions(
77
+ definitions: readonly TranslationLanguageDefinition[]
78
+ ): void {
79
+ const byCode = new Map(
80
+ definitions.map((language) => [language.code, language])
81
+ );
82
+ if (byCode.size !== definitions.length)
83
+ throw new Error('Duplicate language resources.');
84
+ for (const language of definitions) {
85
+ if (canonicalLocale(language.code) !== language.code)
86
+ throw new Error('Use canonical BCP-47 codes.');
87
+ if (!['ltr', 'rtl'].includes(language.direction))
88
+ throw new Error('Language direction must be ltr or rtl.');
89
+ for (const relation of ['fallback', 'parent'] as const) {
90
+ const visited = new Set([language.code]);
91
+ let target = language[relation];
92
+ while (target) {
93
+ if (visited.has(target))
94
+ throw new Error(`Cyclic language ${relation}: ${language.code}`);
95
+ visited.add(target);
96
+ if (!byCode.has(target))
97
+ throw new Error(`Missing ${relation} language resource: ${target}`);
98
+ target = byCode.get(target)?.[relation];
99
+ }
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,385 @@
1
+ import type { MessageFormat } from './core/messages.js';
2
+ import type {
3
+ TranslationDeclaration,
4
+ TranslationDiscoverySource,
5
+ } from './discovery.js';
6
+ import {
7
+ defineShapeTranslationCatalog,
8
+ shapeCatalogTranslationDiscoverySource,
9
+ type ShapeTranslationCatalog,
10
+ type ShapeTranslationCatalogEntry,
11
+ } from './shape-catalog.js';
12
+
13
+ export type LinkedSemanticEntryType =
14
+ | 'label'
15
+ | 'help'
16
+ | 'validation'
17
+ | 'controlled-value'
18
+ | 'workflow-role'
19
+ | 'skos-concept';
20
+
21
+ export interface LinkedSemanticCatalogEntry {
22
+ key: string;
23
+ sourceText: string;
24
+ ownerIri: string;
25
+ namespace?: string;
26
+ description?: string;
27
+ format?: MessageFormat;
28
+ shapeIri?: string;
29
+ propertyIri?: string;
30
+ targetClass?: string;
31
+ type?: LinkedSemanticEntryType;
32
+ }
33
+
34
+ export interface LinkedComponentTranslationCatalog {
35
+ componentIri: string;
36
+ targetClass?: string;
37
+ entries: Array<Omit<LinkedSemanticCatalogEntry, 'ownerIri'>>;
38
+ }
39
+
40
+ export interface LinkedContentTranslationCatalogEntry {
41
+ key: string;
42
+ sourceText: string;
43
+ nodeIri: string;
44
+ propertyIri: string;
45
+ description?: string;
46
+ format?: MessageFormat;
47
+ }
48
+
49
+ export interface LinkedPackageTranslationCatalog {
50
+ schemaVersion: 1;
51
+ packageName: string;
52
+ shapes: ShapeTranslationCatalog[];
53
+ components: LinkedComponentTranslationCatalog[];
54
+ labels: LinkedSemanticCatalogEntry[];
55
+ controlledValues: LinkedSemanticCatalogEntry[];
56
+ workflowRoles: LinkedSemanticCatalogEntry[];
57
+ skosConcepts: LinkedSemanticCatalogEntry[];
58
+ content: LinkedContentTranslationCatalogEntry[];
59
+ }
60
+
61
+ function record(value: unknown, label: string): Record<string, unknown> {
62
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
63
+ throw new Error(`${label} must be an object.`);
64
+ }
65
+ return value as Record<string, unknown>;
66
+ }
67
+
68
+ function requiredString(value: unknown, label: string): string {
69
+ if (typeof value !== 'string' || !value.trim()) {
70
+ throw new Error(`${label} is required.`);
71
+ }
72
+ return value.trim();
73
+ }
74
+
75
+ function optionalString(value: unknown, label: string): string | undefined {
76
+ if (value === undefined) return undefined;
77
+ if (typeof value !== 'string' || !value.trim()) {
78
+ throw new Error(`${label} must be a non-empty string when supplied.`);
79
+ }
80
+ return value.trim();
81
+ }
82
+
83
+ function format(value: unknown, label: string): MessageFormat | undefined {
84
+ if (value === undefined) return undefined;
85
+ if (value !== 'simple' && value !== 'icu') {
86
+ throw new Error(`${label} must be "simple" or "icu".`);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ function array(value: unknown, label: string): unknown[] {
92
+ if (value === undefined) return [];
93
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
94
+ return value;
95
+ }
96
+
97
+ function semanticEntry(
98
+ value: unknown,
99
+ label: string,
100
+ expectedType?: LinkedSemanticEntryType,
101
+ ): LinkedSemanticCatalogEntry {
102
+ const entry = record(value, label);
103
+ const type = (entry.type ?? expectedType) as LinkedSemanticEntryType | undefined;
104
+ if (
105
+ type !== undefined &&
106
+ type !== 'label' &&
107
+ type !== 'help' &&
108
+ type !== 'validation' &&
109
+ type !== 'controlled-value' &&
110
+ type !== 'workflow-role' &&
111
+ type !== 'skos-concept'
112
+ ) {
113
+ throw new Error(`${label}.type is invalid.`);
114
+ }
115
+ if (typeof entry.sourceText !== 'string') {
116
+ throw new Error(`${label}.sourceText must be a string.`);
117
+ }
118
+ return {
119
+ key: requiredString(entry.key, `${label}.key`),
120
+ sourceText: entry.sourceText,
121
+ ownerIri: requiredString(entry.ownerIri, `${label}.ownerIri`),
122
+ ...(optionalString(entry.namespace, `${label}.namespace`)
123
+ ? { namespace: entry.namespace as string }
124
+ : {}),
125
+ ...(optionalString(entry.description, `${label}.description`)
126
+ ? { description: entry.description as string }
127
+ : {}),
128
+ ...(format(entry.format, `${label}.format`)
129
+ ? { format: entry.format as MessageFormat }
130
+ : {}),
131
+ ...(optionalString(entry.shapeIri, `${label}.shapeIri`)
132
+ ? { shapeIri: entry.shapeIri as string }
133
+ : {}),
134
+ ...(optionalString(entry.propertyIri, `${label}.propertyIri`)
135
+ ? { propertyIri: entry.propertyIri as string }
136
+ : {}),
137
+ ...(optionalString(entry.targetClass, `${label}.targetClass`)
138
+ ? { targetClass: entry.targetClass as string }
139
+ : {}),
140
+ ...(type ? { type } : {}),
141
+ };
142
+ }
143
+
144
+ function component(
145
+ value: unknown,
146
+ index: number,
147
+ ): LinkedComponentTranslationCatalog {
148
+ const item = record(value, `components[${index}]`);
149
+ const componentIri = requiredString(
150
+ item.componentIri,
151
+ `components[${index}].componentIri`,
152
+ );
153
+ const entries = array(item.entries, `components[${index}].entries`).map(
154
+ (raw, entryIndex) => {
155
+ const parsed = semanticEntry(
156
+ {
157
+ ...record(raw, `components[${index}].entries[${entryIndex}]`),
158
+ ownerIri: componentIri,
159
+ },
160
+ `components[${index}].entries[${entryIndex}]`,
161
+ );
162
+ const { ownerIri: _ownerIri, ...entry } = parsed;
163
+ return entry;
164
+ },
165
+ );
166
+ return {
167
+ componentIri,
168
+ entries,
169
+ ...(optionalString(item.targetClass, `components[${index}].targetClass`)
170
+ ? { targetClass: item.targetClass as string }
171
+ : {}),
172
+ };
173
+ }
174
+
175
+ function contentEntry(
176
+ value: unknown,
177
+ index: number,
178
+ ): LinkedContentTranslationCatalogEntry {
179
+ const entry = record(value, `content[${index}]`);
180
+ if (typeof entry.sourceText !== 'string') {
181
+ throw new Error(`content[${index}].sourceText must be a string.`);
182
+ }
183
+ return {
184
+ key: requiredString(entry.key, `content[${index}].key`),
185
+ sourceText: entry.sourceText,
186
+ nodeIri: requiredString(entry.nodeIri, `content[${index}].nodeIri`),
187
+ propertyIri: requiredString(
188
+ entry.propertyIri,
189
+ `content[${index}].propertyIri`,
190
+ ),
191
+ ...(optionalString(entry.description, `content[${index}].description`)
192
+ ? { description: entry.description as string }
193
+ : {}),
194
+ ...(format(entry.format, `content[${index}].format`)
195
+ ? { format: entry.format as MessageFormat }
196
+ : {}),
197
+ };
198
+ }
199
+
200
+ function shapeCatalog(value: unknown, index: number): ShapeTranslationCatalog {
201
+ const shape = record(value, `shapes[${index}]`);
202
+ return defineShapeTranslationCatalog({
203
+ schemaVersion: shape.schemaVersion as 1,
204
+ packageName: requiredString(
205
+ shape.packageName,
206
+ `shapes[${index}].packageName`,
207
+ ),
208
+ shapeIri: requiredString(shape.shapeIri, `shapes[${index}].shapeIri`),
209
+ ...(optionalString(shape.targetClass, `shapes[${index}].targetClass`)
210
+ ? { targetClass: shape.targetClass as string }
211
+ : {}),
212
+ entries: array(shape.entries, `shapes[${index}].entries`) as ShapeTranslationCatalogEntry[],
213
+ });
214
+ }
215
+
216
+ function assertNoDuplicateIdentities(
217
+ catalog: LinkedPackageTranslationCatalog,
218
+ ): void {
219
+ const messages = new Set<string>();
220
+ const add = (key: string, namespace = '') => {
221
+ const identity = JSON.stringify([namespace, key]);
222
+ if (messages.has(identity)) {
223
+ throw new Error(`Duplicate linked translation key "${key}".`);
224
+ }
225
+ messages.add(identity);
226
+ };
227
+ for (const shape of catalog.shapes) {
228
+ for (const entry of shape.entries) add(entry.key, entry.namespace);
229
+ }
230
+ for (const component of catalog.components) {
231
+ for (const entry of component.entries) add(entry.key, entry.namespace);
232
+ }
233
+ for (const entries of [
234
+ catalog.labels,
235
+ catalog.controlledValues,
236
+ catalog.workflowRoles,
237
+ catalog.skosConcepts,
238
+ ]) {
239
+ for (const entry of entries) add(entry.key, entry.namespace);
240
+ }
241
+ const content = new Set<string>();
242
+ for (const entry of catalog.content) {
243
+ const identity = JSON.stringify([entry.nodeIri, entry.propertyIri]);
244
+ if (content.has(identity)) {
245
+ throw new Error(
246
+ `Duplicate linked content translation field "${entry.nodeIri}" / "${entry.propertyIri}".`,
247
+ );
248
+ }
249
+ content.add(identity);
250
+ }
251
+ }
252
+
253
+ /** Parse serialized JSON only; package JavaScript is never imported or run. */
254
+ export function parseLinkedPackageTranslationCatalog(
255
+ input: string | Uint8Array,
256
+ ): LinkedPackageTranslationCatalog {
257
+ let value: unknown;
258
+ try {
259
+ value = JSON.parse(
260
+ typeof input === 'string' ? input : new TextDecoder().decode(input),
261
+ );
262
+ } catch {
263
+ throw new Error('Linked translation catalog must be valid JSON.');
264
+ }
265
+ const raw = record(value, 'catalog');
266
+ if (raw.schemaVersion !== 1) {
267
+ throw new Error('Linked translation catalog schemaVersion must be 1.');
268
+ }
269
+ const catalog: LinkedPackageTranslationCatalog = {
270
+ schemaVersion: 1,
271
+ packageName: requiredString(raw.packageName, 'catalog.packageName'),
272
+ shapes: array(raw.shapes, 'catalog.shapes').map(shapeCatalog),
273
+ components: array(raw.components, 'catalog.components').map(component),
274
+ labels: array(raw.labels, 'catalog.labels').map((entry, index) =>
275
+ semanticEntry(entry, `labels[${index}]`, 'label'),
276
+ ),
277
+ controlledValues: array(
278
+ raw.controlledValues,
279
+ 'catalog.controlledValues',
280
+ ).map((entry, index) =>
281
+ semanticEntry(
282
+ entry,
283
+ `controlledValues[${index}]`,
284
+ 'controlled-value',
285
+ ),
286
+ ),
287
+ workflowRoles: array(raw.workflowRoles, 'catalog.workflowRoles').map(
288
+ (entry, index) =>
289
+ semanticEntry(entry, `workflowRoles[${index}]`, 'workflow-role'),
290
+ ),
291
+ skosConcepts: array(raw.skosConcepts, 'catalog.skosConcepts').map(
292
+ (entry, index) =>
293
+ semanticEntry(entry, `skosConcepts[${index}]`, 'skos-concept'),
294
+ ),
295
+ content: array(raw.content, 'catalog.content').map(contentEntry),
296
+ };
297
+ for (const shape of catalog.shapes) {
298
+ if (shape.packageName !== catalog.packageName) {
299
+ throw new Error(
300
+ `Shape catalog package "${shape.packageName}" does not match "${catalog.packageName}".`,
301
+ );
302
+ }
303
+ }
304
+ assertNoDuplicateIdentities(catalog);
305
+ return catalog;
306
+ }
307
+
308
+ function semanticDeclaration(
309
+ packageName: string,
310
+ category: string,
311
+ entry: LinkedSemanticCatalogEntry,
312
+ ): TranslationDeclaration {
313
+ return {
314
+ schemaVersion: 1,
315
+ key: entry.key,
316
+ sourceText: entry.sourceText,
317
+ kind: 'semantic',
318
+ format: entry.format ?? 'simple',
319
+ ...(entry.namespace ? { namespace: entry.namespace } : {}),
320
+ ...(entry.description ? { description: entry.description } : {}),
321
+ provenance: {
322
+ source: 'linked',
323
+ sourceId: `${packageName}:${category}:${entry.ownerIri}`,
324
+ authoritative: true,
325
+ ownerIri: entry.ownerIri,
326
+ fromPackage: packageName,
327
+ ...(entry.shapeIri ? { shapeIri: entry.shapeIri } : {}),
328
+ ...(entry.propertyIri ? { propertyIri: entry.propertyIri } : {}),
329
+ ...(entry.targetClass ? { targetClass: entry.targetClass } : {}),
330
+ },
331
+ };
332
+ }
333
+
334
+ /** Discover safe serialized LINKED package metadata without code execution. */
335
+ export function linkedPackageTranslationDiscoverySource(
336
+ input: string | Uint8Array,
337
+ ): TranslationDiscoverySource {
338
+ const catalog = parseLinkedPackageTranslationCatalog(input);
339
+ return {
340
+ name: `linked-package:${catalog.packageName}`,
341
+ async *discover(context) {
342
+ for (const shape of catalog.shapes) {
343
+ yield* shapeCatalogTranslationDiscoverySource(shape).discover(context);
344
+ }
345
+ for (const component of catalog.components) {
346
+ for (const entry of component.entries) {
347
+ yield semanticDeclaration(catalog.packageName, 'component', {
348
+ ...entry,
349
+ ownerIri: component.componentIri,
350
+ targetClass: entry.targetClass ?? component.targetClass,
351
+ });
352
+ }
353
+ }
354
+ for (const [category, entries] of [
355
+ ['label', catalog.labels],
356
+ ['controlled-value', catalog.controlledValues],
357
+ ['workflow-role', catalog.workflowRoles],
358
+ ['skos-concept', catalog.skosConcepts],
359
+ ] as const) {
360
+ for (const entry of entries) {
361
+ yield semanticDeclaration(catalog.packageName, category, entry);
362
+ }
363
+ }
364
+ for (const entry of catalog.content) {
365
+ yield {
366
+ schemaVersion: 1,
367
+ key: entry.key,
368
+ sourceText: entry.sourceText,
369
+ kind: 'content',
370
+ format: entry.format ?? 'simple',
371
+ ...(entry.description ? { description: entry.description } : {}),
372
+ provenance: {
373
+ source: 'linked',
374
+ sourceId: `${catalog.packageName}:content:${entry.nodeIri}:${entry.propertyIri}`,
375
+ authoritative: true,
376
+ ownerIri: entry.nodeIri,
377
+ nodeIri: entry.nodeIri,
378
+ propertyIri: entry.propertyIri,
379
+ fromPackage: catalog.packageName,
380
+ },
381
+ };
382
+ }
383
+ },
384
+ };
385
+ }