@wads.dev/i18n-editor 0.0.1-alpha.0 → 0.0.1-beta.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.
@@ -0,0 +1,357 @@
1
+ import fs from 'node:fs';
2
+ import fsPromises from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import ts from 'typescript';
6
+ import { collectHtmlI18nReferences } from '@wads.dev/i18n-html/usage';
7
+ import { buildTranslationOwners } from '../core/exportPlan.js';
8
+ const USAGE_CACHE_FORMAT = 'wads-i18n-usage-cache';
9
+ const USAGE_CACHE_VERSION = 1;
10
+ function collectLeafKeys(value, prefix = '', result = []) {
11
+ if (Array.isArray(value)) {
12
+ value.forEach((item, index) => collectLeafKeys(item, `${prefix}[${index}]`, result));
13
+ return result;
14
+ }
15
+ if (value !== null && typeof value === 'object' && !('$type' in value)) {
16
+ Object.entries(value).forEach(([key, child]) => {
17
+ collectLeafKeys(child, prefix ? `${prefix}.${key}` : key, result);
18
+ });
19
+ return result;
20
+ }
21
+ if (prefix)
22
+ result.push(prefix);
23
+ return result;
24
+ }
25
+ function usageCachePath(projectDirectory) {
26
+ return path.join(projectDirectory, 'node_modules', '.cache', '@wads.dev', 'i18n-editor', 'usage.json');
27
+ }
28
+ function usageFingerprint(bundle, config) {
29
+ const referenceLanguage = Object.values(bundle.languages)[0];
30
+ const keys = referenceLanguage ? collectLeafKeys(referenceLanguage.translations).sort() : [];
31
+ return createHash('sha256').update(JSON.stringify({
32
+ keys,
33
+ catalogFile: config.catalogFile,
34
+ levelImports: config.levelImports,
35
+ translationsDirectory: config.translationsDirectory,
36
+ importAliases: config.exportConfig.importAliases,
37
+ })).digest('hex');
38
+ }
39
+ async function readUsageCache(filePath) {
40
+ try {
41
+ const cache = JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
42
+ if (cache.format !== USAGE_CACHE_FORMAT
43
+ || cache.version !== USAGE_CACHE_VERSION
44
+ || !cache.report?.entries)
45
+ return null;
46
+ return cache;
47
+ }
48
+ catch (error) {
49
+ if (error.code === 'ENOENT' || error instanceof SyntaxError)
50
+ return null;
51
+ throw error;
52
+ }
53
+ }
54
+ async function writeUsageCache(filePath, cache) {
55
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
56
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
57
+ try {
58
+ await fsPromises.writeFile(temporaryPath, `${JSON.stringify(cache)}\n`, 'utf8');
59
+ await fsPromises.rename(temporaryPath, filePath);
60
+ }
61
+ catch (error) {
62
+ await fsPromises.rm(temporaryPath, { force: true });
63
+ throw error;
64
+ }
65
+ }
66
+ export async function inspectTranslationUsageCache(options) {
67
+ const filePath = usageCachePath(options.projectDirectory);
68
+ const fingerprint = usageFingerprint(options.bundle, options.config);
69
+ const cache = await readUsageCache(filePath);
70
+ if (!cache)
71
+ return { cacheStatus: 'missing', report: null };
72
+ return {
73
+ cacheStatus: cache.fingerprint === fingerprint ? 'verified' : 'unverified',
74
+ report: cache.report,
75
+ };
76
+ }
77
+ export async function refreshTranslationUsageCache(options) {
78
+ const filePath = usageCachePath(options.projectDirectory);
79
+ const fingerprint = usageFingerprint(options.bundle, options.config);
80
+ const report = analyzeTranslationUsage(options);
81
+ await writeUsageCache(filePath, {
82
+ format: USAGE_CACHE_FORMAT,
83
+ version: USAGE_CACHE_VERSION,
84
+ fingerprint,
85
+ report,
86
+ });
87
+ return { cacheStatus: 'verified', report };
88
+ }
89
+ export async function analyzeTranslationUsageCached(options, refresh = false) {
90
+ if (!refresh) {
91
+ const cached = await inspectTranslationUsageCache(options);
92
+ if (cached.cacheStatus === 'verified' && cached.report)
93
+ return cached.report;
94
+ }
95
+ return (await refreshTranslationUsageCache(options)).report;
96
+ }
97
+ function normalizePath(value) {
98
+ return value.replaceAll(path.sep, '/');
99
+ }
100
+ function isInside(filePath, directory) {
101
+ const relative = path.relative(directory, filePath);
102
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
103
+ }
104
+ function findRootInterface(sourceFile) {
105
+ const interfaces = sourceFile.statements.filter((statement) => {
106
+ return ts.isInterfaceDeclaration(statement);
107
+ });
108
+ return interfaces.find((statement) => {
109
+ return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === true;
110
+ }) || interfaces[0];
111
+ }
112
+ function getTypeSymbol(type) {
113
+ return type.aliasSymbol || type.getSymbol();
114
+ }
115
+ export function analyzeTranslationUsage({ projectDirectory, bundle, config, }) {
116
+ const tsconfigPath = ts.findConfigFile(projectDirectory, fs.existsSync, 'tsconfig.json');
117
+ if (!tsconfigPath)
118
+ throw new Error(`Could not find tsconfig.json from ${projectDirectory}.`);
119
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
120
+ if (configFile.error) {
121
+ throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n'));
122
+ }
123
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(tsconfigPath));
124
+ if (parsed.errors.length > 0) {
125
+ throw new Error(ts.formatDiagnosticsWithColorAndContext(parsed.errors, {
126
+ getCanonicalFileName: (fileName) => fileName,
127
+ getCurrentDirectory: () => projectDirectory,
128
+ getNewLine: () => '\n',
129
+ }));
130
+ }
131
+ const program = ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
132
+ const checker = program.getTypeChecker();
133
+ const referenceLanguage = Object.values(bundle.languages)[0];
134
+ if (!referenceLanguage)
135
+ throw new Error('The bundle does not contain any languages.');
136
+ const leafKeys = collectLeafKeys(referenceLanguage.translations).sort((left, right) => left.localeCompare(right));
137
+ const leafKeySet = new Set(leafKeys);
138
+ const owners = buildTranslationOwners(bundle, config);
139
+ const rootOwner = owners.find((owner) => owner.keyPath === '');
140
+ if (!rootOwner)
141
+ throw new Error('Could not resolve the root translation owner.');
142
+ const rootBasePath = path.resolve(projectDirectory, rootOwner.directory, config.translationsDirectory, 'base.ts');
143
+ const rootSource = program.getSourceFile(rootBasePath);
144
+ if (!rootSource)
145
+ throw new Error(`The root translation type is not part of the TypeScript program: ${rootBasePath}`);
146
+ const rootInterface = findRootInterface(rootSource);
147
+ if (!rootInterface)
148
+ throw new Error(`Could not find a default exported interface in ${rootBasePath}.`);
149
+ const rootTypeNode = rootInterface;
150
+ const keysBySymbol = new Map();
151
+ const collectionKeysBySymbol = new Map();
152
+ const collectionPrefixesBySymbol = new Map();
153
+ const prefixesByTypeSymbol = new Map();
154
+ function canonicalSymbols(symbol) {
155
+ return [...new Set([symbol, ...checker.getRootSymbols(symbol)])];
156
+ }
157
+ function registerSymbol(map, symbol, values) {
158
+ canonicalSymbols(symbol).forEach((candidate) => {
159
+ const registered = map.get(candidate) || new Set();
160
+ for (const value of values)
161
+ registered.add(value);
162
+ map.set(candidate, registered);
163
+ });
164
+ }
165
+ function getSymbolValues(map, symbol) {
166
+ if (!symbol)
167
+ return new Set();
168
+ return new Set(canonicalSymbols(symbol).flatMap((candidate) => [...(map.get(candidate) || [])]));
169
+ }
170
+ function descendantKeys(prefix) {
171
+ return leafKeys.filter((key) => key === prefix || key.startsWith(`${prefix}.`) || key.startsWith(`${prefix}[`));
172
+ }
173
+ function visitValue(value, type, prefix, ownerSymbol) {
174
+ const typeSymbol = getTypeSymbol(type);
175
+ if (typeSymbol) {
176
+ const prefixes = prefixesByTypeSymbol.get(typeSymbol) || new Set();
177
+ prefixes.add(prefix);
178
+ prefixesByTypeSymbol.set(typeSymbol, prefixes);
179
+ }
180
+ if (ownerSymbol) {
181
+ if (leafKeySet.has(prefix))
182
+ registerSymbol(keysBySymbol, ownerSymbol, [prefix]);
183
+ else {
184
+ registerSymbol(collectionKeysBySymbol, ownerSymbol, descendantKeys(prefix));
185
+ registerSymbol(collectionPrefixesBySymbol, ownerSymbol, [prefix]);
186
+ }
187
+ }
188
+ if (Array.isArray(value)) {
189
+ const elementType = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
190
+ if (!elementType)
191
+ return;
192
+ value.forEach((item, index) => visitValue(item, elementType, `${prefix}[${index}]`));
193
+ return;
194
+ }
195
+ if (value !== null && typeof value === 'object' && !('$type' in value)) {
196
+ Object.entries(value).forEach(([propertyName, child]) => {
197
+ const property = type.getProperty(propertyName);
198
+ if (!property)
199
+ return;
200
+ const declaration = property.valueDeclaration ?? property.declarations?.[0] ?? rootTypeNode;
201
+ const childType = checker.getTypeOfSymbolAtLocation(property, declaration);
202
+ visitValue(child, childType, prefix ? `${prefix}.${propertyName}` : propertyName, property);
203
+ });
204
+ }
205
+ }
206
+ visitValue(referenceLanguage.translations, checker.getTypeAtLocation(rootInterface), '');
207
+ const references = new Map(leafKeys.map((key) => [key, []]));
208
+ const uncertainReferences = new Map(leafKeys.map((key) => [key, []]));
209
+ const uncertainKeys = new Set();
210
+ const managedDirectories = owners.map((owner) => {
211
+ return path.resolve(projectDirectory, owner.directory, config.translationsDirectory);
212
+ });
213
+ function addReference(key, node) {
214
+ const sourceFile = node.getSourceFile();
215
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
216
+ addReferenceLocation(key, {
217
+ file: normalizePath(path.relative(projectDirectory, sourceFile.fileName)),
218
+ line: position.line + 1,
219
+ column: position.character + 1,
220
+ });
221
+ }
222
+ function addReferenceLocation(key, reference) {
223
+ const entries = references.get(key);
224
+ if (!entries?.some((entry) => entry.file === reference.file
225
+ && entry.line === reference.line
226
+ && entry.column === reference.column))
227
+ entries?.push(reference);
228
+ }
229
+ function locationFor(node) {
230
+ const sourceFile = node.getSourceFile();
231
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
232
+ return {
233
+ file: normalizePath(path.relative(projectDirectory, sourceFile.fileName)),
234
+ line: position.line + 1,
235
+ column: position.character + 1,
236
+ };
237
+ }
238
+ function addUncertainReference(key, node) {
239
+ const location = locationFor(node);
240
+ const entries = uncertainReferences.get(key);
241
+ if (!entries?.some((entry) => entry.file === location.file
242
+ && entry.line === location.line
243
+ && entry.column === location.column))
244
+ entries?.push(location);
245
+ }
246
+ function markUncertain(type, node) {
247
+ const symbol = getTypeSymbol(type);
248
+ if (!symbol)
249
+ return;
250
+ const prefixes = prefixesByTypeSymbol.get(symbol);
251
+ prefixes?.forEach((prefix) => {
252
+ const dottedPrefix = prefix ? `${prefix}.` : '';
253
+ leafKeys.filter((key) => key === prefix || key.startsWith(dottedPrefix)).forEach((key) => {
254
+ uncertainKeys.add(key);
255
+ addUncertainReference(key, node);
256
+ });
257
+ });
258
+ }
259
+ function markKeysUncertain(keys, node) {
260
+ for (const key of keys) {
261
+ uncertainKeys.add(key);
262
+ addUncertainReference(key, node);
263
+ }
264
+ }
265
+ function isContinuedTranslationAccess(node) {
266
+ const parent = node.parent;
267
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
268
+ const nextSymbol = checker.getSymbolAtLocation(parent.name);
269
+ return getSymbolValues(keysBySymbol, nextSymbol).size > 0
270
+ || getSymbolValues(collectionKeysBySymbol, nextSymbol).size > 0;
271
+ }
272
+ if (ts.isElementAccessExpression(parent) && parent.expression === node)
273
+ return true;
274
+ return false;
275
+ }
276
+ function isSelectorResult(node) {
277
+ let current = node;
278
+ while (ts.isParenthesizedExpression(current.parent) && current.parent.expression === current) {
279
+ current = current.parent;
280
+ }
281
+ return ts.isArrowFunction(current.parent) && current.parent.body === current;
282
+ }
283
+ function visitNode(node) {
284
+ if (ts.isPropertyAccessExpression(node)) {
285
+ const symbol = checker.getSymbolAtLocation(node.name);
286
+ getSymbolValues(keysBySymbol, symbol).forEach((key) => addReference(key, node.name));
287
+ if (!isContinuedTranslationAccess(node) && !isSelectorResult(node)) {
288
+ markKeysUncertain(getSymbolValues(collectionKeysBySymbol, symbol), node.name);
289
+ }
290
+ }
291
+ else if (ts.isElementAccessExpression(node)) {
292
+ const argument = node.argumentExpression;
293
+ if (ts.isStringLiteralLike(argument)) {
294
+ const property = checker.getTypeAtLocation(node.expression).getProperty(argument.text);
295
+ getSymbolValues(keysBySymbol, property).forEach((key) => addReference(key, argument));
296
+ markKeysUncertain(getSymbolValues(collectionKeysBySymbol, property), argument);
297
+ }
298
+ else if (ts.isNumericLiteral(argument)) {
299
+ const expressionSymbol = ts.isPropertyAccessExpression(node.expression)
300
+ ? checker.getSymbolAtLocation(node.expression.name)
301
+ : checker.getSymbolAtLocation(node.expression);
302
+ getSymbolValues(collectionPrefixesBySymbol, expressionSymbol).forEach((prefix) => {
303
+ const indexedKey = `${prefix}[${argument.text}]`;
304
+ if (leafKeySet.has(indexedKey))
305
+ addReference(indexedKey, argument);
306
+ else
307
+ markKeysUncertain(descendantKeys(indexedKey), argument);
308
+ });
309
+ }
310
+ else {
311
+ markUncertain(checker.getTypeAtLocation(node.expression), argument);
312
+ const expressionSymbol = ts.isPropertyAccessExpression(node.expression)
313
+ ? checker.getSymbolAtLocation(node.expression.name)
314
+ : checker.getSymbolAtLocation(node.expression);
315
+ markKeysUncertain(getSymbolValues(collectionKeysBySymbol, expressionSymbol), argument);
316
+ }
317
+ }
318
+ ts.forEachChild(node, visitNode);
319
+ }
320
+ program.getSourceFiles()
321
+ .filter((sourceFile) => !sourceFile.isDeclarationFile)
322
+ .filter((sourceFile) => isInside(sourceFile.fileName, projectDirectory))
323
+ .filter((sourceFile) => !managedDirectories.some((directory) => isInside(sourceFile.fileName, directory)))
324
+ .forEach(visitNode);
325
+ const htmlReferences = collectHtmlI18nReferences({
326
+ directory: projectDirectory,
327
+ ignoredDirectories: managedDirectories,
328
+ });
329
+ htmlReferences.forEach((reference) => {
330
+ if (!leafKeySet.has(reference.key))
331
+ return;
332
+ addReferenceLocation(reference.key, reference);
333
+ });
334
+ const entries = Object.fromEntries(leafKeys.map((key) => {
335
+ const keyReferences = references.get(key) || [];
336
+ const keyUncertainReferences = uncertainReferences.get(key) || [];
337
+ const displayedReferences = keyReferences.length > 0 ? keyReferences : keyUncertainReferences;
338
+ const fileCount = new Set(displayedReferences.map((reference) => reference.file)).size;
339
+ return [key, {
340
+ status: keyReferences.length > 0 ? 'used' : uncertainKeys.has(key) ? 'uncertain' : 'unreferenced',
341
+ referenceCount: keyReferences.length,
342
+ uncertainReferenceCount: keyUncertainReferences.length,
343
+ fileCount,
344
+ references: keyReferences,
345
+ uncertainReferences: keyUncertainReferences,
346
+ }];
347
+ }));
348
+ const analyzedSourceFiles = program.getSourceFiles()
349
+ .filter((sourceFile) => !sourceFile.isDeclarationFile)
350
+ .filter((sourceFile) => isInside(sourceFile.fileName, projectDirectory))
351
+ .filter((sourceFile) => !managedDirectories.some((directory) => isInside(sourceFile.fileName, directory)));
352
+ return {
353
+ analyzedAt: Date.now(),
354
+ sourceFileCount: analyzedSourceFiles.length + new Set(htmlReferences.map((reference) => reference.file)).size,
355
+ entries,
356
+ };
357
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wads.dev/i18n-editor",
3
- "version": "0.0.1-alpha.0",
3
+ "version": "0.0.1-beta.0",
4
4
  "description": "Local web editor for typed i18n projects.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -18,8 +18,8 @@
18
18
  "LICENSE"
19
19
  ],
20
20
  "bin": {
21
- "i18n-edit": "./dist/cli/index.js",
22
- "i18n-editor": "./dist/cli/index.js"
21
+ "i18n-edit": "dist/cli/index.js",
22
+ "i18n-editor": "dist/cli/index.js"
23
23
  },
24
24
  "engines": {
25
25
  "node": ">=20"
@@ -30,11 +30,13 @@
30
30
  "check:node": "tsc --project tsconfig.node.json --noEmit",
31
31
  "check:web": "tsc --project tsconfig.web.json --noEmit",
32
32
  "build": "npm run clean && tsc --project tsconfig.node.json && node ./scripts/build-web.mjs",
33
- "start": "node ./dist/cli/index.js",
33
+ "build:web": "node ./scripts/build-web.mjs",
34
+ "start": "concurrently -P --kill-others --names server,web \"nodemon --watch src --ext ts --exec 'tsx src/cli/index.ts {@}'\" \"nodemon --watch src/web --ext ts,html,css --exec npm run build:web\" --",
34
35
  "prepack": "npm run check && npm run build"
35
36
  },
36
37
  "dependencies": {
37
38
  "@fastify/static": "^10.1.0",
39
+ "@wads.dev/i18n-html": "^0.0.1-alpha.0",
38
40
  "@wads.dev/i18n-ts": "^0.0.1-alpha.1",
39
41
  "commander": "^14.0.3",
40
42
  "diff": "^9.0.0",
@@ -43,7 +45,10 @@
43
45
  },
44
46
  "devDependencies": {
45
47
  "@types/node": "^26.1.1",
46
- "esbuild": "^0.28.1"
48
+ "concurrently": "^9.2.4",
49
+ "esbuild": "^0.28.1",
50
+ "nodemon": "^3.1.14",
51
+ "tsx": "^4.23.1"
47
52
  },
48
53
  "publishConfig": {
49
54
  "access": "public"