@wads.dev/i18n-editor 0.0.1-alpha.0 → 0.0.1-rc.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/README.md +29 -4
- package/dist/cli/index.js +55 -0
- package/dist/core/keyPath.js +18 -4
- package/dist/core/projectApi.d.ts +33 -0
- package/dist/core/removeKey.d.ts +5 -0
- package/dist/core/removeKey.js +16 -0
- package/dist/public/editor.js +1204 -129
- package/dist/public/index.html +101 -57
- package/dist/public/styles.css +172 -12
- package/dist/server/createServer.js +44 -2
- package/dist/server/exportProject.d.ts +5 -1
- package/dist/server/exportProject.js +6 -0
- package/dist/server/projectContext.d.ts +1 -0
- package/dist/server/projectContext.js +1 -1
- package/dist/server/usageAnalysis.d.ts +13 -0
- package/dist/server/usageAnalysis.js +357 -0
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Local web editor for typed i18n projects.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The package compiles the browser editor and its reusable operations from TypeScript, then serves the generated application through a local Fastify server.
|
|
6
6
|
|
|
7
|
-
> Status:
|
|
7
|
+
> Status: prerelease. The package is under active development.
|
|
8
8
|
|
|
9
9
|
## Wads i18n ecosystem
|
|
10
10
|
|
|
@@ -12,10 +12,13 @@ This first alpha compiles the existing browser editor and its reusable operation
|
|
|
12
12
|
| --- | --- |
|
|
13
13
|
| [`@wads.dev/i18n-ts`](https://github.com/wads-dev/i18n-ts) | Framework-independent contracts, language loading, project configuration and portable bundles. |
|
|
14
14
|
| [`@wads.dev/i18n-react`](https://github.com/wads-dev/i18n-react) | Optional React Provider, hooks and rich translation rendering. |
|
|
15
|
+
| [`@wads.dev/i18n-html`](https://github.com/wads-dev/i18n-html) | Optional DOM bindings and static HTML translation-reference discovery. |
|
|
15
16
|
| [`@wads.dev/i18n-editor`](https://github.com/wads-dev/i18n-editor) | Local bundle and project editor. This repository. |
|
|
16
17
|
|
|
17
18
|
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
|
|
|
20
|
+
The interface itself uses the `i18n-ts` runtime with a zero-level English and Portuguese catalog. The browser locale selects the initial language, and the explicit choice is persisted locally for future sessions. The runtime bootstrap exposes the selected, deeply frozen tree as the typed global `Lang`, so editor modules access translations without importing the catalog. Static DOM bindings use `@wads.dev/i18n-html`.
|
|
21
|
+
|
|
19
22
|
## Development
|
|
20
23
|
|
|
21
24
|
```sh
|
|
@@ -24,6 +27,15 @@ npm run check
|
|
|
24
27
|
npm run build
|
|
25
28
|
```
|
|
26
29
|
|
|
30
|
+
Run the editor itself in development mode:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm start
|
|
34
|
+
npm start -- --port 4300
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The server runs directly from TypeScript with `nodemon` and `tsx`, while a second `nodemon` process runs `build:web` whenever the HTML, CSS or browser TypeScript files change.
|
|
38
|
+
|
|
27
39
|
Run the generated executable from this repository:
|
|
28
40
|
|
|
29
41
|
```sh
|
|
@@ -93,9 +105,22 @@ i18n-edit sync --output review.bundle.json
|
|
|
93
105
|
|
|
94
106
|
`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
107
|
|
|
108
|
+
Analyze typed static references to every translation leaf:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
i18n-edit usage
|
|
112
|
+
i18n-edit usage --json
|
|
113
|
+
i18n-edit usage --refresh
|
|
114
|
+
i18n-edit usage --fail-on-unreferenced
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The report separates exact references from uncertain dynamic collection access. Exact entries include reference and file counts plus every source location. `unreferenced` means no static reference or known dynamic access was found; it is a diagnostic and never authorizes automatic deletion. Use `--fail-on-unreferenced` for an opt-in CI check.
|
|
118
|
+
|
|
119
|
+
Usage analysis is cached per project at `node_modules/.cache/@wads.dev/i18n-editor/usage.json`. The CLI reuses a structurally verified cache and regenerates a missing or unverified one; pass `--refresh` to incorporate source-only changes explicitly. The Web Editor renders the latest cached report immediately on startup. If its translation structure is unverified, the browser keeps that data visible while explicitly requesting a fresh analysis. No-wait cache reads never start analysis on the server.
|
|
120
|
+
|
|
96
121
|
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
122
|
|
|
98
|
-
|
|
123
|
+
The package binaries are named `i18n-edit` and `i18n-editor`.
|
|
99
124
|
|
|
100
125
|
The shortest npm invocation will be:
|
|
101
126
|
|
|
@@ -113,4 +138,4 @@ Because the npm package is scoped, `npx i18n-editor` would refer to a different,
|
|
|
113
138
|
|
|
114
139
|
## Current boundary
|
|
115
140
|
|
|
116
|
-
The server reads project configuration and bundles through `GET /api/project
|
|
141
|
+
The server reads project configuration and bundles through `GET /api/project` and can generate a missing bundle through `POST /api/bundle`. The Web Editor requests filesystem comparisons through `POST /api/export-preview`, performs confirmed source regeneration through `POST /api/export`, and reads or refreshes typed usage analysis through `POST /api/usage-analysis`. Project roots and arbitrary output paths cannot be selected by browser requests.
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Command, InvalidArgumentError } from 'commander';
|
|
|
5
5
|
import { createServer } from '../server/createServer.js';
|
|
6
6
|
import { applyProjectExport, planProjectExport } from '../server/exportProject.js';
|
|
7
7
|
import { createProjectContext } from '../server/projectContext.js';
|
|
8
|
+
import { analyzeTranslationUsageCached } from '../server/usageAnalysis.js';
|
|
8
9
|
function parsePort(value) {
|
|
9
10
|
const port = Number(value);
|
|
10
11
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
@@ -120,6 +121,50 @@ async function exportBundle(command, options) {
|
|
|
120
121
|
await applyProjectExport(plan, { deleteObsolete });
|
|
121
122
|
console.log(`${writeCount} files written, ${appliedDeletionCount} deleted.`);
|
|
122
123
|
}
|
|
124
|
+
function printUsageReport(report) {
|
|
125
|
+
Object.entries(report.entries).forEach(([key, usage]) => {
|
|
126
|
+
const status = usage.status === 'used'
|
|
127
|
+
? colorize('used ', 'green')
|
|
128
|
+
: usage.status === 'uncertain'
|
|
129
|
+
? colorize('uncertain ', 'yellow')
|
|
130
|
+
: colorize('unreferenced', 'red');
|
|
131
|
+
console.log(`${status} ${String(usage.referenceCount).padStart(3)} refs ${String(usage.fileCount).padStart(3)} files ${key}`);
|
|
132
|
+
usage.references.forEach((reference) => {
|
|
133
|
+
console.log(` ${reference.file}:${reference.line}:${reference.column}`);
|
|
134
|
+
});
|
|
135
|
+
if (usage.status === 'uncertain')
|
|
136
|
+
usage.uncertainReferences.forEach((reference) => {
|
|
137
|
+
console.log(` ? ${reference.file}:${reference.line}:${reference.column}`);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
const values = Object.values(report.entries);
|
|
141
|
+
const used = values.filter(({ status }) => status === 'used').length;
|
|
142
|
+
const uncertain = values.filter(({ status }) => status === 'uncertain').length;
|
|
143
|
+
const unreferenced = values.filter(({ status }) => status === 'unreferenced').length;
|
|
144
|
+
console.log(`\n${values.length} keys analyzed across ${report.sourceFileCount} source files: ${used} used, ${uncertain} uncertain, ${unreferenced} unreferenced.`);
|
|
145
|
+
}
|
|
146
|
+
async function analyzeUsage(command, options) {
|
|
147
|
+
const projectOptions = getProjectOptions(command, options.file);
|
|
148
|
+
const project = createProjectContext(projectOptions);
|
|
149
|
+
const info = await project.getInfo();
|
|
150
|
+
if (!info.config)
|
|
151
|
+
throw new Error('Could not find i18n.config.json. Pass --config with the project configuration path.');
|
|
152
|
+
if (!info.bundle)
|
|
153
|
+
throw new Error(`Could not find the bundle at ${info.bundlePath}. Run the bundle command first or pass --file.`);
|
|
154
|
+
const report = await analyzeTranslationUsageCached({
|
|
155
|
+
projectDirectory: project.projectDirectory,
|
|
156
|
+
bundle: info.bundle,
|
|
157
|
+
config: info.config,
|
|
158
|
+
}, options.refresh === true);
|
|
159
|
+
if (options.json)
|
|
160
|
+
console.log(JSON.stringify(report, null, 2));
|
|
161
|
+
else
|
|
162
|
+
printUsageReport(report);
|
|
163
|
+
if (options.failOnUnreferenced
|
|
164
|
+
&& Object.values(report.entries).some(({ status }) => status === 'unreferenced')) {
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
123
168
|
function createProgram() {
|
|
124
169
|
const packageJson = createRequire(import.meta.url)('../../package.json');
|
|
125
170
|
const program = new Command();
|
|
@@ -180,6 +225,16 @@ function createProgram() {
|
|
|
180
225
|
await generateBundle(command, options.output);
|
|
181
226
|
await exportBundle(command, { ...options, file: options.output });
|
|
182
227
|
});
|
|
228
|
+
program
|
|
229
|
+
.command('usage')
|
|
230
|
+
.description('analyze static references to every translation key')
|
|
231
|
+
.option('-f, --file <path>', 'input bundle path', 'i18n.bundle.json')
|
|
232
|
+
.option('--json', 'print the usage report as JSON')
|
|
233
|
+
.option('--refresh', 'ignore the disk cache and rebuild the usage report')
|
|
234
|
+
.option('--fail-on-unreferenced', 'exit with code 1 when a key has no static references')
|
|
235
|
+
.action(async (options, command) => {
|
|
236
|
+
await analyzeUsage(command, options);
|
|
237
|
+
});
|
|
183
238
|
return program;
|
|
184
239
|
}
|
|
185
240
|
try {
|
package/dist/core/keyPath.js
CHANGED
|
@@ -31,16 +31,30 @@ export function getKeyPathValue(root, segments) {
|
|
|
31
31
|
return { found: true, value: current };
|
|
32
32
|
}
|
|
33
33
|
export function deleteKeyPathValue(root, segments) {
|
|
34
|
-
const
|
|
35
|
-
const property = segments.at(-1);
|
|
34
|
+
const containers = [];
|
|
36
35
|
let parent = root;
|
|
37
|
-
for (const segment of
|
|
36
|
+
for (const segment of segments.slice(0, -1)) {
|
|
37
|
+
containers.push({ container: parent, segment });
|
|
38
38
|
const nested = asRecord(parent)[segment];
|
|
39
39
|
if (!isContainer(nested))
|
|
40
40
|
throw new Error(`The key path is invalid at ${String(segment)}.`);
|
|
41
41
|
parent = nested;
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
const property = segments.at(-1);
|
|
44
|
+
if (Array.isArray(parent) && typeof property === 'number')
|
|
45
|
+
parent.splice(property, 1);
|
|
46
|
+
else
|
|
47
|
+
delete asRecord(parent)[property];
|
|
48
|
+
for (let index = containers.length - 1; index >= 0; index -= 1) {
|
|
49
|
+
const { container, segment } = containers[index];
|
|
50
|
+
const child = asRecord(container)[segment];
|
|
51
|
+
if (!isContainer(child) || Object.keys(child).length > 0)
|
|
52
|
+
break;
|
|
53
|
+
if (Array.isArray(container) && typeof segment === 'number')
|
|
54
|
+
container.splice(segment, 1);
|
|
55
|
+
else
|
|
56
|
+
delete asRecord(container)[segment];
|
|
57
|
+
}
|
|
44
58
|
}
|
|
45
59
|
export function setKeyPathValue(root, segments, value) {
|
|
46
60
|
const property = segments.at(-1);
|
|
@@ -17,6 +17,9 @@ export type ProjectExportPreviewRequest = {
|
|
|
17
17
|
bundle: I18nBundle;
|
|
18
18
|
config: EditorProjectConfig;
|
|
19
19
|
};
|
|
20
|
+
export type ProjectExportRequest = ProjectExportPreviewRequest & {
|
|
21
|
+
deleteObsolete?: boolean;
|
|
22
|
+
};
|
|
20
23
|
export type ProjectExportPreviewChange = {
|
|
21
24
|
kind: 'base' | 'index' | 'language' | 'obsolete';
|
|
22
25
|
path: string;
|
|
@@ -26,6 +29,36 @@ export type ProjectExportPreviewChange = {
|
|
|
26
29
|
export type ProjectExportPreviewResult = {
|
|
27
30
|
changes: ProjectExportPreviewChange[];
|
|
28
31
|
};
|
|
32
|
+
export type ProjectExportResult = ProjectExportPreviewResult & {
|
|
33
|
+
written: number;
|
|
34
|
+
deleted: number;
|
|
35
|
+
preserved: number;
|
|
36
|
+
};
|
|
37
|
+
export type TranslationUsageReference = {
|
|
38
|
+
file: string;
|
|
39
|
+
line: number;
|
|
40
|
+
column: number;
|
|
41
|
+
};
|
|
42
|
+
export type TranslationUsageEntry = {
|
|
43
|
+
status: 'used' | 'unreferenced' | 'uncertain';
|
|
44
|
+
referenceCount: number;
|
|
45
|
+
uncertainReferenceCount: number;
|
|
46
|
+
fileCount: number;
|
|
47
|
+
references: TranslationUsageReference[];
|
|
48
|
+
uncertainReferences: TranslationUsageReference[];
|
|
49
|
+
};
|
|
50
|
+
export type TranslationUsageReport = {
|
|
51
|
+
analyzedAt: number;
|
|
52
|
+
sourceFileCount: number;
|
|
53
|
+
entries: Record<string, TranslationUsageEntry>;
|
|
54
|
+
};
|
|
55
|
+
export type TranslationUsageRequest = ProjectExportPreviewRequest & {
|
|
56
|
+
wait?: boolean;
|
|
57
|
+
};
|
|
58
|
+
export type TranslationUsageResponse = {
|
|
59
|
+
cacheStatus: 'missing' | 'verified' | 'unverified';
|
|
60
|
+
report: TranslationUsageReport | null;
|
|
61
|
+
};
|
|
29
62
|
export type ApiError = {
|
|
30
63
|
error: string;
|
|
31
64
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { assertBundle } from '@wads.dev/i18n-ts/bundle';
|
|
2
|
+
import { deleteKeyPathValue, getKeyPathValue, parseKeyPath } from './keyPath.js';
|
|
3
|
+
export function removeKey(bundle, { key }) {
|
|
4
|
+
const validBundle = assertBundle(bundle);
|
|
5
|
+
const segments = parseKeyPath(key, 'key');
|
|
6
|
+
Object.entries(validBundle.languages).forEach(([languageKey, language]) => {
|
|
7
|
+
if (!getKeyPathValue(language.translations, segments).found) {
|
|
8
|
+
throw new Error(`Key "${key}" does not exist in language "${languageKey}".`);
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
const nextBundle = structuredClone(validBundle);
|
|
12
|
+
Object.values(nextBundle.languages).forEach((language) => {
|
|
13
|
+
deleteKeyPathValue(language.translations, segments);
|
|
14
|
+
});
|
|
15
|
+
return nextBundle;
|
|
16
|
+
}
|