vite-plugin-ai-auto-i18n 0.1.0 → 0.2.1

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
@@ -1,8 +1,8 @@
1
- # vite-plugin-autoi18n — MVP sketch
1
+ # 🌍 vite-plugin-autoi18n — MVP sketch 🚀
2
2
 
3
3
  Implicit build-time localization for JSX: author English directly, let the compiler extract user-facing copy, persist message identities, and optionally translate through an AI provider.
4
4
 
5
- ## Current MVP scope
5
+ ## 🧩 Current MVP scope
6
6
 
7
7
  - Extracts JSX text nodes and static string JSX attributes.
8
8
  - Uses tag/attribute/semantic-attribute/handler context to form fingerprints; component names and file paths are excluded so ordinary moves and renames do not automatically invalidate IDs.
@@ -11,37 +11,70 @@ Implicit build-time localization for JSX: author English directly, let the compi
11
11
  - Supports a pluggable translation provider; includes an OpenAI-compatible implementation.
12
12
  - Writes locale JSON catalogues and keeps source English as fallback.
13
13
  - Two delivery modes: `build` (one compiled bundle per locale) and `runtime` (one bundle, locales loaded on demand through `virtual:autoi18n`).
14
- - Re-translates a message when its source text changes, tracked in `.autoi18n/locales/<locale>.sources.json`.
14
+ - Re-translates a message when its source text changes, tracked in the `sources` map of `.autoi18n/locales/<locale>.json`.
15
15
  - Includes a deliberately varied document-dashboard example.
16
16
 
17
- ## Install / try
17
+ ## 📦 Installation
18
18
 
19
- This repository is a plugin prototype. Add dependencies from `package.json`, then configure it in Vite:
19
+ ### 1️⃣ Install the package
20
+
21
+ In your React + Vite project:
22
+
23
+ ```sh
24
+ npm install -D vite-plugin-ai-auto-i18n
25
+ ```
26
+
27
+ Works the same with `pnpm add -D vite-plugin-ai-auto-i18n` or `yarn add -D vite-plugin-ai-auto-i18n`. It requires Vite 5 or newer (a peer dependency). For React, also keep `@vitejs/plugin-react` in your Vite config.
28
+
29
+ ### 2️⃣ Add your API key
30
+
31
+ Put the key in `.env.local` and keep that file out of git:
32
+
33
+ ```sh
34
+ OPENAI_API_KEY=your-key-here
35
+ ```
36
+
37
+ ### 3️⃣ Configure Vite
38
+
39
+ Add the plugin to `vite.config.ts`. `loadEnv` reads `.env.local` for the config file:
20
40
 
21
41
  ```ts
22
- import autoI18n, { OpenAICompatibleProvider } from "vite-plugin-autoi18n";
23
-
24
- export default defineConfig({
25
- plugins: [
26
- autoI18n({
27
- locales: ["en", "de", "fr"],
28
- sourceLocale: "en",
29
- provider: new OpenAICompatibleProvider({
30
- apiKey: process.env.OPENAI_API_KEY!,
31
- model: "gpt-4.1-mini"
32
- })
33
- })
34
- ]
42
+ import { defineConfig, loadEnv } from "vite";
43
+ import react from "@vitejs/plugin-react";
44
+ import autoI18n, { OpenAICompatibleProvider } from "vite-plugin-ai-auto-i18n";
45
+
46
+ export default defineConfig(({ mode }) => {
47
+ const env = loadEnv(mode, process.cwd(), "");
48
+
49
+ return {
50
+ plugins: [
51
+ autoI18n({
52
+ locales: ["en", "de", "fr"],
53
+ sourceLocale: "en",
54
+ provider: new OpenAICompatibleProvider({
55
+ apiKey: env.OPENAI_API_KEY!,
56
+ model: "gpt-4.1-mini"
57
+ })
58
+ }),
59
+ react() // in runtime delivery, keep autoI18n before the React plugin
60
+ ]
61
+ };
35
62
  });
36
63
  ```
37
64
 
65
+ ### 4️⃣ Commit the generated files
66
+
67
+ The build writes `.autoi18n/` (registry and catalogues). Commit it so existing translations are reused instead of requested again. ✅
68
+
38
69
  Then write ordinary JSX:
39
70
 
40
71
  ```tsx
41
72
  <button title="Save the current document">Save</button>
42
73
  ```
43
74
 
44
- To try the bundled example (a React document dashboard under `example/`):
75
+ ### 🧪 Try the bundled example
76
+
77
+ A React document dashboard under `example/`:
45
78
 
46
79
  ```sh
47
80
  npm install
@@ -49,25 +82,31 @@ npm run example:dev # dev server
49
82
  npm run example:build # production build into example/dist
50
83
  ```
51
84
 
52
- Run Vite. The plugin writes `.autoi18n/registry.json`, `.autoi18n/locales/<locale>.json` and `.autoi18n/locales/<locale>.sources.json`. The sidecar records the source text each translation was made from; if the source changes, that message is re-translated on the next build.
85
+ Run Vite. The plugin writes `.autoi18n/registry.json` and `.autoi18n/locales/<locale>.json`. Each locale file has two parts: `messages` (the translations) and `sources` (the source text each translation was made from). If the source changes, that message is re-translated on the next build.
86
+
87
+ Upgrading from an earlier version: the old flat locale files and `*.sources.json` sidecars are not read. Every message is re-translated once on the first build, and the sidecars are deleted.
88
+
89
+ If a translation batch fails, the other batches are still stored. The failed messages fall back to the source text until the next build. If every batch for a locale fails, that locale keeps its previous file and the build reports the error after writing the other locales.
90
+
91
+ 🔑 Translations require a provider. With `failOnMissingProvider: true` the build fails when a locale is missing translations and no provider is configured, instead of silently shipping English.
53
92
 
54
- Translations require a provider. With `failOnMissingProvider: true` the build fails when a locale is missing translations and no provider is configured, instead of silently shipping English.
93
+ `OpenAICompatibleProvider` sends messages in batches (`batchSize`, default 40), runs up to `concurrency` batches at once (default 4), aborts a request after `timeoutMs` (default 60000), and retries network errors, timeouts, 408/409/429 and 5xx responses or malformed output up to `maxRetries` times (default 2) with exponential backoff from `retryDelayMs` (default 500). Other 4xx errors fail immediately. If the provider omits some messages, the rest are kept and the missing ones stay in the source language until the next build retries them; the build logs how many were left untranslated.
55
94
 
56
- `OpenAICompatibleProvider` sends messages in batches (`batchSize`, default 40), aborts a request after `timeoutMs` (default 60000), and retries network errors, timeouts, 408/409/429 and 5xx responses or malformed output up to `maxRetries` times (default 2) with exponential backoff from `retryDelayMs` (default 500). Other 4xx errors fail immediately. If the provider omits some messages, the rest are kept and the missing ones stay in the source language until the next build retries them; the build logs how many were left untranslated.
95
+ The plugin translates up to `localeConcurrency` locales at once (default 2). Total parallel requests are roughly `localeConcurrency × concurrency`, so lower one or both if your provider rate-limits your account.
57
96
 
58
97
  Ambiguous identity matches (duplicate identical strings whose count changed) are logged as warnings. Set `onAmbiguous: "error"` to fail the build instead.
59
98
 
60
99
  Messages removed from the source are kept as orphans in `registry.json` so their IDs can be reused if the text returns. Orphans not seen for `orphanRetentionMinutes` minutes (default `43200`, i.e. 30 days) are deleted on the next build, and the build logs how many were removed. Use `0` to delete them immediately or `Infinity` to keep them forever. Once deleted, a returning message gets a new ID and is translated again.
61
100
 
62
- To prune without running a build, use the CLI. In this repository, `npm run prune` builds and then runs it on the example app's `example/.autoi18n/` with `--retention-minutes 0`, so every orphan is removed. Pass options after `--` to override, for example `npm run prune -- --directory .autoi18n --retention-minutes 1440 --debug`. Options: `--directory` (default `.autoi18n`, relative to the current directory), `--retention-minutes` (default `43200`, i.e. 30 days, the plugin default) and `--debug` (prints each removed entry with its source text and last-seen time). The CLI also removes any key from `locales/*.json` and `locales/*.sources.json` that is no longer an active registry entry, so the catalogues match the registry. If no registry exists at that path, the CLI says so and changes nothing. Installed packages can run `node node_modules/vite-plugin-autoi18n/dist/cli/prune.js` with the same options.
101
+ 🧹 To prune without running a build, use the CLI. In this repository, `npm run prune` builds and then runs it on the example app's `example/.autoi18n/` with `--retention-minutes 0`, so every orphan is removed. Pass options after `--` to override, for example `npm run prune -- --directory .autoi18n --retention-minutes 1440 --debug`. Options: `--directory` (default `.autoi18n`, relative to the current directory), `--retention-minutes` (default `43200`, i.e. 30 days, the plugin default) and `--debug` (prints each removed entry with its source text and last-seen time). The CLI also removes any key from the `messages` and `sources` maps of `locales/*.json` that is no longer an active registry entry, so the catalogues match the registry. If no registry exists at that path, the CLI says so and changes nothing. Installed packages can run `node node_modules/vite-plugin-ai-auto-i18n/dist/cli/prune.js` with the same options.
63
102
 
64
- Set `debug: true` (default `false`) to print every translation exchange to the console: each message sent to the provider (`sent`) and each translation received back (`received`), prefixed with `[autoi18n debug]` and an ISO timestamp.
103
+ 🐞 Set `debug: true` (default `false`) to print every translation exchange to the console: each message sent to the provider (`sent`) and each translation received back (`received`), prefixed with `[autoi18n debug]` and an ISO timestamp.
65
104
 
66
- To keep copy out of automatic translation, add `translate="no"` to the element (for example `<h2 translate="no">Sample code</h2>`). This is the standard HTML marker, and it also covers the element's descendants and attributes such as `title`.
105
+ 🙈 To keep copy out of automatic translation, add `translate="no"` to the element (for example `<h2 translate="no">Sample code</h2>`). This is the standard HTML marker, and it also covers the element's descendants and attributes such as `title`.
67
106
 
68
107
  Corrupt `registry.json` or catalogue files are renamed to `<file>.corrupt-<timestamp>` and a warning is logged; the plugin continues with an empty state. Writes are atomic, so an interrupted build does not leave a truncated file.
69
108
 
70
- ### Delivery modes
109
+ ### 🚚 Delivery modes
71
110
 
72
111
  - `delivery: "build"` (default): compiles one locale per build. Use `AUTOI18N_LOCALE=de vite build` (on Windows use `cross-env AUTOI18N_LOCALE=de vite build`) or `locale: "de"` in plugin options, typically in a CI build matrix. When a locale is set explicitly, only that locale is translated during the build. Without one, all configured locales are translated. An unknown locale fails the build.
73
112
  - `delivery: "runtime"`: one bundle for all locales. Text is wrapped in `t(id, source)` calls and catalogues are fetched on demand. Import the API from the virtual module:
@@ -78,13 +117,82 @@ import { locales, resolveSupported, setLocale, getLocale, subscribe, t } from "v
78
117
  setLocale(resolveSupported(navigator.languages)); // call before first render
79
118
  ```
80
119
 
81
- `createLocaleLoader` and `resolveLocale` are available from the `vite-plugin-autoi18n/runtime` subpath, not the main entry.
120
+ `createLocaleLoader` and `resolveLocale` are available from the `vite-plugin-ai-auto-i18n/runtime` subpath, not the main entry.
82
121
 
83
122
  `subscribe(listener)` returns an unsubscribe function. `getVersion()` changes whenever the locale or the catalogues change, so `useSyncExternalStore(subscribe, getVersion, getVersion)` re-renders components that read `t()` during render. Components wrapped in `memo` must subscribe themselves, or they keep the old text until their props change. List `autoI18n()` before `@vitejs/plugin-react` in `plugins`. The React plugin rewrites JSX in dev before other pre-transforms run, so the plugin must see the original JSX. Add a type declaration for `virtual:autoi18n` (see [example/src/autoi18n-virtual.d.ts](/home/bogdan/git/vite-plugin-ai-i18n/example/src/autoi18n-virtual.d.ts)). The example app uses this mode with a locale switcher.
84
123
 
85
124
  In dev, saving a source file re-indexes the project, translates only new or changed messages and pushes the updated catalogues to the browser over an `autoi18n:update` event. Files added or removed are indexed as well. The page is not reloaded, so new JSX text appears once its translation is ready.
86
125
 
87
- ## Important prototype limitations
126
+ ## ⚙️ Configuration reference
127
+
128
+ ### Plugin options (`autoI18n({...})`)
129
+
130
+ | Option | Type | Default | Description |
131
+ |---|---|---|---|
132
+ | `locales` | `string[]` | _required_ | Locales the app ships, for example `["en", "de", "fr"]`. The source locale is never translated, so including it is harmless. |
133
+ | `sourceLocale` | `string` | `"en"` | Language the JSX is written in. Used as the fallback when a translation is missing. |
134
+ | `delivery` | `"build" \| "runtime"` | `"build"` | `build` compiles one locale per build. `runtime` ships every catalogue and switches locale in the browser with `setLocale()` from `virtual:autoi18n`. |
135
+ | `locale` | `string` | `sourceLocale` | Locale to compile in `build` delivery. Also read from the `AUTOI18N_LOCALE` environment variable, which is handy for CI matrix builds. In `build` delivery during a non-dev build, only this locale is translated. |
136
+ | `include` | `string[]` | `["src/**/*.{tsx,jsx,ts,js}"]` | Glob patterns of source files to scan, relative to the Vite root. |
137
+ | `exclude` | `string[]` | `["**/*.test.*", "**/node_modules/**"]` | Glob patterns of files to skip, relative to the Vite root. |
138
+ | `directory` | `string` | `".autoi18n"` | Folder for `registry.json` and `locales/`, relative to the Vite root. |
139
+ | `provider` | `TranslationProvider` | `undefined` | Translates missing and changed messages. Without one, untranslated messages fall back to the source text. |
140
+ | `failOnMissingProvider` | `boolean` | `false` | When `true`, the build fails if a locale has untranslated messages and no `provider` is set. |
141
+ | `onAmbiguous` | `"warn" \| "error"` | `"warn"` | What to do when duplicated identical texts change their occurrence count, so their IDs may have moved. `"error"` fails the build. |
142
+ | `orphanRetentionMinutes` | `number` | `43200` (30 days) | Minutes an orphaned message is kept before it is deleted. `0` deletes on the next build; `Infinity` keeps forever. |
143
+ | `localeConcurrency` | `number` | `2` | Locales translated at the same time. Values below 1 are raised to 1, and fractions are rounded down. |
144
+ | `debug` | `boolean` | `false` | Logs each message sent to the provider, each translation received, and each removed orphan, with timestamps. |
145
+
146
+ ### `OpenAICompatibleProvider` options
147
+
148
+ Create the provider with `new OpenAICompatibleProvider(options)`.
149
+
150
+ | Option | Type | Default | Description |
151
+ |---|---|---|---|
152
+ | `apiKey` | `string` | _required_ | Bearer token for the endpoint. Read it from an environment variable and do not commit it. |
153
+ | `model` | `string` | _required_ | Model name sent in each request, for example `"gpt-4.1-mini"`. |
154
+ | `baseURL` | `string` | `"https://api.openai.com/v1"` | Base URL of any OpenAI-compatible API. Requests go to `${baseURL}/chat/completions`. |
155
+ | `batchSize` | `number` | `40` | Messages per request. Smaller batches keep responses within output limits. |
156
+ | `concurrency` | `number` | `4` | Batches sent in parallel for one locale. |
157
+ | `timeoutMs` | `number` | `60000` | Milliseconds before a single request is aborted. |
158
+ | `maxRetries` | `number` | `2` | Extra attempts for network errors, timeouts, 408/409/429/5xx responses and malformed output. |
159
+ | `retryDelayMs` | `number` | `500` | Delay before the first retry. It doubles on each attempt. |
160
+
161
+ ### Custom provider (`TranslationProvider`)
162
+
163
+ Any object with this shape can be passed as `provider`:
164
+
165
+ ```ts
166
+ interface TranslationProvider {
167
+ translate(input: {
168
+ sourceLocale: string;
169
+ targetLocale: string;
170
+ messages: TranslationRequest[];
171
+ }): Promise<Record<string, string>>; // message ID -> translated text
172
+ }
173
+
174
+ interface TranslationRequest {
175
+ id: string; // opaque ID to echo back in the result
176
+ source: string; // English text
177
+ context: string; // for example "Toolbar > Save button"
178
+ kind: "text" | "attribute" | "mixed"; // text node, static attribute, or both in one message
179
+ placeholders?: string[]; // {name} placeholders that must survive translation
180
+ }
181
+ ```
182
+
183
+ Return only the IDs you translated. Omitted IDs stay in the source language and are requested again on the next build. If your provider can only translate some batches, throw `PartialTranslationError` (exported from the package) with the translations you have and the number of failed messages, so the successful ones are still stored. Any other error fails the whole locale, which keeps its previous file.
184
+
185
+ ### Environment variables
186
+
187
+ | Variable | Effect |
188
+ |---|---|
189
+ | `AUTOI18N_LOCALE` | Same as the `locale` option. The option wins when both are set. |
190
+
191
+ ### Prune CLI
192
+
193
+ The `prune` CLI options (`--directory`, `--retention-minutes`, `--debug`) are described in the orphan paragraph of [Try the bundled example](#-try-the-bundled-example).
194
+
195
+ ## ⚠️ Important prototype limitations
88
196
 
89
197
  This is a compiler MVP, not production-ready localization yet:
90
198
 
@@ -96,7 +204,7 @@ This is a compiler MVP, not production-ready localization yet:
96
204
  6. The example uses a React plugin in its Vite config; install `@vitejs/plugin-react` in the consuming example.
97
205
  7. The initial fingerprint is a heuristic. It excludes file and line position, but component names and nearby semantic signals can still change during refactors.
98
206
 
99
- ## Design rules
207
+ ## 🧭 Design rules
100
208
 
101
209
  - IDs are opaque and persistent; never expose semantic fingerprints as runtime keys.
102
210
  - AI suggestions never silently overwrite an existing translation.
@@ -2,9 +2,11 @@
2
2
  var debugLog = (message) => console.log(`[autoi18n debug] ${(/* @__PURE__ */ new Date()).toISOString()} ${message}`);
3
3
 
4
4
  // src/io.ts
5
- import { randomUUID } from "crypto";
5
+ import { createHash, randomUUID } from "crypto";
6
6
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
7
7
  import path from "path";
8
+ var toJson = (value) => JSON.stringify(value, null, 2) + "\n";
9
+ var hashContent = (text) => createHash("sha256").update(text).digest("hex");
8
10
  async function readJsonFile(file, fallback, warn) {
9
11
  let text;
10
12
  try {
@@ -30,6 +32,28 @@ async function writeFileAtomic(file, content) {
30
32
  await rename(temp, file);
31
33
  }
32
34
 
35
+ // src/catalog.ts
36
+ import path2 from "path";
37
+ var emptyLocaleFile = () => ({ messages: {}, sources: {} });
38
+ function localeFilePath(outDir, locale) {
39
+ return path2.join(outDir, `${locale}.json`);
40
+ }
41
+ async function readLocaleFile(file, warn) {
42
+ const parsed = await readJsonFile(file, null, warn);
43
+ if (!parsed || typeof parsed.messages !== "object" || typeof parsed.sources !== "object") return emptyLocaleFile();
44
+ return { messages: parsed.messages, sources: parsed.sources };
45
+ }
46
+ var writeLocaleFile = (file, content) => writeFileAtomic(file, toJson(content));
47
+ function pruneCatalog(catalog, keep) {
48
+ const kept = {};
49
+ const removed = [];
50
+ for (const [id, text] of Object.entries(catalog)) {
51
+ if (keep.has(id)) kept[id] = text;
52
+ else removed.push(id);
53
+ }
54
+ return { catalog: kept, removed };
55
+ }
56
+
33
57
  // src/identity/registry.ts
34
58
  import { randomUUID as randomUUID2 } from "crypto";
35
59
  var emptyRegistry = () => ({ version: 1, entries: {} });
@@ -63,6 +87,7 @@ function reconcile(registry, messages) {
63
87
  list.push(message);
64
88
  incoming.set(message.fingerprint, list);
65
89
  }
90
+ const assigned = /* @__PURE__ */ new Map();
66
91
  const seen = /* @__PURE__ */ new Set();
67
92
  const ambiguous = [];
68
93
  for (const [fingerprint, current] of incoming) {
@@ -76,7 +101,7 @@ function reconcile(registry, messages) {
76
101
  const message = current[i];
77
102
  const candidate = previous[i];
78
103
  if (candidate) {
79
- message.id = candidate.id;
104
+ assigned.set(message, candidate.id);
80
105
  candidate.status = "active";
81
106
  candidate.lastSeen = now;
82
107
  candidate.source = message.source;
@@ -84,7 +109,7 @@ function reconcile(registry, messages) {
84
109
  seen.add(candidate.id);
85
110
  } else {
86
111
  const id = `msg_${randomUUID2().replaceAll("-", "").slice(0, 16)}`;
87
- message.id = id;
112
+ assigned.set(message, id);
88
113
  registry.entries[id] = {
89
114
  id,
90
115
  source: message.source,
@@ -104,7 +129,12 @@ function reconcile(registry, messages) {
104
129
  }
105
130
  }
106
131
  for (const entry of Object.values(registry.entries)) if (!seen.has(entry.id)) entry.status = "orphaned";
107
- return { registry, ambiguous };
132
+ const resolved = messages.map((message) => {
133
+ const id = assigned.get(message);
134
+ if (id === void 0) throw new Error(`autoi18n: no message ID was assigned to ${message.file}:${message.start}`);
135
+ return { ...message, id };
136
+ });
137
+ return { registry, ambiguous, messages: resolved };
108
138
  }
109
139
  var MINUTE_MS = 60 * 1e3;
110
140
  var DEFAULT_ORPHAN_RETENTION_MINUTES = 30 * 24 * 60;
@@ -120,13 +150,17 @@ function pruneOrphans(registry, retentionMinutes, now = Date.now()) {
120
150
  return removed;
121
151
  }
122
152
  async function saveRegistry(file, registry) {
123
- await writeFileAtomic(file, JSON.stringify(registry, null, 2) + "\n");
153
+ await writeFileAtomic(file, toJson(registry));
124
154
  }
125
155
 
126
156
  export {
127
157
  debugLog,
128
- readJsonFile,
129
- writeFileAtomic,
158
+ hashContent,
159
+ emptyLocaleFile,
160
+ localeFilePath,
161
+ readLocaleFile,
162
+ writeLocaleFile,
163
+ pruneCatalog,
130
164
  loadRegistry,
131
165
  reconcile,
132
166
  DEFAULT_ORPHAN_RETENTION_MINUTES,
@@ -0,0 +1,38 @@
1
+ // src/runtime/loader.ts
2
+ function createLocaleLoader(loaders, fallback = "en") {
3
+ const cache = /* @__PURE__ */ new Map();
4
+ function loadLocale(locale) {
5
+ const selected = loaders[locale] ? locale : fallback;
6
+ const cached = cache.get(selected);
7
+ if (cached) return cached;
8
+ const loader = loaders[selected];
9
+ if (!loader) return Promise.reject(new Error(`No catalogue loader for locale "${selected}"`));
10
+ const pending = loader().then((module) => "default" in module ? module.default : module);
11
+ cache.set(selected, pending);
12
+ pending.catch(() => {
13
+ if (cache.get(selected) === pending) cache.delete(selected);
14
+ });
15
+ return pending;
16
+ }
17
+ loadLocale.prime = (locale, catalog) => {
18
+ cache.set(locale, Promise.resolve(catalog));
19
+ };
20
+ return loadLocale;
21
+ }
22
+
23
+ // src/runtime/resolve.ts
24
+ function resolveLocale(preferred, supported, fallback = "en") {
25
+ for (const candidate of preferred) {
26
+ const exact = supported.find((x) => x.toLowerCase() === candidate.toLowerCase());
27
+ if (exact) return exact;
28
+ const base = candidate.split("-")[0].toLowerCase();
29
+ const language = supported.find((x) => x.split("-")[0].toLowerCase() === base);
30
+ if (language) return language;
31
+ }
32
+ return fallback;
33
+ }
34
+
35
+ export {
36
+ createLocaleLoader,
37
+ resolveLocale
38
+ };
package/dist/cli/prune.js CHANGED
@@ -2,27 +2,29 @@ import {
2
2
  DEFAULT_ORPHAN_RETENTION_MINUTES,
3
3
  debugLog,
4
4
  loadRegistry,
5
+ pruneCatalog,
5
6
  pruneOrphans,
6
- readJsonFile,
7
+ readLocaleFile,
7
8
  saveRegistry,
8
- writeFileAtomic
9
- } from "../chunk-ASK7BKZ3.js";
9
+ writeLocaleFile
10
+ } from "../chunk-LLL7ZSKW.js";
10
11
 
11
12
  // src/cli/prune.ts
12
13
  import { existsSync } from "fs";
13
14
  import { readdir } from "fs/promises";
14
15
  import path from "path";
15
- async function pruneCatalogs(localesDir, activeIds) {
16
+ async function pruneLocaleFiles(localesDir, activeIds) {
16
17
  if (!existsSync(localesDir)) return [];
17
18
  const results = [];
18
19
  for (const name of await readdir(localesDir)) {
19
20
  if (!name.endsWith(".json")) continue;
20
21
  const file = path.join(localesDir, name);
21
- const catalog = await readJsonFile(file, {}, (message) => console.warn(message));
22
- const removed = Object.keys(catalog).filter((id) => !activeIds.has(id));
22
+ const current = await readLocaleFile(file, (message) => console.warn(message));
23
+ const messages = pruneCatalog(current.messages, activeIds);
24
+ const sources = pruneCatalog(current.sources, activeIds);
25
+ const removed = [.../* @__PURE__ */ new Set([...messages.removed, ...sources.removed])];
23
26
  if (!removed.length) continue;
24
- for (const id of removed) delete catalog[id];
25
- await writeFileAtomic(file, JSON.stringify(catalog, null, 2) + "\n");
27
+ await writeLocaleFile(file, { messages: messages.catalog, sources: sources.catalog });
26
28
  results.push({ file, removed });
27
29
  }
28
30
  return results;
@@ -56,7 +58,7 @@ async function main() {
56
58
  const removed = pruneOrphans(registry, args.retentionMinutes);
57
59
  if (removed.length) await saveRegistry(registryFile, registry);
58
60
  const activeIds = new Set(Object.values(registry.entries).filter((entry) => entry.status === "active").map((entry) => entry.id));
59
- const catalogs = await pruneCatalogs(path.join(path.dirname(registryFile), "locales"), activeIds);
61
+ const catalogs = await pruneLocaleFiles(path.join(path.dirname(registryFile), "locales"), activeIds);
60
62
  if (args.debug) {
61
63
  for (const entry of removed) debugLog(`removed ${entry.id} ${JSON.stringify(entry.source)} (last seen ${entry.lastSeen})`);
62
64
  for (const { file, removed: ids } of catalogs) {
package/dist/index.d.ts CHANGED
@@ -16,22 +16,18 @@ interface ExtractedMessage {
16
16
  attribute?: string;
17
17
  context: string;
18
18
  fingerprint: string;
19
- id?: string;
20
19
  }
21
- interface RegistryEntry {
20
+ /** A message with the stable ID that the registry assigned to it. */
21
+ type ResolvedMessage = ExtractedMessage & {
22
+ id: string;
23
+ };
24
+ type RegistryEntry = Pick<ExtractedMessage, "source" | "kind" | "tag" | "component" | "attribute" | "context" | "fingerprint"> & {
22
25
  id: string;
23
- source: string;
24
- kind: MessageKind;
25
- tag: string;
26
- component: string;
27
- attribute?: string;
28
- context: string;
29
- fingerprint: string;
30
26
  location: string;
31
27
  status: "active" | "orphaned";
32
28
  firstSeen: string;
33
29
  lastSeen: string;
34
- }
30
+ };
35
31
  interface Registry {
36
32
  version: 1;
37
33
  entries: Record<string, RegistryEntry>;
@@ -52,6 +48,16 @@ interface TranslationProvider {
52
48
  }): Promise<Catalog>;
53
49
  }
54
50
 
51
+ /**
52
+ * Some batches of a translation request failed while others succeeded. `partial` holds the translations
53
+ * that did come back, so the caller can keep them and report the rest.
54
+ */
55
+ declare class PartialTranslationError extends Error {
56
+ readonly partial: Catalog;
57
+ readonly failed: number;
58
+ constructor(message: string, partial: Catalog, failed: number);
59
+ }
60
+
55
61
  interface OpenAICompatibleOptions {
56
62
  apiKey: string;
57
63
  model: string;
@@ -64,10 +70,16 @@ interface OpenAICompatibleOptions {
64
70
  maxRetries?: number;
65
71
  /** Base delay before a retry; doubles on each attempt. Default 500. */
66
72
  retryDelayMs?: number;
73
+ /** Batches sent in parallel. Default 4. */
74
+ concurrency?: number;
67
75
  }
68
76
  declare class OpenAICompatibleProvider implements TranslationProvider {
69
77
  private options;
70
78
  constructor(options: OpenAICompatibleOptions);
79
+ /**
80
+ * Translates in parallel batches. If every batch fails, the first error is thrown. If only some fail,
81
+ * a PartialTranslationError carries the translations that succeeded.
82
+ */
71
83
  translate(input: {
72
84
  sourceLocale: string;
73
85
  targetLocale: string;
@@ -98,9 +110,14 @@ interface AutoI18nOptions {
98
110
  * Default 43200 (30 days). `0` deletes orphans on the next build; `Infinity` keeps them forever.
99
111
  */
100
112
  orphanRetentionMinutes?: number;
113
+ /**
114
+ * Locales translated at the same time. Default 2. Batches within a locale are limited separately by
115
+ * `OpenAICompatibleOptions.concurrency`. Lower this if your provider rate-limits per account.
116
+ */
117
+ localeConcurrency?: number;
101
118
  /** Log each message sent to the translation provider and each translation received, with timestamps. Default false. */
102
119
  debug?: boolean;
103
120
  }
104
121
  declare function autoI18n(options: AutoI18nOptions): Plugin;
105
122
 
106
- export { type AutoI18nOptions, type Catalog, type ExtractedMessage, type MessageKind, type OpenAICompatibleOptions, OpenAICompatibleProvider, type Registry, type RegistryEntry, type TranslationProvider, type TranslationRequest, autoI18n as default };
123
+ export { type AutoI18nOptions, type Catalog, type ExtractedMessage, type MessageKind, type OpenAICompatibleOptions, OpenAICompatibleProvider, PartialTranslationError, type Registry, type RegistryEntry, type ResolvedMessage, type TranslationProvider, type TranslationRequest, autoI18n as default };
package/dist/index.js CHANGED
@@ -1,23 +1,153 @@
1
1
  import {
2
2
  DEFAULT_ORPHAN_RETENTION_MINUTES,
3
3
  debugLog,
4
+ emptyLocaleFile,
5
+ hashContent,
4
6
  loadRegistry,
7
+ localeFilePath,
8
+ pruneCatalog,
5
9
  pruneOrphans,
6
- readJsonFile,
10
+ readLocaleFile,
7
11
  reconcile,
8
12
  saveRegistry,
9
- writeFileAtomic
10
- } from "./chunk-ASK7BKZ3.js";
13
+ writeLocaleFile
14
+ } from "./chunk-LLL7ZSKW.js";
11
15
  import {
16
+ createLocaleLoader,
12
17
  resolveLocale
13
- } from "./chunk-RVYJXXTU.js";
18
+ } from "./chunk-ZDZ3YVET.js";
14
19
 
15
20
  // src/index.ts
16
21
  import fg from "fast-glob";
17
- import { createHash as createHash2 } from "crypto";
18
- import { readFile } from "fs/promises";
22
+ import { readFile, rm } from "fs/promises";
19
23
  import path from "path";
20
24
 
25
+ // src/concurrency.ts
26
+ async function mapWithConcurrency(items, limit, task) {
27
+ const results = new Array(items.length);
28
+ let next = 0;
29
+ const worker = async () => {
30
+ while (next < items.length) {
31
+ const index = next++;
32
+ try {
33
+ results[index] = { status: "fulfilled", value: await task(items[index]) };
34
+ } catch (reason) {
35
+ results[index] = { status: "rejected", reason };
36
+ }
37
+ }
38
+ };
39
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
40
+ return results;
41
+ }
42
+
43
+ // src/compiler/placeholders.ts
44
+ var PLACEHOLDER_PATTERN = "\\{([^{}]+)\\}";
45
+ var PLACEHOLDER = new RegExp(PLACEHOLDER_PATTERN, "g");
46
+ function placeholderNames(text) {
47
+ return [...text.matchAll(PLACEHOLDER)].map((match) => match[1]);
48
+ }
49
+ function sameNames(a, b) {
50
+ const left = new Set(a);
51
+ const right = new Set(b);
52
+ return left.size === right.size && [...left].every((name) => right.has(name));
53
+ }
54
+ function isUsableTranslation(text, placeholders) {
55
+ return text.trim() !== "" && sameNames(placeholderNames(text), placeholders);
56
+ }
57
+
58
+ // src/errors.ts
59
+ var PartialTranslationError = class extends Error {
60
+ constructor(message, partial, failed) {
61
+ super(message);
62
+ this.partial = partial;
63
+ this.failed = failed;
64
+ this.name = "PartialTranslationError";
65
+ }
66
+ partial;
67
+ failed;
68
+ };
69
+
70
+ // src/translate.ts
71
+ async function translateLocale(input) {
72
+ const { provider, sourceLocale, locale, requests, previous, debug } = input;
73
+ const active = new Set(requests.map((x) => x.id));
74
+ const previousMessages = pruneCatalog(previous.messages, active).catalog;
75
+ const previousSources = pruneCatalog(previous.sources, active).catalog;
76
+ const missing = requests.filter((x) => previousMessages[x.id] === void 0 || previousSources[x.id] !== x.source);
77
+ let translated = {};
78
+ let failed = 0;
79
+ let failure;
80
+ if (missing.length && provider) {
81
+ if (debug) {
82
+ for (const m of missing) debugLog(`sent ${sourceLocale} -> ${locale} ${m.id}: ${JSON.stringify(m.source)} (context: ${m.context})`);
83
+ }
84
+ try {
85
+ translated = await provider.translate({ sourceLocale, targetLocale: locale, messages: missing });
86
+ } catch (error) {
87
+ if (!(error instanceof PartialTranslationError)) throw error;
88
+ translated = error.partial;
89
+ failed = error.failed;
90
+ failure = error.message;
91
+ }
92
+ if (debug) {
93
+ for (const m of missing) {
94
+ const text = translated[m.id];
95
+ debugLog(`received ${sourceLocale} -> ${locale} ${m.id}: ${typeof text === "string" ? JSON.stringify(text) : "(no translation)"}`);
96
+ }
97
+ }
98
+ }
99
+ const messages = { ...previousMessages };
100
+ const sources = { ...previousSources };
101
+ let rejected = 0;
102
+ for (const request of missing) {
103
+ delete messages[request.id];
104
+ delete sources[request.id];
105
+ const text = translated[request.id];
106
+ if (typeof text !== "string" || !text.trim()) continue;
107
+ if (request.kind === "mixed" && !isUsableTranslation(text, request.placeholders ?? [])) {
108
+ rejected++;
109
+ continue;
110
+ }
111
+ messages[request.id] = text;
112
+ sources[request.id] = request.source;
113
+ }
114
+ const untranslated = requests.filter((x) => messages[x.id] === void 0).length;
115
+ return { file: { messages, sources }, untranslated, rejected, requested: missing.length, failed, failure };
116
+ }
117
+
118
+ // src/engine.ts
119
+ async function buildCatalogs(input) {
120
+ const { registry, ambiguous, messages } = reconcile(input.registry, input.files.flatMap((f) => f.messages));
121
+ const pruned = pruneOrphans(registry, input.orphanRetentionMinutes);
122
+ const sourceCatalog = {};
123
+ for (const m of messages) sourceCatalog[m.id] = m.source;
124
+ const requests = messages.map((m) => ({ id: m.id, source: m.source, context: `${m.component} > ${m.context}`, kind: m.kind, placeholders: m.placeholders }));
125
+ const outcomes = await mapWithConcurrency(input.locales, input.localeConcurrency, (locale) => translateLocale({
126
+ provider: input.provider,
127
+ sourceLocale: input.sourceLocale,
128
+ locale,
129
+ requests,
130
+ previous: input.previous.get(locale) ?? emptyLocaleFile(),
131
+ debug: input.debug
132
+ }));
133
+ const locales = /* @__PURE__ */ new Map();
134
+ const failures = /* @__PURE__ */ new Map();
135
+ outcomes.forEach((outcome, index) => {
136
+ const locale = input.locales[index];
137
+ if (outcome.status === "fulfilled") locales.set(locale, outcome.value);
138
+ else failures.set(locale, outcome.reason);
139
+ });
140
+ return {
141
+ registry,
142
+ ambiguous,
143
+ pruned,
144
+ messages,
145
+ sourceFile: { messages: sourceCatalog, sources: {} },
146
+ locales,
147
+ failures
148
+ };
149
+ }
150
+
21
151
  // src/compiler/extract.ts
22
152
  import { parse } from "@babel/parser";
23
153
  import traverseModule from "@babel/traverse";
@@ -169,22 +299,6 @@ import { parse as parse2 } from "@babel/parser";
169
299
  import traverseModule2 from "@babel/traverse";
170
300
  import generateModule from "@babel/generator";
171
301
  import * as t2 from "@babel/types";
172
-
173
- // src/compiler/placeholders.ts
174
- var PLACEHOLDER = /\{([^{}]+)\}/g;
175
- function placeholderNames(text) {
176
- return [...text.matchAll(PLACEHOLDER)].map((match) => match[1]);
177
- }
178
- function sameNames(a, b) {
179
- const left = new Set(a);
180
- const right = new Set(b);
181
- return left.size === right.size && [...left].every((name) => right.has(name));
182
- }
183
- function isUsableTranslation(text, placeholders) {
184
- return text.trim() !== "" && sameNames(placeholderNames(text), placeholders);
185
- }
186
-
187
- // src/compiler/transform.ts
188
302
  var traverse2 = traverseModule2.default ?? traverseModule2;
189
303
  var generate = generateModule.default ?? generateModule;
190
304
  var RUNTIME_MODULE = "virtual:autoi18n";
@@ -205,7 +319,7 @@ function transformSource(code, filename, messages, locale, catalog, runtime = fa
205
319
  traverse2(ast, {
206
320
  JSXElement(p) {
207
321
  const message = byStart.get(p.node.start ?? -1);
208
- if (message?.kind !== "mixed" || !message.id) return;
322
+ if (message?.kind !== "mixed") return;
209
323
  const names = message.placeholders ?? [];
210
324
  const expressions = p.node.children.filter((c) => t2.isJSXExpressionContainer(c)).map((c) => c.expression);
211
325
  const prefix = message.prefix ?? "";
@@ -236,7 +350,7 @@ function transformSource(code, filename, messages, locale, catalog, runtime = fa
236
350
  },
237
351
  JSXText(p) {
238
352
  const message = byStart.get(p.node.start ?? -1);
239
- if (!message?.id) return;
353
+ if (!message) return;
240
354
  const parts = [];
241
355
  if (runtime) {
242
356
  if (message.prefix) parts.push(t2.stringLiteral(message.prefix));
@@ -253,7 +367,7 @@ function transformSource(code, filename, messages, locale, catalog, runtime = fa
253
367
  const valueNode = p.node.value;
254
368
  if (!t2.isStringLiteral(valueNode)) return;
255
369
  const message = byStart.get(valueNode.start ?? -1);
256
- if (!message?.id) return;
370
+ if (!message) return;
257
371
  p.node.value = runtime ? t2.jsxExpressionContainer(lookup(message)) : lookup(message);
258
372
  }
259
373
  });
@@ -268,16 +382,34 @@ function transformSource(code, filename, messages, locale, catalog, runtime = fa
268
382
  return { code: result.code, map: result.map };
269
383
  }
270
384
 
385
+ // src/runtime/format.ts
386
+ function formatPlaceholders(text, values, placeholder) {
387
+ const parts = [];
388
+ let last = 0;
389
+ for (const match of text.matchAll(placeholder)) {
390
+ if (!Object.hasOwn(values, match[1])) continue;
391
+ const index = match.index ?? 0;
392
+ if (index > last) parts.push(text.slice(last, index));
393
+ parts.push(values[match[1]]);
394
+ last = index + match[0].length;
395
+ }
396
+ if (last < text.length) parts.push(text.slice(last));
397
+ return parts;
398
+ }
399
+
271
400
  // src/runtime/virtual.ts
272
401
  function createRuntimeModule(catalogFiles, sourceLocale) {
273
- const loaders = Object.entries(catalogFiles).map(([locale, file]) => ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(file)})`).join(",\n");
402
+ const loaders = Object.entries(catalogFiles).map(([locale, file]) => ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(file)}).then(m => (m.default ?? m).messages)`).join(",\n");
274
403
  return [
275
404
  `const loaders = {
276
405
  ${loaders}
277
406
  };`,
278
407
  `const sourceLocale = ${JSON.stringify(sourceLocale)};`,
279
408
  `const resolveLocale = ${resolveLocale.toString()};`,
280
- `const cache = new Map();`,
409
+ `const createLocaleLoader = ${createLocaleLoader.toString()};`,
410
+ `const formatPlaceholders = ${formatPlaceholders.toString()};`,
411
+ `const PLACEHOLDER = new RegExp(${JSON.stringify(PLACEHOLDER_PATTERN)}, "g");`,
412
+ `const loadLocale = createLocaleLoader(loaders, sourceLocale);`,
281
413
  `const listeners = new Set();`,
282
414
  `let currentLocale = sourceLocale;`,
283
415
  `let messages = {};`,
@@ -287,25 +419,17 @@ ${loaders}
287
419
  `export function getLocale() { return currentLocale; }`,
288
420
  `export function getVersion() { return version; }`,
289
421
  `export function resolveSupported(preferred) { return resolveLocale(preferred, locales, sourceLocale); }`,
290
- `function loadCatalog(locale) {`,
291
- ` if (!cache.has(locale)) {`,
292
- ` const pending = loaders[locale]().then(m => m.default ?? m);`,
293
- ` cache.set(locale, pending);`,
294
- ` pending.catch(() => { if (cache.get(locale) === pending) cache.delete(locale); });`,
295
- ` }`,
296
- ` return cache.get(locale);`,
297
- `}`,
298
422
  `export async function setLocale(locale) {`,
299
423
  ` const request = ++latestRequest;`,
300
424
  ` let selected = loaders[locale] ? locale : sourceLocale;`,
301
425
  ` let catalog;`,
302
426
  ` try {`,
303
- ` catalog = await loadCatalog(selected);`,
427
+ ` catalog = await loadLocale(selected);`,
304
428
  ` } catch (error) {`,
305
429
  ` if (selected === sourceLocale) throw error;`,
306
430
  ` console.warn('autoi18n: could not load "' + selected + '", falling back to "' + sourceLocale + '".', error);`,
307
431
  ` selected = sourceLocale;`,
308
- ` catalog = await loadCatalog(selected);`,
432
+ ` catalog = await loadLocale(selected);`,
309
433
  ` }`,
310
434
  ` // A slower earlier request must not overwrite the locale the user picked last.`,
311
435
  ` if (request !== latestRequest) return;`,
@@ -319,23 +443,11 @@ ${loaders}
319
443
  `}`,
320
444
  `export function subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }`,
321
445
  `export function t(id, source) { return messages[id] ?? source; }`,
322
- `// Splits a translated sentence at its {name} placeholders so values keep their React nodes; unknown names stay literal.`,
323
- `export function format(text, values) {`,
324
- ` const parts = [];`,
325
- ` let last = 0;`,
326
- ` for (const match of text.matchAll(/\\{([^{}]+)\\}/g)) {`,
327
- ` if (!Object.hasOwn(values, match[1])) continue;`,
328
- ` if (match.index > last) parts.push(text.slice(last, match.index));`,
329
- ` parts.push(values[match[1]]);`,
330
- ` last = match.index + match[0].length;`,
331
- ` }`,
332
- ` if (last < text.length) parts.push(text.slice(last));`,
333
- ` return parts;`,
334
- `}`,
446
+ `export function format(text, values) { return formatPlaceholders(text, values, PLACEHOLDER); }`,
335
447
  `// Dev server pushes fresh catalogues after a source change; no page reload is needed.`,
336
448
  `if (import.meta.hot) {`,
337
449
  ` import.meta.hot.on("autoi18n:update", data => {`,
338
- ` for (const [locale, catalog] of Object.entries(data.catalogs)) cache.set(locale, Promise.resolve(catalog));`,
450
+ ` for (const [locale, catalog] of Object.entries(data.catalogs)) loadLocale.prime(locale, catalog);`,
339
451
  ` if (data.catalogs[currentLocale]) messages = data.catalogs[currentLocale];`,
340
452
  ` notify();`,
341
453
  ` });`,
@@ -343,42 +455,6 @@ ${loaders}
343
455
  ].join("\n") + "\n";
344
456
  }
345
457
 
346
- // src/translate.ts
347
- async function translateLocale(input) {
348
- const { provider, sourceLocale, locale, requests, previous, previousSources, debug } = input;
349
- const missing = requests.filter((x) => previous[x.id] === void 0 || previousSources[x.id] !== x.source);
350
- let translated = {};
351
- if (missing.length && provider) {
352
- if (debug) {
353
- for (const m of missing) debugLog(`sent ${sourceLocale} -> ${locale} ${m.id}: ${JSON.stringify(m.source)} (context: ${m.context})`);
354
- }
355
- translated = await provider.translate({ sourceLocale, targetLocale: locale, messages: missing });
356
- if (debug) {
357
- for (const m of missing) {
358
- const text = translated[m.id];
359
- debugLog(`received ${sourceLocale} -> ${locale} ${m.id}: ${typeof text === "string" ? JSON.stringify(text) : "(no translation)"}`);
360
- }
361
- }
362
- }
363
- const catalog = { ...previous };
364
- const sources = { ...previousSources };
365
- let rejected = 0;
366
- for (const request of missing) {
367
- delete catalog[request.id];
368
- delete sources[request.id];
369
- const text = translated[request.id];
370
- if (typeof text !== "string" || !text.trim()) continue;
371
- if (request.kind === "mixed" && !isUsableTranslation(text, request.placeholders ?? [])) {
372
- rejected++;
373
- continue;
374
- }
375
- catalog[request.id] = text;
376
- sources[request.id] = request.source;
377
- }
378
- const untranslated = requests.filter((x) => catalog[x.id] === void 0).length;
379
- return { catalog, sources, untranslated, rejected, requested: missing.length };
380
- }
381
-
382
458
  // src/ai/provider.ts
383
459
  var ProviderError = class extends Error {
384
460
  constructor(message, retryable) {
@@ -393,13 +469,31 @@ var OpenAICompatibleProvider = class {
393
469
  this.options = options;
394
470
  }
395
471
  options;
472
+ /**
473
+ * Translates in parallel batches. If every batch fails, the first error is thrown. If only some fail,
474
+ * a PartialTranslationError carries the translations that succeeded.
475
+ */
396
476
  async translate(input) {
397
477
  const batchSize = Math.max(1, this.options.batchSize ?? 40);
478
+ const batches = [];
479
+ for (let start = 0; start < input.messages.length; start += batchSize) batches.push(input.messages.slice(start, start + batchSize));
480
+ const settled = await mapWithConcurrency(batches, this.options.concurrency ?? 4, (batch) => this.translateBatch({ ...input, messages: batch }));
398
481
  const result = {};
399
- for (let start = 0; start < input.messages.length; start += batchSize) {
400
- Object.assign(result, await this.translateBatch({ ...input, messages: input.messages.slice(start, start + batchSize) }));
401
- }
402
- return result;
482
+ const errors = [];
483
+ let failed = 0;
484
+ let succeeded = 0;
485
+ settled.forEach((outcome, index) => {
486
+ if (outcome.status === "fulfilled") {
487
+ succeeded++;
488
+ Object.assign(result, outcome.value);
489
+ } else {
490
+ errors.push(outcome.reason);
491
+ failed += batches[index].length;
492
+ }
493
+ });
494
+ if (!errors.length) return result;
495
+ if (!succeeded) throw errors[0];
496
+ throw new PartialTranslationError(`${failed} messages failed to translate: ${errors[0].message}`, result, failed);
403
497
  }
404
498
  async translateBatch(input) {
405
499
  const maxRetries = this.options.maxRetries ?? 2;
@@ -457,9 +551,14 @@ var UPDATE_EVENT = "autoi18n:update";
457
551
  var DEFAULT_INCLUDE = ["src/**/*.{tsx,jsx,ts,js}"];
458
552
  var DEFAULT_EXCLUDE = ["**/*.test.*", "**/node_modules/**"];
459
553
  var WATCH_DEBOUNCE_MS = 150;
460
- var hashOf = (code) => createHash2("sha1").update(code).digest("hex");
461
- var toJson = (value) => JSON.stringify(value, null, 2) + "\n";
462
- var pathWithGlob = path;
554
+ var reporterFor = (logger) => ({
555
+ info: (m) => logger.info(m),
556
+ warn: (m) => logger.warn(m),
557
+ error: (m) => logger.error(m)
558
+ });
559
+ async function removeLegacySidecars(outDir, locales) {
560
+ await Promise.all(locales.map((locale) => rm(path.join(outDir, `${locale}.sources.json`), { force: true })));
561
+ }
463
562
  function autoI18n(options) {
464
563
  const sourceLocale = options.sourceLocale ?? "en";
465
564
  const directory = options.directory ?? ".autoi18n";
@@ -469,6 +568,7 @@ function autoI18n(options) {
469
568
  let config;
470
569
  let registryFile = "";
471
570
  let catalogFiles = {};
571
+ let extractions = /* @__PURE__ */ new Map();
472
572
  let fileMessages = /* @__PURE__ */ new Map();
473
573
  let fileHashes = /* @__PURE__ */ new Map();
474
574
  let catalogs = /* @__PURE__ */ new Map();
@@ -476,7 +576,6 @@ function autoI18n(options) {
476
576
  let server;
477
577
  let queue = Promise.resolve();
478
578
  let watchTimer;
479
- const devReporter = () => ({ info: (m) => config.logger.info(m), warn: (m) => config.logger.warn(m), error: (m) => config.logger.error(m) });
480
579
  const selectedLocale = () => options.locale ?? process.env.AUTOI18N_LOCALE ?? sourceLocale;
481
580
  function localesToTranslate() {
482
581
  const others = options.locales.filter((x) => x !== sourceLocale);
@@ -486,62 +585,74 @@ function autoI18n(options) {
486
585
  }
487
586
  async function sync(report) {
488
587
  const files = await fg(options.include ?? DEFAULT_INCLUDE, { cwd: config.root, ignore: options.exclude ?? DEFAULT_EXCLUDE, absolute: true });
489
- const all = [];
490
- const nextMessages = /* @__PURE__ */ new Map();
491
- const nextHashes = /* @__PURE__ */ new Map();
588
+ const nextExtractions = /* @__PURE__ */ new Map();
589
+ const indexed = [];
492
590
  for (const file of files) {
493
591
  const code = await readFile(file, "utf8");
494
- const messages = extractMessages(code, file);
495
- nextMessages.set(file, messages);
496
- nextHashes.set(file, hashOf(code));
497
- all.push(...messages);
592
+ const hash = hashContent(code);
593
+ const cached = extractions.get(file);
594
+ const entry = cached?.hash === hash ? cached : { hash, messages: extractMessages(code, file) };
595
+ nextExtractions.set(file, entry);
596
+ indexed.push({ file, messages: entry.messages });
498
597
  }
499
- const { registry, ambiguous } = reconcile(await loadRegistry(registryFile, report.warn), all);
500
- const pruned = pruneOrphans(registry, orphanRetentionMinutes);
501
- if (debug) for (const entry of pruned) debugLog(`removed ${entry.id} ${JSON.stringify(entry.source)} (last seen ${entry.lastSeen})`);
502
- if (pruned.length) report.info(`autoi18n: removed ${pruned.length} registry entries no longer in the source.`);
503
- await saveRegistry(registryFile, registry);
504
- if (ambiguous.length) {
505
- const message = `autoi18n: ${ambiguous.length} duplicated texts changed occurrence count, so their IDs may have moved (e.g. "${ambiguous[0]}").`;
598
+ const outDir = path.resolve(config.root, directory, "locales");
599
+ const locales = localesToTranslate();
600
+ const previous = /* @__PURE__ */ new Map();
601
+ for (const locale of locales) previous.set(locale, await readLocaleFile(localeFilePath(outDir, locale), report.warn));
602
+ const result = await buildCatalogs({
603
+ files: indexed,
604
+ registry: await loadRegistry(registryFile, report.warn),
605
+ orphanRetentionMinutes,
606
+ sourceLocale,
607
+ locales,
608
+ localeConcurrency: Math.max(1, Math.floor(options.localeConcurrency ?? 2)),
609
+ previous,
610
+ provider: options.provider,
611
+ debug
612
+ });
613
+ if (debug) for (const entry of result.pruned) debugLog(`removed ${entry.id} ${JSON.stringify(entry.source)} (last seen ${entry.lastSeen})`);
614
+ if (result.pruned.length) report.info(`autoi18n: removed ${result.pruned.length} registry entries no longer in the source.`);
615
+ await saveRegistry(registryFile, result.registry);
616
+ if (result.ambiguous.length) {
617
+ const message = `autoi18n: ${result.ambiguous.length} duplicated texts changed occurrence count, so their IDs may have moved (e.g. "${result.ambiguous[0]}").`;
506
618
  if (options.onAmbiguous === "error") report.error(message);
507
619
  else report.warn(message);
508
620
  }
509
- const outDir = path.resolve(config.root, directory, "locales");
510
- const sourceCatalog = {};
511
- for (const m of all) if (m.id) sourceCatalog[m.id] = m.source;
512
- await writeFileAtomic(path.join(outDir, `${sourceLocale}.json`), toJson(sourceCatalog));
513
- const requests = all.filter((m) => m.id).map((m) => ({ id: m.id, source: m.source, context: `${m.component} > ${m.context}`, kind: m.kind, placeholders: m.placeholders }));
514
- const nextFiles = Object.fromEntries(options.locales.map((locale) => [locale, path.join(outDir, `${locale}.json`)]));
515
- const nextCatalogs = /* @__PURE__ */ new Map([[sourceLocale, sourceCatalog]]);
516
- for (const locale of localesToTranslate()) {
517
- const catalogPath = nextFiles[locale];
518
- const sourcesPath = path.join(outDir, `${locale}.sources.json`);
519
- const result = await translateLocale({
520
- provider: options.provider,
521
- sourceLocale,
522
- locale,
523
- requests,
524
- previous: await readJsonFile(catalogPath, {}, report.warn),
525
- previousSources: await readJsonFile(sourcesPath, {}, report.warn),
526
- debug
527
- });
528
- if (result.requested && !options.provider && options.failOnMissingProvider) {
529
- report.error(`Missing ${result.requested} ${locale} translations and no provider is configured.`);
621
+ await writeLocaleFile(localeFilePath(outDir, sourceLocale), result.sourceFile);
622
+ await removeLegacySidecars(outDir, [sourceLocale, ...options.locales]);
623
+ const nextCatalogs = /* @__PURE__ */ new Map([[sourceLocale, result.sourceFile.messages]]);
624
+ for (const [locale, translation] of result.locales) {
625
+ await writeLocaleFile(localeFilePath(outDir, locale), translation.file);
626
+ nextCatalogs.set(locale, translation.file.messages);
627
+ }
628
+ for (const locale of result.failures.keys()) nextCatalogs.set(locale, previous.get(locale)?.messages ?? {});
629
+ let offset = 0;
630
+ fileMessages = /* @__PURE__ */ new Map();
631
+ for (const { file, messages } of indexed) {
632
+ fileMessages.set(file, result.messages.slice(offset, offset + messages.length));
633
+ offset += messages.length;
634
+ }
635
+ fileHashes = new Map([...nextExtractions].map(([file, entry]) => [file, entry.hash]));
636
+ extractions = nextExtractions;
637
+ catalogs = nextCatalogs;
638
+ catalogFiles = Object.fromEntries(options.locales.map((locale) => [locale, localeFilePath(outDir, locale)]));
639
+ for (const [locale, translation] of result.locales) {
640
+ if (translation.requested && !options.provider && options.failOnMissingProvider) {
641
+ report.error(`Missing ${translation.requested} ${locale} translations and no provider is configured.`);
530
642
  }
531
- if (result.rejected) report.info(`autoi18n: rejected ${result.rejected} ${locale} translations with changed placeholders.`);
532
- if (result.untranslated) {
643
+ if (translation.rejected) report.info(`autoi18n: rejected ${translation.rejected} ${locale} translations with changed placeholders.`);
644
+ if (translation.untranslated) {
533
645
  const log = options.provider ? report.warn : report.info;
534
- log(`autoi18n: ${result.untranslated} ${locale} messages have no translation and fall back to the source text.`);
646
+ log(`autoi18n: ${translation.untranslated} ${locale} messages have no translation and fall back to the source text.`);
647
+ }
648
+ if (translation.failed) {
649
+ report.warn(`autoi18n: ${translation.failed} ${locale} translation batches failed (${translation.failure}); they will be requested again on the next build.`);
535
650
  }
536
- await writeFileAtomic(catalogPath, toJson(result.catalog));
537
- await writeFileAtomic(sourcesPath, toJson(result.sources));
538
- nextCatalogs.set(locale, result.catalog);
539
651
  }
540
- fileMessages = nextMessages;
541
- fileHashes = nextHashes;
542
- catalogs = nextCatalogs;
543
- catalogFiles = nextFiles;
544
- if (all.length) report.info(`autoi18n: indexed ${all.length} messages across ${files.length} files.`);
652
+ if (result.messages.length) report.info(`autoi18n: indexed ${result.messages.length} messages across ${files.length} files.`);
653
+ const failures = [...result.failures];
654
+ for (const [locale, error] of failures.slice(1)) report.warn(`autoi18n: ${locale} translation failed: ${error.message}`);
655
+ if (failures.length) throw failures[0][1];
545
656
  }
546
657
  function runSync(report) {
547
658
  const next = queue.then(() => sync(report));
@@ -553,7 +664,7 @@ function autoI18n(options) {
553
664
  }
554
665
  async function retranslate() {
555
666
  try {
556
- await runSync(devReporter());
667
+ await runSync(reporterFor(config.logger));
557
668
  pushCatalogs();
558
669
  } catch (error) {
559
670
  config.logger.error(`autoi18n: re-translation failed: ${error.message}`);
@@ -565,10 +676,8 @@ function autoI18n(options) {
565
676
  }
566
677
  function isSourceCandidate(file) {
567
678
  if (!/\.[jt]sx?$/.test(file)) return false;
568
- if (!pathWithGlob.matchesGlob) return true;
569
679
  const relative = path.relative(config.root, file).split(path.sep).join("/");
570
- const matches = pathWithGlob.matchesGlob;
571
- return (options.include ?? DEFAULT_INCLUDE).some((p) => matches(relative, p)) && !(options.exclude ?? DEFAULT_EXCLUDE).some((p) => matches(relative, p));
680
+ return (options.include ?? DEFAULT_INCLUDE).some((p) => path.matchesGlob(relative, p)) && !(options.exclude ?? DEFAULT_EXCLUDE).some((p) => path.matchesGlob(relative, p));
572
681
  }
573
682
  return {
574
683
  name: "vite-plugin-autoi18n",
@@ -605,11 +714,11 @@ function autoI18n(options) {
605
714
  async transform(code, id) {
606
715
  const file = id.split("?")[0];
607
716
  if (!fileMessages.has(file)) return null;
608
- if (hashOf(code) !== fileHashes.get(file)) {
609
- await runSync(devReporter());
717
+ if (hashContent(code) !== fileHashes.get(file)) {
718
+ await runSync(reporterFor(config.logger));
610
719
  }
611
720
  const messages = fileMessages.get(file);
612
- if (!messages || hashOf(code) !== fileHashes.get(file)) {
721
+ if (!messages || hashContent(code) !== fileHashes.get(file)) {
613
722
  if (!warnedFiles.has(file)) {
614
723
  warnedFiles.add(file);
615
724
  this.warn(`autoi18n: ${file} changed while it was being indexed; its messages are left untranslated.`);
@@ -629,5 +738,6 @@ function autoI18n(options) {
629
738
  }
630
739
  export {
631
740
  OpenAICompatibleProvider,
741
+ PartialTranslationError,
632
742
  autoI18n as default
633
743
  };
@@ -8,7 +8,14 @@ declare function resolveLocale(preferred: string[], supported: string[], fallbac
8
8
  type LocaleLoader = () => Promise<{
9
9
  default: Record<string, string>;
10
10
  } | Record<string, string>>;
11
-
12
- declare function createLocaleLoader(loaders: Record<string, LocaleLoader>, fallback?: string): (locale: string) => Promise<Record<string, string>>;
11
+ /**
12
+ * Returns a cached catalogue loader. Unknown locales load the fallback, and a failed load is not cached.
13
+ * `prime` stores a catalogue that was already fetched, such as one pushed by the dev server.
14
+ * Serialised into the runtime module via toString(), so it must not reference anything outside its body.
15
+ */
16
+ declare function createLocaleLoader(loaders: Record<string, LocaleLoader>, fallback?: string): {
17
+ (locale: string): Promise<Record<string, string>>;
18
+ prime(locale: string, catalog: Record<string, string>): void;
19
+ };
13
20
 
14
21
  export { type LocaleLoader, createLocaleLoader, resolveLocale };
@@ -1,24 +1,7 @@
1
1
  import {
2
+ createLocaleLoader,
2
3
  resolveLocale
3
- } from "../chunk-RVYJXXTU.js";
4
-
5
- // src/runtime/index.ts
6
- function createLocaleLoader(loaders, fallback = "en") {
7
- const cache = /* @__PURE__ */ new Map();
8
- return function loadLocale(locale) {
9
- const selected = loaders[locale] ? locale : fallback;
10
- const cached = cache.get(selected);
11
- if (cached) return cached;
12
- const loader = loaders[selected];
13
- if (!loader) return Promise.reject(new Error(`No catalogue loader for locale "${selected}"`));
14
- const pending = loader().then((module) => "default" in module ? module.default : module);
15
- cache.set(selected, pending);
16
- pending.catch(() => {
17
- if (cache.get(selected) === pending) cache.delete(selected);
18
- });
19
- return pending;
20
- };
21
- }
4
+ } from "../chunk-ZDZ3YVET.js";
22
5
  export {
23
6
  createLocaleLoader,
24
7
  resolveLocale
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "vite-plugin-ai-auto-i18n",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Implicit, build-time AI localization for JSX applications",
6
+ "engines": {
7
+ "node": ">=20.17.0 <21 || >=22.5.0"
8
+ },
6
9
  "main": "./dist/index.js",
7
10
  "types": "./dist/index.d.ts",
8
11
  "exports": {
@@ -19,7 +22,8 @@
19
22
  "dist"
20
23
  ],
21
24
  "scripts": {
22
- "build": "tsup src/index.ts src/runtime/index.ts src/cli/prune.ts --format esm --dts",
25
+ "build": "tsup --clean src/index.ts src/runtime/index.ts src/cli/prune.ts --format esm --dts",
26
+ "publish": "npm run build && npm publish",
23
27
  "prune": "npm run build && node dist/cli/prune.js --directory example/.autoi18n --retention-minutes 0",
24
28
  "example:dev": "vite example",
25
29
  "example:build": "vite build example",
@@ -1,135 +0,0 @@
1
- // src/debug.ts
2
- var debugLog = (message) => console.log(`[autoi18n debug] ${(/* @__PURE__ */ new Date()).toISOString()} ${message}`);
3
-
4
- // src/io.ts
5
- import { randomUUID } from "crypto";
6
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
7
- import path from "path";
8
- async function readJsonFile(file, fallback, warn) {
9
- let text;
10
- try {
11
- text = await readFile(file, "utf8");
12
- } catch (error) {
13
- if (error.code === "ENOENT") return fallback;
14
- warn?.(`autoi18n: could not read ${file}: ${error.message}`);
15
- return fallback;
16
- }
17
- try {
18
- return JSON.parse(text);
19
- } catch (error) {
20
- const backup = `${file}.corrupt-${Date.now()}`;
21
- await rename(file, backup).catch(() => void 0);
22
- warn?.(`autoi18n: ${file} is not valid JSON (${error.message}); moved to ${backup} and starting fresh.`);
23
- return fallback;
24
- }
25
- }
26
- async function writeFileAtomic(file, content) {
27
- await mkdir(path.dirname(file), { recursive: true });
28
- const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
29
- await writeFile(temp, content, "utf8");
30
- await rename(temp, file);
31
- }
32
-
33
- // src/identity/registry.ts
34
- import { randomUUID as randomUUID2 } from "crypto";
35
- var emptyRegistry = () => ({ version: 1, entries: {} });
36
- async function loadRegistry(file, warn) {
37
- const parsed = await readJsonFile(file, null, warn);
38
- if (!parsed || parsed.version !== 1 || !parsed.entries) {
39
- if (parsed) warn?.(`autoi18n: ${file} has an unsupported format; message IDs will be regenerated.`);
40
- return emptyRegistry();
41
- }
42
- return parsed;
43
- }
44
- function orderKey(file, start) {
45
- return `${file}:${String(start).padStart(12, "0")}`;
46
- }
47
- function locationKey(location) {
48
- const split = location.lastIndexOf(":");
49
- return orderKey(location.slice(0, split), Number(location.slice(split + 1)));
50
- }
51
- function reconcile(registry, messages) {
52
- const now = (/* @__PURE__ */ new Date()).toISOString();
53
- const oldEntries = Object.values(registry.entries);
54
- const groups = /* @__PURE__ */ new Map();
55
- for (const entry of oldEntries) {
56
- const list = groups.get(entry.fingerprint) ?? [];
57
- list.push(entry);
58
- groups.set(entry.fingerprint, list);
59
- }
60
- const incoming = /* @__PURE__ */ new Map();
61
- for (const message of messages) {
62
- const list = incoming.get(message.fingerprint) ?? [];
63
- list.push(message);
64
- incoming.set(message.fingerprint, list);
65
- }
66
- const seen = /* @__PURE__ */ new Set();
67
- const ambiguous = [];
68
- for (const [fingerprint, current] of incoming) {
69
- const previous = (groups.get(fingerprint) ?? []).filter((x) => x.status === "active");
70
- current.sort((a, b) => orderKey(a.file, a.start).localeCompare(orderKey(b.file, b.start)));
71
- previous.sort((a, b) => locationKey(a.location).localeCompare(locationKey(b.location)));
72
- if (current.length !== previous.length && (current.length > 1 || previous.length > 1)) {
73
- ambiguous.push(current[0].source);
74
- }
75
- for (let i = 0; i < current.length; i++) {
76
- const message = current[i];
77
- const candidate = previous[i];
78
- if (candidate) {
79
- message.id = candidate.id;
80
- candidate.status = "active";
81
- candidate.lastSeen = now;
82
- candidate.source = message.source;
83
- candidate.location = `${message.file}:${message.start}`;
84
- seen.add(candidate.id);
85
- } else {
86
- const id = `msg_${randomUUID2().replaceAll("-", "").slice(0, 16)}`;
87
- message.id = id;
88
- registry.entries[id] = {
89
- id,
90
- source: message.source,
91
- kind: message.kind,
92
- tag: message.tag,
93
- component: message.component,
94
- attribute: message.attribute,
95
- context: message.context,
96
- fingerprint: message.fingerprint,
97
- location: `${message.file}:${message.start}`,
98
- status: "active",
99
- firstSeen: now,
100
- lastSeen: now
101
- };
102
- seen.add(id);
103
- }
104
- }
105
- }
106
- for (const entry of Object.values(registry.entries)) if (!seen.has(entry.id)) entry.status = "orphaned";
107
- return { registry, ambiguous };
108
- }
109
- var DAY_MS = 24 * 60 * 60 * 1e3;
110
- var DEFAULT_ORPHAN_RETENTION_DAYS = 30;
111
- function pruneOrphans(registry, retentionDays, now = Date.now()) {
112
- const cutoff = now - retentionDays * DAY_MS;
113
- const removed = [];
114
- for (const [id, entry] of Object.entries(registry.entries)) {
115
- if (entry.status === "orphaned" && Date.parse(entry.lastSeen) <= cutoff) {
116
- delete registry.entries[id];
117
- removed.push(entry);
118
- }
119
- }
120
- return removed;
121
- }
122
- async function saveRegistry(file, registry) {
123
- await writeFileAtomic(file, JSON.stringify(registry, null, 2) + "\n");
124
- }
125
-
126
- export {
127
- debugLog,
128
- readJsonFile,
129
- writeFileAtomic,
130
- loadRegistry,
131
- reconcile,
132
- DEFAULT_ORPHAN_RETENTION_DAYS,
133
- pruneOrphans,
134
- saveRegistry
135
- };
@@ -1,15 +0,0 @@
1
- // src/runtime/resolve.ts
2
- function resolveLocale(preferred, supported, fallback = "en") {
3
- for (const candidate of preferred) {
4
- const exact = supported.find((x) => x.toLowerCase() === candidate.toLowerCase());
5
- if (exact) return exact;
6
- const base = candidate.split("-")[0].toLowerCase();
7
- const language = supported.find((x) => x.split("-")[0].toLowerCase() === base);
8
- if (language) return language;
9
- }
10
- return fallback;
11
- }
12
-
13
- export {
14
- resolveLocale
15
- };