vite-plugin-ai-auto-i18n 0.0.0-stage โ 0.2.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 +212 -2
- package/dist/chunk-LLL7ZSKW.js +169 -0
- package/dist/chunk-ZDZ3YVET.js +38 -0
- package/dist/cli/prune.js +74 -0
- package/dist/index.js +743 -0
- package/dist/runtime/index.js +8 -0
- package/package.json +55 -5
package/README.md
CHANGED
|
@@ -1,3 +1,213 @@
|
|
|
1
|
-
#
|
|
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
|
+
|
|
5
|
+
## ๐งฉ Current MVP scope
|
|
6
|
+
|
|
7
|
+
- Extracts JSX text nodes and static string JSX attributes.
|
|
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.
|
|
9
|
+
- Persists opaque message IDs in `.autoi18n/registry.json`.
|
|
10
|
+
- Marks disappeared messages as orphaned and deletes them after a retention period (default 30 days, see `orphanRetentionMinutes`).
|
|
11
|
+
- Supports a pluggable translation provider; includes an OpenAI-compatible implementation.
|
|
12
|
+
- Writes locale JSON catalogues and keeps source English as fallback.
|
|
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 the `sources` map of `.autoi18n/locales/<locale>.json`.
|
|
15
|
+
- Includes a deliberately varied document-dashboard example.
|
|
16
|
+
|
|
17
|
+
## ๐ฆ Installation
|
|
18
|
+
|
|
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:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
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
|
+
};
|
|
62
|
+
});
|
|
63
|
+
```
|
|
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
|
+
|
|
69
|
+
Then write ordinary JSX:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
<button title="Save the current document">Save</button>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### ๐งช Try the bundled example
|
|
76
|
+
|
|
77
|
+
A React document dashboard under `example/`:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npm install
|
|
81
|
+
npm run example:dev # dev server
|
|
82
|
+
npm run example:build # production build into example/dist
|
|
83
|
+
```
|
|
84
|
+
|
|
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.
|
|
92
|
+
|
|
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.
|
|
94
|
+
|
|
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.
|
|
96
|
+
|
|
97
|
+
Ambiguous identity matches (duplicate identical strings whose count changed) are logged as warnings. Set `onAmbiguous: "error"` to fail the build instead.
|
|
98
|
+
|
|
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.
|
|
100
|
+
|
|
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.
|
|
102
|
+
|
|
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.
|
|
104
|
+
|
|
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`.
|
|
106
|
+
|
|
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.
|
|
108
|
+
|
|
109
|
+
### ๐ Delivery modes
|
|
110
|
+
|
|
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.
|
|
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:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { locales, resolveSupported, setLocale, getLocale, subscribe, t } from "virtual:autoi18n";
|
|
116
|
+
|
|
117
|
+
setLocale(resolveSupported(navigator.languages)); // call before first render
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`createLocaleLoader` and `resolveLocale` are available from the `vite-plugin-ai-auto-i18n/runtime` subpath, not the main entry.
|
|
121
|
+
|
|
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.
|
|
123
|
+
|
|
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.
|
|
125
|
+
|
|
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
|
|
196
|
+
|
|
197
|
+
This is a compiler MVP, not production-ready localization yet:
|
|
198
|
+
|
|
199
|
+
1. Runtime delivery loads a catalogue per locale; a message's source text is used until its catalogue has loaded.
|
|
200
|
+
2. Mixed JSX sentences with simple expressions (`You have {count} documents`, where `count` is an identifier or member path) are translated as one message with named `{name}` placeholders; translations that change the placeholders are rejected. Sentences with complex expressions (calls, JSX, computed access) are left entirely in the source language. ICU plural generation is not implemented yet.
|
|
201
|
+
3. Duplicate identical occurrences are paired by stable source ordering. This handles ordinary line shifts, but cannot guarantee identity if indistinguishable duplicates are reordered or moved independently. Such cases are reported through `onAmbiguous`; they are not resolved automatically.
|
|
202
|
+
4. Dev-server HMR re-indexes the whole project after saved or added/removed source files (debounced by 150 ms) rather than only the changed module. This is simple and correct, but may be slow in very large projects.
|
|
203
|
+
5. Only JSX text, and the attributes `alt`, `title`, `placeholder`, `label`, `summary`, `aria-label`, `aria-description`, `aria-placeholder`, `aria-roledescription` and `aria-valuetext` on intrinsic (lowercase) elements, are extracted. Text inside capitalised components (for example `<Button>Save</Button>`) and other attributes such as `href` are not extracted. JS string literals, template literals, Markdown, server-rendered content and rich-text components need explicit policies.
|
|
204
|
+
6. The example uses a React plugin in its Vite config; install `@vitejs/plugin-react` in the consuming example.
|
|
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.
|
|
206
|
+
|
|
207
|
+
## ๐งญ Design rules
|
|
208
|
+
|
|
209
|
+
- IDs are opaque and persistent; never expose semantic fingerprints as runtime keys.
|
|
210
|
+
- AI suggestions never silently overwrite an existing translation.
|
|
211
|
+
- Source English is always a valid fallback.
|
|
212
|
+
- Ambiguous identity matches should be surfaced, not guessed.
|
|
213
|
+
- Translation happens outside the render path.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/debug.ts
|
|
2
|
+
var debugLog = (message) => console.log(`[autoi18n debug] ${(/* @__PURE__ */ new Date()).toISOString()} ${message}`);
|
|
3
|
+
|
|
4
|
+
// src/io.ts
|
|
5
|
+
import { createHash, randomUUID } from "crypto";
|
|
6
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
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");
|
|
10
|
+
async function readJsonFile(file, fallback, warn) {
|
|
11
|
+
let text;
|
|
12
|
+
try {
|
|
13
|
+
text = await readFile(file, "utf8");
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code === "ENOENT") return fallback;
|
|
16
|
+
warn?.(`autoi18n: could not read ${file}: ${error.message}`);
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(text);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
const backup = `${file}.corrupt-${Date.now()}`;
|
|
23
|
+
await rename(file, backup).catch(() => void 0);
|
|
24
|
+
warn?.(`autoi18n: ${file} is not valid JSON (${error.message}); moved to ${backup} and starting fresh.`);
|
|
25
|
+
return fallback;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function writeFileAtomic(file, content) {
|
|
29
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
30
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
31
|
+
await writeFile(temp, content, "utf8");
|
|
32
|
+
await rename(temp, file);
|
|
33
|
+
}
|
|
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
|
+
|
|
57
|
+
// src/identity/registry.ts
|
|
58
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
59
|
+
var emptyRegistry = () => ({ version: 1, entries: {} });
|
|
60
|
+
async function loadRegistry(file, warn) {
|
|
61
|
+
const parsed = await readJsonFile(file, null, warn);
|
|
62
|
+
if (!parsed || parsed.version !== 1 || !parsed.entries) {
|
|
63
|
+
if (parsed) warn?.(`autoi18n: ${file} has an unsupported format; message IDs will be regenerated.`);
|
|
64
|
+
return emptyRegistry();
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
function orderKey(file, start) {
|
|
69
|
+
return `${file}:${String(start).padStart(12, "0")}`;
|
|
70
|
+
}
|
|
71
|
+
function locationKey(location) {
|
|
72
|
+
const split = location.lastIndexOf(":");
|
|
73
|
+
return orderKey(location.slice(0, split), Number(location.slice(split + 1)));
|
|
74
|
+
}
|
|
75
|
+
function reconcile(registry, messages) {
|
|
76
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
77
|
+
const oldEntries = Object.values(registry.entries);
|
|
78
|
+
const groups = /* @__PURE__ */ new Map();
|
|
79
|
+
for (const entry of oldEntries) {
|
|
80
|
+
const list = groups.get(entry.fingerprint) ?? [];
|
|
81
|
+
list.push(entry);
|
|
82
|
+
groups.set(entry.fingerprint, list);
|
|
83
|
+
}
|
|
84
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
85
|
+
for (const message of messages) {
|
|
86
|
+
const list = incoming.get(message.fingerprint) ?? [];
|
|
87
|
+
list.push(message);
|
|
88
|
+
incoming.set(message.fingerprint, list);
|
|
89
|
+
}
|
|
90
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
91
|
+
const seen = /* @__PURE__ */ new Set();
|
|
92
|
+
const ambiguous = [];
|
|
93
|
+
for (const [fingerprint, current] of incoming) {
|
|
94
|
+
const previous = (groups.get(fingerprint) ?? []).filter((x) => x.status === "active");
|
|
95
|
+
current.sort((a, b) => orderKey(a.file, a.start).localeCompare(orderKey(b.file, b.start)));
|
|
96
|
+
previous.sort((a, b) => locationKey(a.location).localeCompare(locationKey(b.location)));
|
|
97
|
+
if (current.length !== previous.length && (current.length > 1 || previous.length > 1)) {
|
|
98
|
+
ambiguous.push(current[0].source);
|
|
99
|
+
}
|
|
100
|
+
for (let i = 0; i < current.length; i++) {
|
|
101
|
+
const message = current[i];
|
|
102
|
+
const candidate = previous[i];
|
|
103
|
+
if (candidate) {
|
|
104
|
+
assigned.set(message, candidate.id);
|
|
105
|
+
candidate.status = "active";
|
|
106
|
+
candidate.lastSeen = now;
|
|
107
|
+
candidate.source = message.source;
|
|
108
|
+
candidate.location = `${message.file}:${message.start}`;
|
|
109
|
+
seen.add(candidate.id);
|
|
110
|
+
} else {
|
|
111
|
+
const id = `msg_${randomUUID2().replaceAll("-", "").slice(0, 16)}`;
|
|
112
|
+
assigned.set(message, id);
|
|
113
|
+
registry.entries[id] = {
|
|
114
|
+
id,
|
|
115
|
+
source: message.source,
|
|
116
|
+
kind: message.kind,
|
|
117
|
+
tag: message.tag,
|
|
118
|
+
component: message.component,
|
|
119
|
+
attribute: message.attribute,
|
|
120
|
+
context: message.context,
|
|
121
|
+
fingerprint: message.fingerprint,
|
|
122
|
+
location: `${message.file}:${message.start}`,
|
|
123
|
+
status: "active",
|
|
124
|
+
firstSeen: now,
|
|
125
|
+
lastSeen: now
|
|
126
|
+
};
|
|
127
|
+
seen.add(id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const entry of Object.values(registry.entries)) if (!seen.has(entry.id)) entry.status = "orphaned";
|
|
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 };
|
|
138
|
+
}
|
|
139
|
+
var MINUTE_MS = 60 * 1e3;
|
|
140
|
+
var DEFAULT_ORPHAN_RETENTION_MINUTES = 30 * 24 * 60;
|
|
141
|
+
function pruneOrphans(registry, retentionMinutes, now = Date.now()) {
|
|
142
|
+
const cutoff = now - retentionMinutes * MINUTE_MS;
|
|
143
|
+
const removed = [];
|
|
144
|
+
for (const [id, entry] of Object.entries(registry.entries)) {
|
|
145
|
+
if (entry.status === "orphaned" && Date.parse(entry.lastSeen) <= cutoff) {
|
|
146
|
+
delete registry.entries[id];
|
|
147
|
+
removed.push(entry);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return removed;
|
|
151
|
+
}
|
|
152
|
+
async function saveRegistry(file, registry) {
|
|
153
|
+
await writeFileAtomic(file, toJson(registry));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export {
|
|
157
|
+
debugLog,
|
|
158
|
+
hashContent,
|
|
159
|
+
emptyLocaleFile,
|
|
160
|
+
localeFilePath,
|
|
161
|
+
readLocaleFile,
|
|
162
|
+
writeLocaleFile,
|
|
163
|
+
pruneCatalog,
|
|
164
|
+
loadRegistry,
|
|
165
|
+
reconcile,
|
|
166
|
+
DEFAULT_ORPHAN_RETENTION_MINUTES,
|
|
167
|
+
pruneOrphans,
|
|
168
|
+
saveRegistry
|
|
169
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ORPHAN_RETENTION_MINUTES,
|
|
3
|
+
debugLog,
|
|
4
|
+
loadRegistry,
|
|
5
|
+
pruneCatalog,
|
|
6
|
+
pruneOrphans,
|
|
7
|
+
readLocaleFile,
|
|
8
|
+
saveRegistry,
|
|
9
|
+
writeLocaleFile
|
|
10
|
+
} from "../chunk-LLL7ZSKW.js";
|
|
11
|
+
|
|
12
|
+
// src/cli/prune.ts
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { readdir } from "fs/promises";
|
|
15
|
+
import path from "path";
|
|
16
|
+
async function pruneLocaleFiles(localesDir, activeIds) {
|
|
17
|
+
if (!existsSync(localesDir)) return [];
|
|
18
|
+
const results = [];
|
|
19
|
+
for (const name of await readdir(localesDir)) {
|
|
20
|
+
if (!name.endsWith(".json")) continue;
|
|
21
|
+
const file = path.join(localesDir, name);
|
|
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])];
|
|
26
|
+
if (!removed.length) continue;
|
|
27
|
+
await writeLocaleFile(file, { messages: messages.catalog, sources: sources.catalog });
|
|
28
|
+
results.push({ file, removed });
|
|
29
|
+
}
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const args = { directory: ".autoi18n", retentionMinutes: DEFAULT_ORPHAN_RETENTION_MINUTES, debug: false };
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (arg === "--debug") {
|
|
37
|
+
args.debug = true;
|
|
38
|
+
} else if (arg === "--directory" || arg === "--retention-minutes") {
|
|
39
|
+
const value = argv[++i];
|
|
40
|
+
if (value === void 0) throw new Error(`${arg} needs a value`);
|
|
41
|
+
if (arg === "--directory") args.directory = value;
|
|
42
|
+
else args.retentionMinutes = Number(value);
|
|
43
|
+
} else {
|
|
44
|
+
throw new Error(`Unknown option ${arg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!(args.retentionMinutes >= 0)) throw new Error("--retention-minutes must be a number of minutes, 0 or more");
|
|
48
|
+
return args;
|
|
49
|
+
}
|
|
50
|
+
async function main() {
|
|
51
|
+
const args = parseArgs(process.argv.slice(2));
|
|
52
|
+
const registryFile = path.resolve(process.cwd(), args.directory, "registry.json");
|
|
53
|
+
if (!existsSync(registryFile)) {
|
|
54
|
+
console.log(`autoi18n: no registry found at ${registryFile}; nothing to prune.`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const registry = await loadRegistry(registryFile, (message) => console.warn(message));
|
|
58
|
+
const removed = pruneOrphans(registry, args.retentionMinutes);
|
|
59
|
+
if (removed.length) await saveRegistry(registryFile, registry);
|
|
60
|
+
const activeIds = new Set(Object.values(registry.entries).filter((entry) => entry.status === "active").map((entry) => entry.id));
|
|
61
|
+
const catalogs = await pruneLocaleFiles(path.join(path.dirname(registryFile), "locales"), activeIds);
|
|
62
|
+
if (args.debug) {
|
|
63
|
+
for (const entry of removed) debugLog(`removed ${entry.id} ${JSON.stringify(entry.source)} (last seen ${entry.lastSeen})`);
|
|
64
|
+
for (const { file, removed: ids } of catalogs) {
|
|
65
|
+
for (const id of ids) debugLog(`removed ${id} from ${file}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
console.log(`autoi18n: removed ${removed.length} registry entries orphaned for more than ${args.retentionMinutes} minutes.`);
|
|
69
|
+
if (catalogs.length) console.log(`autoi18n: removed stale keys from ${catalogs.length} locale files.`);
|
|
70
|
+
}
|
|
71
|
+
main().catch((error) => {
|
|
72
|
+
console.error(`autoi18n: ${error.message}`);
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ORPHAN_RETENTION_MINUTES,
|
|
3
|
+
debugLog,
|
|
4
|
+
emptyLocaleFile,
|
|
5
|
+
hashContent,
|
|
6
|
+
loadRegistry,
|
|
7
|
+
localeFilePath,
|
|
8
|
+
pruneCatalog,
|
|
9
|
+
pruneOrphans,
|
|
10
|
+
readLocaleFile,
|
|
11
|
+
reconcile,
|
|
12
|
+
saveRegistry,
|
|
13
|
+
writeLocaleFile
|
|
14
|
+
} from "./chunk-LLL7ZSKW.js";
|
|
15
|
+
import {
|
|
16
|
+
createLocaleLoader,
|
|
17
|
+
resolveLocale
|
|
18
|
+
} from "./chunk-ZDZ3YVET.js";
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
import fg from "fast-glob";
|
|
22
|
+
import { readFile, rm } from "fs/promises";
|
|
23
|
+
import path from "path";
|
|
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
|
+
|
|
151
|
+
// src/compiler/extract.ts
|
|
152
|
+
import { parse } from "@babel/parser";
|
|
153
|
+
import traverseModule from "@babel/traverse";
|
|
154
|
+
import * as t from "@babel/types";
|
|
155
|
+
|
|
156
|
+
// src/identity/fingerprint.ts
|
|
157
|
+
import { createHash } from "crypto";
|
|
158
|
+
function normalizeSource(value) {
|
|
159
|
+
return value.replace(/\s+/g, " ").trim();
|
|
160
|
+
}
|
|
161
|
+
function createFingerprint(message) {
|
|
162
|
+
const material = [normalizeSource(message.source), message.kind, message.tag.toLowerCase(), message.attribute ?? "", message.context].join("");
|
|
163
|
+
return createHash("sha256").update(material).digest("hex").slice(0, 24);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/compiler/extract.ts
|
|
167
|
+
var traverse = traverseModule.default ?? traverseModule;
|
|
168
|
+
var COMPONENT_TAG = /^[A-Z]/;
|
|
169
|
+
var IGNORED_TAGS = /* @__PURE__ */ new Set(["script", "style", "code", "pre"]);
|
|
170
|
+
var TRANSLATABLE_ATTRS = /* @__PURE__ */ new Set(["alt", "title", "placeholder", "label", "summary", "aria-label", "aria-description", "aria-placeholder", "aria-roledescription", "aria-valuetext"]);
|
|
171
|
+
var IGNORE_ATTR = "translate";
|
|
172
|
+
var IGNORE_VALUE = "no";
|
|
173
|
+
function hasIgnoreMarker(opening) {
|
|
174
|
+
return opening.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: IGNORE_ATTR }) && t.isStringLiteral(a.value, { value: IGNORE_VALUE }));
|
|
175
|
+
}
|
|
176
|
+
function isExtractableTag(name) {
|
|
177
|
+
return t.isJSXIdentifier(name) && !COMPONENT_TAG.test(name.name) && !IGNORED_TAGS.has(name.name);
|
|
178
|
+
}
|
|
179
|
+
function placeholderExpressionName(expression) {
|
|
180
|
+
if (t.isIdentifier(expression)) return expression.name;
|
|
181
|
+
if (t.isMemberExpression(expression) && !expression.computed && t.isIdentifier(expression.property)) {
|
|
182
|
+
const object = placeholderExpressionName(expression.object);
|
|
183
|
+
return object ? `${object}.${expression.property.name}` : void 0;
|
|
184
|
+
}
|
|
185
|
+
return void 0;
|
|
186
|
+
}
|
|
187
|
+
function extractMessages(code, filename) {
|
|
188
|
+
const ast = parse(code, { sourceType: "unambiguous", sourceFilename: filename, plugins: ["jsx", "typescript", "decorators-legacy"] });
|
|
189
|
+
const found = [];
|
|
190
|
+
function componentName(nodePath) {
|
|
191
|
+
const fn = nodePath.findParent((p) => p.isFunctionDeclaration() || p.isFunctionExpression() || p.isArrowFunctionExpression());
|
|
192
|
+
if (!fn) return "<module>";
|
|
193
|
+
const parent = fn.parentPath;
|
|
194
|
+
if (parent?.isVariableDeclarator() && t.isIdentifier(parent.node.id)) return parent.node.id.name;
|
|
195
|
+
if (fn.isFunctionDeclaration() && fn.node.id) return fn.node.id.name;
|
|
196
|
+
return "<anonymous>";
|
|
197
|
+
}
|
|
198
|
+
function elementContext(elementNode, tag, attr) {
|
|
199
|
+
const openingElement = t.isJSXElement(elementNode) ? elementNode.openingElement : elementNode;
|
|
200
|
+
const attrs = openingElement.attributes.filter((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && ["aria-label", "title", "name", "type", "role"].includes(a.name.name)).map((a) => `${a.name.name}=${t.isStringLiteral(a.value) ? a.value.value : "{expr}"}`).sort().join(";");
|
|
201
|
+
const handlers = openingElement.attributes.filter((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && /^on[A-Z]/.test(a.name.name)).map((a) => {
|
|
202
|
+
const expression = t.isJSXExpressionContainer(a.value) ? a.value.expression : null;
|
|
203
|
+
const semanticHandler = expression && t.isIdentifier(expression) ? expression.name : expression?.type ?? "handler";
|
|
204
|
+
return `${a.name.name}=${semanticHandler}`;
|
|
205
|
+
}).sort().join(",");
|
|
206
|
+
return [tag, attr ?? "", attrs, handlers].filter(Boolean).join(" | ");
|
|
207
|
+
}
|
|
208
|
+
function isMarkedUntranslated(nodePath) {
|
|
209
|
+
for (let p = nodePath; p; p = p.parentPath) {
|
|
210
|
+
const opening = p.isJSXOpeningElement() ? p.node : p.isJSXElement() ? p.node.openingElement : void 0;
|
|
211
|
+
if (opening && hasIgnoreMarker(opening)) return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
traverse(ast, {
|
|
216
|
+
JSXElement(p) {
|
|
217
|
+
if (isMarkedUntranslated(p)) return;
|
|
218
|
+
const children = p.node.children;
|
|
219
|
+
const expressions = children.filter((child) => t.isJSXExpressionContainer(child));
|
|
220
|
+
if (!expressions.length) return;
|
|
221
|
+
if (children.some((child) => !t.isJSXText(child) && !t.isJSXExpressionContainer(child))) return;
|
|
222
|
+
if (!expressions.every((child) => placeholderExpressionName(child.expression) !== void 0)) return;
|
|
223
|
+
if (!children.some((child) => t.isJSXText(child) && /[A-Za-z]/.test(child.value))) return;
|
|
224
|
+
const rawTag = p.node.openingElement.name;
|
|
225
|
+
if (!isExtractableTag(rawTag)) return;
|
|
226
|
+
const names = [];
|
|
227
|
+
for (const child of expressions) {
|
|
228
|
+
const base = placeholderExpressionName(child.expression);
|
|
229
|
+
let name = base;
|
|
230
|
+
for (let suffix = 2; names.includes(name); suffix++) name = `${base}_${suffix}`;
|
|
231
|
+
names.push(name);
|
|
232
|
+
}
|
|
233
|
+
let raw = "";
|
|
234
|
+
let index = 0;
|
|
235
|
+
for (const child of children) raw += t.isJSXText(child) ? child.value : `{${names[index++]}}`;
|
|
236
|
+
const source = normalizeSource(raw);
|
|
237
|
+
if (!source) return;
|
|
238
|
+
const tag = rawTag.name;
|
|
239
|
+
const item = {
|
|
240
|
+
source,
|
|
241
|
+
prefix: raw.match(/^\s*/)?.[0] ?? "",
|
|
242
|
+
suffix: raw.match(/\s*$/)?.[0] ?? "",
|
|
243
|
+
kind: "mixed",
|
|
244
|
+
placeholders: names,
|
|
245
|
+
file: filename,
|
|
246
|
+
start: p.node.start ?? 0,
|
|
247
|
+
end: p.node.end ?? 0,
|
|
248
|
+
tag,
|
|
249
|
+
component: componentName(p),
|
|
250
|
+
context: elementContext(p.node, tag),
|
|
251
|
+
fingerprint: ""
|
|
252
|
+
};
|
|
253
|
+
item.fingerprint = createFingerprint(item);
|
|
254
|
+
found.push(item);
|
|
255
|
+
},
|
|
256
|
+
JSXText(p) {
|
|
257
|
+
if (isMarkedUntranslated(p)) return;
|
|
258
|
+
const raw = p.node.value;
|
|
259
|
+
const source = normalizeSource(raw);
|
|
260
|
+
if (!source) return;
|
|
261
|
+
const element = p.findParent((x) => x.isJSXElement());
|
|
262
|
+
if (!element?.isJSXElement()) return;
|
|
263
|
+
if (element.node.children.some((child) => t.isJSXExpressionContainer(child))) return;
|
|
264
|
+
const rawTag = element.node.openingElement.name;
|
|
265
|
+
if (!isExtractableTag(rawTag)) return;
|
|
266
|
+
const tag = rawTag.name;
|
|
267
|
+
const component = componentName(element);
|
|
268
|
+
const context = elementContext(element.node, tag);
|
|
269
|
+
const prefix = raw.match(/^\s*/)?.[0] ?? "";
|
|
270
|
+
const suffix = raw.match(/\s*$/)?.[0] ?? "";
|
|
271
|
+
const item = { source, prefix, suffix, kind: "text", file: filename, start: p.node.start ?? 0, end: p.node.end ?? 0, tag, component, context, fingerprint: "" };
|
|
272
|
+
item.fingerprint = createFingerprint(item);
|
|
273
|
+
found.push(item);
|
|
274
|
+
},
|
|
275
|
+
JSXAttribute(p) {
|
|
276
|
+
const attr = p.node;
|
|
277
|
+
if (!t.isJSXIdentifier(attr.name) || !TRANSLATABLE_ATTRS.has(attr.name.name)) return;
|
|
278
|
+
if (isMarkedUntranslated(p)) return;
|
|
279
|
+
if (!t.isStringLiteral(attr.value)) return;
|
|
280
|
+
const source = normalizeSource(attr.value.value);
|
|
281
|
+
if (!source || !/[A-Za-z]/.test(source)) return;
|
|
282
|
+
const element = p.parentPath;
|
|
283
|
+
if (!element.isJSXOpeningElement() || !isExtractableTag(element.node.name)) return;
|
|
284
|
+
const tag = element.node.name.name;
|
|
285
|
+
const parentElement = p.findParent((x) => x.isJSXElement());
|
|
286
|
+
if (!parentElement?.isJSXElement()) return;
|
|
287
|
+
const component = componentName(parentElement);
|
|
288
|
+
const context = elementContext(parentElement.node, tag, attr.name.name);
|
|
289
|
+
const item = { source, kind: "attribute", file: filename, start: attr.value.start ?? 0, end: attr.value.end ?? 0, tag, component, attribute: attr.name.name, context, fingerprint: "" };
|
|
290
|
+
item.fingerprint = createFingerprint(item);
|
|
291
|
+
found.push(item);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return found.sort((a, b) => a.start - b.start);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/compiler/transform.ts
|
|
298
|
+
import { parse as parse2 } from "@babel/parser";
|
|
299
|
+
import traverseModule2 from "@babel/traverse";
|
|
300
|
+
import generateModule from "@babel/generator";
|
|
301
|
+
import * as t2 from "@babel/types";
|
|
302
|
+
var traverse2 = traverseModule2.default ?? traverseModule2;
|
|
303
|
+
var generate = generateModule.default ?? generateModule;
|
|
304
|
+
var RUNTIME_MODULE = "virtual:autoi18n";
|
|
305
|
+
var RUNTIME_LOOKUP = "__autoI18nT";
|
|
306
|
+
var RUNTIME_FORMAT = "__autoI18nFormat";
|
|
307
|
+
function transformSource(code, filename, messages, locale, catalog, runtime = false, sourceLocale = "en") {
|
|
308
|
+
const ast = parse2(code, { sourceType: "unambiguous", sourceFilename: filename, plugins: ["jsx", "typescript", "decorators-legacy"] });
|
|
309
|
+
const byStart = new Map(messages.map((m) => [m.start, m]));
|
|
310
|
+
let usesRuntime = false;
|
|
311
|
+
let usesFormat = false;
|
|
312
|
+
const lookup = (message) => {
|
|
313
|
+
if (runtime) {
|
|
314
|
+
usesRuntime = true;
|
|
315
|
+
return t2.callExpression(t2.identifier(RUNTIME_LOOKUP), [t2.stringLiteral(message.id), t2.stringLiteral(message.source)]);
|
|
316
|
+
}
|
|
317
|
+
return t2.stringLiteral(locale === sourceLocale ? message.source : catalog[message.id] ?? message.source);
|
|
318
|
+
};
|
|
319
|
+
traverse2(ast, {
|
|
320
|
+
JSXElement(p) {
|
|
321
|
+
const message = byStart.get(p.node.start ?? -1);
|
|
322
|
+
if (message?.kind !== "mixed") return;
|
|
323
|
+
const names = message.placeholders ?? [];
|
|
324
|
+
const expressions = p.node.children.filter((c) => t2.isJSXExpressionContainer(c)).map((c) => c.expression);
|
|
325
|
+
const prefix = message.prefix ?? "";
|
|
326
|
+
const suffix = message.suffix ?? "";
|
|
327
|
+
if (runtime) {
|
|
328
|
+
usesFormat = true;
|
|
329
|
+
const values = t2.objectExpression(names.map((name, i) => t2.objectProperty(t2.stringLiteral(name), t2.cloneNode(expressions[i]))));
|
|
330
|
+
const text2 = [prefix && t2.stringLiteral(prefix), lookup(message), suffix && t2.stringLiteral(suffix)].filter(Boolean);
|
|
331
|
+
const joined = text2.reduce((left, right) => t2.binaryExpression("+", left, right));
|
|
332
|
+
p.node.children = [t2.jsxExpressionContainer(t2.callExpression(t2.identifier(RUNTIME_FORMAT), [joined, values]))];
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const translated = locale === sourceLocale ? message.source : catalog[message.id] ?? message.source;
|
|
336
|
+
const text = isUsableTranslation(translated, names) ? translated : message.source;
|
|
337
|
+
const children = [];
|
|
338
|
+
let last = 0;
|
|
339
|
+
const pushText = (value) => {
|
|
340
|
+
if (value) children.push(t2.jsxExpressionContainer(t2.stringLiteral(value)));
|
|
341
|
+
};
|
|
342
|
+
pushText(prefix);
|
|
343
|
+
for (const match of text.matchAll(PLACEHOLDER)) {
|
|
344
|
+
pushText(text.slice(last, match.index));
|
|
345
|
+
children.push(t2.jsxExpressionContainer(t2.cloneNode(expressions[names.indexOf(match[1])])));
|
|
346
|
+
last = match.index + match[0].length;
|
|
347
|
+
}
|
|
348
|
+
pushText(text.slice(last) + suffix);
|
|
349
|
+
p.node.children = children;
|
|
350
|
+
},
|
|
351
|
+
JSXText(p) {
|
|
352
|
+
const message = byStart.get(p.node.start ?? -1);
|
|
353
|
+
if (!message) return;
|
|
354
|
+
const parts = [];
|
|
355
|
+
if (runtime) {
|
|
356
|
+
if (message.prefix) parts.push(t2.stringLiteral(message.prefix));
|
|
357
|
+
parts.push(lookup(message));
|
|
358
|
+
if (message.suffix) parts.push(t2.stringLiteral(message.suffix));
|
|
359
|
+
} else {
|
|
360
|
+
const translated = lookup(message).value;
|
|
361
|
+
parts.push(t2.stringLiteral(`${message.prefix ?? ""}${translated}${message.suffix ?? ""}`));
|
|
362
|
+
}
|
|
363
|
+
const expression = parts.reduce((left, right) => t2.binaryExpression("+", left, right));
|
|
364
|
+
p.replaceWith(t2.jsxExpressionContainer(expression));
|
|
365
|
+
},
|
|
366
|
+
JSXAttribute(p) {
|
|
367
|
+
const valueNode = p.node.value;
|
|
368
|
+
if (!t2.isStringLiteral(valueNode)) return;
|
|
369
|
+
const message = byStart.get(valueNode.start ?? -1);
|
|
370
|
+
if (!message) return;
|
|
371
|
+
p.node.value = runtime ? t2.jsxExpressionContainer(lookup(message)) : lookup(message);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
const runtimeSpecifiers = [
|
|
375
|
+
...usesRuntime ? [t2.importSpecifier(t2.identifier(RUNTIME_LOOKUP), t2.identifier("t"))] : [],
|
|
376
|
+
...usesFormat ? [t2.importSpecifier(t2.identifier(RUNTIME_FORMAT), t2.identifier("format"))] : []
|
|
377
|
+
];
|
|
378
|
+
if (runtimeSpecifiers.length) {
|
|
379
|
+
ast.program.body.unshift(t2.importDeclaration(runtimeSpecifiers, t2.stringLiteral(RUNTIME_MODULE)));
|
|
380
|
+
}
|
|
381
|
+
const result = generate(ast, { sourceMaps: true, sourceFileName: filename }, code);
|
|
382
|
+
return { code: result.code, map: result.map };
|
|
383
|
+
}
|
|
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
|
+
|
|
400
|
+
// src/runtime/virtual.ts
|
|
401
|
+
function createRuntimeModule(catalogFiles, sourceLocale) {
|
|
402
|
+
const loaders = Object.entries(catalogFiles).map(([locale, file]) => ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(file)}).then(m => (m.default ?? m).messages)`).join(",\n");
|
|
403
|
+
return [
|
|
404
|
+
`const loaders = {
|
|
405
|
+
${loaders}
|
|
406
|
+
};`,
|
|
407
|
+
`const sourceLocale = ${JSON.stringify(sourceLocale)};`,
|
|
408
|
+
`const resolveLocale = ${resolveLocale.toString()};`,
|
|
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);`,
|
|
413
|
+
`const listeners = new Set();`,
|
|
414
|
+
`let currentLocale = sourceLocale;`,
|
|
415
|
+
`let messages = {};`,
|
|
416
|
+
`let version = 0;`,
|
|
417
|
+
`let latestRequest = 0;`,
|
|
418
|
+
`export const locales = Object.keys(loaders);`,
|
|
419
|
+
`export function getLocale() { return currentLocale; }`,
|
|
420
|
+
`export function getVersion() { return version; }`,
|
|
421
|
+
`export function resolveSupported(preferred) { return resolveLocale(preferred, locales, sourceLocale); }`,
|
|
422
|
+
`export async function setLocale(locale) {`,
|
|
423
|
+
` const request = ++latestRequest;`,
|
|
424
|
+
` let selected = loaders[locale] ? locale : sourceLocale;`,
|
|
425
|
+
` let catalog;`,
|
|
426
|
+
` try {`,
|
|
427
|
+
` catalog = await loadLocale(selected);`,
|
|
428
|
+
` } catch (error) {`,
|
|
429
|
+
` if (selected === sourceLocale) throw error;`,
|
|
430
|
+
` console.warn('autoi18n: could not load "' + selected + '", falling back to "' + sourceLocale + '".', error);`,
|
|
431
|
+
` selected = sourceLocale;`,
|
|
432
|
+
` catalog = await loadLocale(selected);`,
|
|
433
|
+
` }`,
|
|
434
|
+
` // A slower earlier request must not overwrite the locale the user picked last.`,
|
|
435
|
+
` if (request !== latestRequest) return;`,
|
|
436
|
+
` messages = catalog;`,
|
|
437
|
+
` currentLocale = selected;`,
|
|
438
|
+
` notify();`,
|
|
439
|
+
`}`,
|
|
440
|
+
`function notify() {`,
|
|
441
|
+
` version++;`,
|
|
442
|
+
` listeners.forEach(fn => fn(currentLocale));`,
|
|
443
|
+
`}`,
|
|
444
|
+
`export function subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }`,
|
|
445
|
+
`export function t(id, source) { return messages[id] ?? source; }`,
|
|
446
|
+
`export function format(text, values) { return formatPlaceholders(text, values, PLACEHOLDER); }`,
|
|
447
|
+
`// Dev server pushes fresh catalogues after a source change; no page reload is needed.`,
|
|
448
|
+
`if (import.meta.hot) {`,
|
|
449
|
+
` import.meta.hot.on("autoi18n:update", data => {`,
|
|
450
|
+
` for (const [locale, catalog] of Object.entries(data.catalogs)) loadLocale.prime(locale, catalog);`,
|
|
451
|
+
` if (data.catalogs[currentLocale]) messages = data.catalogs[currentLocale];`,
|
|
452
|
+
` notify();`,
|
|
453
|
+
` });`,
|
|
454
|
+
`}`
|
|
455
|
+
].join("\n") + "\n";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/ai/provider.ts
|
|
459
|
+
var ProviderError = class extends Error {
|
|
460
|
+
constructor(message, retryable) {
|
|
461
|
+
super(message);
|
|
462
|
+
this.retryable = retryable;
|
|
463
|
+
}
|
|
464
|
+
retryable;
|
|
465
|
+
};
|
|
466
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
467
|
+
var OpenAICompatibleProvider = class {
|
|
468
|
+
constructor(options) {
|
|
469
|
+
this.options = options;
|
|
470
|
+
}
|
|
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
|
+
*/
|
|
476
|
+
async translate(input) {
|
|
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 }));
|
|
481
|
+
const 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);
|
|
497
|
+
}
|
|
498
|
+
async translateBatch(input) {
|
|
499
|
+
const maxRetries = this.options.maxRetries ?? 2;
|
|
500
|
+
const retryDelayMs = this.options.retryDelayMs ?? 500;
|
|
501
|
+
for (let attempt = 0; ; attempt++) {
|
|
502
|
+
try {
|
|
503
|
+
return await this.request(input);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
const retryable = !(error instanceof ProviderError) || error.retryable;
|
|
506
|
+
if (!retryable || attempt >= maxRetries) throw error;
|
|
507
|
+
await sleep(retryDelayMs * 2 ** attempt);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async request(input) {
|
|
512
|
+
const response = await fetch(`${this.options.baseURL ?? "https://api.openai.com/v1"}/chat/completions`, {
|
|
513
|
+
method: "POST",
|
|
514
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? 6e4),
|
|
515
|
+
headers: { Authorization: `Bearer ${this.options.apiKey}`, "Content-Type": "application/json" },
|
|
516
|
+
body: JSON.stringify({
|
|
517
|
+
model: this.options.model,
|
|
518
|
+
temperature: 0,
|
|
519
|
+
response_format: { type: "json_object" },
|
|
520
|
+
messages: [
|
|
521
|
+
{ role: "system", content: `You are a professional software localization engine. Translate UI messages from ${input.sourceLocale} to ${input.targetLocale}. Preserve placeholders exactly. Respect context and UI conventions. Return ONLY a JSON object mapping message IDs to translated strings. Do not translate product names or code.` },
|
|
522
|
+
{ role: "user", content: JSON.stringify(input.messages.map((m) => ({ id: m.id, text: m.source, context: m.context, kind: m.kind }))) }
|
|
523
|
+
]
|
|
524
|
+
})
|
|
525
|
+
});
|
|
526
|
+
if (!response.ok) {
|
|
527
|
+
const retryable = response.status === 408 || response.status === 409 || response.status === 429 || response.status >= 500;
|
|
528
|
+
throw new ProviderError(`Translation API failed (${response.status}): ${await response.text()}`, retryable);
|
|
529
|
+
}
|
|
530
|
+
const json = await response.json();
|
|
531
|
+
const content = json.choices?.[0]?.message?.content;
|
|
532
|
+
if (typeof content !== "string" || !content) throw new ProviderError("Translation provider returned no content", true);
|
|
533
|
+
let parsed;
|
|
534
|
+
try {
|
|
535
|
+
parsed = JSON.parse(content);
|
|
536
|
+
} catch {
|
|
537
|
+
throw new ProviderError("Translation provider returned invalid JSON", true);
|
|
538
|
+
}
|
|
539
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ProviderError("Translation provider returned a non-object", true);
|
|
540
|
+
const requested = new Set(input.messages.map((m) => m.id));
|
|
541
|
+
const result = {};
|
|
542
|
+
for (const [id, text] of Object.entries(parsed)) {
|
|
543
|
+
if (requested.has(id) && typeof text === "string" && text.trim()) result[id] = text;
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/index.ts
|
|
550
|
+
var UPDATE_EVENT = "autoi18n:update";
|
|
551
|
+
var DEFAULT_INCLUDE = ["src/**/*.{tsx,jsx,ts,js}"];
|
|
552
|
+
var DEFAULT_EXCLUDE = ["**/*.test.*", "**/node_modules/**"];
|
|
553
|
+
var WATCH_DEBOUNCE_MS = 150;
|
|
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
|
+
}
|
|
562
|
+
function autoI18n(options) {
|
|
563
|
+
const sourceLocale = options.sourceLocale ?? "en";
|
|
564
|
+
const directory = options.directory ?? ".autoi18n";
|
|
565
|
+
const delivery = options.delivery ?? "build";
|
|
566
|
+
const debug = options.debug ?? false;
|
|
567
|
+
const orphanRetentionMinutes = options.orphanRetentionMinutes ?? DEFAULT_ORPHAN_RETENTION_MINUTES;
|
|
568
|
+
let config;
|
|
569
|
+
let registryFile = "";
|
|
570
|
+
let catalogFiles = {};
|
|
571
|
+
let extractions = /* @__PURE__ */ new Map();
|
|
572
|
+
let fileMessages = /* @__PURE__ */ new Map();
|
|
573
|
+
let fileHashes = /* @__PURE__ */ new Map();
|
|
574
|
+
let catalogs = /* @__PURE__ */ new Map();
|
|
575
|
+
const warnedFiles = /* @__PURE__ */ new Set();
|
|
576
|
+
let server;
|
|
577
|
+
let queue = Promise.resolve();
|
|
578
|
+
let watchTimer;
|
|
579
|
+
const selectedLocale = () => options.locale ?? process.env.AUTOI18N_LOCALE ?? sourceLocale;
|
|
580
|
+
function localesToTranslate() {
|
|
581
|
+
const others = options.locales.filter((x) => x !== sourceLocale);
|
|
582
|
+
const explicit = options.locale ?? process.env.AUTOI18N_LOCALE;
|
|
583
|
+
if (delivery === "build" && explicit && config.command !== "serve") return others.filter((x) => x === explicit);
|
|
584
|
+
return others;
|
|
585
|
+
}
|
|
586
|
+
async function sync(report) {
|
|
587
|
+
const files = await fg(options.include ?? DEFAULT_INCLUDE, { cwd: config.root, ignore: options.exclude ?? DEFAULT_EXCLUDE, absolute: true });
|
|
588
|
+
const nextExtractions = /* @__PURE__ */ new Map();
|
|
589
|
+
const indexed = [];
|
|
590
|
+
for (const file of files) {
|
|
591
|
+
const code = await readFile(file, "utf8");
|
|
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 });
|
|
597
|
+
}
|
|
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]}").`;
|
|
618
|
+
if (options.onAmbiguous === "error") report.error(message);
|
|
619
|
+
else report.warn(message);
|
|
620
|
+
}
|
|
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.`);
|
|
642
|
+
}
|
|
643
|
+
if (translation.rejected) report.info(`autoi18n: rejected ${translation.rejected} ${locale} translations with changed placeholders.`);
|
|
644
|
+
if (translation.untranslated) {
|
|
645
|
+
const log = options.provider ? report.warn : report.info;
|
|
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.`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
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];
|
|
656
|
+
}
|
|
657
|
+
function runSync(report) {
|
|
658
|
+
const next = queue.then(() => sync(report));
|
|
659
|
+
queue = next.catch(() => void 0);
|
|
660
|
+
return next;
|
|
661
|
+
}
|
|
662
|
+
function pushCatalogs() {
|
|
663
|
+
server?.ws.send({ type: "custom", event: UPDATE_EVENT, data: { catalogs: Object.fromEntries(catalogs) } });
|
|
664
|
+
}
|
|
665
|
+
async function retranslate() {
|
|
666
|
+
try {
|
|
667
|
+
await runSync(reporterFor(config.logger));
|
|
668
|
+
pushCatalogs();
|
|
669
|
+
} catch (error) {
|
|
670
|
+
config.logger.error(`autoi18n: re-translation failed: ${error.message}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function scheduleRetranslate() {
|
|
674
|
+
clearTimeout(watchTimer);
|
|
675
|
+
watchTimer = setTimeout(() => void retranslate(), WATCH_DEBOUNCE_MS);
|
|
676
|
+
}
|
|
677
|
+
function isSourceCandidate(file) {
|
|
678
|
+
if (!/\.[jt]sx?$/.test(file)) return false;
|
|
679
|
+
const relative = path.relative(config.root, file).split(path.sep).join("/");
|
|
680
|
+
return (options.include ?? DEFAULT_INCLUDE).some((p) => path.matchesGlob(relative, p)) && !(options.exclude ?? DEFAULT_EXCLUDE).some((p) => path.matchesGlob(relative, p));
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
name: "vite-plugin-autoi18n",
|
|
684
|
+
enforce: "pre",
|
|
685
|
+
configResolved(resolved) {
|
|
686
|
+
config = resolved;
|
|
687
|
+
registryFile = path.resolve(config.root, directory, "registry.json");
|
|
688
|
+
const selected = selectedLocale();
|
|
689
|
+
if (selected !== sourceLocale && !options.locales.includes(selected)) {
|
|
690
|
+
throw new Error(`autoi18n: locale "${selected}" is not one of the configured locales (${options.locales.join(", ")}).`);
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
resolveId(id) {
|
|
694
|
+
if (id === RUNTIME_MODULE) return `\0${RUNTIME_MODULE}`;
|
|
695
|
+
},
|
|
696
|
+
load(id) {
|
|
697
|
+
if (id === `\0${RUNTIME_MODULE}`) return createRuntimeModule(catalogFiles, sourceLocale);
|
|
698
|
+
},
|
|
699
|
+
configureServer(devServer) {
|
|
700
|
+
server = devServer;
|
|
701
|
+
devServer.watcher.on("add", (file) => {
|
|
702
|
+
if (isSourceCandidate(file)) scheduleRetranslate();
|
|
703
|
+
});
|
|
704
|
+
devServer.watcher.on("unlink", (file) => {
|
|
705
|
+
if (fileMessages.has(file)) scheduleRetranslate();
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
closeBundle() {
|
|
709
|
+
clearTimeout(watchTimer);
|
|
710
|
+
},
|
|
711
|
+
async buildStart() {
|
|
712
|
+
await sync({ info: (m) => this.info(m), warn: (m) => this.warn(m), error: (m) => this.error(m) });
|
|
713
|
+
},
|
|
714
|
+
async transform(code, id) {
|
|
715
|
+
const file = id.split("?")[0];
|
|
716
|
+
if (!fileMessages.has(file)) return null;
|
|
717
|
+
if (hashContent(code) !== fileHashes.get(file)) {
|
|
718
|
+
await runSync(reporterFor(config.logger));
|
|
719
|
+
}
|
|
720
|
+
const messages = fileMessages.get(file);
|
|
721
|
+
if (!messages || hashContent(code) !== fileHashes.get(file)) {
|
|
722
|
+
if (!warnedFiles.has(file)) {
|
|
723
|
+
warnedFiles.add(file);
|
|
724
|
+
this.warn(`autoi18n: ${file} changed while it was being indexed; its messages are left untranslated.`);
|
|
725
|
+
}
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
const locale = config.command === "serve" ? sourceLocale : selectedLocale();
|
|
729
|
+
const result = transformSource(code, file, messages, locale, catalogs.get(locale) ?? {}, delivery === "runtime", sourceLocale);
|
|
730
|
+
return { code: result.code, map: result.map };
|
|
731
|
+
},
|
|
732
|
+
async handleHotUpdate({ file }) {
|
|
733
|
+
if (file.startsWith(path.resolve(config.root, directory, "locales") + path.sep)) return [];
|
|
734
|
+
if (!fileMessages.has(file)) return;
|
|
735
|
+
await retranslate();
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
export {
|
|
740
|
+
OpenAICompatibleProvider,
|
|
741
|
+
PartialTranslationError,
|
|
742
|
+
autoI18n as default
|
|
743
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "vite-plugin-ai-auto-i18n",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Implicit, build-time AI localization for JSX applications",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.17.0 <21 || >=22.5.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./runtime": {
|
|
17
|
+
"types": "./dist/runtime/index.d.ts",
|
|
18
|
+
"import": "./dist/runtime/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
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",
|
|
27
|
+
"prune": "npm run build && node dist/cli/prune.js --directory example/.autoi18n --retention-minutes 0",
|
|
28
|
+
"example:dev": "vite example",
|
|
29
|
+
"example:build": "vite build example",
|
|
30
|
+
"preview": "vite build example && vite preview example --port 4173 --strictPort",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"vite": ">=5"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@babel/generator": "^7.26.0",
|
|
39
|
+
"@babel/parser": "^7.26.0",
|
|
40
|
+
"@babel/traverse": "^7.26.0",
|
|
41
|
+
"@babel/types": "^7.26.0",
|
|
42
|
+
"fast-glob": "^3.3.2"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/babel__generator": "^7.6.8",
|
|
46
|
+
"@types/babel__traverse": "^7.20.6",
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
49
|
+
"react": "^19.3.1",
|
|
50
|
+
"react-dom": "^19.3.1",
|
|
51
|
+
"tsup": "^8.3.5",
|
|
52
|
+
"typescript": "^5.7.2",
|
|
53
|
+
"vite": "^6.0.0",
|
|
54
|
+
"vitest": "^2.1.8"
|
|
55
|
+
}
|
|
56
|
+
}
|