@wads.dev/i18n-editor 0.0.1-alpha.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,277 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import ts from 'typescript';
4
+ import { resolveImportAlias } from '@wads.dev/i18n-ts/config';
5
+ import { createTwoFilesPatch } from 'diff';
6
+ import { buildExportPlan, buildTranslationOwners } from '../core/exportPlan.js';
7
+ import { generateSourceFiles } from '../core/sourceFiles.js';
8
+ import { createProjectContext } from './projectContext.js';
9
+ async function readOptionalFile(filePath) {
10
+ try {
11
+ return await fs.readFile(filePath, 'utf8');
12
+ }
13
+ catch (error) {
14
+ if (error.code === 'ENOENT')
15
+ return null;
16
+ throw error;
17
+ }
18
+ }
19
+ function assertInsideProject(projectDirectory, filePath) {
20
+ const absolutePath = path.resolve(projectDirectory, filePath);
21
+ const relativePath = path.relative(projectDirectory, absolutePath);
22
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
23
+ throw new Error(`Refusing to write outside the project directory: ${filePath}`);
24
+ }
25
+ return absolutePath;
26
+ }
27
+ function readInterfaceName(content) {
28
+ return content?.match(/export\s+default\s+interface\s+([$A-Z_a-z][$\w]*)/)?.[1] || null;
29
+ }
30
+ function readLanguageTypeName(content) {
31
+ return content?.match(/export\s+type\s+([$A-Z_a-z][$\w]*)\s*=/)?.[1] || null;
32
+ }
33
+ function readPropertyTypes(content, ownerKeyPath) {
34
+ if (!content)
35
+ return {};
36
+ const sourceFile = ts.createSourceFile('base.ts', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
37
+ const result = {};
38
+ const interfaceNode = sourceFile.statements.find((statement) => {
39
+ return ts.isInterfaceDeclaration(statement)
40
+ && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === true;
41
+ });
42
+ if (!interfaceNode)
43
+ return result;
44
+ function visit(members, parentPath) {
45
+ members.forEach((member) => {
46
+ if (!ts.isPropertySignature(member) || !member.type || !member.name)
47
+ return;
48
+ const name = ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)
49
+ ? member.name.text
50
+ : member.name.getText(sourceFile);
51
+ const keyPath = parentPath ? `${parentPath}.${name}` : name;
52
+ if (ts.isTypeLiteralNode(member.type))
53
+ visit(member.type.members, keyPath);
54
+ else
55
+ result[keyPath] = member.type.getText(sourceFile);
56
+ });
57
+ }
58
+ visit(interfaceNode.members, ownerKeyPath);
59
+ return result;
60
+ }
61
+ function readTypeImports(content) {
62
+ if (!content)
63
+ return [];
64
+ const sourceFile = ts.createSourceFile('base.ts', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
65
+ return sourceFile.statements.flatMap((statement) => {
66
+ if (!ts.isImportDeclaration(statement)
67
+ || !statement.importClause?.name
68
+ || !ts.isStringLiteral(statement.moduleSpecifier))
69
+ return [];
70
+ return [{ name: statement.importClause.name.text, path: statement.moduleSpecifier.text }];
71
+ });
72
+ }
73
+ function readValueImports(content) {
74
+ if (!content)
75
+ return [];
76
+ const sourceFile = ts.createSourceFile('language.ts', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
77
+ return sourceFile.statements.flatMap((statement) => {
78
+ if (!ts.isImportDeclaration(statement)
79
+ || statement.importClause?.isTypeOnly
80
+ || !statement.importClause?.name
81
+ || !ts.isStringLiteral(statement.moduleSpecifier))
82
+ return [];
83
+ return [{ name: statement.importClause.name.text, path: statement.moduleSpecifier.text }];
84
+ });
85
+ }
86
+ function normalizeProjectPath(filePath) {
87
+ return filePath.replaceAll(path.sep, '/').replaceAll(/\/+/g, '/');
88
+ }
89
+ function resolveTypeImportPath(baseFilePath, importPath, config) {
90
+ let resolved;
91
+ if (importPath.startsWith('.')) {
92
+ resolved = path.posix.normalize(path.posix.join(path.posix.dirname(baseFilePath), importPath));
93
+ }
94
+ else {
95
+ resolved = resolveImportAlias(importPath, config.exportConfig.importAliases);
96
+ if (resolved === importPath)
97
+ return null;
98
+ }
99
+ return /\.[cm]?[jt]sx?$/.test(resolved) ? resolved : `${resolved}.ts`;
100
+ }
101
+ function isInsideDirectory(filePath, directory) {
102
+ return filePath === directory || filePath.startsWith(`${directory}/`);
103
+ }
104
+ function isIgnoredDeletionFile(filePath, deletion) {
105
+ return deletion.ignoredExtensions.includes(path.extname(filePath).toLowerCase());
106
+ }
107
+ async function listManagedFiles(directory) {
108
+ try {
109
+ const entries = await fs.readdir(directory, { withFileTypes: true });
110
+ const nested = await Promise.all(entries.map(async (entry) => {
111
+ const entryPath = path.join(directory, entry.name);
112
+ if (entry.isDirectory() && !entry.isSymbolicLink())
113
+ return listManagedFiles(entryPath);
114
+ return [entryPath];
115
+ }));
116
+ return nested.flat();
117
+ }
118
+ catch (error) {
119
+ if (error.code === 'ENOENT')
120
+ return [];
121
+ throw error;
122
+ }
123
+ }
124
+ async function removeEmptyDescendants(directory) {
125
+ let entries;
126
+ try {
127
+ entries = await fs.readdir(directory, { withFileTypes: true });
128
+ }
129
+ catch (error) {
130
+ if (error.code === 'ENOENT')
131
+ return;
132
+ throw error;
133
+ }
134
+ await Promise.all(entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()).map(async (entry) => {
135
+ const childPath = path.join(directory, entry.name);
136
+ await removeEmptyDescendants(childPath);
137
+ try {
138
+ await fs.rmdir(childPath);
139
+ }
140
+ catch (error) {
141
+ if (!['ENOTEMPTY', 'ENOENT'].includes(error.code || ''))
142
+ throw error;
143
+ }
144
+ }));
145
+ }
146
+ export async function planProjectExport(options = {}, state) {
147
+ const projectDirectory = path.resolve(options.projectDirectory ?? process.cwd());
148
+ const project = createProjectContext(options);
149
+ const info = await project.getInfo();
150
+ const config = state?.config ?? info.config;
151
+ const bundle = state?.bundle ?? info.bundle;
152
+ if (!config) {
153
+ throw new Error('Could not find i18n.config.json. Pass --config with the project configuration path.');
154
+ }
155
+ if (!bundle) {
156
+ throw new Error(`Could not find the bundle at ${info.bundlePath}. Run the bundle command first or pass --file.`);
157
+ }
158
+ const exportFiles = buildExportPlan(bundle, config);
159
+ const owners = buildTranslationOwners(bundle, config);
160
+ const managedDirectoryPaths = owners.map((owner) => {
161
+ return normalizeProjectPath(`${owner.directory}/${config.translationsDirectory}`);
162
+ });
163
+ const managedDirectories = managedDirectoryPaths.map((directory) => assertInsideProject(projectDirectory, directory));
164
+ const plannedPaths = new Set(exportFiles.map((file) => normalizeProjectPath(file.path)));
165
+ const existingInterfaceNames = {};
166
+ const existingPropertyTypes = {};
167
+ const existingTypeImports = {};
168
+ const existingValueImportOrder = {};
169
+ const baseContents = new Map();
170
+ await Promise.all(exportFiles.filter((file) => file.kind === 'base').map(async (file) => {
171
+ const content = await readOptionalFile(assertInsideProject(projectDirectory, file.path));
172
+ baseContents.set(file.path, content);
173
+ const name = readInterfaceName(content);
174
+ if (name)
175
+ existingInterfaceNames[file.path] = name;
176
+ }));
177
+ owners.forEach((owner) => {
178
+ const expectedPath = `${owner.directory}/${config.translationsDirectory}/base.ts`.replaceAll(/\/+/g, '/');
179
+ const baseFile = exportFiles.find((file) => file.kind === 'base' && file.path === expectedPath);
180
+ if (!baseFile)
181
+ return;
182
+ const content = baseContents.get(baseFile.path) || null;
183
+ const imports = readTypeImports(content);
184
+ const obsoleteTypeNames = new Set();
185
+ existingTypeImports[baseFile.path] = imports.filter((item) => {
186
+ const targetPath = resolveTypeImportPath(baseFile.path, item.path, config);
187
+ if (!targetPath)
188
+ return true;
189
+ if (plannedPaths.has(targetPath))
190
+ return false;
191
+ if (managedDirectoryPaths.some((directory) => isInsideDirectory(targetPath, directory))) {
192
+ obsoleteTypeNames.add(item.name);
193
+ return false;
194
+ }
195
+ return true;
196
+ });
197
+ const propertyTypes = readPropertyTypes(content, owner.keyPath);
198
+ Object.entries(propertyTypes).forEach(([key, type]) => {
199
+ const usesObsoleteType = [...obsoleteTypeNames].some((name) => new RegExp(`\\b${name}\\b`).test(type));
200
+ if (!usesObsoleteType)
201
+ existingPropertyTypes[key] = type;
202
+ });
203
+ });
204
+ await Promise.all(exportFiles.filter((file) => file.kind === 'language').map(async (file) => {
205
+ const content = await readOptionalFile(assertInsideProject(projectDirectory, file.path));
206
+ existingValueImportOrder[file.path] = readValueImports(content)
207
+ .map((item) => resolveTypeImportPath(file.path, item.path, config))
208
+ .filter((targetPath) => targetPath !== null && plannedPaths.has(targetPath));
209
+ }));
210
+ const catalogFile = info.catalogPath
211
+ ? path.relative(projectDirectory, info.catalogPath).replaceAll(path.sep, '/')
212
+ : config.catalogFile;
213
+ const existingCatalog = catalogFile
214
+ ? await readOptionalFile(assertInsideProject(projectDirectory, catalogFile))
215
+ : null;
216
+ const generatedFiles = generateSourceFiles(bundle, config, {
217
+ catalogFile,
218
+ existingInterfaceNames,
219
+ existingPropertyTypes,
220
+ existingTypeImports,
221
+ existingValueImportOrder,
222
+ existingLanguageTypeName: readLanguageTypeName(existingCatalog) || undefined,
223
+ });
224
+ const generatedChanges = await Promise.all(generatedFiles.map(async (file) => {
225
+ const absolutePath = assertInsideProject(projectDirectory, file.path);
226
+ const existing = await readOptionalFile(absolutePath);
227
+ const status = existing === null ? 'create' : existing === file.content ? 'unchanged' : 'modify';
228
+ return {
229
+ ...file,
230
+ absolutePath,
231
+ status,
232
+ diff: status === 'unchanged'
233
+ ? undefined
234
+ : createTwoFilesPatch(file.path, file.path, existing ?? '', file.content, existing === null ? 'missing' : 'current', 'generated', { context: 3 }),
235
+ };
236
+ }));
237
+ const generatedAbsolutePaths = new Set(generatedChanges.map((change) => change.absolutePath));
238
+ const existingManagedFiles = config.deletion === false
239
+ ? []
240
+ : (await Promise.all(managedDirectories.map(listManagedFiles))).flat();
241
+ const deletedChanges = [...new Set(existingManagedFiles)]
242
+ .filter((filePath) => !generatedAbsolutePaths.has(filePath))
243
+ .filter((filePath) => config.deletion !== false && !isIgnoredDeletionFile(filePath, config.deletion))
244
+ .map((absolutePath) => ({
245
+ kind: 'obsolete',
246
+ path: normalizeProjectPath(path.relative(projectDirectory, absolutePath)),
247
+ absolutePath,
248
+ status: 'delete',
249
+ }));
250
+ const changes = [...generatedChanges, ...deletedChanges]
251
+ .sort((left, right) => left.path.localeCompare(right.path));
252
+ return { projectDirectory, bundlePath: info.bundlePath, changes, managedDirectories, deletion: config.deletion };
253
+ }
254
+ export async function applyProjectExport(plan, options = {}) {
255
+ for (const change of plan.changes.filter((item) => item.status !== 'delete')) {
256
+ if (change.status === 'unchanged' || change.content === undefined)
257
+ continue;
258
+ await fs.mkdir(path.dirname(change.absolutePath), { recursive: true });
259
+ const temporaryPath = `${change.absolutePath}.i18n-editor-${process.pid}.tmp`;
260
+ try {
261
+ await fs.writeFile(temporaryPath, change.content, 'utf8');
262
+ await fs.rename(temporaryPath, change.absolutePath);
263
+ }
264
+ catch (error) {
265
+ await fs.rm(temporaryPath, { force: true });
266
+ throw error;
267
+ }
268
+ }
269
+ const shouldDelete = plan.deletion !== false
270
+ && (plan.deletion.autoDelete || options.deleteObsolete === true);
271
+ if (shouldDelete) {
272
+ for (const change of plan.changes.filter((item) => item.status === 'delete')) {
273
+ await fs.rm(change.absolutePath, { force: true, recursive: true });
274
+ }
275
+ await Promise.all(plan.managedDirectories.map(removeEmptyDescendants));
276
+ }
277
+ }
@@ -0,0 +1,11 @@
1
+ import type { GenerateBundleResult, ProjectInfo } from '../core/projectApi.js';
2
+ export type ProjectContextOptions = {
3
+ projectDirectory?: string;
4
+ configFile?: string;
5
+ catalogFile?: string;
6
+ bundleFile?: string;
7
+ };
8
+ export declare function createProjectContext(options?: ProjectContextOptions): {
9
+ getInfo: () => Promise<ProjectInfo>;
10
+ generateBundle: () => Promise<GenerateBundleResult>;
11
+ };
@@ -0,0 +1,122 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseBundle } from '@wads.dev/i18n-ts/bundle';
6
+ import { PROJECT_CONFIG_FORMAT, PROJECT_CONFIG_VERSION, } from '@wads.dev/i18n-ts/config';
7
+ import { normalizeEditorProjectConfig } from '../core/projectConfig.js';
8
+ const DEFAULT_CATALOG_CANDIDATES = [
9
+ 'src/shared/i18n/index.ts',
10
+ 'src/shared/i18n/translations/index.ts',
11
+ 'src/i18n/index.ts',
12
+ 'src/translations/index.ts',
13
+ ];
14
+ async function isFile(filePath) {
15
+ try {
16
+ return (await fs.stat(filePath)).isFile();
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function resolveFromProject(projectDirectory, filePath) {
23
+ return path.resolve(projectDirectory, filePath);
24
+ }
25
+ async function readConfig(configPath) {
26
+ if (!await isFile(configPath))
27
+ return null;
28
+ const raw = JSON.parse(await fs.readFile(configPath, 'utf8'));
29
+ if (raw.format !== PROJECT_CONFIG_FORMAT || raw.version !== PROJECT_CONFIG_VERSION) {
30
+ throw new Error(`Invalid project configuration. Expected ${PROJECT_CONFIG_FORMAT} version ${PROJECT_CONFIG_VERSION}.`);
31
+ }
32
+ const config = normalizeEditorProjectConfig(raw);
33
+ const catalogFile = typeof raw.catalogFile === 'string' && raw.catalogFile.trim()
34
+ ? raw.catalogFile.trim()
35
+ : 'src/shared/i18n/index.ts';
36
+ return { ...config, catalogFile };
37
+ }
38
+ async function readBundle(bundlePath) {
39
+ if (!await isFile(bundlePath))
40
+ return null;
41
+ return parseBundle(await fs.readFile(bundlePath, 'utf8'));
42
+ }
43
+ async function findCatalogPath(projectDirectory, explicitCatalogFile, config) {
44
+ const candidates = [
45
+ explicitCatalogFile,
46
+ config?.catalogFile,
47
+ ...DEFAULT_CATALOG_CANDIDATES,
48
+ ].filter((candidate) => Boolean(candidate));
49
+ for (const candidate of candidates) {
50
+ const resolved = resolveFromProject(projectDirectory, candidate);
51
+ if (await isFile(resolved))
52
+ return resolved;
53
+ }
54
+ return null;
55
+ }
56
+ function getBundlerCliPath() {
57
+ const runtimeEntry = fileURLToPath(import.meta.resolve('@wads.dev/i18n-ts'));
58
+ return path.resolve(path.dirname(runtimeEntry), '../cli/exportBundle.js');
59
+ }
60
+ async function runBundler(projectDirectory, catalogPath, bundlePath) {
61
+ const bundlerCliPath = getBundlerCliPath();
62
+ await new Promise((resolve, reject) => {
63
+ const child = spawn(process.execPath, [
64
+ bundlerCliPath,
65
+ '--input', catalogPath,
66
+ '--output', bundlePath,
67
+ ], {
68
+ cwd: projectDirectory,
69
+ stdio: ['ignore', 'ignore', 'pipe'],
70
+ });
71
+ let stderr = '';
72
+ child.stderr.setEncoding('utf8');
73
+ child.stderr.on('data', (chunk) => {
74
+ stderr += chunk;
75
+ });
76
+ child.once('error', reject);
77
+ child.once('exit', (code) => {
78
+ if (code === 0)
79
+ resolve();
80
+ else
81
+ reject(new Error(stderr.trim() || `The bundle generator exited with code ${String(code)}.`));
82
+ });
83
+ });
84
+ }
85
+ export function createProjectContext(options = {}) {
86
+ const projectDirectory = path.resolve(options.projectDirectory ?? process.cwd());
87
+ const configPath = resolveFromProject(projectDirectory, options.configFile ?? 'i18n.config.json');
88
+ const bundlePath = resolveFromProject(projectDirectory, options.bundleFile ?? 'i18n.bundle.json');
89
+ let generation = null;
90
+ async function getInfo() {
91
+ const config = await readConfig(configPath);
92
+ const catalogPath = await findCatalogPath(projectDirectory, options.catalogFile, config);
93
+ return {
94
+ projectDirectory,
95
+ configPath: config ? configPath : null,
96
+ catalogPath,
97
+ bundlePath,
98
+ config,
99
+ bundle: await readBundle(bundlePath),
100
+ canGenerateBundle: catalogPath !== null,
101
+ };
102
+ }
103
+ async function generateBundle() {
104
+ if (generation)
105
+ return generation;
106
+ generation = (async () => {
107
+ const info = await getInfo();
108
+ if (!info.catalogPath) {
109
+ throw new Error('Could not find the TypeScript catalog. Set catalogFile in i18n.config.json or pass --input to the Editor.');
110
+ }
111
+ await runBundler(projectDirectory, info.catalogPath, bundlePath);
112
+ const bundle = await readBundle(bundlePath);
113
+ if (!bundle)
114
+ throw new Error('The bundle generator finished without creating the bundle file.');
115
+ return { bundle, bundlePath };
116
+ })().finally(() => {
117
+ generation = null;
118
+ });
119
+ return generation;
120
+ }
121
+ return { getInfo, generateBundle };
122
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@wads.dev/i18n-editor",
3
+ "version": "0.0.1-alpha.0",
4
+ "description": "Local web editor for typed i18n projects.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Wads.dev",
8
+ "url": "https://github.com/wads-dev"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/wads-dev/i18n-editor.git"
13
+ },
14
+ "type": "module",
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "bin": {
21
+ "i18n-edit": "./dist/cli/index.js",
22
+ "i18n-editor": "./dist/cli/index.js"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "scripts": {
28
+ "clean": "node ./scripts/clean-dist.mjs",
29
+ "check": "npm run check:node && npm run check:web",
30
+ "check:node": "tsc --project tsconfig.node.json --noEmit",
31
+ "check:web": "tsc --project tsconfig.web.json --noEmit",
32
+ "build": "npm run clean && tsc --project tsconfig.node.json && node ./scripts/build-web.mjs",
33
+ "start": "node ./dist/cli/index.js",
34
+ "prepack": "npm run check && npm run build"
35
+ },
36
+ "dependencies": {
37
+ "@fastify/static": "^10.1.0",
38
+ "@wads.dev/i18n-ts": "^0.0.1-alpha.1",
39
+ "commander": "^14.0.3",
40
+ "diff": "^9.0.0",
41
+ "fastify": "^5.10.0",
42
+ "typescript": "^6.0.3"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^26.1.1",
46
+ "esbuild": "^0.28.1"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }