@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wads.dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # @wads.dev/i18n-editor
2
+
3
+ Local web editor for typed i18n projects.
4
+
5
+ This first alpha compiles the existing browser editor and its reusable operations from TypeScript, then serves the generated application through a local Fastify server.
6
+
7
+ > Status: `0.0.1-alpha.0`. The package is under active development and has not been published to npm yet.
8
+
9
+ ## Wads i18n ecosystem
10
+
11
+ | Project | Responsibility |
12
+ | --- | --- |
13
+ | [`@wads.dev/i18n-ts`](https://github.com/wads-dev/i18n-ts) | Framework-independent contracts, language loading, project configuration and portable bundles. |
14
+ | [`@wads.dev/i18n-react`](https://github.com/wads-dev/i18n-react) | Optional React Provider, hooks and rich translation rendering. |
15
+ | [`@wads.dev/i18n-editor`](https://github.com/wads-dev/i18n-editor) | Local bundle and project editor. This repository. |
16
+
17
+ The Editor consumes the bundle and `i18n.config.json` formats defined by `i18n-ts`. It is framework-independent: React projects may use `i18n-react`, but the Editor does not require it.
18
+
19
+ ## Development
20
+
21
+ ```sh
22
+ npm install
23
+ npm run check
24
+ npm run build
25
+ ```
26
+
27
+ Run the generated executable from this repository:
28
+
29
+ ```sh
30
+ ./dist/cli/index.js
31
+ ```
32
+
33
+ Or run it from a consuming project that keeps the repository under `wads.dev/i18n-editor`:
34
+
35
+ ```sh
36
+ ./wads.dev/i18n-editor/dist/cli/index.js
37
+ ```
38
+
39
+ The default address is `http://127.0.0.1:4173`. Override it when needed:
40
+
41
+ ```sh
42
+ ./wads.dev/i18n-editor/dist/cli/index.js --port 4300
43
+ ```
44
+
45
+ The current directory is the project root. On startup, the server reads `i18n.config.json` and `i18n.bundle.json`. If the bundle does not exist, it uses `catalogFile` from the configuration to generate it automatically. Paths can be overridden explicitly:
46
+
47
+ ```sh
48
+ ./wads.dev/i18n-editor/dist/cli/index.js \
49
+ --project . \
50
+ --config i18n.config.json \
51
+ --input src/shared/i18n/index.ts \
52
+ --bundle i18n.bundle.json
53
+ ```
54
+
55
+ ## CLI commands
56
+
57
+ Generate the default `i18n.bundle.json` without starting the server:
58
+
59
+ ```sh
60
+ i18n-edit bundle
61
+ i18n-edit bundle --output review.bundle.json
62
+ ```
63
+
64
+ Print the same generated-file preview used by the Web Editor, enriched with the current filesystem state:
65
+
66
+ ```sh
67
+ i18n-edit preview
68
+ i18n-edit preview --file review.bundle.json
69
+ ```
70
+
71
+ Preview is read-only. It resolves the bundle and `i18n.config.json`, then classifies every managed path as unchanged, modified, new or deleted. New paths are green, modified paths are yellow and files that exist inside a managed `i18n` directory but are absent from the generation plan are red. Unified diffs for new and modified files are shown by default; use `--no-diff` for the compact file list. Deleted files never print their full removed contents.
72
+
73
+ Plan the source files generated from a bundle:
74
+
75
+ ```sh
76
+ i18n-edit export
77
+ i18n-edit export --file review.bundle.json
78
+ ```
79
+
80
+ The export command shows the same status and unified-diff preview before asking for confirmation. Divergent files are warnings and remain preserved unless the project enables `deletion.autoDelete` or the command explicitly receives `--delete`. Use `--no-diff` for the compact plan, and use `--yes` or `-y` only in automation or after reviewing the plan:
81
+
82
+ ```sh
83
+ i18n-edit export --file review.bundle.json --delete
84
+ i18n-edit export --file review.bundle.json --delete --yes
85
+ ```
86
+
87
+ Generate a fresh bundle and immediately apply its export plan with one command:
88
+
89
+ ```sh
90
+ i18n-edit sync
91
+ i18n-edit sync --output review.bundle.json
92
+ ```
93
+
94
+ `sync` is equivalent to running `bundle` followed by `export` with the same bundle path. It preserves the export confirmation and accepts `--yes`, `--delete` and `--no-diff` when automation is intentional.
95
+
96
+ Each configured `i18n` directory is checked against the generated plan. Generated `base.ts`, language files and the configured root catalog are written; other files become deletion candidates unless their extensions are ignored by project configuration. Set `deletion` to `false` to disable this detection entirely. Files outside configured `i18n` directories are never candidates. Writes are restricted to the selected project directory and use a temporary file followed by an atomic rename.
97
+
98
+ After the npm package is published, its binaries will be named `i18n-edit` and `i18n-editor`.
99
+
100
+ The shortest npm invocation will be:
101
+
102
+ ```sh
103
+ npx @wads.dev/i18n-editor
104
+ ```
105
+
106
+ The explicit binary form will also be available:
107
+
108
+ ```sh
109
+ npm exec --package=@wads.dev/i18n-editor -- i18n-edit
110
+ ```
111
+
112
+ Because the npm package is scoped, `npx i18n-editor` would refer to a different, unscoped package. Use the scoped command above.
113
+
114
+ ## Current boundary
115
+
116
+ The server reads project configuration and bundles through `GET /api/project`. It can generate a missing bundle through `POST /api/bundle`; this is currently its only project write. The Web Editor can explicitly request a read-only filesystem comparison through `POST /api/export-preview`. Source regeneration is available explicitly through the CLI `export` command and is not exposed as a browser route yet.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { Command, InvalidArgumentError } from 'commander';
5
+ import { createServer } from '../server/createServer.js';
6
+ import { applyProjectExport, planProjectExport } from '../server/exportProject.js';
7
+ import { createProjectContext } from '../server/projectContext.js';
8
+ function parsePort(value) {
9
+ const port = Number(value);
10
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
11
+ throw new InvalidArgumentError('Port must be an integer between 0 and 65535.');
12
+ }
13
+ return port;
14
+ }
15
+ function getProjectOptions(command, bundleFile) {
16
+ const options = command.optsWithGlobals();
17
+ return {
18
+ projectDirectory: options.project,
19
+ configFile: options.config,
20
+ catalogFile: options.input,
21
+ bundleFile,
22
+ };
23
+ }
24
+ function printExportPlan(plan, showDiff) {
25
+ const created = plan.changes.filter((change) => change.status === 'create').length;
26
+ const modified = plan.changes.filter((change) => change.status === 'modify').length;
27
+ const unchanged = plan.changes.filter((change) => change.status === 'unchanged').length;
28
+ const deleted = plan.changes.filter((change) => change.status === 'delete').length;
29
+ console.log(`Export plan from ${plan.bundlePath}`);
30
+ printPreview(plan, showDiff);
31
+ console.log(`${created} files to create, ${modified} to overwrite, ${deleted} to delete, ${unchanged} unchanged.`);
32
+ return { writeCount: created + modified, deletionCount: deleted };
33
+ }
34
+ function colorize(value, color) {
35
+ if (!process.stdout.isTTY)
36
+ return value;
37
+ const code = color === 'green' ? 32 : color === 'red' ? 31 : 33;
38
+ return `\u001B[${code}m${value}\u001B[0m`;
39
+ }
40
+ function colorizeDiff(diff) {
41
+ if (!process.stdout.isTTY)
42
+ return diff;
43
+ return diff.split('\n').map((line) => {
44
+ if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) {
45
+ return colorize(line, 'yellow');
46
+ }
47
+ if (line.startsWith('+'))
48
+ return colorize(line, 'green');
49
+ if (line.startsWith('-'))
50
+ return colorize(line, 'red');
51
+ return line;
52
+ }).join('\n');
53
+ }
54
+ function printPreview(plan, showDiff) {
55
+ plan.changes.forEach((change) => {
56
+ if (change.status === 'create')
57
+ console.log(colorize(`+ new ${change.path}`, 'green'));
58
+ else if (change.status === 'delete')
59
+ console.log(colorize(`- delete ${change.path}`, 'red'));
60
+ else if (change.status === 'modify')
61
+ console.log(colorize(`~ modified ${change.path}`, 'yellow'));
62
+ else
63
+ console.log(` unchanged ${change.path}`);
64
+ });
65
+ const deletionCount = plan.changes.filter((change) => change.status === 'delete').length;
66
+ if (deletionCount > 0) {
67
+ const warning = plan.deletion !== false && plan.deletion.autoDelete
68
+ ? `Warning: ${deletionCount} divergent file${deletionCount === 1 ? '' : 's'} will be deleted during export because deletion.autoDelete is enabled.`
69
+ : `Warning: ${deletionCount} deletion candidate${deletionCount === 1 ? '' : 's'} detected. They will be preserved unless export runs with --delete.`;
70
+ console.log(`\n${colorize(warning, 'yellow')}`);
71
+ }
72
+ if (!showDiff)
73
+ return;
74
+ plan.changes.forEach((change) => {
75
+ if (!change.diff || change.status === 'delete')
76
+ return;
77
+ console.log(`\n${colorize(`diff ${change.path}`, 'yellow')}`);
78
+ process.stdout.write(colorizeDiff(change.diff));
79
+ });
80
+ }
81
+ async function confirmExport(deletionCount) {
82
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
83
+ throw new Error('Confirmation requires an interactive terminal. Re-run with --yes to apply non-interactively.');
84
+ }
85
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
86
+ try {
87
+ const deletionMessage = deletionCount > 0 ? ` and delete ${deletionCount} divergent file${deletionCount === 1 ? '' : 's'}` : '';
88
+ const answer = await prompt.question(`Apply this export plan${deletionMessage}? [y/N] `);
89
+ return /^(y|yes)$/i.test(answer.trim());
90
+ }
91
+ finally {
92
+ prompt.close();
93
+ }
94
+ }
95
+ async function generateBundle(command, output) {
96
+ const projectOptions = getProjectOptions(command, output);
97
+ const result = await createProjectContext(projectOptions).generateBundle();
98
+ console.log(`i18n bundle created at ${result.bundlePath}`);
99
+ console.log(`${Object.keys(result.bundle.languages).length} languages exported.`);
100
+ }
101
+ async function exportBundle(command, options) {
102
+ const plan = await planProjectExport(getProjectOptions(command, options.file));
103
+ const { writeCount, deletionCount } = printExportPlan(plan, options.diff);
104
+ const deleteObsolete = plan.deletion !== false
105
+ && (plan.deletion.autoDelete || options.delete === true);
106
+ const appliedDeletionCount = deleteObsolete ? deletionCount : 0;
107
+ const changeCount = writeCount + appliedDeletionCount;
108
+ if (changeCount === 0) {
109
+ if (deletionCount > 0) {
110
+ console.log('No files were changed. Deletion candidates were preserved; re-run with --delete to remove them.');
111
+ return;
112
+ }
113
+ console.log('The project already matches the bundle.');
114
+ return;
115
+ }
116
+ if (!options.yes && !await confirmExport(appliedDeletionCount)) {
117
+ console.log('Export cancelled. No files were changed.');
118
+ return;
119
+ }
120
+ await applyProjectExport(plan, { deleteObsolete });
121
+ console.log(`${writeCount} files written, ${appliedDeletionCount} deleted.`);
122
+ }
123
+ function createProgram() {
124
+ const packageJson = createRequire(import.meta.url)('../../package.json');
125
+ const program = new Command();
126
+ program
127
+ .name('i18n-edit')
128
+ .description('Inspect, edit, bundle and regenerate typed i18n projects. Starts the web editor when no command is provided.')
129
+ .version(packageJson.version)
130
+ .showHelpAfterError()
131
+ .showSuggestionAfterError()
132
+ .configureHelp({ showGlobalOptions: true })
133
+ .option('--project <path>', 'project root', process.cwd())
134
+ .option('--config <path>', 'project configuration (default: i18n.config.json)')
135
+ .option('--input <path>', 'TypeScript catalog that exports Langs');
136
+ program
137
+ .command('serve', { isDefault: true })
138
+ .description('start the local web editor')
139
+ .option('--bundle <path>', 'bundle served by the project API')
140
+ .option('--host <host>', 'host used by the web editor', '127.0.0.1')
141
+ .option('--port <port>', 'port used by the web editor', parsePort, 4173)
142
+ .action(async (options, command) => {
143
+ const server = await createServer(getProjectOptions(command, options.bundle));
144
+ const address = await server.listen({ host: options.host, port: options.port });
145
+ console.log(`i18n editor running at ${address}`);
146
+ });
147
+ program
148
+ .command('bundle')
149
+ .description('generate a portable translation bundle')
150
+ .option('-o, --output <path>', 'output bundle path', 'i18n.bundle.json')
151
+ .action(async (options, command) => {
152
+ await generateBundle(command, options.output);
153
+ });
154
+ program
155
+ .command('preview')
156
+ .description('show the files that would be generated from a bundle')
157
+ .option('-f, --file <path>', 'input bundle path', 'i18n.bundle.json')
158
+ .option('--no-diff', 'hide generated content diffs')
159
+ .action(async (options, command) => {
160
+ printPreview(await planProjectExport(getProjectOptions(command, options.file)), options.diff);
161
+ });
162
+ program
163
+ .command('export')
164
+ .description('regenerate project translation files from a bundle')
165
+ .option('-f, --file <path>', 'input bundle path', 'i18n.bundle.json')
166
+ .option('-y, --yes', 'apply the plan without confirmation')
167
+ .option('--delete', 'delete divergent files reported by the plan')
168
+ .option('--no-diff', 'hide generated content diffs')
169
+ .action(async (options, command) => {
170
+ await exportBundle(command, options);
171
+ });
172
+ program
173
+ .command('sync')
174
+ .description('bundle the project, then regenerate its translation files')
175
+ .option('-o, --output <path>', 'bundle path used between the two operations', 'i18n.bundle.json')
176
+ .option('-y, --yes', 'apply the export plan without confirmation')
177
+ .option('--delete', 'delete divergent files reported by the plan')
178
+ .option('--no-diff', 'hide generated content diffs')
179
+ .action(async (options, command) => {
180
+ await generateBundle(command, options.output);
181
+ await exportBundle(command, { ...options, file: options.output });
182
+ });
183
+ return program;
184
+ }
185
+ try {
186
+ await createProgram().parseAsync(process.argv);
187
+ }
188
+ catch (error) {
189
+ console.error(error instanceof Error ? error.message : String(error));
190
+ process.exitCode = 1;
191
+ }
@@ -0,0 +1,16 @@
1
+ import { type I18nBundle } from '@wads.dev/i18n-ts/bundle';
2
+ import { type EditorProjectConfig } from './projectConfig.js';
3
+ export type ExportPlanFile = {
4
+ kind: 'base' | 'index' | 'language';
5
+ languageKey?: string;
6
+ path: string;
7
+ };
8
+ export type TranslationOwner = {
9
+ directory: string;
10
+ keyPath: string;
11
+ importPathStyle: 'alias' | 'relative';
12
+ };
13
+ export declare function getTranslationOwnerChain(fullKey: string, config: EditorProjectConfig): TranslationOwner[];
14
+ export declare function getTranslationOwner(fullKey: string, config: EditorProjectConfig): TranslationOwner;
15
+ export declare function buildTranslationOwners(bundle: I18nBundle, config: EditorProjectConfig): TranslationOwner[];
16
+ export declare function buildExportPlan(bundle: I18nBundle, config: EditorProjectConfig): ExportPlanFile[];
@@ -0,0 +1,143 @@
1
+ import { assertBundle } from '@wads.dev/i18n-ts/bundle';
2
+ import { flattenPathReplacer, getDefaultLevelImport, resolveImportAlias, } from '@wads.dev/i18n-ts/config';
3
+ import { normalizeEditorProjectConfig } from './projectConfig.js';
4
+ function getConfiguredLevelImport(config, level) {
5
+ return config.levelImports[level] || getDefaultLevelImport(level);
6
+ }
7
+ function appendPath(basePath, nextPath) {
8
+ return [basePath, nextPath]
9
+ .filter(Boolean)
10
+ .join('/')
11
+ .replaceAll(/\/+/g, '/')
12
+ .replace(/^\.\//, '')
13
+ .replace(/\/$/, '');
14
+ }
15
+ function expandTemplate(template, value) {
16
+ return template.replaceAll('{value}', value);
17
+ }
18
+ function hasAlias(path, aliases) {
19
+ return Object.keys(aliases).some((alias) => path.startsWith(alias));
20
+ }
21
+ function getReplacement(replacer, fullKey, segment) {
22
+ const flattened = flattenPathReplacer(replacer);
23
+ if (Object.hasOwn(flattened, fullKey))
24
+ return { found: true, value: flattened[fullKey] };
25
+ if (Object.hasOwn(flattened, segment))
26
+ return { found: true, value: flattened[segment] };
27
+ return { found: false, value: undefined };
28
+ }
29
+ export function getTranslationOwnerChain(fullKey, config) {
30
+ const segments = fullKey.split('.');
31
+ const groupCount = Math.min(config.levelCount, Math.max(segments.length - 1, 0));
32
+ const rootImport = getConfiguredLevelImport(config, 0);
33
+ let directory = resolveImportAlias(expandTemplate(rootImport.path, ''), config.exportConfig.importAliases);
34
+ let consumedSegments = 0;
35
+ const owners = [{ directory, keyPath: '', importPathStyle: 'relative' }];
36
+ for (let level = 1; level <= groupCount; level += 1) {
37
+ const segment = segments[level - 1];
38
+ const objectPath = segments.slice(0, level).join('.');
39
+ const levelImport = getConfiguredLevelImport(config, level);
40
+ const fullReplacement = getReplacement(levelImport.fullReplacer, objectPath, segment);
41
+ if (fullReplacement.found) {
42
+ if (fullReplacement.value !== null) {
43
+ directory = resolveImportAlias(fullReplacement.value, config.exportConfig.importAliases);
44
+ consumedSegments = level;
45
+ owners.push({
46
+ directory,
47
+ keyPath: objectPath,
48
+ importPathStyle: hasAlias(fullReplacement.value, config.exportConfig.importAliases) ? 'alias' : 'relative',
49
+ });
50
+ }
51
+ break;
52
+ }
53
+ const valueReplacement = getReplacement(levelImport.valueReplacer, objectPath, segment);
54
+ const value = typeof valueReplacement.value === 'string' ? valueReplacement.value : segment;
55
+ const template = expandTemplate(levelImport.path, value);
56
+ const resolved = resolveImportAlias(template, config.exportConfig.importAliases);
57
+ directory = hasAlias(template, config.exportConfig.importAliases) || template.startsWith('/')
58
+ ? resolved
59
+ : appendPath(directory, resolved);
60
+ consumedSegments = level;
61
+ owners.push({
62
+ directory,
63
+ keyPath: objectPath,
64
+ importPathStyle: hasAlias(template, config.exportConfig.importAliases) ? 'alias' : 'relative',
65
+ });
66
+ }
67
+ void consumedSegments;
68
+ return owners;
69
+ }
70
+ export function getTranslationOwner(fullKey, config) {
71
+ return getTranslationOwnerChain(fullKey, config).at(-1);
72
+ }
73
+ function isFunctionDescriptor(value) {
74
+ return value !== null
75
+ && typeof value === 'object'
76
+ && '$type' in value
77
+ && value.$type === 'function';
78
+ }
79
+ function collectTranslationKeys(value, path = '', keys = []) {
80
+ if (isFunctionDescriptor(value)) {
81
+ keys.push(path);
82
+ return keys;
83
+ }
84
+ if (Array.isArray(value)) {
85
+ keys.push(path);
86
+ return keys;
87
+ }
88
+ if (value && typeof value === 'object') {
89
+ Object.entries(value).forEach(([key, nestedValue]) => {
90
+ collectTranslationKeys(nestedValue, path ? `${path}.${key}` : key, keys);
91
+ });
92
+ return keys;
93
+ }
94
+ keys.push(path);
95
+ return keys;
96
+ }
97
+ export function buildTranslationOwners(bundle, config) {
98
+ const validBundle = assertBundle(bundle);
99
+ const validConfig = normalizeEditorProjectConfig(config);
100
+ const owners = new Map();
101
+ const referenceLanguage = Object.values(validBundle.languages)[0];
102
+ if (referenceLanguage) {
103
+ collectTranslationKeys(referenceLanguage.translations).forEach((key) => {
104
+ getTranslationOwnerChain(key, validConfig).forEach((owner) => {
105
+ const existing = owners.get(owner.directory);
106
+ if (existing && existing.keyPath !== owner.keyPath) {
107
+ throw new Error(`The export path "${owner.directory}" resolves multiple translation owners.`);
108
+ }
109
+ owners.set(owner.directory, owner);
110
+ });
111
+ });
112
+ }
113
+ return [...owners.values()].sort((left, right) => left.keyPath.localeCompare(right.keyPath));
114
+ }
115
+ export function buildExportPlan(bundle, config) {
116
+ const validBundle = assertBundle(bundle);
117
+ const validConfig = normalizeEditorProjectConfig(config);
118
+ const owners = buildTranslationOwners(validBundle, validConfig);
119
+ return owners
120
+ .flatMap(({ directory, keyPath }) => {
121
+ const translationsDirectory = appendPath(directory, validConfig.translationsDirectory);
122
+ const structuralFiles = [
123
+ { kind: 'base', path: appendPath(translationsDirectory, 'base.ts') },
124
+ ];
125
+ if (keyPath === '') {
126
+ structuralFiles.push({
127
+ kind: 'index',
128
+ path: validConfig.catalogFile
129
+ || appendPath(translationsDirectory, 'index.ts'),
130
+ });
131
+ }
132
+ const languageFiles = Object.keys(validBundle.languages).map((languageKey) => {
133
+ const language = validConfig.languageReplacer[languageKey] || languageKey;
134
+ return {
135
+ kind: 'language',
136
+ languageKey,
137
+ path: appendPath(translationsDirectory, validConfig.languageFileTemplate.replaceAll('{language}', language)),
138
+ };
139
+ });
140
+ return [...structuralFiles, ...languageFiles];
141
+ })
142
+ .sort((left, right) => left.path.localeCompare(right.path));
143
+ }
@@ -0,0 +1,13 @@
1
+ export type KeyPathSegment = string | number;
2
+ export type KeyPathResult = {
3
+ found: false;
4
+ } | {
5
+ found: true;
6
+ value: unknown;
7
+ };
8
+ type MutableContainer = Record<PropertyKey, unknown> | unknown[];
9
+ export declare function parseKeyPath(key: string, parameterName: string): KeyPathSegment[];
10
+ export declare function getKeyPathValue(root: unknown, segments: KeyPathSegment[]): KeyPathResult;
11
+ export declare function deleteKeyPathValue(root: MutableContainer, segments: KeyPathSegment[]): void;
12
+ export declare function setKeyPathValue(root: MutableContainer, segments: KeyPathSegment[], value: unknown): void;
13
+ export {};
@@ -0,0 +1,59 @@
1
+ function isContainer(value) {
2
+ return value !== null && typeof value === 'object';
3
+ }
4
+ function asRecord(value) {
5
+ return value;
6
+ }
7
+ export function parseKeyPath(key, parameterName) {
8
+ if (key.trim() === '') {
9
+ throw new Error(`${parameterName} must be a non-empty key.`);
10
+ }
11
+ const segments = [];
12
+ const matcher = /(?:^|\.)([^.[\]]+)|\[(\d+)\]/g;
13
+ let match;
14
+ let matchedCharacters = '';
15
+ while ((match = matcher.exec(key)) !== null) {
16
+ segments.push(match[2] === undefined ? match[1] : Number(match[2]));
17
+ matchedCharacters += match[0];
18
+ }
19
+ if (segments.length === 0 || matchedCharacters !== key) {
20
+ throw new Error(`${parameterName} contains an invalid path: ${key}`);
21
+ }
22
+ return segments;
23
+ }
24
+ export function getKeyPathValue(root, segments) {
25
+ let current = root;
26
+ for (const segment of segments) {
27
+ if (!isContainer(current) || !Object.hasOwn(current, segment))
28
+ return { found: false };
29
+ current = asRecord(current)[segment];
30
+ }
31
+ return { found: true, value: current };
32
+ }
33
+ export function deleteKeyPathValue(root, segments) {
34
+ const parentPath = segments.slice(0, -1);
35
+ const property = segments.at(-1);
36
+ let parent = root;
37
+ for (const segment of parentPath) {
38
+ const nested = asRecord(parent)[segment];
39
+ if (!isContainer(nested))
40
+ throw new Error(`The key path is invalid at ${String(segment)}.`);
41
+ parent = nested;
42
+ }
43
+ delete asRecord(parent)[property];
44
+ }
45
+ export function setKeyPathValue(root, segments, value) {
46
+ const property = segments.at(-1);
47
+ let current = root;
48
+ segments.slice(0, -1).forEach((segment, index) => {
49
+ if (!Object.hasOwn(current, segment)) {
50
+ asRecord(current)[segment] = typeof segments[index + 1] === 'number' ? [] : {};
51
+ }
52
+ const nested = asRecord(current)[segment];
53
+ if (!isContainer(nested)) {
54
+ throw new Error(`The destination key cannot be created inside ${String(segment)}.`);
55
+ }
56
+ current = nested;
57
+ });
58
+ asRecord(current)[property] = value;
59
+ }
@@ -0,0 +1,6 @@
1
+ import { type I18nBundle } from '@wads.dev/i18n-ts/bundle';
2
+ export type MoveKeyOptions = {
3
+ sourceKey: string;
4
+ targetKey: string;
5
+ };
6
+ export declare function moveKey(bundle: I18nBundle, { sourceKey, targetKey }: MoveKeyOptions): I18nBundle;
@@ -0,0 +1,32 @@
1
+ import { assertBundle } from '@wads.dev/i18n-ts/bundle';
2
+ import { deleteKeyPathValue, getKeyPathValue, parseKeyPath, setKeyPathValue, } from './keyPath.js';
3
+ function cloneBundle(bundle) {
4
+ return structuredClone(bundle);
5
+ }
6
+ export function moveKey(bundle, { sourceKey, targetKey }) {
7
+ const validBundle = assertBundle(bundle);
8
+ const sourceSegments = parseKeyPath(sourceKey, 'sourceKey');
9
+ const targetSegments = parseKeyPath(targetKey, 'targetKey');
10
+ if (sourceKey === targetKey)
11
+ return cloneBundle(validBundle);
12
+ if (targetKey.startsWith(`${sourceKey}.`) || targetKey.startsWith(`${sourceKey}[`)) {
13
+ throw new Error('The destination key cannot be nested inside the source key.');
14
+ }
15
+ const valuesByLanguage = Object.entries(validBundle.languages).map(([languageKey, language]) => {
16
+ const source = getKeyPathValue(language.translations, sourceSegments);
17
+ if (!source.found) {
18
+ throw new Error(`The source key does not exist in language "${languageKey}".`);
19
+ }
20
+ if (getKeyPathValue(language.translations, targetSegments).found) {
21
+ throw new Error(`The destination key already exists in language "${languageKey}".`);
22
+ }
23
+ return [languageKey, source.value];
24
+ });
25
+ const nextBundle = cloneBundle(validBundle);
26
+ valuesByLanguage.forEach(([languageKey, value]) => {
27
+ const translations = nextBundle.languages[languageKey].translations;
28
+ deleteKeyPathValue(translations, sourceSegments);
29
+ setKeyPathValue(translations, targetSegments, value);
30
+ });
31
+ return nextBundle;
32
+ }
@@ -0,0 +1,31 @@
1
+ import type { I18nBundle } from '@wads.dev/i18n-ts/bundle';
2
+ import type { EditorProjectConfig } from './projectConfig.js';
3
+ export type ProjectInfo = {
4
+ projectDirectory: string;
5
+ configPath: string | null;
6
+ catalogPath: string | null;
7
+ bundlePath: string;
8
+ config: EditorProjectConfig | null;
9
+ bundle: I18nBundle | null;
10
+ canGenerateBundle: boolean;
11
+ };
12
+ export type GenerateBundleResult = {
13
+ bundle: I18nBundle;
14
+ bundlePath: string;
15
+ };
16
+ export type ProjectExportPreviewRequest = {
17
+ bundle: I18nBundle;
18
+ config: EditorProjectConfig;
19
+ };
20
+ export type ProjectExportPreviewChange = {
21
+ kind: 'base' | 'index' | 'language' | 'obsolete';
22
+ path: string;
23
+ status: 'create' | 'modify' | 'unchanged' | 'delete';
24
+ diff?: string;
25
+ };
26
+ export type ProjectExportPreviewResult = {
27
+ changes: ProjectExportPreviewChange[];
28
+ };
29
+ export type ApiError = {
30
+ error: string;
31
+ };
@@ -0,0 +1 @@
1
+ export {};