@webmobix/i18next-plugins 0.0.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 +163 -0
- package/dist/backend/index.d.ts +55 -0
- package/dist/backend.js +89 -0
- package/dist/cli/auth.d.ts +24 -0
- package/dist/cli/flatten.d.ts +8 -0
- package/dist/cli/import.d.ts +20 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli.js +367 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/post-processor/index.d.ts +30 -0
- package/dist/post-processor.js +61 -0
- package/dist/translator-support/index.d.ts +99 -0
- package/dist/translator-support.js +185 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# @leo-translate/i18next-plugins
|
|
2
|
+
|
|
3
|
+
i18next backend and post-processor plugins for Leo Translate.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @leo-translate/i18next-plugins
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Plugins
|
|
12
|
+
|
|
13
|
+
### LeoBackend
|
|
14
|
+
|
|
15
|
+
A custom i18next backend that fetches translation JSON files from a remote URL (e.g. Cloudflare R2).
|
|
16
|
+
|
|
17
|
+
Translation files are resolved using the pattern:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
{remoteUrl}/{appId}/{language}/{namespace}.json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
#### Options
|
|
24
|
+
|
|
25
|
+
| Option | Type | Default | Description |
|
|
26
|
+
| ----------------------- | --------------------- | --------------------------- | --------------------------------------------------------- |
|
|
27
|
+
| `appId` | `string \| undefined` | `undefined` | The ID of the application. Required for fetching to work. |
|
|
28
|
+
| `remoteUrl` | `string` | Leo Translate R2 public URL | Base URL for fetching translation files. |
|
|
29
|
+
| `localStorageKeyPrefix` | `string` | `'leo-translate'` | Key prefix used by the Chrome extension for overrides. |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
### LeoPostProcessor
|
|
34
|
+
|
|
35
|
+
A pass-through i18next post-processor stub. Register it with i18next and opt in per translation call or globally.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Option 1 — Root import
|
|
42
|
+
|
|
43
|
+
Import both plugins from the package root:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { LeoBackend, LeoPostProcessor } from '@leo-translate/i18next-plugins';
|
|
47
|
+
import i18n from 'i18next';
|
|
48
|
+
|
|
49
|
+
i18n
|
|
50
|
+
.use(LeoBackend)
|
|
51
|
+
.use(LeoPostProcessor)
|
|
52
|
+
.init({
|
|
53
|
+
backend: {
|
|
54
|
+
appId: 'my-app',
|
|
55
|
+
remoteUrl: 'https://translations.example.com',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Option 2 — Sub-path imports
|
|
61
|
+
|
|
62
|
+
Import each plugin individually from its own sub-path:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { LeoBackend } from '@leo-translate/i18next-plugins/backend';
|
|
66
|
+
import { LeoPostProcessor } from '@leo-translate/i18next-plugins/post-processor';
|
|
67
|
+
import i18n from 'i18next';
|
|
68
|
+
|
|
69
|
+
i18n
|
|
70
|
+
.use(LeoBackend)
|
|
71
|
+
.use(LeoPostProcessor)
|
|
72
|
+
.init({
|
|
73
|
+
backend: {
|
|
74
|
+
appId: 'my-app',
|
|
75
|
+
remoteUrl: 'https://translations.example.com',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Sub-path imports are useful when you only need one plugin and want to avoid pulling in the other.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## CLI — `leo-translate`
|
|
85
|
+
|
|
86
|
+
The package ships a `leo-translate` binary for managing translation bundles from the command line.
|
|
87
|
+
|
|
88
|
+
### Installation
|
|
89
|
+
|
|
90
|
+
Install the package globally or as a dev dependency to make the binary available:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# global
|
|
94
|
+
pnpm add -g @leo-translate/i18next-plugins
|
|
95
|
+
|
|
96
|
+
# dev dependency (run via pnpm exec or package.json scripts)
|
|
97
|
+
pnpm add -D @leo-translate/i18next-plugins
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Commands
|
|
101
|
+
|
|
102
|
+
#### `leo-translate import <folder>`
|
|
103
|
+
|
|
104
|
+
Discovers all i18next namespace JSON files under a locale folder and uploads them to the Leo Translate backend.
|
|
105
|
+
|
|
106
|
+
**Folder structure expected:**
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
locales/
|
|
110
|
+
en/
|
|
111
|
+
common.json
|
|
112
|
+
dashboard.json
|
|
113
|
+
de/
|
|
114
|
+
common.json
|
|
115
|
+
dashboard.json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Usage:**
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
leo-translate import ./locales --app-id my-app
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Options:**
|
|
125
|
+
|
|
126
|
+
| Flag | Type | Default | Description |
|
|
127
|
+
| ------------- | --------- | ----------------------------------- | --------------------------------------------------------------------------- |
|
|
128
|
+
| `--app-id` | `string` | — | **Required.** Leo Translate app ID (slug). |
|
|
129
|
+
| `--url` | `string` | `https://leo-translate.webmobix.io` | Backend URL to upload to. |
|
|
130
|
+
| `--overwrite` | `boolean` | `false` | Overwrite existing published values. By default, existing keys are skipped. |
|
|
131
|
+
| `--dry-run` | `boolean` | `false` | Print the full import plan without making any changes. |
|
|
132
|
+
| `--language` | `string` | all languages | Import only the specified language subfolder (e.g. `en`). |
|
|
133
|
+
|
|
134
|
+
**Examples:**
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# Preview what would be imported
|
|
138
|
+
leo-translate import ./locales --app-id my-app --dry-run
|
|
139
|
+
|
|
140
|
+
# Import only English, overwriting existing keys
|
|
141
|
+
leo-translate import ./locales --app-id my-app --language en --overwrite
|
|
142
|
+
|
|
143
|
+
# Import to a self-hosted backend
|
|
144
|
+
leo-translate import ./locales --app-id my-app --url https://translate.example.com
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Authentication:**
|
|
148
|
+
|
|
149
|
+
The first time a live import is run, the CLI opens a browser window to complete an OAuth 2.0 PKCE login flow. After a successful login the token is cached in `~/.leo-translate/credentials.json` (mode `0600`) and reused on subsequent runs until it expires.
|
|
150
|
+
|
|
151
|
+
**Import results:**
|
|
152
|
+
|
|
153
|
+
After each upload the CLI prints a per-namespace summary of created, updated, and skipped keys. If any errors are returned by the API the CLI exits with code `1`.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
#### `leo-translate logout`
|
|
158
|
+
|
|
159
|
+
Clears the locally cached authentication token. The next command that requires auth will re-trigger the browser login flow.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
leo-translate logout
|
|
163
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { BackendModule, Services, InitOptions, ReadCallback } from 'i18next';
|
|
2
|
+
export interface LeoBackendOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Base URL for fetching translation files from the remote (e.g. Cloudflare R2).
|
|
5
|
+
* Example: 'https://translations.example.com'
|
|
6
|
+
*/
|
|
7
|
+
remoteUrl?: string;
|
|
8
|
+
/**
|
|
9
|
+
* The ID of the application
|
|
10
|
+
*/
|
|
11
|
+
appId: string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* The localStorage key prefix used by the Leo Translate Chrome extension
|
|
14
|
+
* to store translation overrides.
|
|
15
|
+
* Defaults to 'leo-translate'.
|
|
16
|
+
*/
|
|
17
|
+
localStorageKeyPrefix?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Base URL of the Leo Translate API used to report missing keys.
|
|
20
|
+
* Example: 'https://leo-translate.webmobix.io'
|
|
21
|
+
*
|
|
22
|
+
* Requires `saveMissingSecret` to also be set.
|
|
23
|
+
* When absent, `create()` is a no-op.
|
|
24
|
+
*/
|
|
25
|
+
saveMissingApiUrl?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Per-app secret for the save-missing-keys endpoint (generated via the dashboard
|
|
28
|
+
* or `POST /api/backend/apps/:appId/secrets/generate`).
|
|
29
|
+
*
|
|
30
|
+
* Keep this out of production builds — set it via an environment variable in
|
|
31
|
+
* local/staging only. When absent, `create()` is a no-op.
|
|
32
|
+
*/
|
|
33
|
+
saveMissingSecret?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Debounce interval in milliseconds for batching missing keys before sending
|
|
36
|
+
* them to the API. Individual calls to `create()` are accumulated and flushed
|
|
37
|
+
* in a single POST after this delay.
|
|
38
|
+
* Defaults to 2000 ms.
|
|
39
|
+
*/
|
|
40
|
+
saveMissingDebounceMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare class LeoBackend implements BackendModule<LeoBackendOptions> {
|
|
43
|
+
static readonly type: "backend";
|
|
44
|
+
readonly type: "backend";
|
|
45
|
+
private services;
|
|
46
|
+
private options;
|
|
47
|
+
private i18nextOptions;
|
|
48
|
+
private pendingMissingKeys;
|
|
49
|
+
private flushTimer;
|
|
50
|
+
constructor(services: Services, options: LeoBackendOptions, i18nextOptions?: InitOptions);
|
|
51
|
+
init(services: Services, options: LeoBackendOptions, i18nextOptions: InitOptions): void;
|
|
52
|
+
create(languages: readonly string[], namespace: string, key: string, fallbackValue: string): void;
|
|
53
|
+
private flushMissingKeys;
|
|
54
|
+
read(language: string, namespace: string, callback: ReadCallback): void;
|
|
55
|
+
}
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const u = {
|
|
2
|
+
remoteUrl: "https://leo-translate-cdn.webmobix.io",
|
|
3
|
+
appId: void 0,
|
|
4
|
+
localStorageKeyPrefix: "leo-translate",
|
|
5
|
+
saveMissingApiUrl: "https://leo-translate.webmobix.io",
|
|
6
|
+
saveMissingSecret: "",
|
|
7
|
+
saveMissingDebounceMs: 2e3
|
|
8
|
+
};
|
|
9
|
+
class a extends Error {
|
|
10
|
+
constructor(s, t = !1) {
|
|
11
|
+
super(s), this.retry = t;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const c = class c {
|
|
15
|
+
constructor(s, t, i) {
|
|
16
|
+
this.type = "backend", this.pendingMissingKeys = [], this.flushTimer = null, s && this.init(s, t, i ?? {});
|
|
17
|
+
}
|
|
18
|
+
init(s, t, i) {
|
|
19
|
+
this.services = s, this.i18nextOptions = i, this.options = {
|
|
20
|
+
...u,
|
|
21
|
+
...t
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
create(s, t, i, n) {
|
|
25
|
+
const { saveMissingApiUrl: o, saveMissingSecret: r, appId: e, saveMissingDebounceMs: h } = this.options;
|
|
26
|
+
if (console.debug(`[leo-translate:backend] Missing key: ${t}:${i}`, {
|
|
27
|
+
languages: s,
|
|
28
|
+
namespace: t,
|
|
29
|
+
key: i,
|
|
30
|
+
fallbackValue: n,
|
|
31
|
+
appId: e
|
|
32
|
+
}), !(!o || !r || !e)) {
|
|
33
|
+
for (const l of s)
|
|
34
|
+
this.pendingMissingKeys.push({ namespace: t, key: i, language: l, fallbackValue: n });
|
|
35
|
+
this.flushTimer !== null && clearTimeout(this.flushTimer), this.flushTimer = setTimeout(() => {
|
|
36
|
+
this.flushMissingKeys();
|
|
37
|
+
}, h);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
flushMissingKeys() {
|
|
41
|
+
const s = this.pendingMissingKeys.splice(0);
|
|
42
|
+
if (this.flushTimer = null, s.length === 0) return;
|
|
43
|
+
const { saveMissingApiUrl: t, saveMissingSecret: i, appId: n } = this.options, o = `${t}/api/backend/apps/${n}/missing-keys`;
|
|
44
|
+
console.debug(`[leo-translate:backend] Flushing ${s.length} missing key(s) to ${o}`), fetch(o, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
credentials: "omit",
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"X-Leo-Save-Missing-Key": i
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({ keys: s })
|
|
52
|
+
}).catch(() => {
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
read(s, t, i) {
|
|
56
|
+
const { remoteUrl: n, appId: o } = this.options;
|
|
57
|
+
if (!n || !o) {
|
|
58
|
+
i(null, {});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const r = `${n}/${o}/latest/${s}/${t}.json`;
|
|
62
|
+
fetch(r).then(
|
|
63
|
+
(e) => {
|
|
64
|
+
const { ok: h, status: l } = e;
|
|
65
|
+
if (!h) {
|
|
66
|
+
const g = l >= 500 && l < 600;
|
|
67
|
+
throw new a(`failed loading ${r}`, g);
|
|
68
|
+
}
|
|
69
|
+
return e.text();
|
|
70
|
+
},
|
|
71
|
+
() => {
|
|
72
|
+
throw new a(`failed loading ${r}`);
|
|
73
|
+
}
|
|
74
|
+
).then((e) => {
|
|
75
|
+
try {
|
|
76
|
+
i(null, JSON.parse(e));
|
|
77
|
+
} catch {
|
|
78
|
+
throw new a(`failed parsing ${r} to json`, !1);
|
|
79
|
+
}
|
|
80
|
+
}).catch((e) => {
|
|
81
|
+
e instanceof a && i(e.message, e.retry);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
c.type = "backend";
|
|
86
|
+
let p = c;
|
|
87
|
+
export {
|
|
88
|
+
p as LeoBackend
|
|
89
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface AuthResult {
|
|
2
|
+
token: string;
|
|
3
|
+
user: {
|
|
4
|
+
id: string;
|
|
5
|
+
email: string;
|
|
6
|
+
name: string | null;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Returns a valid session token for the given backend URL.
|
|
11
|
+
*
|
|
12
|
+
* If a cached non-expired token exists it is returned immediately.
|
|
13
|
+
* Otherwise the user is sent through an OAuth 2.0 PKCE flow:
|
|
14
|
+
* 1. A temporary HTTP server is started on a random local port.
|
|
15
|
+
* 2. The browser is opened to /oauth/authorize.
|
|
16
|
+
* 3. After login/consent the server captures the auth code.
|
|
17
|
+
* 4. The code is exchanged for a session token.
|
|
18
|
+
* 5. The token is cached to ~/.leo-translate/credentials.json.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getToken(backendUrl: string): Promise<AuthResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Remove the cached credentials for the given backend URL.
|
|
23
|
+
*/
|
|
24
|
+
export declare function clearCredentials(): void;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively flattens a nested object into dot-notation key/value pairs.
|
|
3
|
+
*
|
|
4
|
+
* Example:
|
|
5
|
+
* { header: { title: "Hello" }, footer: "Bye" }
|
|
6
|
+
* → { "header.title": "Hello", "footer": "Bye" }
|
|
7
|
+
*/
|
|
8
|
+
export declare function flattenKeys(obj: unknown, prefix?: string): Record<string, string>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ImportOptions {
|
|
2
|
+
folder: string;
|
|
3
|
+
appId: string;
|
|
4
|
+
appSecret: string;
|
|
5
|
+
backendUrl: string;
|
|
6
|
+
overwrite: boolean;
|
|
7
|
+
dryRun: boolean;
|
|
8
|
+
/** If set, only import this language (subfolder name). Otherwise import all. */
|
|
9
|
+
language?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Discovers all language/namespace pairs under `options.folder`, then uploads
|
|
13
|
+
* them to the Leo Translate backend via the bulk import API.
|
|
14
|
+
*
|
|
15
|
+
* Expected directory structure:
|
|
16
|
+
* <folder>/
|
|
17
|
+
* <language>/ ← BCP-47 language tag (e.g. "en", "de", "pt-BR")
|
|
18
|
+
* <namespace>.json ← flat or nested i18next bundle
|
|
19
|
+
*/
|
|
20
|
+
export declare function runImport(options: ImportOptions): Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand as k, runMain as P } from "citty";
|
|
3
|
+
import { readdirSync as v, statSync as D, readFileSync as S, mkdirSync as I, writeFileSync as N } from "node:fs";
|
|
4
|
+
import { resolve as _, join as y, basename as U, extname as R } from "node:path";
|
|
5
|
+
import { randomBytes as O, createHash as F } from "node:crypto";
|
|
6
|
+
import { createServer as J } from "node:http";
|
|
7
|
+
import { homedir as q } from "node:os";
|
|
8
|
+
import K from "open";
|
|
9
|
+
function T(e, n = "") {
|
|
10
|
+
if (e === null || typeof e != "object" || Array.isArray(e))
|
|
11
|
+
return {};
|
|
12
|
+
const o = {};
|
|
13
|
+
for (const [l, a] of Object.entries(e)) {
|
|
14
|
+
const i = n ? `${n}.${l}` : l;
|
|
15
|
+
a !== null && typeof a == "object" && !Array.isArray(a) ? Object.assign(o, T(a, i)) : typeof a == "string" ? o[i] = a : a != null && (o[i] = String(a));
|
|
16
|
+
}
|
|
17
|
+
return o;
|
|
18
|
+
}
|
|
19
|
+
function B(e) {
|
|
20
|
+
return v(e).filter((n) => {
|
|
21
|
+
try {
|
|
22
|
+
return D(y(e, n)).isDirectory();
|
|
23
|
+
} catch {
|
|
24
|
+
return !1;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function M(e) {
|
|
29
|
+
return v(e).filter((n) => R(n) === ".json");
|
|
30
|
+
}
|
|
31
|
+
function H(e) {
|
|
32
|
+
const n = S(e, "utf8");
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(n);
|
|
35
|
+
} catch (o) {
|
|
36
|
+
const l = o instanceof Error ? o.message : String(o);
|
|
37
|
+
throw new Error(`Failed to parse ${e}: ${l}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function Y(e, n, o, l, a, i, p) {
|
|
41
|
+
const g = `${n}/api/backend/apps/${encodeURIComponent(o)}/import`, s = await fetch(g, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"X-Leo-Save-Missing-Key": e,
|
|
45
|
+
"Content-Type": "application/json"
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ language: l, namespace: a, keys: i, overwrite: p })
|
|
48
|
+
});
|
|
49
|
+
if (!s.ok) {
|
|
50
|
+
const u = await s.json().catch(() => ({ error: s.statusText }));
|
|
51
|
+
throw new Error(`API error (${s.status}): ${u.error ?? s.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
return s.json();
|
|
54
|
+
}
|
|
55
|
+
async function z(e) {
|
|
56
|
+
const { appId: n, overwrite: o, dryRun: l } = e, a = e.backendUrl.replace(/\/+$/, ""), i = _(e.folder), p = B(i);
|
|
57
|
+
p.length === 0 && (console.error(
|
|
58
|
+
`[leo-translate] No language subdirectories found in ${i}.
|
|
59
|
+
Expected structure: <folder>/<language>/<namespace>.json`
|
|
60
|
+
), process.exit(1));
|
|
61
|
+
const g = e.language ? p.filter((t) => t === e.language) : p;
|
|
62
|
+
g.length === 0 && (console.error(
|
|
63
|
+
`[leo-translate] Language '${e.language}' not found in ${i}.
|
|
64
|
+
Available: ${p.join(", ")}`
|
|
65
|
+
), process.exit(1));
|
|
66
|
+
const s = [];
|
|
67
|
+
for (const t of g) {
|
|
68
|
+
const r = y(i, t), d = M(r);
|
|
69
|
+
if (d.length === 0) {
|
|
70
|
+
console.warn(`[leo-translate] No JSON files found in ${r}, skipping.`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const m of d) {
|
|
74
|
+
const h = U(m, ".json");
|
|
75
|
+
let w;
|
|
76
|
+
try {
|
|
77
|
+
w = H(y(r, m));
|
|
78
|
+
} catch (b) {
|
|
79
|
+
const E = b instanceof Error ? b.message : String(b);
|
|
80
|
+
console.error(`[leo-translate] Error reading ${m}: ${E}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const A = T(w);
|
|
84
|
+
if (Object.keys(A).length === 0) {
|
|
85
|
+
console.warn(`[leo-translate] ${t}/${m} is empty or has no string values, skipping.`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
s.push({ language: t, namespace: h, keys: A });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (s.length === 0) {
|
|
92
|
+
console.log("[leo-translate] Nothing to import.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (l) {
|
|
96
|
+
console.log(
|
|
97
|
+
`[leo-translate] Dry run — would import into app '${n}' at ${a} (overwrite: ${o ? "yes" : "no"})
|
|
98
|
+
`
|
|
99
|
+
);
|
|
100
|
+
let t = 0;
|
|
101
|
+
for (const r of s) {
|
|
102
|
+
const d = Object.keys(r.keys).length;
|
|
103
|
+
t += d, console.log(` ${r.language}/${r.namespace} (${d} keys)`);
|
|
104
|
+
for (const [m, h] of Object.entries(r.keys)) {
|
|
105
|
+
const w = h.length > 80 ? `${h.slice(0, 77)}…` : h;
|
|
106
|
+
console.log(` ${m}: ${w}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
console.log(
|
|
110
|
+
`
|
|
111
|
+
[leo-translate] Dry run complete — ${s.length} namespace(s), ${t} key(s) total. No changes were made.`
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
e.appSecret || (console.error(
|
|
116
|
+
`[leo-translate] Error: --app-secret is required for import.
|
|
117
|
+
Generate a secret in the Leo Translate dashboard under App Settings → Secrets.`
|
|
118
|
+
), process.exit(1)), console.log(
|
|
119
|
+
`[leo-translate] Importing ${s.length} namespace(s) into app '${n}' (overwrite: ${o ? "yes" : "no"})…
|
|
120
|
+
`
|
|
121
|
+
);
|
|
122
|
+
const u = [];
|
|
123
|
+
let c = !1;
|
|
124
|
+
for (const t of s) {
|
|
125
|
+
process.stdout.write(
|
|
126
|
+
` ${t.language}/${t.namespace} (${Object.keys(t.keys).length} keys)… `
|
|
127
|
+
);
|
|
128
|
+
try {
|
|
129
|
+
const r = await Y(
|
|
130
|
+
e.appSecret,
|
|
131
|
+
a,
|
|
132
|
+
n,
|
|
133
|
+
t.language,
|
|
134
|
+
t.namespace,
|
|
135
|
+
t.keys,
|
|
136
|
+
o
|
|
137
|
+
);
|
|
138
|
+
u.push({
|
|
139
|
+
language: t.language,
|
|
140
|
+
namespace: t.namespace,
|
|
141
|
+
...r
|
|
142
|
+
});
|
|
143
|
+
const d = [];
|
|
144
|
+
if (r.created > 0 && d.push(`${r.created} created`), r.updated > 0 && d.push(`${r.updated} updated`), r.skipped > 0 && d.push(`${r.skipped} skipped`), console.log(d.length > 0 ? d.join(", ") : "no changes"), r.errors.length > 0) {
|
|
145
|
+
c = !0;
|
|
146
|
+
for (const m of r.errors)
|
|
147
|
+
console.error(` ! ${m}`);
|
|
148
|
+
}
|
|
149
|
+
} catch (r) {
|
|
150
|
+
c = !0;
|
|
151
|
+
const d = r instanceof Error ? r.message : String(r);
|
|
152
|
+
console.error(`FAILED
|
|
153
|
+
! ${d}`), u.push({
|
|
154
|
+
language: t.language,
|
|
155
|
+
namespace: t.namespace,
|
|
156
|
+
created: 0,
|
|
157
|
+
updated: 0,
|
|
158
|
+
skipped: 0,
|
|
159
|
+
errors: [d]
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const f = u.reduce(
|
|
164
|
+
(t, r) => ({
|
|
165
|
+
created: t.created + r.created,
|
|
166
|
+
updated: t.updated + r.updated,
|
|
167
|
+
skipped: t.skipped + r.skipped,
|
|
168
|
+
errors: t.errors + r.errors.length
|
|
169
|
+
}),
|
|
170
|
+
{ created: 0, updated: 0, skipped: 0, errors: 0 }
|
|
171
|
+
);
|
|
172
|
+
console.log(
|
|
173
|
+
`
|
|
174
|
+
[leo-translate] Done — ${f.created} created, ${f.updated} updated, ${f.skipped} skipped, ${f.errors} error(s)`
|
|
175
|
+
), c && process.exit(1), process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
const j = y(q(), ".leo-translate"), C = y(j, "credentials.json"), x = 49152, X = 65535;
|
|
178
|
+
function G(e) {
|
|
179
|
+
try {
|
|
180
|
+
const n = S(C, "utf8"), o = JSON.parse(n);
|
|
181
|
+
return o.backendUrl !== e || new Date(o.expiresAt) <= /* @__PURE__ */ new Date() ? null : o;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function L(e) {
|
|
187
|
+
I(j, { recursive: !0 }), N(C, JSON.stringify(e, null, 2), { mode: 384 });
|
|
188
|
+
}
|
|
189
|
+
function V() {
|
|
190
|
+
return O(32).toString("base64url");
|
|
191
|
+
}
|
|
192
|
+
function Q(e) {
|
|
193
|
+
return F("sha256").update(e).digest("base64url");
|
|
194
|
+
}
|
|
195
|
+
function W() {
|
|
196
|
+
return new Promise((e, n) => {
|
|
197
|
+
const o = Math.floor(Math.random() * (X - x + 1)) + x;
|
|
198
|
+
let l, a;
|
|
199
|
+
const i = new Promise((s, u) => {
|
|
200
|
+
l = s, a = u;
|
|
201
|
+
}), p = /* @__PURE__ */ new Set(), g = J((s, u) => {
|
|
202
|
+
const c = new URL(s.url ?? "/", `http://127.0.0.1:${o}`);
|
|
203
|
+
if (c.pathname !== "/callback") {
|
|
204
|
+
u.writeHead(404), u.end();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const f = c.searchParams.get("code"), t = c.searchParams.get("state") ?? "", r = c.searchParams.get("error");
|
|
208
|
+
u.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }), f ? (u.end(
|
|
209
|
+
'<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:60px"><h2>Authentication successful</h2><p>You can close this tab and return to the terminal.</p></body></html>'
|
|
210
|
+
), l({ code: f, state: t })) : (u.end(
|
|
211
|
+
`<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:60px"><h2>Authentication failed</h2><p>${r ?? "Unknown error"}</p></body></html>`
|
|
212
|
+
), a(new Error(`OAuth error: ${r ?? "unknown"}`)));
|
|
213
|
+
});
|
|
214
|
+
g.on("connection", (s) => {
|
|
215
|
+
p.add(s), s.once("close", () => p.delete(s));
|
|
216
|
+
}), g.on("error", n), g.listen(o, "127.0.0.1", () => {
|
|
217
|
+
e({
|
|
218
|
+
port: o,
|
|
219
|
+
waitForCode: () => i,
|
|
220
|
+
close: () => {
|
|
221
|
+
for (const s of p) s.destroy();
|
|
222
|
+
g.close();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function Z(e, n, o, l) {
|
|
229
|
+
const a = await fetch(`${e}/api/ext/auth/token`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { "Content-Type": "application/json" },
|
|
232
|
+
body: JSON.stringify({ code: n, code_verifier: o, redirect_uri: l })
|
|
233
|
+
});
|
|
234
|
+
if (!a.ok) {
|
|
235
|
+
const i = await a.json().catch(() => ({ error: a.statusText }));
|
|
236
|
+
throw new Error(`Token exchange failed (${a.status}): ${i.error ?? a.statusText}`);
|
|
237
|
+
}
|
|
238
|
+
return a.json();
|
|
239
|
+
}
|
|
240
|
+
async function ee(e) {
|
|
241
|
+
const n = e.replace(/\/+$/, ""), o = G(n);
|
|
242
|
+
if (o)
|
|
243
|
+
return { token: o.token, user: o.user };
|
|
244
|
+
console.log("[leo-translate] No cached credentials found. Opening browser for authentication…");
|
|
245
|
+
const l = V(), a = Q(l), i = O(8).toString("hex"), { port: p, waitForCode: g, close: s } = await W(), u = `http://127.0.0.1:${p}/callback`, c = new URL(`${n}/oauth/authorize`);
|
|
246
|
+
c.searchParams.set("response_type", "code"), c.searchParams.set("client_id", "leo-translate-cli"), c.searchParams.set("redirect_uri", u), c.searchParams.set("code_challenge", a), c.searchParams.set("code_challenge_method", "S256"), c.searchParams.set("state", i), console.log(`[leo-translate] Opening: ${c.toString()}`), await K(c.toString());
|
|
247
|
+
let f;
|
|
248
|
+
try {
|
|
249
|
+
f = await Promise.race([
|
|
250
|
+
g(),
|
|
251
|
+
new Promise(
|
|
252
|
+
(r, d) => setTimeout(() => d(new Error("Authentication timed out (5 minutes)")), 5 * 60 * 1e3)
|
|
253
|
+
)
|
|
254
|
+
]);
|
|
255
|
+
} finally {
|
|
256
|
+
s();
|
|
257
|
+
}
|
|
258
|
+
if (f.state !== i)
|
|
259
|
+
throw new Error("OAuth state mismatch — possible CSRF attack. Authentication aborted.");
|
|
260
|
+
const t = await Z(n, f.code, l, u);
|
|
261
|
+
return L({
|
|
262
|
+
token: t.token,
|
|
263
|
+
expiresAt: t.expiresAt,
|
|
264
|
+
backendUrl: n,
|
|
265
|
+
user: t.user
|
|
266
|
+
}), console.log(`[leo-translate] Authenticated as ${t.user.email}`), { token: t.token, user: t.user };
|
|
267
|
+
}
|
|
268
|
+
function te() {
|
|
269
|
+
try {
|
|
270
|
+
const e = S(C, "utf8"), n = JSON.parse(e);
|
|
271
|
+
L({ ...n, expiresAt: (/* @__PURE__ */ new Date(0)).toISOString() });
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const $ = "https://leo-translate.webmobix.io", re = k({
|
|
276
|
+
meta: {
|
|
277
|
+
name: "import",
|
|
278
|
+
description: "Import i18next namespace bundles into Leo Translate"
|
|
279
|
+
},
|
|
280
|
+
args: {
|
|
281
|
+
folder: {
|
|
282
|
+
type: "positional",
|
|
283
|
+
description: "Path to locale folder with <language>/<namespace>.json structure",
|
|
284
|
+
required: !0
|
|
285
|
+
},
|
|
286
|
+
"app-id": {
|
|
287
|
+
type: "string",
|
|
288
|
+
description: "Leo Translate app ID",
|
|
289
|
+
required: !0
|
|
290
|
+
},
|
|
291
|
+
"app-secret": {
|
|
292
|
+
type: "string",
|
|
293
|
+
description: "App secret for backend API authentication (from App Settings → Secrets)",
|
|
294
|
+
required: !0
|
|
295
|
+
},
|
|
296
|
+
url: {
|
|
297
|
+
type: "string",
|
|
298
|
+
description: `Backend URL (default: ${$})`,
|
|
299
|
+
required: !1
|
|
300
|
+
},
|
|
301
|
+
overwrite: {
|
|
302
|
+
type: "boolean",
|
|
303
|
+
description: "Overwrite existing published values (default: skip existing)",
|
|
304
|
+
default: !1
|
|
305
|
+
},
|
|
306
|
+
"dry-run": {
|
|
307
|
+
type: "boolean",
|
|
308
|
+
description: "Preview what would be imported without making any changes",
|
|
309
|
+
default: !1
|
|
310
|
+
},
|
|
311
|
+
language: {
|
|
312
|
+
type: "string",
|
|
313
|
+
description: "Import only this language subfolder (default: all)",
|
|
314
|
+
required: !1
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
async run({ args: e }) {
|
|
318
|
+
await z({
|
|
319
|
+
folder: e.folder,
|
|
320
|
+
appId: e["app-id"],
|
|
321
|
+
appSecret: e["app-secret"],
|
|
322
|
+
backendUrl: e.url ?? $,
|
|
323
|
+
overwrite: e.overwrite,
|
|
324
|
+
dryRun: e["dry-run"],
|
|
325
|
+
language: e.language
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}), ne = k({
|
|
329
|
+
meta: {
|
|
330
|
+
name: "login",
|
|
331
|
+
description: "Authenticate with the Leo Translate backend"
|
|
332
|
+
},
|
|
333
|
+
args: {
|
|
334
|
+
url: {
|
|
335
|
+
type: "string",
|
|
336
|
+
description: `Backend URL (default: ${$})`,
|
|
337
|
+
required: !1
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
async run({ args: e }) {
|
|
341
|
+
const n = e.url ?? $;
|
|
342
|
+
console.log(`[leo-translate] Logging in to ${n}…`);
|
|
343
|
+
const { user: o } = await ee(n);
|
|
344
|
+
console.log(`[leo-translate] Authenticated as ${o.email}.`), process.exit(0);
|
|
345
|
+
}
|
|
346
|
+
}), oe = k({
|
|
347
|
+
meta: {
|
|
348
|
+
name: "logout",
|
|
349
|
+
description: "Clear cached authentication credentials"
|
|
350
|
+
},
|
|
351
|
+
args: {},
|
|
352
|
+
run() {
|
|
353
|
+
te(), console.log("[leo-translate] Credentials cleared.");
|
|
354
|
+
}
|
|
355
|
+
}), ae = k({
|
|
356
|
+
meta: {
|
|
357
|
+
name: "leo-translate",
|
|
358
|
+
version: "0.1.0",
|
|
359
|
+
description: "Leo Translate CLI — manage translation bundles"
|
|
360
|
+
},
|
|
361
|
+
subCommands: {
|
|
362
|
+
import: re,
|
|
363
|
+
login: ne,
|
|
364
|
+
logout: oe
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
P(ae);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { LeoBackend } from './backend/index';
|
|
2
|
+
export type { LeoBackendOptions } from './backend/index';
|
|
3
|
+
export { LeoPostProcessor } from './post-processor/index';
|
|
4
|
+
export type { LeoPostProcessorOptions } from './post-processor/index';
|
|
5
|
+
export { leoTranslatorSupport } from './translator-support/index';
|
|
6
|
+
export type { LeoTranslatorSupport } from './translator-support/index';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { LeoBackend as e } from "./backend.js";
|
|
2
|
+
import { LeoPostProcessor as p } from "./post-processor.js";
|
|
3
|
+
import { leoTranslatorSupport as a } from "./translator-support.js";
|
|
4
|
+
export {
|
|
5
|
+
e as LeoBackend,
|
|
6
|
+
p as LeoPostProcessor,
|
|
7
|
+
a as leoTranslatorSupport
|
|
8
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { i18n, PostProcessorModule } from 'i18next';
|
|
2
|
+
/**
|
|
3
|
+
* Extracts and decodes an invisible key marker from a text node string.
|
|
4
|
+
* Returns the decoded `namespace:key` string, or `null` if no marker found.
|
|
5
|
+
*/
|
|
6
|
+
export declare function decodeMarker(text: string): string | null;
|
|
7
|
+
/** Enable or disable key marker embedding. Called by LeoTranslatorSupport. */
|
|
8
|
+
export declare function setDiscoveryActive(active: boolean): void;
|
|
9
|
+
/** Returns the current discovery mode state. */
|
|
10
|
+
export declare function isDiscoveryActive(): boolean;
|
|
11
|
+
export interface LeoPostProcessorOptions {
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
interface ExtendedPostProcessor extends PostProcessorModule {
|
|
15
|
+
init?: (instance: i18n) => void;
|
|
16
|
+
instance?: i18n;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Leo Translate postProcessor plugin for i18next.
|
|
20
|
+
*
|
|
21
|
+
* Register with i18next:
|
|
22
|
+
* import { LeoPostProcessor } from '@leo-translate/i18next-plugins/post-processor';
|
|
23
|
+
* i18n.use(LeoPostProcessor).init({ postProcess: ['leoPostProcessor'] });
|
|
24
|
+
*
|
|
25
|
+
* Zero-cost passthrough when discovery mode is off. When on (toggled by the
|
|
26
|
+
* Chrome extension sidebar), each translated string is prefixed with an
|
|
27
|
+
* invisible encoded marker so the content script can locate keys in the DOM.
|
|
28
|
+
*/
|
|
29
|
+
export declare const LeoPostProcessor: ExtendedPostProcessor;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const h = ["", "", "", ""], y = {
|
|
2
|
+
"": 0,
|
|
3
|
+
"": 1,
|
|
4
|
+
"": 2,
|
|
5
|
+
"": 3
|
|
6
|
+
}, P = "", g = "";
|
|
7
|
+
function C(t) {
|
|
8
|
+
let o = P;
|
|
9
|
+
for (let n = 0; n < t.length; n++) {
|
|
10
|
+
const s = t.charCodeAt(n);
|
|
11
|
+
for (let e = 7; e >= 0; e--)
|
|
12
|
+
o += h[s >> e * 2 & 3];
|
|
13
|
+
}
|
|
14
|
+
return o + g;
|
|
15
|
+
}
|
|
16
|
+
function N(t) {
|
|
17
|
+
const o = t.match(/\u2060([\u200B\u200C\u200E\u200F]+)\u2061/);
|
|
18
|
+
if (!o) return null;
|
|
19
|
+
const n = o[1];
|
|
20
|
+
if (n.length % 8 !== 0) return null;
|
|
21
|
+
let s = "";
|
|
22
|
+
for (let e = 0; e < n.length; e += 8) {
|
|
23
|
+
let r = 0;
|
|
24
|
+
for (let i = 0; i < 8; i++) {
|
|
25
|
+
const c = y[n[e + i]];
|
|
26
|
+
if (c === void 0) return null;
|
|
27
|
+
r = r << 2 | c;
|
|
28
|
+
}
|
|
29
|
+
s += String.fromCharCode(r);
|
|
30
|
+
}
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
let a = !1;
|
|
34
|
+
function v(t) {
|
|
35
|
+
a = t;
|
|
36
|
+
}
|
|
37
|
+
function m() {
|
|
38
|
+
return a;
|
|
39
|
+
}
|
|
40
|
+
const M = {
|
|
41
|
+
type: "postProcessor",
|
|
42
|
+
name: "leoPostProcessor",
|
|
43
|
+
init(t) {
|
|
44
|
+
this.instance = t;
|
|
45
|
+
},
|
|
46
|
+
process(t, o, n, s) {
|
|
47
|
+
var u, d, f;
|
|
48
|
+
if (!a || typeof t != "string") return t;
|
|
49
|
+
const e = ((d = (u = this.instance) == null ? void 0 : u.options) == null ? void 0 : d.nsSeparator) || ":";
|
|
50
|
+
console.debug("[leo-translate:postProcessor] Pre mrking key:", { key: o });
|
|
51
|
+
const r = Array.isArray(o) ? o[0] : o, c = (f = s.options) == null ? void 0 : f.defaultNS, p = Array.isArray(c) ? c[0] : c, A = n.ns || p || "translation";
|
|
52
|
+
let l = r;
|
|
53
|
+
return r.includes(e) || (l = `${A}${e}${r}`), console.debug("[leo-translate:postProcessor] Marking key:", l), C(l) + t;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
export {
|
|
57
|
+
M as LeoPostProcessor,
|
|
58
|
+
N as decodeMarker,
|
|
59
|
+
m as isDiscoveryActive,
|
|
60
|
+
v as setDiscoveryActive
|
|
61
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { i18n, Module } from 'i18next';
|
|
2
|
+
export declare class LeoTranslatorSupport implements Module {
|
|
3
|
+
type: "3rdParty";
|
|
4
|
+
private i18n;
|
|
5
|
+
/**
|
|
6
|
+
* All translation keys loaded from i18n resource bundles, keyed by their
|
|
7
|
+
* composite `namespace:key` identifier.
|
|
8
|
+
*
|
|
9
|
+
* Built by {@link registerBundle} as it recursively flattens every bundle
|
|
10
|
+
* that i18next commits to its store (via the `loaded` event). Each entry
|
|
11
|
+
* stores the namespace and the translated string value so the sidebar can
|
|
12
|
+
* display current values and detect overrides.
|
|
13
|
+
*
|
|
14
|
+
* A key is present here as soon as its bundle is loaded — regardless of
|
|
15
|
+
* whether the key is actually rendered anywhere on the page. Compare with
|
|
16
|
+
* `keyPositions` in the sidebar (and the content script's scan results),
|
|
17
|
+
* which only contain keys whose invisible DOM markers are currently visible.
|
|
18
|
+
*/
|
|
19
|
+
private discoveredKeys;
|
|
20
|
+
/**
|
|
21
|
+
* Translation keys that i18next could not resolve, keyed by their composite
|
|
22
|
+
* `namespace:key` identifier.
|
|
23
|
+
*
|
|
24
|
+
* Populated via i18next's `missingKey` event. Entries carry the languages
|
|
25
|
+
* for which the key was missing so the sidebar can surface them as actionable
|
|
26
|
+
* items needing a translation.
|
|
27
|
+
*/
|
|
28
|
+
private missingKeys;
|
|
29
|
+
private isBrowser;
|
|
30
|
+
/**
|
|
31
|
+
* Set to `true` immediately before emitting a synthetic `languageChanged`
|
|
32
|
+
* event (i.e. one triggered by an override apply or discovery toggle, not
|
|
33
|
+
* by a real locale switch). The `languageChanged` handler checks this flag
|
|
34
|
+
* and skips `syncWithExtension()` when it is set, breaking the feedback loop:
|
|
35
|
+
*
|
|
36
|
+
* applyLocalOverride → i18n.emit('languageChanged') → languageChanged handler
|
|
37
|
+
* → syncWithExtension → KEYS_DISCOVERED → service worker re-applies overrides
|
|
38
|
+
* → UPDATE_REQUEST → applyLocalOverride → … (infinite loop)
|
|
39
|
+
*/
|
|
40
|
+
private isSyntheticLanguageChange;
|
|
41
|
+
/**
|
|
42
|
+
* Set to `true` while {@link applyLocalOverride} is executing. Guards both
|
|
43
|
+
* the `loaded` and `languageChanged` event handlers from calling
|
|
44
|
+
* `syncWithExtension()` — `addResourceBundle()` can internally fire the
|
|
45
|
+
* `loaded` event which was previously unguarded, creating an infinite loop:
|
|
46
|
+
*
|
|
47
|
+
* applyLocalOverride → addResourceBundle → 'loaded' event
|
|
48
|
+
* → syncWithExtension → KEYS_DISCOVERED → service worker re-applies overrides
|
|
49
|
+
* → UPDATE_REQUEST → applyLocalOverride → … (infinite loop)
|
|
50
|
+
*/
|
|
51
|
+
private isApplyingOverride;
|
|
52
|
+
/**
|
|
53
|
+
* Pending sync timer id. Used by {@link scheduleSyncWithExtension} to
|
|
54
|
+
* coalesce multiple rapid sync requests (e.g. several `loaded` or
|
|
55
|
+
* `missingKey` events firing in quick succession) into a single
|
|
56
|
+
* `window.postMessage` call, preventing message flooding.
|
|
57
|
+
*/
|
|
58
|
+
private pendingSyncTimer;
|
|
59
|
+
/** i18next namespace separator — read from options on init, defaults to ':'. */
|
|
60
|
+
private nsSeparator;
|
|
61
|
+
/** i18next key separator for nested keys — read from options on init, defaults to '.'. */
|
|
62
|
+
private keySeparator;
|
|
63
|
+
constructor();
|
|
64
|
+
init(i18nInstance: i18n): void;
|
|
65
|
+
/**
|
|
66
|
+
* Recursively flatten a (possibly nested) resource bundle into the
|
|
67
|
+
* discoveredKeys map. Keys are joined with the configured keySeparator
|
|
68
|
+
* and prefixed with `ns<nsSeparator>` so identifiers match what the app
|
|
69
|
+
* passes to `t()`.
|
|
70
|
+
*
|
|
71
|
+
* Example: bundle = { header: { title: "Hi" } }
|
|
72
|
+
* → identifier = "common:header.title" (with default separators)
|
|
73
|
+
*/
|
|
74
|
+
private registerBundle;
|
|
75
|
+
private setupListeners;
|
|
76
|
+
/**
|
|
77
|
+
* Schedule a debounced sync with the extension. Coalesces multiple rapid
|
|
78
|
+
* sync requests (e.g. several `loaded` or `missingKey` events firing in
|
|
79
|
+
* quick succession) into a single `window.postMessage` call. Uses
|
|
80
|
+
* `setTimeout(0)` so the sync fires after the current call stack completes
|
|
81
|
+
* but before the next browser paint.
|
|
82
|
+
*/
|
|
83
|
+
private scheduleSyncWithExtension;
|
|
84
|
+
private syncWithExtension;
|
|
85
|
+
/**
|
|
86
|
+
* Build a nested object from a dot-separated key path and a leaf value.
|
|
87
|
+
*
|
|
88
|
+
* Example: key = "header.faq", value = "LOL"
|
|
89
|
+
* → { header: { faq: "LOL" } }
|
|
90
|
+
*
|
|
91
|
+
* This is required because i18next's addResourceBundle does NOT interpret
|
|
92
|
+
* dot-separated key strings as nested paths — it treats them as literal
|
|
93
|
+
* property names. We must supply the correctly nested structure so the
|
|
94
|
+
* deep-merge preserves all sibling keys and the lookup via t() succeeds.
|
|
95
|
+
*/
|
|
96
|
+
private buildNestedObject;
|
|
97
|
+
private applyLocalOverride;
|
|
98
|
+
}
|
|
99
|
+
export declare const leoTranslatorSupport: LeoTranslatorSupport;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { setDiscoveryActive as h } from "./post-processor.js";
|
|
2
|
+
class c {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.type = "3rdParty", this.i18n = null, this.discoveredKeys = /* @__PURE__ */ new Map(), this.missingKeys = /* @__PURE__ */ new Map(), this.isBrowser = typeof window < "u" && typeof document < "u", this.isSyntheticLanguageChange = !1, this.isApplyingOverride = !1, this.pendingSyncTimer = null, this.nsSeparator = ":", this.keySeparator = ".", this.isBrowser && this.setupListeners();
|
|
5
|
+
}
|
|
6
|
+
init(e) {
|
|
7
|
+
this.i18n = e;
|
|
8
|
+
const t = e.options;
|
|
9
|
+
typeof t.nsSeparator == "string" && (this.nsSeparator = t.nsSeparator), typeof t.keySeparator == "string" && (this.keySeparator = t.keySeparator), console.debug(
|
|
10
|
+
"[leo-translate:support] Initializing (nsSeparator=%s, keySeparator=%s)",
|
|
11
|
+
this.nsSeparator,
|
|
12
|
+
this.keySeparator
|
|
13
|
+
), this.i18n.on("loaded", (s) => {
|
|
14
|
+
this.isApplyingOverride || (console.debug("[leo-translate:support] i18n loaded event:", s), Object.entries(s).forEach(([i, n]) => {
|
|
15
|
+
Object.keys(n).forEach((r) => {
|
|
16
|
+
var a;
|
|
17
|
+
const o = (a = this.i18n) == null ? void 0 : a.getResourceBundle(i, r);
|
|
18
|
+
console.debug(
|
|
19
|
+
`[leo-translate:support] loaded event — getResourceBundle(${i}, ${r}):`,
|
|
20
|
+
o,
|
|
21
|
+
"keys:",
|
|
22
|
+
o ? Object.keys(o) : "N/A"
|
|
23
|
+
), o && typeof o == "object" && this.registerBundle(r, o);
|
|
24
|
+
});
|
|
25
|
+
}), this.scheduleSyncWithExtension());
|
|
26
|
+
}), this.i18n.on("languageChanged", (s) => {
|
|
27
|
+
var n, r;
|
|
28
|
+
if (this.isSyntheticLanguageChange || this.isApplyingOverride) return;
|
|
29
|
+
console.debug("[leo-translate:support] languageChanged event:", s), this.discoveredKeys.clear();
|
|
30
|
+
const i = (r = (n = this.i18n) == null ? void 0 : n.store) == null ? void 0 : r.data;
|
|
31
|
+
if (i && typeof i == "object") {
|
|
32
|
+
const o = i[s];
|
|
33
|
+
if (o)
|
|
34
|
+
for (const [a, l] of Object.entries(o))
|
|
35
|
+
l && typeof l == "object" && this.registerBundle(a, l);
|
|
36
|
+
}
|
|
37
|
+
this.scheduleSyncWithExtension();
|
|
38
|
+
}), this.i18n.on("missingKey", (s, i, n) => {
|
|
39
|
+
const r = `${i}${this.nsSeparator}${n}`;
|
|
40
|
+
console.debug(`[leo-translate:support] missingKey: ${r}`, s), this.missingKeys.set(r, {
|
|
41
|
+
ns: i,
|
|
42
|
+
key: n,
|
|
43
|
+
languages: [...s]
|
|
44
|
+
}), this.isApplyingOverride || this.scheduleSyncWithExtension();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Recursively flatten a (possibly nested) resource bundle into the
|
|
49
|
+
* discoveredKeys map. Keys are joined with the configured keySeparator
|
|
50
|
+
* and prefixed with `ns<nsSeparator>` so identifiers match what the app
|
|
51
|
+
* passes to `t()`.
|
|
52
|
+
*
|
|
53
|
+
* Example: bundle = { header: { title: "Hi" } }
|
|
54
|
+
* → identifier = "common:header.title" (with default separators)
|
|
55
|
+
*/
|
|
56
|
+
registerBundle(e, t, s = "") {
|
|
57
|
+
console.debug(`[leo-translate:support] Registering bundle: ${e}`, t);
|
|
58
|
+
for (const [i, n] of Object.entries(t)) {
|
|
59
|
+
const r = s ? `${s}${this.keySeparator}${i}` : i;
|
|
60
|
+
if (typeof n == "string") {
|
|
61
|
+
const o = `${e}${this.nsSeparator}${r}`;
|
|
62
|
+
this.discoveredKeys.set(o, { ns: e, value: n });
|
|
63
|
+
} else n !== null && typeof n == "object" && !Array.isArray(n) && this.registerBundle(e, n, r);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
setupListeners() {
|
|
67
|
+
window.addEventListener("message", (e) => {
|
|
68
|
+
var s;
|
|
69
|
+
if (((s = e.data) == null ? void 0 : s.source) !== "LEO_TRANSLATOR_EXTENSION") return;
|
|
70
|
+
console.debug("[leo-translate:support] Received message from extension:", e.data);
|
|
71
|
+
const t = e.data;
|
|
72
|
+
switch (t.type) {
|
|
73
|
+
case "LEO_TRANSLATOR_PING":
|
|
74
|
+
console.debug("[leo-translate:support] Received PING — responding with sync"), this.syncWithExtension();
|
|
75
|
+
break;
|
|
76
|
+
case "LEO_TRANSLATOR_UPDATE_REQUEST":
|
|
77
|
+
console.debug("[leo-translate:support] Received update request:", t.payload), t.payload && typeof t.payload.ns == "string" && typeof t.payload.key == "string" && typeof t.payload.value == "string" && this.applyLocalOverride(t.payload);
|
|
78
|
+
break;
|
|
79
|
+
case "LEO_TRANSLATOR_READY":
|
|
80
|
+
this.syncWithExtension();
|
|
81
|
+
break;
|
|
82
|
+
case "LEO_TRANSLATOR_TOGGLE_DISCOVERY": {
|
|
83
|
+
const i = !!t.active;
|
|
84
|
+
if (console.debug("[leo-translate:support] Received TOGGLE_DISCOVERY — active:", i), h(i), this.i18n) {
|
|
85
|
+
this.isSyntheticLanguageChange = !0;
|
|
86
|
+
try {
|
|
87
|
+
this.i18n.emit("languageChanged", this.i18n.language);
|
|
88
|
+
} finally {
|
|
89
|
+
this.isSyntheticLanguageChange = !1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
i && requestAnimationFrame(() => {
|
|
93
|
+
requestAnimationFrame(() => {
|
|
94
|
+
console.debug("[leo-translate:support] Re-render settled, posting SCAN_READY"), window.postMessage(
|
|
95
|
+
{ source: "LEO_TRANSLATOR_SUPPORT", type: "LEO_TRANSLATOR_SCAN_READY" },
|
|
96
|
+
"*"
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Schedule a debounced sync with the extension. Coalesces multiple rapid
|
|
107
|
+
* sync requests (e.g. several `loaded` or `missingKey` events firing in
|
|
108
|
+
* quick succession) into a single `window.postMessage` call. Uses
|
|
109
|
+
* `setTimeout(0)` so the sync fires after the current call stack completes
|
|
110
|
+
* but before the next browser paint.
|
|
111
|
+
*/
|
|
112
|
+
scheduleSyncWithExtension() {
|
|
113
|
+
this.pendingSyncTimer === null && (this.pendingSyncTimer = setTimeout(() => {
|
|
114
|
+
this.pendingSyncTimer = null, this.syncWithExtension();
|
|
115
|
+
}, 0));
|
|
116
|
+
}
|
|
117
|
+
syncWithExtension() {
|
|
118
|
+
var i;
|
|
119
|
+
if (!this.isBrowser) return;
|
|
120
|
+
const e = Array.from(this.discoveredKeys.entries()).map(([n, r]) => ({
|
|
121
|
+
id: n,
|
|
122
|
+
...r
|
|
123
|
+
})), t = Array.from(this.missingKeys.entries()).map(([n, r]) => ({
|
|
124
|
+
id: n,
|
|
125
|
+
...r
|
|
126
|
+
})), s = ((i = this.i18n) == null ? void 0 : i.language) ?? "";
|
|
127
|
+
console.debug(
|
|
128
|
+
`[leo-translate:support] Syncing ${e.length} discovered keys and ${t.length} missing keys with extension (language: ${s})`
|
|
129
|
+
), window.postMessage(
|
|
130
|
+
{
|
|
131
|
+
source: "LEO_TRANSLATOR_SUPPORT",
|
|
132
|
+
type: "LEO_TRANSLATOR_KEYS_DISCOVERED",
|
|
133
|
+
payload: e,
|
|
134
|
+
missingKeys: t,
|
|
135
|
+
language: s
|
|
136
|
+
},
|
|
137
|
+
"*"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build a nested object from a dot-separated key path and a leaf value.
|
|
142
|
+
*
|
|
143
|
+
* Example: key = "header.faq", value = "LOL"
|
|
144
|
+
* → { header: { faq: "LOL" } }
|
|
145
|
+
*
|
|
146
|
+
* This is required because i18next's addResourceBundle does NOT interpret
|
|
147
|
+
* dot-separated key strings as nested paths — it treats them as literal
|
|
148
|
+
* property names. We must supply the correctly nested structure so the
|
|
149
|
+
* deep-merge preserves all sibling keys and the lookup via t() succeeds.
|
|
150
|
+
*/
|
|
151
|
+
buildNestedObject(e, t) {
|
|
152
|
+
const s = e.split(this.keySeparator), i = {};
|
|
153
|
+
let n = i;
|
|
154
|
+
for (let r = 0; r < s.length - 1; r++) {
|
|
155
|
+
const o = {};
|
|
156
|
+
n[s[r]] = o, n = o;
|
|
157
|
+
}
|
|
158
|
+
return n[s[s.length - 1]] = t, i;
|
|
159
|
+
}
|
|
160
|
+
applyLocalOverride(e) {
|
|
161
|
+
if (console.debug("[leo-translate:support] Applying local override:", e), !this.i18n) return;
|
|
162
|
+
this.isApplyingOverride = !0, this.isSyntheticLanguageChange = !0;
|
|
163
|
+
try {
|
|
164
|
+
this.i18n.addResourceBundle(
|
|
165
|
+
this.i18n.language,
|
|
166
|
+
e.ns,
|
|
167
|
+
this.buildNestedObject(e.key, e.value),
|
|
168
|
+
!0,
|
|
169
|
+
!0
|
|
170
|
+
), this.i18n.emit("languageChanged", this.i18n.language);
|
|
171
|
+
} finally {
|
|
172
|
+
this.isSyntheticLanguageChange = !1, this.isApplyingOverride = !1;
|
|
173
|
+
}
|
|
174
|
+
const t = `${e.ns}${this.nsSeparator}${e.key}`;
|
|
175
|
+
this.discoveredKeys.set(t, {
|
|
176
|
+
ns: e.ns,
|
|
177
|
+
value: e.value
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const g = new c();
|
|
182
|
+
export {
|
|
183
|
+
c as LeoTranslatorSupport,
|
|
184
|
+
g as leoTranslatorSupport
|
|
185
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webmobix/i18next-plugins",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "i18next backend and postProcessor plugins for Leo Translate",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"leo-translate": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./backend": {
|
|
19
|
+
"import": "./dist/backend.js",
|
|
20
|
+
"types": "./dist/backend/index.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./post-processor": {
|
|
23
|
+
"import": "./dist/post-processor.js",
|
|
24
|
+
"types": "./dist/post-processor/index.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./translator-support": {
|
|
27
|
+
"import": "./dist/translator-support.js",
|
|
28
|
+
"types": "./dist/translator-support/index.d.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"i18next": ">=23.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^25.3.3",
|
|
36
|
+
"i18next": "^24.0.0",
|
|
37
|
+
"typescript": "^5.4.5",
|
|
38
|
+
"vite": "^5.4.19",
|
|
39
|
+
"vite-plugin-dts": "^4.0.0"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"i18next",
|
|
43
|
+
"react-i18next",
|
|
44
|
+
"backend",
|
|
45
|
+
"post-processor",
|
|
46
|
+
"leo-translate"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"citty": "^0.2.1",
|
|
50
|
+
"open": "^11.0.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "vite build && tsc --emitDeclarationOnly",
|
|
54
|
+
"dev": "vite build --watch",
|
|
55
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
56
|
+
}
|
|
57
|
+
}
|