@_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
package/src/index.ts ADDED
@@ -0,0 +1,207 @@
1
+ // Isomorphic barrel (Plan 014 AD-G): side-effect imports register the ontology
2
+ // + shapes into the LINKED tree. Safe on client and server — no backend/Node
3
+ // code here (that lives in ./backend.ts). Ontology FIRST, then shapes.
4
+ import './ontologies/translation.js';
5
+ import './shapes/TranslationKey.js';
6
+ import './shapes/TranslationKeyVersion.js';
7
+ import './shapes/TranslationRelease.js';
8
+ import './shapes/TranslationUnit.js';
9
+ import './shapes/TranslationRevision.js';
10
+ import './shapes/GlossaryTerm.js';
11
+ import './shapes/TranslationInventoryOrigin.js';
12
+
13
+ export {
14
+ canAuthorTranslation,
15
+ configureTranslationAuthorization,
16
+ } from './authorization.js';
17
+ export type {
18
+ TranslationAuthoringAction,
19
+ TranslationAuthorizationRequest,
20
+ TranslationAuthorizationResolver,
21
+ } from './authorization.js';
22
+
23
+ export { TranslationKey } from './shapes/TranslationKey.js';
24
+ export { TranslationKeyVersion } from './shapes/TranslationKeyVersion.js';
25
+ export { TranslationRelease } from './shapes/TranslationRelease.js';
26
+ export { TranslationUnit } from './shapes/TranslationUnit.js';
27
+ export { TranslationRevision } from './shapes/TranslationRevision.js';
28
+ export { GlossaryTerm } from './shapes/GlossaryTerm.js';
29
+ export { TranslationInventoryOrigin } from './shapes/TranslationInventoryOrigin.js';
30
+ export { tr } from './ontologies/translation.js';
31
+ export {
32
+ canonicalLanguageTag,
33
+ classifyKeyVersion,
34
+ createArgumentSignature,
35
+ createMonotonicVersionId,
36
+ deriveBackfillKeyVersion,
37
+ deriveTranslationKeyVersionContract,
38
+ sha256Hex,
39
+ } from './key-version.js';
40
+ export type {
41
+ DerivedBackfillKeyVersion,
42
+ ExtractedTranslationKeyContract,
43
+ KeyVersionDecision,
44
+ TranslationKeyVersionContract,
45
+ TranslationKeyVersionLike,
46
+ } from './key-version.js';
47
+ export {
48
+ defineShapeTranslationCatalog,
49
+ installShapeTranslationCatalog,
50
+ shapeCatalogTranslationDiscoverySource,
51
+ } from './shape-catalog.js';
52
+ export type {
53
+ ShapeTranslationCatalog,
54
+ ShapeTranslationCatalogEntry,
55
+ ShapeTranslationCatalogTarget,
56
+ ShapeTranslationInstallReport,
57
+ } from './shape-catalog.js';
58
+ export {
59
+ discoverTranslationInventory,
60
+ manifestTranslationDiscoverySource,
61
+ parseTranslationDiscoveryManifest,
62
+ translationBuildDeclarationsFromInventory,
63
+ translationDeclarationIdentity,
64
+ } from './discovery.js';
65
+ export type {
66
+ DiscoverTranslationInventoryOptions,
67
+ TranslationDeclaration,
68
+ TranslationDeclarationProvenance,
69
+ TranslationDeclarationSource,
70
+ TranslationDiscoveryContext,
71
+ TranslationDiscoveryManifest,
72
+ TranslationDiscoveryManifestEntry,
73
+ TranslationDiscoverySource,
74
+ TranslationInventoryConflict,
75
+ TranslationInventoryItem,
76
+ TranslationInventorySnapshot,
77
+ TranslationInventoryStatus,
78
+ } from './discovery.js';
79
+ export {
80
+ linkedPackageTranslationDiscoverySource,
81
+ parseLinkedPackageTranslationCatalog,
82
+ } from './linked-discovery.js';
83
+ export type {
84
+ LinkedComponentTranslationCatalog,
85
+ LinkedContentTranslationCatalogEntry,
86
+ LinkedPackageTranslationCatalog,
87
+ LinkedSemanticCatalogEntry,
88
+ LinkedSemanticEntryType,
89
+ } from './linked-discovery.js';
90
+ export {
91
+ clearTranslationFormatAdapters,
92
+ getTranslationFormatAdapter,
93
+ isConfirmedBuildDeclaration,
94
+ listTranslationFormatAdapters,
95
+ normalizeParsedTranslationExchangeDocument,
96
+ registerTranslationFormatAdapter,
97
+ validateTranslationExchangeDocument,
98
+ } from './exchange.js';
99
+ export type {
100
+ ParsedTranslationExchangeDocument,
101
+ ParsedTranslationExchangeEntry,
102
+ SerializedTranslationDocument,
103
+ TranslationBuildDeclaration,
104
+ TranslationDeclarationKind,
105
+ TranslationDeclarationStatus,
106
+ TranslationExchangeDocument,
107
+ TranslationExchangeEntry,
108
+ TranslationExchangeKind,
109
+ TranslationExchangeValue,
110
+ TranslationFormat,
111
+ TranslationFormatAdapter,
112
+ TranslationImportDisposition,
113
+ TranslationImportDryRun,
114
+ TranslationImportDryRunEntry,
115
+ } from './exchange.js';
116
+ export {
117
+ commitVersionedImport,
118
+ flattenMessages,
119
+ hasIcuSyntax,
120
+ planVersionedImport,
121
+ runImport,
122
+ tolgeeCdnSource,
123
+ translationUnitContentHash,
124
+ } from './import.js';
125
+ export type {
126
+ ImportMergePolicy,
127
+ ImportOptions,
128
+ ImportReport,
129
+ ImportTarget,
130
+ ParsedUnit,
131
+ TranslationImportSource,
132
+ VersionedImportCommitReport,
133
+ VersionedImportCurrentState,
134
+ VersionedImportDecision,
135
+ VersionedImportPlan,
136
+ VersionedImportPlanOptions,
137
+ VersionedImportTarget,
138
+ } from './import.js';
139
+ export { createTranslationExchangeDocument } from './export.js';
140
+ export type {
141
+ TranslationExportFinding,
142
+ TranslationExportOptions,
143
+ TranslationExportResult,
144
+ } from './export.js';
145
+ export { translationCsvAdapter } from './formats/csv.js';
146
+ export { xliff12Adapter, xliff20Adapter } from './formats/xliff.js';
147
+ export type { XliffFinding, XliffParseOptions } from './formats/xliff.js';
148
+ export {
149
+ DEFAULT_TRANSLATION_JSON_ARCHIVE_LIMITS,
150
+ i18nextJsonFormatAdapter,
151
+ parseI18nextJson,
152
+ serializeI18nextJsonZip,
153
+ } from './formats/json.js';
154
+ export type {
155
+ I18nextJsonLayout,
156
+ TranslationJsonArchiveLimits,
157
+ TranslationJsonParseOptions,
158
+ TranslationJsonSerializeOptions,
159
+ } from './formats/json.js';
160
+ export {
161
+ DEFAULT_TRANSLATION_ARCHIVE_LIMITS,
162
+ createTranslationArchive,
163
+ parseTranslationArchive,
164
+ planTranslationArchiveRestore,
165
+ TRANSLATION_ARCHIVE_COLLECTIONS,
166
+ TRANSLATION_ARCHIVE_EXCLUSIONS,
167
+ TRANSLATION_ARCHIVE_MANIFEST_PATH,
168
+ } from './archive.js';
169
+ export type {
170
+ ParsedTranslationArchive,
171
+ PlanTranslationArchiveRestoreOptions,
172
+ SerializedTranslationArchive,
173
+ TranslationArchiveCollection,
174
+ TranslationArchiveContentMapping,
175
+ TranslationArchiveEntity,
176
+ TranslationArchiveEntityInput,
177
+ TranslationArchiveFileInventory,
178
+ TranslationArchiveJson,
179
+ TranslationArchiveManifest,
180
+ TranslationArchiveObjectInput,
181
+ TranslationArchiveObjectInventory,
182
+ TranslationArchiveRestoreDecision,
183
+ TranslationArchiveRestorePlan,
184
+ TranslationArchiveSnapshot,
185
+ TranslationArchiveTargetRecord,
186
+ } from './archive.js';
187
+ export type {
188
+ GlossaryTermRecord,
189
+ TranslationEntryRecord,
190
+ TranslationRevisionRecord,
191
+ TranslationRevisionStatus,
192
+ TranslationMemoryMatchKind,
193
+ TranslationMemoryMatchRecord,
194
+ TranslationMemoryPretranslateCandidate,
195
+ TranslationMemoryPretranslateReport,
196
+ TranslationMemoryRecord,
197
+ TranslationProposalDecision,
198
+ TranslationReleaseChannel,
199
+ TranslationReleaseRecord,
200
+ TranslationReleaseStatus,
201
+ TranslationState,
202
+ TranslationKeyVersionRecord,
203
+ TranslationUnitRecord,
204
+ } from './records.js';
205
+
206
+ export { TranslationLanguage } from './shapes/TranslationLanguage.js';
207
+ export * from './languages.js';
@@ -0,0 +1,269 @@
1
+ import { access, readdir, readFile, realpath } from 'node:fs/promises';
2
+ import {
3
+ dirname,
4
+ extname,
5
+ isAbsolute,
6
+ join,
7
+ relative,
8
+ resolve,
9
+ sep,
10
+ } from 'node:path';
11
+ import {
12
+ extractTranslationKeys,
13
+ syncTranslationKeys,
14
+ translationDeclarationsFromExtractedKeys,
15
+ type ExtractedTranslationKey,
16
+ type TranslationKeySyncOptions,
17
+ type TranslationKeySyncReport,
18
+ type TranslationKeySyncTarget,
19
+ } from './key-sync.js';
20
+ import {
21
+ manifestTranslationDiscoverySource,
22
+ type TranslationDiscoverySource,
23
+ } from './discovery.js';
24
+ import { linkedPackageTranslationDiscoverySource } from './linked-discovery.js';
25
+
26
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
27
+ const IGNORED_DIRECTORIES = new Set([
28
+ '.git',
29
+ 'build',
30
+ 'coverage',
31
+ 'dist',
32
+ 'lib',
33
+ 'node_modules',
34
+ ]);
35
+
36
+ /** Conventional code-canonical manifest for finite dynamic key families. */
37
+ export const TRANSLATION_DISCOVERY_MANIFEST_FILE =
38
+ 'translation.discovery.json';
39
+
40
+ /** package.json field pointing at a serialized LINKED translation catalog. */
41
+ export const LINKED_TRANSLATION_CATALOG_FIELD =
42
+ 'linkedTranslationCatalog';
43
+
44
+ interface PackageManifest {
45
+ name?: unknown;
46
+ dependencies?: unknown;
47
+ optionalDependencies?: unknown;
48
+ [LINKED_TRANSLATION_CATALOG_FIELD]?: unknown;
49
+ }
50
+
51
+ async function readJsonFile(path: string): Promise<unknown> {
52
+ return JSON.parse(await readFile(path, 'utf8')) as unknown;
53
+ }
54
+
55
+ async function readOptionalJsonFile(path: string): Promise<unknown | null> {
56
+ try {
57
+ return await readJsonFile(path);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function dependencyNames(manifest: PackageManifest): string[] {
65
+ const names = new Set<string>();
66
+ for (const group of [manifest.dependencies, manifest.optionalDependencies]) {
67
+ if (!group || typeof group !== 'object' || Array.isArray(group)) continue;
68
+ for (const name of Object.keys(group)) names.add(name);
69
+ }
70
+ return [...names].sort();
71
+ }
72
+
73
+ async function dependencyDirectory(
74
+ workspaceRoot: string,
75
+ packageDirectory: string,
76
+ dependency: string,
77
+ ): Promise<string | null> {
78
+ let search = packageDirectory;
79
+ while (true) {
80
+ const candidate = join(search, 'node_modules', ...dependency.split('/'));
81
+ const boundary = relative(workspaceRoot, candidate);
82
+ if (!isAbsolute(boundary) && !boundary.startsWith('..')) {
83
+ try {
84
+ await access(join(candidate, 'package.json'));
85
+ return candidate;
86
+ } catch {
87
+ // Hoisted dependencies may live in a parent node_modules directory.
88
+ }
89
+ }
90
+ if (search === workspaceRoot) return null;
91
+ const parent = dirname(search);
92
+ if (parent === search || relative(workspaceRoot, parent).startsWith('..')) {
93
+ return null;
94
+ }
95
+ search = parent;
96
+ }
97
+ }
98
+
99
+ async function safePackageFile(
100
+ packageDirectory: string,
101
+ file: string,
102
+ ): Promise<string> {
103
+ const clean = file.trim();
104
+ if (!clean) {
105
+ throw new Error(
106
+ `${LINKED_TRANSLATION_CATALOG_FIELD} must be a non-empty relative path.`,
107
+ );
108
+ }
109
+ const lexicalFile = resolve(packageDirectory, clean);
110
+ const lexicalBoundary = relative(packageDirectory, lexicalFile);
111
+ if (
112
+ isAbsolute(lexicalBoundary) ||
113
+ lexicalBoundary === '..' ||
114
+ lexicalBoundary.startsWith(`..${sep}`)
115
+ ) {
116
+ throw new Error(
117
+ `${LINKED_TRANSLATION_CATALOG_FIELD} must stay inside its package.`,
118
+ );
119
+ }
120
+ const [realPackageDirectory, realFile] = await Promise.all([
121
+ realpath(packageDirectory),
122
+ realpath(lexicalFile),
123
+ ]);
124
+ const boundary = relative(realPackageDirectory, realFile);
125
+ if (
126
+ isAbsolute(boundary) ||
127
+ boundary === '..' ||
128
+ boundary.startsWith(`..${sep}`)
129
+ ) {
130
+ throw new Error(
131
+ `${LINKED_TRANSLATION_CATALOG_FIELD} must stay inside its package.`,
132
+ );
133
+ }
134
+ return realFile;
135
+ }
136
+
137
+ /**
138
+ * Discover serialized app and dependency metadata from one exact workspace.
139
+ *
140
+ * This reads JSON only. Dependency JavaScript is never imported or evaluated.
141
+ * Dynamic keys live in `translation.discovery.json`; reusable LINKED packages
142
+ * opt in through package.json's `linkedTranslationCatalog` relative path.
143
+ */
144
+ export async function loadWorkspaceTranslationDiscoverySources(
145
+ root: string,
146
+ ): Promise<TranslationDiscoverySource[]> {
147
+ const workspaceRoot = resolve(root);
148
+ const sources: TranslationDiscoverySource[] = [];
149
+ const appManifest = await readOptionalJsonFile(
150
+ join(workspaceRoot, TRANSLATION_DISCOVERY_MANIFEST_FILE),
151
+ );
152
+ if (appManifest) {
153
+ sources.push(manifestTranslationDiscoverySource(appManifest));
154
+ }
155
+
156
+ const visited = new Set<string>();
157
+ const inspectPackage = async (packageDirectory: string): Promise<void> => {
158
+ const absoluteDirectory = resolve(packageDirectory);
159
+ if (visited.has(absoluteDirectory)) return;
160
+ const raw = await readOptionalJsonFile(
161
+ join(absoluteDirectory, 'package.json'),
162
+ );
163
+ if (!raw) return;
164
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
165
+ throw new Error(`Invalid package.json in ${absoluteDirectory}.`);
166
+ }
167
+ visited.add(absoluteDirectory);
168
+ const manifest = raw as PackageManifest;
169
+ const catalogFile = manifest[LINKED_TRANSLATION_CATALOG_FIELD];
170
+ if (catalogFile !== undefined) {
171
+ if (typeof catalogFile !== 'string') {
172
+ throw new Error(
173
+ `${LINKED_TRANSLATION_CATALOG_FIELD} in ${absoluteDirectory}/package.json must be a string.`,
174
+ );
175
+ }
176
+ sources.push(
177
+ linkedPackageTranslationDiscoverySource(
178
+ await readFile(
179
+ await safePackageFile(absoluteDirectory, catalogFile),
180
+ ),
181
+ ),
182
+ );
183
+ }
184
+ for (const dependency of dependencyNames(manifest)) {
185
+ const directory = await dependencyDirectory(
186
+ workspaceRoot,
187
+ absoluteDirectory,
188
+ dependency,
189
+ );
190
+ if (directory) await inspectPackage(directory);
191
+ }
192
+ };
193
+
194
+ await inspectPackage(workspaceRoot);
195
+ return sources.sort((left, right) => left.name.localeCompare(right.name));
196
+ }
197
+
198
+ export interface ScanTranslationSourceOptions {
199
+ extensions?: Iterable<string>;
200
+ ignoredDirectories?: Iterable<string>;
201
+ }
202
+
203
+ /** Recursively extract static translation keys from an application's source tree. */
204
+ export async function scanTranslationSourceTree(
205
+ root: string,
206
+ options: ScanTranslationSourceOptions = {},
207
+ ): Promise<ExtractedTranslationKey[]> {
208
+ const absoluteRoot = resolve(root);
209
+ const extensions = new Set(options.extensions ?? SOURCE_EXTENSIONS);
210
+ const ignored = new Set(options.ignoredDirectories ?? IGNORED_DIRECTORIES);
211
+ const files: string[] = [];
212
+
213
+ const walk = async (directory: string): Promise<void> => {
214
+ const entries = await readdir(directory, { withFileTypes: true });
215
+ entries.sort((a, b) => a.name.localeCompare(b.name));
216
+ for (const entry of entries) {
217
+ if (entry.isSymbolicLink()) continue;
218
+ const path = resolve(directory, entry.name);
219
+ if (entry.isDirectory()) {
220
+ if (!ignored.has(entry.name)) await walk(path);
221
+ } else if (entry.isFile() && extensions.has(extname(entry.name))) {
222
+ files.push(path);
223
+ }
224
+ }
225
+ };
226
+
227
+ await walk(absoluteRoot);
228
+ const extracted = await Promise.all(
229
+ files.map(async (file) =>
230
+ extractTranslationKeys(await readFile(file, 'utf8'), relative(absoluteRoot, file)),
231
+ ),
232
+ );
233
+ return extracted.flat();
234
+ }
235
+
236
+ /** Portable discovery adapter for an ordinary application's source tree. */
237
+ export function sourceTreeTranslationDiscoverySource(
238
+ root: string,
239
+ options: ScanTranslationSourceOptions = {},
240
+ ): TranslationDiscoverySource {
241
+ return {
242
+ name: `source-tree:${resolve(root)}`,
243
+ async *discover() {
244
+ const extracted = await scanTranslationSourceTree(root, options);
245
+ yield* translationDeclarationsFromExtractedKeys(extracted);
246
+ },
247
+ };
248
+ }
249
+
250
+ export interface SyncTranslationSourceTreeOptions
251
+ extends ScanTranslationSourceOptions,
252
+ TranslationKeySyncOptions {
253
+ root: string;
254
+ appId: string;
255
+ target: TranslationKeySyncTarget;
256
+ }
257
+
258
+ /** Scan an app source tree and report or apply its keys through a provider adapter. */
259
+ export async function syncTranslationSourceTree({
260
+ root,
261
+ appId,
262
+ target,
263
+ dryRun,
264
+ extensions,
265
+ ignoredDirectories,
266
+ }: SyncTranslationSourceTreeOptions): Promise<TranslationKeySyncReport> {
267
+ const extracted = await scanTranslationSourceTree(root, { extensions, ignoredDirectories });
268
+ return syncTranslationKeys(target, appId, extracted, { dryRun });
269
+ }
@@ -0,0 +1,168 @@
1
+ import ts from 'typescript';
2
+ import type {
3
+ TranslationDeclaration,
4
+ TranslationDiscoverySource,
5
+ } from './discovery.js';
6
+
7
+ export interface ExtractedTranslationKey {
8
+ key: string;
9
+ sourceText: string;
10
+ file: string;
11
+ line: number;
12
+ }
13
+
14
+ export interface TranslationKeySyncTarget {
15
+ list(data: { appId: string }): Promise<Array<{ key: string; sourceText?: string }>>;
16
+ upsert(data: {
17
+ appId: string;
18
+ key: string;
19
+ sourceText: string;
20
+ kind: 'ui';
21
+ }): Promise<unknown>;
22
+ }
23
+
24
+ export interface TranslationKeySyncReport {
25
+ created: string[];
26
+ updated: string[];
27
+ unchanged: string[];
28
+ orphaned: string[];
29
+ conflicts: Array<{ key: string; defaults: string[] }>;
30
+ }
31
+
32
+ export interface TranslationKeySyncOptions {
33
+ /** Report changes without writing them to the target. */
34
+ dryRun?: boolean;
35
+ }
36
+
37
+ function literalText(node: ts.Node | undefined): string | undefined {
38
+ return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
39
+ ? node.text
40
+ : undefined;
41
+ }
42
+
43
+ /** Extract static `t(key, default)` and `<T keyName defaultValue>` call sites. */
44
+ export function extractTranslationKeys(
45
+ source: string,
46
+ file = 'source.tsx',
47
+ ): ExtractedTranslationKey[] {
48
+ const sourceFile = ts.createSourceFile(
49
+ file,
50
+ source,
51
+ ts.ScriptTarget.Latest,
52
+ true,
53
+ file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
54
+ );
55
+ const extracted: ExtractedTranslationKey[] = [];
56
+ const add = (keyNode: ts.Node | undefined, defaultNode: ts.Node | undefined) => {
57
+ const key = literalText(keyNode);
58
+ const sourceText = literalText(defaultNode);
59
+ if (!key || sourceText === undefined) return;
60
+ const { line } = sourceFile.getLineAndCharacterOfPosition(keyNode!.getStart(sourceFile));
61
+ extracted.push({ key, sourceText, file, line: line + 1 });
62
+ };
63
+ const visit = (node: ts.Node) => {
64
+ if (
65
+ ts.isCallExpression(node) &&
66
+ ts.isIdentifier(node.expression) &&
67
+ node.expression.text === 't'
68
+ ) {
69
+ add(node.arguments[0], node.arguments[1]);
70
+ } else if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
71
+ if (node.tagName.getText(sourceFile) === 'T') {
72
+ const attrs = new Map(
73
+ node.attributes.properties
74
+ .filter(ts.isJsxAttribute)
75
+ .map((attr) => [attr.name.getText(sourceFile), attr.initializer]),
76
+ );
77
+ const fromAttribute = (initializer: ts.JsxAttributeValue | undefined) => {
78
+ if (!initializer) return undefined;
79
+ if (ts.isStringLiteral(initializer)) return initializer;
80
+ return ts.isJsxExpression(initializer) ? initializer.expression : undefined;
81
+ };
82
+ add(fromAttribute(attrs.get('keyName')), fromAttribute(attrs.get('defaultValue')));
83
+ }
84
+ }
85
+ ts.forEachChild(node, visit);
86
+ };
87
+ visit(sourceFile);
88
+ return extracted;
89
+ }
90
+
91
+ /** Adapt the shipped static extractor to the portable discovery contract. */
92
+ export function translationDeclarationsFromExtractedKeys(
93
+ extracted: Iterable<ExtractedTranslationKey>,
94
+ ): TranslationDeclaration[] {
95
+ return [...extracted].map((item) => ({
96
+ schemaVersion: 1,
97
+ key: item.key,
98
+ sourceText: item.sourceText,
99
+ kind: 'ui',
100
+ format: 'simple',
101
+ provenance: {
102
+ source: 'code',
103
+ sourceId: item.file,
104
+ authoritative: true,
105
+ file: item.file,
106
+ line: item.line,
107
+ },
108
+ }));
109
+ }
110
+
111
+ /** Create a portable source from already-extracted static call sites. */
112
+ export function staticTranslationDiscoverySource(
113
+ extracted: Iterable<ExtractedTranslationKey>,
114
+ name = 'static-code',
115
+ ): TranslationDiscoverySource {
116
+ const declarations = translationDeclarationsFromExtractedKeys(extracted);
117
+ return {
118
+ name,
119
+ async *discover() {
120
+ yield* declarations;
121
+ },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Apply extracted keys. Source drift is delegated to the provider, which marks
127
+ * existing units stale. Orphans are deliberately report-only.
128
+ */
129
+ export async function syncTranslationKeys(
130
+ target: TranslationKeySyncTarget,
131
+ appId: string,
132
+ extracted: ExtractedTranslationKey[],
133
+ options: TranslationKeySyncOptions = {},
134
+ ): Promise<TranslationKeySyncReport> {
135
+ const existing = await target.list({ appId });
136
+ const existingByKey = new Map(existing.map((item) => [item.key, item]));
137
+ const defaultsByKey = new Map<string, Set<string>>();
138
+ for (const item of extracted) {
139
+ const defaults = defaultsByKey.get(item.key) ?? new Set<string>();
140
+ defaults.add(item.sourceText);
141
+ defaultsByKey.set(item.key, defaults);
142
+ }
143
+ const report: TranslationKeySyncReport = {
144
+ created: [], updated: [], unchanged: [], orphaned: [], conflicts: [],
145
+ };
146
+ for (const [key, defaults] of defaultsByKey) {
147
+ if (defaults.size > 1) {
148
+ report.conflicts.push({ key, defaults: [...defaults].sort() });
149
+ continue;
150
+ }
151
+ const sourceText = [...defaults][0];
152
+ const current = existingByKey.get(key);
153
+ if (!current) report.created.push(key);
154
+ else if (current.sourceText !== sourceText) report.updated.push(key);
155
+ else {
156
+ report.unchanged.push(key);
157
+ continue;
158
+ }
159
+ if (!options.dryRun) {
160
+ await target.upsert({ appId, key, sourceText, kind: 'ui' });
161
+ }
162
+ }
163
+ report.orphaned = existing
164
+ .map((item) => item.key)
165
+ .filter((key) => !defaultsByKey.has(key))
166
+ .sort();
167
+ return report;
168
+ }