@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.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Local web editor for typed i18n projects.
4
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.
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: `0.0.1-alpha.0`. The package is under active development and has not been published to npm yet.
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
- After the npm package is published, its binaries will be named `i18n-edit` and `i18n-editor`.
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`. 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.
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 {
@@ -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 parentPath = segments.slice(0, -1);
35
- const property = segments.at(-1);
34
+ const containers = [];
36
35
  let parent = root;
37
- for (const segment of parentPath) {
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
- delete asRecord(parent)[property];
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,5 @@
1
+ import { type I18nBundle } from '@wads.dev/i18n-ts/bundle';
2
+ export type RemoveKeyOptions = {
3
+ key: string;
4
+ };
5
+ export declare function removeKey(bundle: I18nBundle, { key }: RemoveKeyOptions): I18nBundle;
@@ -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
+ }