react-autolocale 0.2.0 → 0.3.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -32
- package/dist/cli.js +778 -51
- package/dist/config-CYHyGpdX.js +21 -0
- package/dist/config.d.ts +56 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +8 -7
- package/dist/project-CGAWETvT.js +1400 -0
- package/dist/{suggestion-UZQ8ngd0.js → redirect-pDFblLdm.js} +1 -24
- package/dist/suggestion-CkZQqMvZ.js +24 -0
- package/dist/translate-COrJX1KS.d.ts +22 -0
- package/dist/vite.d.ts +9 -28
- package/dist/vite.js +79 -475
- package/package.json +20 -6
- package/dist/overrides-ZQ6meLck.js +0 -557
package/dist/vite.js
CHANGED
|
@@ -1,375 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import fs from "node:fs";
|
|
1
|
+
import { n as redirectScript } from "./redirect-pDFblLdm.js";
|
|
2
|
+
import { A as readBanner, C as isSourceFile, D as translateHead, E as transformJsx, I as getLanguage, O as messageId, P as ollama, T as parserPlugins, _ as loadDicts, d as OVERRIDES_FILE, m as readOverrides, p as mergeOverrides, r as loadProject, w as walk } from "./project-CGAWETvT.js";
|
|
4
3
|
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { JSDOM, VirtualConsole } from "jsdom";
|
|
5
6
|
import { parse } from "@babel/parser";
|
|
6
7
|
import _traverse from "@babel/traverse";
|
|
7
|
-
import _generate from "@babel/generator";
|
|
8
8
|
import * as t from "@babel/types";
|
|
9
|
-
import { JSDOM, VirtualConsole } from "jsdom";
|
|
10
9
|
import { createServer } from "vite";
|
|
11
|
-
//#region src/core/transform.ts
|
|
12
|
-
const traverse$2 = _traverse.default ?? _traverse;
|
|
13
|
-
const generate = _generate.default ?? _generate;
|
|
14
|
-
/** Attributes whose string value is user-visible text. */
|
|
15
|
-
const TRANSLATABLE_ATTRS = /* @__PURE__ */ new Set([
|
|
16
|
-
"placeholder",
|
|
17
|
-
"title",
|
|
18
|
-
"alt",
|
|
19
|
-
"aria-label",
|
|
20
|
-
"aria-description"
|
|
21
|
-
]);
|
|
22
|
-
/** Elements whose text content is code, not copy. */
|
|
23
|
-
const SKIP_ELEMENTS = /* @__PURE__ */ new Set([
|
|
24
|
-
"script",
|
|
25
|
-
"style",
|
|
26
|
-
"code",
|
|
27
|
-
"pre",
|
|
28
|
-
"kbd",
|
|
29
|
-
"samp",
|
|
30
|
-
"svg",
|
|
31
|
-
"textarea"
|
|
32
|
-
]);
|
|
33
|
-
const T_IMPORT = "__RLT";
|
|
34
|
-
const TD_IMPORT = "__RLTD";
|
|
35
|
-
const HOOK_IMPORT = "__rlUseT";
|
|
36
|
-
const HOOK_VAR = "__rl";
|
|
37
|
-
const RUNTIME = "react-autolocale";
|
|
38
|
-
function parserPlugins(file) {
|
|
39
|
-
if (/\.tsx$/.test(file)) return ["typescript", "jsx"];
|
|
40
|
-
if (/\.ts$/.test(file)) return ["typescript"];
|
|
41
|
-
return ["jsx"];
|
|
42
|
-
}
|
|
43
|
-
/** Collapses JSX text the way React does: lines are trimmed and joined with one space. */
|
|
44
|
-
function cleanJsxText(raw) {
|
|
45
|
-
const lines = raw.split(/\r\n|\n|\r/);
|
|
46
|
-
let last = 0;
|
|
47
|
-
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i])) last = i;
|
|
48
|
-
let out = "";
|
|
49
|
-
for (let i = 0; i < lines.length; i++) {
|
|
50
|
-
let line = lines[i].replace(/\t/g, " ");
|
|
51
|
-
if (i !== 0) line = line.replace(/^[ ]+/, "");
|
|
52
|
-
if (i !== lines.length - 1) line = line.replace(/[ ]+$/, "");
|
|
53
|
-
if (line) {
|
|
54
|
-
if (i !== last) line += " ";
|
|
55
|
-
out += line;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
/** Text worth translating: must contain at least one letter in any script. */
|
|
61
|
-
function isTranslatable(text) {
|
|
62
|
-
return /\p{L}/u.test(text);
|
|
63
|
-
}
|
|
64
|
-
function isComponentName(name) {
|
|
65
|
-
return !!name && /^[A-Z]/.test(name);
|
|
66
|
-
}
|
|
67
|
-
/** Finds the function that is a React component (PascalCase name, possibly wrapped in memo/forwardRef). */
|
|
68
|
-
function enclosingComponent(path) {
|
|
69
|
-
let p = path.parentPath;
|
|
70
|
-
while (p) {
|
|
71
|
-
if (p.isFunction()) {
|
|
72
|
-
const fn = p;
|
|
73
|
-
let name;
|
|
74
|
-
if ((fn.node.type === "FunctionDeclaration" || fn.node.type === "FunctionExpression") && fn.node.id) name = fn.node.id.name;
|
|
75
|
-
if (!name) {
|
|
76
|
-
let up = fn.parentPath;
|
|
77
|
-
while (up && up.isCallExpression()) up = up.parentPath;
|
|
78
|
-
if (up?.isVariableDeclarator() && t.isIdentifier(up.node.id)) name = up.node.id.name;
|
|
79
|
-
}
|
|
80
|
-
if (isComponentName(name)) return fn;
|
|
81
|
-
}
|
|
82
|
-
p = p.parentPath;
|
|
83
|
-
}
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
/** `translate="no"` on an element (or any ancestor) keeps its text and attributes exactly as written. */
|
|
87
|
-
function hasNoTranslate(opening) {
|
|
88
|
-
return opening.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: "translate" }) && (t.isStringLiteral(a.value, { value: "no" }) || t.isJSXExpressionContainer(a.value) && t.isStringLiteral(a.value.expression, { value: "no" })));
|
|
89
|
-
}
|
|
90
|
-
function insideNoTranslate(path) {
|
|
91
|
-
for (let p = path; p; p = p.parentPath) if (p.isJSXElement() && hasNoTranslate(p.node.openingElement)) return true;
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
/** A value we can safely hand to the runtime: `name`, `user.name`, `this.props.count`, `items?.[0]`. */
|
|
95
|
-
function simpleValueName(node) {
|
|
96
|
-
const root = (n) => t.isIdentifier(n) || t.isThisExpression(n) || (t.isMemberExpression(n) || t.isOptionalMemberExpression(n)) && root(n.object) && (n.computed ? t.isNumericLiteral(n.property) || t.isStringLiteral(n.property) : t.isIdentifier(n.property));
|
|
97
|
-
if (!root(node)) return null;
|
|
98
|
-
if (t.isIdentifier(node)) return node.name;
|
|
99
|
-
if ((t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && !node.computed && t.isIdentifier(node.property)) return node.property.name;
|
|
100
|
-
return "value";
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* "Hello {name}!" -> template "Hello {name}!" plus the values to fill in at runtime.
|
|
104
|
-
* Only text mixed with plain variables qualifies: no nested elements, calls, conditions or JSX.
|
|
105
|
-
*/
|
|
106
|
-
function buildTemplate(node) {
|
|
107
|
-
let text = "";
|
|
108
|
-
const values = [];
|
|
109
|
-
const used = /* @__PURE__ */ new Set();
|
|
110
|
-
for (const child of node.children) if (t.isJSXText(child)) text += cleanJsxText(child.value);
|
|
111
|
-
else if (t.isJSXExpressionContainer(child)) {
|
|
112
|
-
const e = child.expression;
|
|
113
|
-
if (t.isJSXEmptyExpression(e)) continue;
|
|
114
|
-
if (t.isStringLiteral(e)) {
|
|
115
|
-
text += e.value;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const base = simpleValueName(e);
|
|
119
|
-
if (!base) return null;
|
|
120
|
-
let name = base;
|
|
121
|
-
for (let i = 2; used.has(name); i++) name = `${base}_${i}`;
|
|
122
|
-
used.add(name);
|
|
123
|
-
values.push({
|
|
124
|
-
name,
|
|
125
|
-
expr: e
|
|
126
|
-
});
|
|
127
|
-
text += `{${name}}`;
|
|
128
|
-
} else return null;
|
|
129
|
-
const letters = text.replace(/\{\w+\}/g, "");
|
|
130
|
-
if (!values.length || !isTranslatable(letters)) return null;
|
|
131
|
-
return {
|
|
132
|
-
text: text.trim(),
|
|
133
|
-
values,
|
|
134
|
-
lead: /^\s/.test(text),
|
|
135
|
-
trail: /\s$/.test(text)
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
/** Parses a file, extracts translatable strings, and rewrites JSX to render translations. */
|
|
139
|
-
function transformJsx(code, file, opts = {}) {
|
|
140
|
-
const rewrite = opts.rewrite !== false;
|
|
141
|
-
if (!code.includes("<")) return null;
|
|
142
|
-
let ast;
|
|
143
|
-
try {
|
|
144
|
-
ast = parse(code, {
|
|
145
|
-
sourceType: "module",
|
|
146
|
-
sourceFilename: file,
|
|
147
|
-
plugins: parserPlugins(file)
|
|
148
|
-
});
|
|
149
|
-
} catch {
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
const strings = /* @__PURE__ */ new Set();
|
|
153
|
-
/** What kind of element each string sits in ("button", "h1", "placeholder", ...), for context-aware engines. */
|
|
154
|
-
const contexts = {};
|
|
155
|
-
const record = (text, context) => {
|
|
156
|
-
strings.add(text);
|
|
157
|
-
contexts[text] ??= context;
|
|
158
|
-
};
|
|
159
|
-
const hookTargets = /* @__PURE__ */ new Set();
|
|
160
|
-
let usedT = false;
|
|
161
|
-
let usedTD = false;
|
|
162
|
-
const space = () => t.jsxExpressionContainer(t.stringLiteral(" "));
|
|
163
|
-
traverse$2(ast, {
|
|
164
|
-
"JSXElement|JSXFragment"(path) {
|
|
165
|
-
const node = path.node;
|
|
166
|
-
const name = t.isJSXElement(node) && t.isJSXIdentifier(node.openingElement.name) ? node.openingElement.name.name : null;
|
|
167
|
-
if (name && SKIP_ELEMENTS.has(name)) return;
|
|
168
|
-
if (insideNoTranslate(path)) return;
|
|
169
|
-
const context = name ?? "text";
|
|
170
|
-
if (node.children.some((c) => t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression) && !t.isStringLiteral(c.expression))) {
|
|
171
|
-
const tpl = buildTemplate(node);
|
|
172
|
-
if (!tpl) return;
|
|
173
|
-
record(tpl.text, context);
|
|
174
|
-
if (!rewrite) return;
|
|
175
|
-
const valueObject = t.objectExpression(tpl.values.map((v) => t.objectProperty(t.identifier(v.name), t.cloneNode(v.expr))));
|
|
176
|
-
node.children = [
|
|
177
|
-
...tpl.lead ? [space()] : [],
|
|
178
|
-
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(TD_IMPORT), [t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(tpl.text)), t.jsxAttribute(t.jsxIdentifier("v"), t.jsxExpressionContainer(valueObject))], true), null, []),
|
|
179
|
-
...tpl.trail ? [space()] : []
|
|
180
|
-
];
|
|
181
|
-
usedTD = true;
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
const children = [];
|
|
185
|
-
let changed = false;
|
|
186
|
-
for (const child of node.children) {
|
|
187
|
-
const cleaned = t.isJSXText(child) ? cleanJsxText(child.value) : "";
|
|
188
|
-
const text = cleaned.trim();
|
|
189
|
-
if (!t.isJSXText(child) || !text || !isTranslatable(text)) {
|
|
190
|
-
children.push(child);
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
record(text, context);
|
|
194
|
-
if (!rewrite) {
|
|
195
|
-
children.push(child);
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
if (/^\s/.test(cleaned)) children.push(space());
|
|
199
|
-
children.push(t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(T_IMPORT), [t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(text))], true), null, []));
|
|
200
|
-
if (/\s$/.test(cleaned)) children.push(space());
|
|
201
|
-
usedT = true;
|
|
202
|
-
changed = true;
|
|
203
|
-
}
|
|
204
|
-
if (changed) node.children = children;
|
|
205
|
-
},
|
|
206
|
-
JSXAttribute(path) {
|
|
207
|
-
const { name, value } = path.node;
|
|
208
|
-
if (!t.isJSXIdentifier(name) || !TRANSLATABLE_ATTRS.has(name.name)) return;
|
|
209
|
-
if (!t.isStringLiteral(value)) return;
|
|
210
|
-
const opening = path.parent;
|
|
211
|
-
if (!t.isJSXIdentifier(opening.name) || !/^[a-z]/.test(opening.name.name)) return;
|
|
212
|
-
if (insideNoTranslate(path.parentPath.parentPath)) return;
|
|
213
|
-
const text = value.value.trim();
|
|
214
|
-
if (!text || !isTranslatable(text)) return;
|
|
215
|
-
const component = enclosingComponent(path);
|
|
216
|
-
if (!component) return;
|
|
217
|
-
record(text, name.name);
|
|
218
|
-
if (!rewrite) return;
|
|
219
|
-
path.node.value = t.jsxExpressionContainer(t.callExpression(t.identifier(HOOK_VAR), [t.stringLiteral(text)]));
|
|
220
|
-
hookTargets.add(component);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
if (strings.size === 0) return null;
|
|
224
|
-
if (!rewrite) return {
|
|
225
|
-
code,
|
|
226
|
-
map: null,
|
|
227
|
-
strings: [...strings],
|
|
228
|
-
contexts
|
|
229
|
-
};
|
|
230
|
-
for (const fn of hookTargets) {
|
|
231
|
-
if (!t.isBlockStatement(fn.node.body)) fn.node.body = t.blockStatement([t.returnStatement(fn.node.body)]);
|
|
232
|
-
fn.node.body.body.unshift(t.variableDeclaration("const", [t.variableDeclarator(t.identifier(HOOK_VAR), t.callExpression(t.identifier(HOOK_IMPORT), []))]));
|
|
233
|
-
}
|
|
234
|
-
const specifiers = [];
|
|
235
|
-
if (usedT) specifiers.push(t.importSpecifier(t.identifier(T_IMPORT), t.identifier("__T")));
|
|
236
|
-
if (usedTD) specifiers.push(t.importSpecifier(t.identifier(TD_IMPORT), t.identifier("__TD")));
|
|
237
|
-
if (hookTargets.size) specifiers.push(t.importSpecifier(t.identifier(HOOK_IMPORT), t.identifier("__useT")));
|
|
238
|
-
ast.program.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(RUNTIME)));
|
|
239
|
-
const out = generate(ast, {
|
|
240
|
-
sourceMaps: true,
|
|
241
|
-
sourceFileName: file
|
|
242
|
-
}, code);
|
|
243
|
-
return {
|
|
244
|
-
code: out.code,
|
|
245
|
-
map: out.map,
|
|
246
|
-
strings: [...strings],
|
|
247
|
-
contexts
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
/** Strings (and where they appear), without rewriting. */
|
|
251
|
-
function extractItems(code, file) {
|
|
252
|
-
const r = transformJsx(code, file, { rewrite: false });
|
|
253
|
-
return {
|
|
254
|
-
strings: r?.strings ?? [],
|
|
255
|
-
contexts: r?.contexts ?? {}
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
//#endregion
|
|
259
|
-
//#region src/core/scan.ts
|
|
260
|
-
const EXTENSIONS = /* @__PURE__ */ new Set([
|
|
261
|
-
".tsx",
|
|
262
|
-
".jsx",
|
|
263
|
-
".ts",
|
|
264
|
-
".js"
|
|
265
|
-
]);
|
|
266
|
-
const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
267
|
-
"node_modules",
|
|
268
|
-
"dist",
|
|
269
|
-
"build",
|
|
270
|
-
"coverage"
|
|
271
|
-
]);
|
|
272
|
-
function isSourceFile(file) {
|
|
273
|
-
return EXTENSIONS.has(path.extname(file)) && !file.endsWith(".d.ts") && !/\.(test|spec)\./.test(file);
|
|
274
|
-
}
|
|
275
|
-
function* walk(dir) {
|
|
276
|
-
if (!fs.existsSync(dir)) return;
|
|
277
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
278
|
-
if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
|
|
279
|
-
const full = path.join(dir, entry.name);
|
|
280
|
-
if (entry.isDirectory()) yield* walk(full);
|
|
281
|
-
else if (isSourceFile(full)) yield full;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
/** Every translatable string under the configured folders, in stable file order, and where each appears. */
|
|
285
|
-
function scanProjectDetailed(root, include) {
|
|
286
|
-
const found = /* @__PURE__ */ new Set();
|
|
287
|
-
const contexts = {};
|
|
288
|
-
for (const dir of include) for (const file of walk(path.resolve(root, dir))) {
|
|
289
|
-
const items = extractItems(fs.readFileSync(file, "utf8"), file);
|
|
290
|
-
for (const s of items.strings) {
|
|
291
|
-
found.add(s);
|
|
292
|
-
contexts[s] ??= items.contexts[s] ?? "";
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return {
|
|
296
|
-
strings: [...found],
|
|
297
|
-
contexts
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
//#endregion
|
|
301
|
-
//#region src/core/head.ts
|
|
302
|
-
const META_SELECTORS = [
|
|
303
|
-
"meta[name=\"description\"]",
|
|
304
|
-
"meta[property=\"og:title\"]",
|
|
305
|
-
"meta[property=\"og:description\"]",
|
|
306
|
-
"meta[name=\"twitter:title\"]",
|
|
307
|
-
"meta[name=\"twitter:description\"]"
|
|
308
|
-
];
|
|
309
|
-
/** Title and description-like tags worth translating (only used when seo is on). */
|
|
310
|
-
function headStrings(html) {
|
|
311
|
-
const doc = new JSDOM(html).window.document;
|
|
312
|
-
const out = /* @__PURE__ */ new Set();
|
|
313
|
-
const title = doc.title.trim();
|
|
314
|
-
if (title && /\p{L}/u.test(title)) out.add(title);
|
|
315
|
-
for (const sel of META_SELECTORS) {
|
|
316
|
-
const v = doc.querySelector(sel)?.getAttribute("content")?.trim();
|
|
317
|
-
if (v && /\p{L}/u.test(v)) out.add(v);
|
|
318
|
-
}
|
|
319
|
-
return [...out];
|
|
320
|
-
}
|
|
321
|
-
function translateHead(doc, dict) {
|
|
322
|
-
const title = doc.title.trim();
|
|
323
|
-
if (title && dict[title]) doc.title = dict[title];
|
|
324
|
-
for (const sel of META_SELECTORS) {
|
|
325
|
-
const el = doc.querySelector(sel);
|
|
326
|
-
const v = el?.getAttribute("content")?.trim();
|
|
327
|
-
if (el && v && dict[v]) el.setAttribute("content", dict[v]);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
//#endregion
|
|
331
|
-
//#region src/core/banner.ts
|
|
332
|
-
const FILE = (root) => path.join(cacheDir(root), "_banner.json");
|
|
333
|
-
/** The banner's fixed phrases in every language, so a visitor sees them in their own language. */
|
|
334
|
-
async function ensureBanner(opts) {
|
|
335
|
-
const source = JSON.stringify(BANNER_EN);
|
|
336
|
-
let cached = {};
|
|
337
|
-
try {
|
|
338
|
-
const file = JSON.parse(fs.readFileSync(FILE(opts.root), "utf8"));
|
|
339
|
-
if (file._source === source) cached = file;
|
|
340
|
-
} catch {}
|
|
341
|
-
const out = { en: BANNER_EN };
|
|
342
|
-
let changed = false;
|
|
343
|
-
const keys = Object.keys(BANNER_EN);
|
|
344
|
-
for (const lang of opts.langs) {
|
|
345
|
-
if (lang === "en") continue;
|
|
346
|
-
if (cached[lang]) {
|
|
347
|
-
out[lang] = cached[lang];
|
|
348
|
-
continue;
|
|
349
|
-
}
|
|
350
|
-
opts.log?.(`[react-autolocale] ${lang}: translating banner text...`);
|
|
351
|
-
const jobs = keys.map((k) => mask(BANNER_EN[k], ["{language}"]));
|
|
352
|
-
const translated = await opts.getTranslator().translate(jobs.map((j) => j.masked), "en", lang);
|
|
353
|
-
const text = { ...BANNER_EN };
|
|
354
|
-
keys.forEach((k, i) => {
|
|
355
|
-
text[k] = unmask(translated[i] ?? "", jobs[i].phrases) ?? BANNER_EN[k];
|
|
356
|
-
});
|
|
357
|
-
out[lang] = text;
|
|
358
|
-
cached[lang] = text;
|
|
359
|
-
changed = true;
|
|
360
|
-
}
|
|
361
|
-
if (changed) {
|
|
362
|
-
fs.mkdirSync(cacheDir(opts.root), { recursive: true });
|
|
363
|
-
fs.writeFileSync(FILE(opts.root), JSON.stringify({
|
|
364
|
-
_source: source,
|
|
365
|
-
...cached
|
|
366
|
-
}, null, 2) + "\n");
|
|
367
|
-
}
|
|
368
|
-
return out;
|
|
369
|
-
}
|
|
370
|
-
//#endregion
|
|
371
10
|
//#region src/core/routes.ts
|
|
372
|
-
const traverse
|
|
11
|
+
const traverse = _traverse.default ?? _traverse;
|
|
373
12
|
function str(node) {
|
|
374
13
|
if (t.isJSXExpressionContainer(node)) return str(node.expression);
|
|
375
14
|
if (t.isStringLiteral(node)) return node.value;
|
|
@@ -423,7 +62,7 @@ function routesInCode(code, file) {
|
|
|
423
62
|
}
|
|
424
63
|
const found = /* @__PURE__ */ new Set();
|
|
425
64
|
const add = (p) => isStatic(p) && found.add(p);
|
|
426
|
-
traverse
|
|
65
|
+
traverse(ast, {
|
|
427
66
|
JSXOpeningElement(p) {
|
|
428
67
|
const own = jsxRoutePath(p.node);
|
|
429
68
|
if (own === null) return;
|
|
@@ -458,62 +97,6 @@ function discoverRoutes(root, include) {
|
|
|
458
97
|
return [...all];
|
|
459
98
|
}
|
|
460
99
|
//#endregion
|
|
461
|
-
//#region src/core/site.ts
|
|
462
|
-
const traverse = _traverse.default ?? _traverse;
|
|
463
|
-
function literal(node) {
|
|
464
|
-
if (!node) return void 0;
|
|
465
|
-
if (t.isJSXExpressionContainer(node)) return literal(node.expression);
|
|
466
|
-
if (t.isStringLiteral(node)) return node.value;
|
|
467
|
-
if (t.isBooleanLiteral(node)) return node.value;
|
|
468
|
-
if (t.isTemplateLiteral(node) && node.expressions.length === 0) return node.quasis[0].value.cooked ?? void 0;
|
|
469
|
-
if (t.isArrayExpression(node)) {
|
|
470
|
-
const items = node.elements.map((e) => literal(e));
|
|
471
|
-
return items.every((i) => typeof i === "string") ? items : void 0;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
/** Reads the literal props of `<AutoScale ...>` from source code. Returns null if the file has none. */
|
|
475
|
-
function parseAutoScale(code, file) {
|
|
476
|
-
if (!code.includes("AutoScale")) return null;
|
|
477
|
-
let ast;
|
|
478
|
-
try {
|
|
479
|
-
ast = parse(code, {
|
|
480
|
-
sourceType: "module",
|
|
481
|
-
plugins: parserPlugins(file)
|
|
482
|
-
});
|
|
483
|
-
} catch {
|
|
484
|
-
return null;
|
|
485
|
-
}
|
|
486
|
-
let found = null;
|
|
487
|
-
traverse(ast, { JSXOpeningElement(path) {
|
|
488
|
-
const name = path.node.name;
|
|
489
|
-
if (found || !t.isJSXIdentifier(name) || name.name !== "AutoScale") return;
|
|
490
|
-
const props = {};
|
|
491
|
-
for (const attr of path.node.attributes) if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) props[attr.name.name] = attr.value === null ? true : literal(attr.value);
|
|
492
|
-
const { original, languages, exclude, seo, siteUrl } = props;
|
|
493
|
-
if (typeof original !== "string" || !Array.isArray(languages)) throw new Error(`${file}: <AutoScale> needs literal props, e.g. original="en" languages={["fr", "es"]}. Values must be written inline (not variables) so they can be read at build time.`);
|
|
494
|
-
found = {
|
|
495
|
-
original,
|
|
496
|
-
languages: languages.filter((l) => l !== original),
|
|
497
|
-
exclude: Array.isArray(exclude) ? exclude : [],
|
|
498
|
-
seo: seo === true || seo === "true",
|
|
499
|
-
siteUrl: typeof siteUrl === "string" ? siteUrl : void 0
|
|
500
|
-
};
|
|
501
|
-
} });
|
|
502
|
-
return found;
|
|
503
|
-
}
|
|
504
|
-
/** Finds the first <AutoScale> in the project's source folders. */
|
|
505
|
-
function findSite(root, include) {
|
|
506
|
-
for (const dir of include) for (const file of walk(path.resolve(root, dir))) {
|
|
507
|
-
const site = parseAutoScale(fs.readFileSync(file, "utf8"), file);
|
|
508
|
-
if (site) return site;
|
|
509
|
-
}
|
|
510
|
-
throw new Error(`No <AutoScale> found in ${include.join(", ")}. Wrap your app: <AutoScale original="en" languages={["fr", "es"]}><App /></AutoScale>`);
|
|
511
|
-
}
|
|
512
|
-
/** Folders scanned for JSX. */
|
|
513
|
-
function defaultInclude(root) {
|
|
514
|
-
return fs.existsSync(path.join(root, "src")) ? ["src"] : ["."];
|
|
515
|
-
}
|
|
516
|
-
//#endregion
|
|
517
100
|
//#region src/vite/seo.ts
|
|
518
101
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
519
102
|
const restOf = (p) => p.pathname.slice(p.lang.length + 1) || "/";
|
|
@@ -784,7 +367,7 @@ async function prerender(opts) {
|
|
|
784
367
|
if (site.seo) applySeo({
|
|
785
368
|
outDir,
|
|
786
369
|
site,
|
|
787
|
-
dicts,
|
|
370
|
+
dicts: opts.headDicts ?? dicts,
|
|
788
371
|
pages,
|
|
789
372
|
log
|
|
790
373
|
});
|
|
@@ -796,52 +379,51 @@ async function prerender(opts) {
|
|
|
796
379
|
//#region src/vite/index.ts
|
|
797
380
|
const VIRTUAL_ID = "virtual:react-autolocale";
|
|
798
381
|
const RESOLVED_ID = "\0" + VIRTUAL_ID;
|
|
382
|
+
/** One module per language, so each dictionary is fetched only when that language is used. */
|
|
383
|
+
const DICT_PREFIX = VIRTUAL_ID + "/dict/";
|
|
384
|
+
/**
|
|
385
|
+
* Reads the generated catalogs and serves them to the runtime. It never translates, downloads a model or calls
|
|
386
|
+
* a translation API: missing translations show the source text, and `react-autolocale sync` fills them.
|
|
387
|
+
*/
|
|
799
388
|
function reactAutolocale(options = {}) {
|
|
800
389
|
let config;
|
|
801
390
|
let root = process.cwd();
|
|
802
|
-
let include = [];
|
|
803
391
|
let includeDirs = [];
|
|
804
|
-
let
|
|
805
|
-
let dicts = {
|
|
392
|
+
let project;
|
|
393
|
+
let dicts = {
|
|
394
|
+
byId: {},
|
|
395
|
+
byText: {},
|
|
396
|
+
missing: {},
|
|
397
|
+
usedLegacy: false
|
|
398
|
+
};
|
|
806
399
|
let banner = {};
|
|
807
|
-
let translator;
|
|
808
400
|
let server;
|
|
809
401
|
let rendered = false;
|
|
810
402
|
const log = (msg) => console.log(msg);
|
|
811
|
-
const
|
|
403
|
+
const warn = (msg) => config?.logger ? config.logger.warn(`[react-autolocale] ${msg}`) : log(`[react-autolocale] ${msg}`);
|
|
812
404
|
async function refresh() {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
});
|
|
827
|
-
dicts = await ensureTranslations({
|
|
828
|
-
root,
|
|
829
|
-
site,
|
|
830
|
-
strings,
|
|
831
|
-
getTranslator,
|
|
832
|
-
log,
|
|
833
|
-
contexts,
|
|
834
|
-
glossary: options.glossary,
|
|
835
|
-
overrides: mergeOverrides(readOverrides(root), options.overrides)
|
|
836
|
-
});
|
|
405
|
+
project = await loadProject(root, { log });
|
|
406
|
+
if (options.include) project.include = options.include;
|
|
407
|
+
includeDirs = project.include.map((d) => path.resolve(root, d) + path.sep);
|
|
408
|
+
dicts = loadDicts(project, mergeOverrides(readOverrides(root), options.overrides));
|
|
409
|
+
banner = readBanner(project.output, root);
|
|
410
|
+
}
|
|
411
|
+
/** One summary per build or server start, never a translation job. */
|
|
412
|
+
function report() {
|
|
413
|
+
if (options.engine || options.glossary) warn("`engine` and `glossary` in vite.config are ignored: builds no longer translate. Move them to auto-locale.config.ts (provider, glossary).");
|
|
414
|
+
if (dicts.usedLegacy) warn(`Using translations from node_modules/.cache/react-autolocale (older version). Run \`npx react-autolocale generate\` to move them into ${project.outputRel}/.`);
|
|
415
|
+
const gaps = Object.entries(dicts.missing).filter(([, n]) => n > 0);
|
|
416
|
+
if (!Object.values(dicts.byId).some((d) => Object.keys(d).length) && project.site.languages.length) warn(`No translations in ${project.outputRel}/ yet; pages show the source text. Run \`npx react-autolocale generate\`.`);
|
|
417
|
+
else if (gaps.length) warn(`Untranslated strings (shown in ${project.site.original}): ${gaps.map(([l, n]) => `${l} ${n}`).join(", ")}. Run \`npx react-autolocale sync\`.`);
|
|
837
418
|
}
|
|
838
419
|
function virtualModule() {
|
|
420
|
+
const { site } = project;
|
|
839
421
|
const meta = site.languages.map((code) => ({
|
|
840
422
|
code,
|
|
841
423
|
native: getLanguage(code)?.native ?? code,
|
|
842
424
|
rtl: !!getLanguage(code)?.rtl
|
|
843
425
|
}));
|
|
844
|
-
const loaders = site.languages.
|
|
426
|
+
const loaders = site.languages.map((code) => `${JSON.stringify(code)}: () => import(${JSON.stringify(DICT_PREFIX + code)})`);
|
|
845
427
|
return [
|
|
846
428
|
`export const original = ${JSON.stringify(site.original)};`,
|
|
847
429
|
`export const originalNative = ${JSON.stringify(getLanguage(site.original)?.native ?? site.original)};`,
|
|
@@ -852,23 +434,35 @@ function reactAutolocale(options = {}) {
|
|
|
852
434
|
].join("\n");
|
|
853
435
|
}
|
|
854
436
|
let timer;
|
|
855
|
-
let busy = false;
|
|
856
437
|
function scheduleRefresh() {
|
|
857
438
|
clearTimeout(timer);
|
|
858
439
|
timer = setTimeout(async () => {
|
|
859
|
-
if (
|
|
860
|
-
busy = true;
|
|
440
|
+
if (!server) return;
|
|
861
441
|
try {
|
|
862
442
|
await refresh();
|
|
863
|
-
const mod
|
|
864
|
-
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
443
|
+
for (const [id, mod] of server.moduleGraph.idToModuleMap) if (id.startsWith(RESOLVED_ID)) server.moduleGraph.invalidateModule(mod);
|
|
865
444
|
server.ws.send({ type: "full-reload" });
|
|
866
445
|
} catch (err) {
|
|
867
|
-
|
|
868
|
-
} finally {
|
|
869
|
-
busy = false;
|
|
446
|
+
warn(err.message);
|
|
870
447
|
}
|
|
871
|
-
},
|
|
448
|
+
}, 300);
|
|
449
|
+
}
|
|
450
|
+
const untranslated = /* @__PURE__ */ new Set();
|
|
451
|
+
let warnTimer;
|
|
452
|
+
function noteUntranslated(strings) {
|
|
453
|
+
const { site } = project;
|
|
454
|
+
for (const s of strings) {
|
|
455
|
+
if (site.exclude.includes(s.trim())) continue;
|
|
456
|
+
const id = messageId(s);
|
|
457
|
+
if (site.languages.some((l) => !dicts.byId[l]?.[id])) untranslated.add(s);
|
|
458
|
+
}
|
|
459
|
+
if (!untranslated.size) return;
|
|
460
|
+
clearTimeout(warnTimer);
|
|
461
|
+
warnTimer = setTimeout(() => {
|
|
462
|
+
const list = [...untranslated];
|
|
463
|
+
warn(`${list.length} string${list.length === 1 ? " has" : "s have"} no translation yet (e.g. "${list[0]}"). Run \`npx react-autolocale sync\`; the page reloads when it finishes.`);
|
|
464
|
+
untranslated.clear();
|
|
465
|
+
}, 1500);
|
|
872
466
|
}
|
|
873
467
|
return {
|
|
874
468
|
name: "react-autolocale",
|
|
@@ -886,19 +480,30 @@ function reactAutolocale(options = {}) {
|
|
|
886
480
|
},
|
|
887
481
|
configureServer(s) {
|
|
888
482
|
server = s;
|
|
889
|
-
|
|
890
|
-
s.watcher.
|
|
891
|
-
|
|
483
|
+
s.watcher.add([path.resolve(root, OVERRIDES_FILE), path.resolve(root, ".auto-locale/manifest.json")]);
|
|
484
|
+
s.watcher.on("all", (_event, changed) => {
|
|
485
|
+
if (!project) return;
|
|
486
|
+
if (changed === path.resolve(root, "react-autolocale.overrides.json") || changed === project.configFile || changed.startsWith(project.output + path.sep) && changed.endsWith(".json") || changed === path.join(project.meta, "manifest.json")) scheduleRefresh();
|
|
487
|
+
});
|
|
892
488
|
},
|
|
893
489
|
async buildStart() {
|
|
894
490
|
if (config.build.ssr) return;
|
|
895
491
|
await refresh();
|
|
492
|
+
if (server) server.watcher.add(project.output);
|
|
493
|
+
report();
|
|
896
494
|
},
|
|
897
495
|
resolveId(id) {
|
|
898
|
-
|
|
496
|
+
if (id === VIRTUAL_ID) return RESOLVED_ID;
|
|
497
|
+
if (id.startsWith(DICT_PREFIX)) return "\0" + id;
|
|
498
|
+
return null;
|
|
899
499
|
},
|
|
900
500
|
load(id) {
|
|
901
|
-
|
|
501
|
+
if (id === RESOLVED_ID) return virtualModule();
|
|
502
|
+
if (id.startsWith("\0" + DICT_PREFIX)) {
|
|
503
|
+
const lang = id.slice(31);
|
|
504
|
+
return `export default ${JSON.stringify(dicts.byId[lang] ?? {})};`;
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
902
507
|
},
|
|
903
508
|
transform(code, id) {
|
|
904
509
|
const file = id.split("?")[0];
|
|
@@ -906,9 +511,7 @@ function reactAutolocale(options = {}) {
|
|
|
906
511
|
if (!includeDirs.some((d) => file.startsWith(d))) return null;
|
|
907
512
|
const result = transformJsx(code, file);
|
|
908
513
|
if (!result) return null;
|
|
909
|
-
if (config.command === "serve" &&
|
|
910
|
-
if (!site.languages.every((l) => result.strings.every((s) => site.exclude.includes(s.trim()) || dicts[l]?.[s]))) scheduleRefresh();
|
|
911
|
-
}
|
|
514
|
+
if (config.command === "serve" && project) noteUntranslated(result.strings);
|
|
912
515
|
return {
|
|
913
516
|
code: result.code,
|
|
914
517
|
map: result.map
|
|
@@ -920,14 +523,15 @@ function reactAutolocale(options = {}) {
|
|
|
920
523
|
if (config.command !== "build" || config.build.ssr || rendered) return;
|
|
921
524
|
rendered = true;
|
|
922
525
|
const extra = typeof options.routes === "function" ? await options.routes() : options.routes ?? [];
|
|
923
|
-
const routes = [.../* @__PURE__ */ new Set([...discoverRoutes(root, include), ...extra])];
|
|
526
|
+
const routes = [.../* @__PURE__ */ new Set([...discoverRoutes(root, project.include), ...extra])];
|
|
924
527
|
await prerender({
|
|
925
528
|
root,
|
|
926
529
|
outDir: path.resolve(root, config.build.outDir),
|
|
927
|
-
site,
|
|
928
|
-
include,
|
|
530
|
+
site: project.site,
|
|
531
|
+
include: project.include,
|
|
929
532
|
routes,
|
|
930
|
-
dicts,
|
|
533
|
+
dicts: dicts.byId,
|
|
534
|
+
headDicts: dicts.byText,
|
|
931
535
|
log
|
|
932
536
|
});
|
|
933
537
|
}
|