react-autolocale 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/dist/vite.js ADDED
@@ -0,0 +1,938 @@
1
+ import { a as redirectScript, t as BANNER_EN } from "./suggestion-UZQ8ngd0.js";
2
+ import { a as cacheDir, c as createLocalTranslator, d as toTranslator, f as unmask, i as readOverrides, l as mask, o as cacheFile, p as getLanguage, r as mergeOverrides, s as ensureTranslations, t as OVERRIDES_FILE, u as ollama } from "./overrides-ZQ6meLck.js";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { parse } from "@babel/parser";
6
+ import _traverse from "@babel/traverse";
7
+ import _generate from "@babel/generator";
8
+ import * as t from "@babel/types";
9
+ import { JSDOM, VirtualConsole } from "jsdom";
10
+ 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
+ //#region src/core/routes.ts
372
+ const traverse$1 = _traverse.default ?? _traverse;
373
+ function str(node) {
374
+ if (t.isJSXExpressionContainer(node)) return str(node.expression);
375
+ if (t.isStringLiteral(node)) return node.value;
376
+ if (t.isTemplateLiteral(node) && node.expressions.length === 0) return node.quasis[0].value.cooked ?? null;
377
+ return null;
378
+ }
379
+ /** "/a" + "b" -> "/a/b"; an absolute child replaces the parent. */
380
+ function join(parent, child) {
381
+ if (child.startsWith("/")) return child;
382
+ return (parent.replace(/\/+$/, "") + "/" + child).replace(/\/+/g, "/");
383
+ }
384
+ /** Only fixed paths can become static pages: no :params, wildcards or optional segments. */
385
+ function isStatic(p) {
386
+ return p.startsWith("/") && !/[:*?()]/.test(p);
387
+ }
388
+ function jsxRoutePath(el) {
389
+ if (!t.isJSXIdentifier(el.name) || el.name.name !== "Route") return null;
390
+ for (const a of el.attributes) if (t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: "path" })) return str(a.value);
391
+ return null;
392
+ }
393
+ const PAGE_PROPS = /* @__PURE__ */ new Set([
394
+ "element",
395
+ "Component",
396
+ "lazy",
397
+ "index"
398
+ ]);
399
+ /** A <Route> that only groups children (no element, Component, lazy or index) is not a page itself. */
400
+ function jsxIsPage(el, hasChildren) {
401
+ if (!hasChildren) return true;
402
+ return el.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && PAGE_PROPS.has(a.name.name));
403
+ }
404
+ function objectIsPage(obj) {
405
+ if (!obj.properties.some((p) => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: "children" }))) return true;
406
+ return obj.properties.some((p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && PAGE_PROPS.has(p.key.name));
407
+ }
408
+ function objectPath(obj) {
409
+ for (const p of obj.properties) if (t.isObjectProperty(p) && (t.isIdentifier(p.key, { name: "path" }) || t.isStringLiteral(p.key, { value: "path" }))) return str(p.value);
410
+ return null;
411
+ }
412
+ /** Static route paths defined in one file (React Router <Route path> and route objects with `path`). */
413
+ function routesInCode(code, file) {
414
+ if (!code.includes("path")) return [];
415
+ let ast;
416
+ try {
417
+ ast = parse(code, {
418
+ sourceType: "module",
419
+ plugins: parserPlugins(file)
420
+ });
421
+ } catch {
422
+ return [];
423
+ }
424
+ const found = /* @__PURE__ */ new Set();
425
+ const add = (p) => isStatic(p) && found.add(p);
426
+ traverse$1(ast, {
427
+ JSXOpeningElement(p) {
428
+ const own = jsxRoutePath(p.node);
429
+ if (own === null) return;
430
+ const chain = [own];
431
+ for (let up = p.parentPath.parentPath; up; up = up.parentPath) if (up.isJSXElement()) {
432
+ const parentPath = jsxRoutePath(up.node.openingElement);
433
+ if (parentPath !== null) chain.unshift(parentPath);
434
+ }
435
+ const el = p.parent;
436
+ if (jsxIsPage(p.node, !!el.children?.some((c) => t.isJSXElement(c)))) add(chain.reduce((acc, seg) => join(acc, seg), "/"));
437
+ },
438
+ ObjectExpression(p) {
439
+ const own = objectPath(p.node);
440
+ if (own === null) return;
441
+ const chain = [own];
442
+ for (let up = p.parentPath; up; up = up.parentPath) if (up.isObjectProperty() && t.isIdentifier(up.node.key, { name: "children" })) {
443
+ const parentObj = up.parentPath;
444
+ if (parentObj?.isObjectExpression()) {
445
+ const parentPath = objectPath(parentObj.node);
446
+ if (parentPath !== null) chain.unshift(parentPath);
447
+ }
448
+ }
449
+ if (objectIsPage(p.node)) add(chain.reduce((acc, seg) => join(acc, seg), "/"));
450
+ }
451
+ });
452
+ return [...found];
453
+ }
454
+ /** Every static route found in the project's source folders. */
455
+ function discoverRoutes(root, include) {
456
+ const all = /* @__PURE__ */ new Set();
457
+ for (const dir of include) for (const file of walk(path.resolve(root, dir))) for (const r of routesInCode(fs.readFileSync(file, "utf8"), file)) all.add(r);
458
+ return [...all];
459
+ }
460
+ //#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
+ //#region src/vite/seo.ts
518
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
519
+ const restOf = (p) => p.pathname.slice(p.lang.length + 1) || "/";
520
+ function buildSitemap(base, pages) {
521
+ const byRest = /* @__PURE__ */ new Map();
522
+ for (const p of pages) {
523
+ const rest = restOf(p);
524
+ byRest.set(rest, [...byRest.get(rest) ?? [], p.lang]);
525
+ }
526
+ return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n" + pages.map((p) => {
527
+ const rest = restOf(p);
528
+ const alts = byRest.get(rest).map((l) => ` <xhtml:link rel="alternate" hreflang="${l}" href="${esc(base + "/" + l + rest)}"/>`).join("\n");
529
+ return ` <url>\n <loc>${esc(base + p.pathname)}</loc>\n${alts}\n </url>`;
530
+ }).join("\n") + `\n</urlset>\n`;
531
+ }
532
+ /**
533
+ * seo mode: translated title/meta, canonical, hreflang alternates and a sitemap.
534
+ * Only runs when <AutoScale seo> is set; without it these files are never touched.
535
+ */
536
+ function applySeo(opts) {
537
+ const { outDir, site, dicts, pages, log } = opts;
538
+ const base = (site.siteUrl ?? "").replace(/\/+$/, "");
539
+ if (!base) log("[react-autolocale] seo: no siteUrl set, so hreflang and canonical use relative URLs and no sitemap is written. Add siteUrl=\"https://your-site.com\" to <AutoScale>.");
540
+ const langsByRest = /* @__PURE__ */ new Map();
541
+ for (const p of pages) langsByRest.set(restOf(p), [...langsByRest.get(restOf(p)) ?? [], p.lang]);
542
+ for (const page of pages) {
543
+ const rest = restOf(page);
544
+ const doc = new JSDOM(fs.readFileSync(page.file, "utf8")).window.document;
545
+ if (page.lang !== site.original) translateHead(doc, dicts[page.lang] ?? {});
546
+ doc.querySelectorAll("link[rel=\"canonical\"]").forEach((n) => n.remove());
547
+ const add = (attrs) => {
548
+ const link = doc.createElement("link");
549
+ for (const [k, v] of Object.entries(attrs)) link.setAttribute(k, v);
550
+ doc.head.append(link);
551
+ };
552
+ add({
553
+ rel: "canonical",
554
+ href: `${base}/${page.lang}${rest}`
555
+ });
556
+ const langs = langsByRest.get(rest);
557
+ for (const l of langs) add({
558
+ rel: "alternate",
559
+ hreflang: l,
560
+ href: `${base}/${l}${rest}`
561
+ });
562
+ add({
563
+ rel: "alternate",
564
+ hreflang: "x-default",
565
+ href: `${base}/${langs.includes(site.original) ? site.original : langs[0]}${rest}`
566
+ });
567
+ fs.writeFileSync(page.file, "<!DOCTYPE html>" + doc.documentElement.outerHTML);
568
+ }
569
+ if (base) {
570
+ const sitemapPages = pages.filter((p) => p.pathname.startsWith(`/${p.lang}/`) && p.file !== path.join(outDir, "index.html"));
571
+ fs.writeFileSync(path.join(outDir, "sitemap.xml"), buildSitemap(base, sitemapPages));
572
+ log("[react-autolocale] seo: wrote sitemap.xml (reference it from robots.txt)");
573
+ }
574
+ }
575
+ //#endregion
576
+ //#region src/vite/prerender.ts
577
+ const MAX_PAGES = 500;
578
+ /** Normalizes an internal link like "/fr/about?x=1#y" to "/fr/about/", or returns [] for anything else. */
579
+ function pageLink(href, lang) {
580
+ let url;
581
+ try {
582
+ url = new URL(href, `http://localhost/${lang}/`);
583
+ } catch {
584
+ return [];
585
+ }
586
+ if (url.origin !== "http://localhost") return [];
587
+ const p = url.pathname.replace(/\/+$/, "");
588
+ if (p !== `/${lang}` && !p.startsWith(`/${lang}/`)) return [];
589
+ if (/\.[a-z0-9]+$/i.test(p)) return [];
590
+ return [p + "/"];
591
+ }
592
+ /** Normalizes a user-supplied route like "thank-you" or "/users/1" to "/thank-you/"; null if unusable. */
593
+ function routePath(route) {
594
+ const p = route.trim().split(/[?#]/)[0].replace(/^\/+|\/+$/g, "");
595
+ if (/^[a-z]{2,3}$/i.test(p) || /\.[a-z0-9]+$/i.test(p)) return p ? null : "/";
596
+ return p ? `/${p}/` : "/";
597
+ }
598
+ /** The module script in the project's index.html, e.g. "/src/main.tsx". */
599
+ function findEntry(root) {
600
+ const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
601
+ for (const tag of html.match(/<script\b[^>]*>/gi) ?? []) if (/type=["']module["']/i.test(tag)) {
602
+ const src = /src=["']([^"']+)["']/i.exec(tag)?.[1];
603
+ if (src) return src;
604
+ }
605
+ throw new Error("Could not find <script type=\"module\" src=...> in index.html.");
606
+ }
607
+ /** Makes the jsdom window the global scope, so browser-only code in the app can run in Node. Returns an undo function. */
608
+ function installDom(dom) {
609
+ const win = dom.window;
610
+ const keys = /* @__PURE__ */ new Set([
611
+ "window",
612
+ "document",
613
+ "navigator",
614
+ "localStorage",
615
+ "sessionStorage",
616
+ "location",
617
+ "history"
618
+ ]);
619
+ for (const k of Object.getOwnPropertyNames(win)) if (!(k in globalThis)) keys.add(k);
620
+ const saved = /* @__PURE__ */ new Map();
621
+ for (const k of keys) {
622
+ saved.set(k, Object.getOwnPropertyDescriptor(globalThis, k));
623
+ try {
624
+ Object.defineProperty(globalThis, k, {
625
+ configurable: true,
626
+ get: () => win[k]
627
+ });
628
+ } catch {}
629
+ }
630
+ return () => {
631
+ for (const [k, d] of saved) if (d) Object.defineProperty(globalThis, k, d);
632
+ else delete globalThis[k];
633
+ };
634
+ }
635
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
636
+ /** Waits until the page has content and has stopped changing (so lazy routes and Suspense have resolved). */
637
+ async function waitForRender(dom) {
638
+ const doc = dom.window.document;
639
+ const root = doc.getElementById("root");
640
+ const deadline = Date.now() + 1e4;
641
+ let last = "";
642
+ let changedAt = Date.now();
643
+ while (Date.now() < deadline) {
644
+ const now = (root ?? doc.body).innerHTML;
645
+ if (now !== last) last = now, changedAt = Date.now();
646
+ else if (now && Date.now() - changedAt >= 400) return;
647
+ await sleep(25);
648
+ }
649
+ }
650
+ /** Browser APIs apps commonly call during render that jsdom does not implement. */
651
+ function polyfill(win) {
652
+ class Noop {
653
+ observe() {}
654
+ unobserve() {}
655
+ disconnect() {}
656
+ takeRecords() {
657
+ return [];
658
+ }
659
+ }
660
+ win.matchMedia ??= (query) => ({
661
+ matches: false,
662
+ media: query,
663
+ onchange: null,
664
+ addListener() {},
665
+ removeListener() {},
666
+ addEventListener() {},
667
+ removeEventListener() {},
668
+ dispatchEvent: () => false
669
+ });
670
+ win.ResizeObserver ??= Noop;
671
+ win.IntersectionObserver ??= Noop;
672
+ win.scrollTo ??= () => {};
673
+ win.Element.prototype.scrollIntoView ??= () => {};
674
+ }
675
+ /**
676
+ * Renders the app once per language in a DOM and writes static pages: dist/<lang>/index.html
677
+ * (and dist/index.html for the original language). Only #root's content and one inline state script
678
+ * are added; everything else in the page is left as built.
679
+ */
680
+ async function prerender(opts) {
681
+ const { root, outDir, site, include, dicts, log } = opts;
682
+ const extraRoutes = (opts.routes ?? []).map(routePath).filter((r) => r !== null);
683
+ const sourceUrls = include.flatMap((dir) => [...walk(path.resolve(root, dir))]).filter(isSourceFile).map((f) => "/" + path.relative(root, f).split(path.sep).join("/"));
684
+ const template = fs.readFileSync(path.join(outDir, "index.html"), "utf8");
685
+ const entry = findEntry(root);
686
+ const server = await createServer({
687
+ root,
688
+ appType: "custom",
689
+ logLevel: "silent",
690
+ server: {
691
+ middlewareMode: true,
692
+ hmr: false,
693
+ watch: null
694
+ }
695
+ });
696
+ const failures = [];
697
+ const pages = [];
698
+ const ssrEnv = server.environments?.ssr;
699
+ const runner = ssrEnv?.runner;
700
+ const loadModule = (url) => runner ? runner.import(url) : server.ssrLoadModule(url);
701
+ const resetModules = () => runner ? runner.clearCache() : server.moduleGraph.invalidateAll();
702
+ await Promise.all(sourceUrls.map((u) => (ssrEnv?.transformRequest ? ssrEnv.transformRequest(u) : server.transformRequest(u, { ssr: true })).catch(() => null)));
703
+ /** Renders one URL and returns the same-language page links found in it. */
704
+ async function renderPage(lang, pathname) {
705
+ const dict = lang === site.original ? {} : dicts[lang] ?? {};
706
+ const errors = [];
707
+ const virtualConsole = new VirtualConsole();
708
+ virtualConsole.on("jsdomError", (e) => errors.push(String(e.message).split("\n")[0]));
709
+ const dom = new JSDOM(template, {
710
+ url: `http://localhost${pathname}`,
711
+ pretendToBeVisual: true,
712
+ virtualConsole
713
+ });
714
+ const win = dom.window;
715
+ win.__AUTOSCALE__ = {
716
+ lang,
717
+ dict
718
+ };
719
+ win.addEventListener("error", (e) => errors.push(String(e.error?.message ?? e.message ?? e)));
720
+ polyfill(win);
721
+ const restore = installDom(dom);
722
+ try {
723
+ resetModules();
724
+ await loadModule(entry);
725
+ await waitForRender(dom);
726
+ } catch (err) {
727
+ errors.push(err.message);
728
+ } finally {
729
+ restore();
730
+ }
731
+ const rootEl = dom.window.document.getElementById("root");
732
+ if (errors.length || rootEl && !rootEl.childElementCount) {
733
+ failures.push({
734
+ pathname,
735
+ error: errors[0] ?? "the page rendered nothing"
736
+ });
737
+ dom.window.close();
738
+ return [];
739
+ }
740
+ const doc = dom.window.document;
741
+ doc.documentElement.lang = lang;
742
+ doc.documentElement.dir = getLanguage(lang)?.rtl ? "rtl" : "ltr";
743
+ const state = doc.createElement("script");
744
+ state.textContent = `window.__AUTOSCALE__=${JSON.stringify({
745
+ lang,
746
+ dict
747
+ }).replace(/</g, "\\u003c")}`;
748
+ doc.head.prepend(state);
749
+ const links = [...doc.querySelectorAll("a[href]")].flatMap((a) => pageLink(a.getAttribute("href"), lang));
750
+ const html = dom.serialize();
751
+ const dir = path.join(outDir, ...pathname.split("/").filter(Boolean));
752
+ fs.mkdirSync(dir, { recursive: true });
753
+ fs.writeFileSync(path.join(dir, "index.html"), html);
754
+ pages.push({
755
+ lang,
756
+ pathname,
757
+ file: path.join(dir, "index.html")
758
+ });
759
+ if (pathname === `/${site.original}/`) {
760
+ const rootDoc = new JSDOM(html).window.document;
761
+ const redirect = rootDoc.createElement("script");
762
+ redirect.textContent = redirectScript([site.original, ...site.languages], site.original);
763
+ rootDoc.head.prepend(redirect);
764
+ const rootFile = path.join(outDir, "index.html");
765
+ fs.writeFileSync(rootFile, "<!DOCTYPE html>" + rootDoc.documentElement.outerHTML);
766
+ pages.push({
767
+ lang,
768
+ pathname,
769
+ file: rootFile
770
+ });
771
+ }
772
+ log(`[react-autolocale] wrote ${pathname}`);
773
+ dom.window.close();
774
+ return links;
775
+ }
776
+ try {
777
+ for (const lang of [site.original, ...site.languages]) {
778
+ const queue = [`/${lang}/`, ...extraRoutes.map((r) => `/${lang}${r}`)];
779
+ const seen = new Set(queue);
780
+ if (seen.size < queue.length) queue.splice(0, queue.length, ...seen);
781
+ for (let i = 0; i < queue.length; i++) for (const next of await renderPage(lang, queue[i])) if (!seen.has(next) && seen.size < MAX_PAGES) seen.add(next), queue.push(next);
782
+ }
783
+ if (failures.length) throw new Error(`${failures.length} page(s) failed to prerender:\n` + failures.map((f) => ` ${f.pathname} ${f.error}`).join("\n"));
784
+ if (site.seo) applySeo({
785
+ outDir,
786
+ site,
787
+ dicts,
788
+ pages,
789
+ log
790
+ });
791
+ } finally {
792
+ await server.close();
793
+ }
794
+ }
795
+ //#endregion
796
+ //#region src/vite/index.ts
797
+ const VIRTUAL_ID = "virtual:react-autolocale";
798
+ const RESOLVED_ID = "\0" + VIRTUAL_ID;
799
+ function reactAutolocale(options = {}) {
800
+ let config;
801
+ let root = process.cwd();
802
+ let include = [];
803
+ let includeDirs = [];
804
+ let site;
805
+ let dicts = {};
806
+ let banner = {};
807
+ let translator;
808
+ let server;
809
+ let rendered = false;
810
+ const log = (msg) => console.log(msg);
811
+ const getTranslator = () => translator ??= options.engine ? toTranslator(options.engine) : createLocalTranslator(log);
812
+ async function refresh() {
813
+ include = options.include ?? defaultInclude(root);
814
+ includeDirs = include.map((d) => path.resolve(root, d) + path.sep);
815
+ site = findSite(root, include);
816
+ const { strings, contexts } = scanProjectDetailed(root, include);
817
+ if (site.seo) {
818
+ const html = path.join(root, "index.html");
819
+ if (fs.existsSync(html)) strings.push(...headStrings(fs.readFileSync(html, "utf8")));
820
+ }
821
+ banner = await ensureBanner({
822
+ root,
823
+ langs: [site.original, ...site.languages],
824
+ getTranslator,
825
+ log
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
+ });
837
+ }
838
+ function virtualModule() {
839
+ const meta = site.languages.map((code) => ({
840
+ code,
841
+ native: getLanguage(code)?.native ?? code,
842
+ rtl: !!getLanguage(code)?.rtl
843
+ }));
844
+ const loaders = site.languages.filter((code) => fs.existsSync(cacheFile(root, code))).map((code) => `${JSON.stringify(code)}: () => import(${JSON.stringify(cacheFile(root, code))})`);
845
+ return [
846
+ `export const original = ${JSON.stringify(site.original)};`,
847
+ `export const originalNative = ${JSON.stringify(getLanguage(site.original)?.native ?? site.original)};`,
848
+ `export const seo = ${JSON.stringify(site.seo)};`,
849
+ `export const banner = ${JSON.stringify(banner)};`,
850
+ `export const languages = ${JSON.stringify(meta)};`,
851
+ `export const loaders = { ${loaders.join(", ")} };`
852
+ ].join("\n");
853
+ }
854
+ let timer;
855
+ let busy = false;
856
+ function scheduleRefresh() {
857
+ clearTimeout(timer);
858
+ timer = setTimeout(async () => {
859
+ if (busy || !server) return;
860
+ busy = true;
861
+ try {
862
+ await refresh();
863
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
864
+ if (mod) server.moduleGraph.invalidateModule(mod);
865
+ server.ws.send({ type: "full-reload" });
866
+ } catch (err) {
867
+ log(`[react-autolocale] ${err.message}`);
868
+ } finally {
869
+ busy = false;
870
+ }
871
+ }, 400);
872
+ }
873
+ return {
874
+ name: "react-autolocale",
875
+ enforce: "pre",
876
+ config() {
877
+ return {
878
+ optimizeDeps: { exclude: ["react-autolocale"] },
879
+ ssr: { noExternal: ["react-autolocale"] },
880
+ test: { server: { deps: { inline: ["react-autolocale"] } } }
881
+ };
882
+ },
883
+ configResolved(resolved) {
884
+ config = resolved;
885
+ root = resolved.root;
886
+ },
887
+ configureServer(s) {
888
+ server = s;
889
+ const file = path.resolve(root, OVERRIDES_FILE);
890
+ s.watcher.add(file);
891
+ s.watcher.on("change", (changed) => changed === file && scheduleRefresh());
892
+ },
893
+ async buildStart() {
894
+ if (config.build.ssr) return;
895
+ await refresh();
896
+ },
897
+ resolveId(id) {
898
+ return id === VIRTUAL_ID ? RESOLVED_ID : null;
899
+ },
900
+ load(id) {
901
+ return id === RESOLVED_ID ? virtualModule() : null;
902
+ },
903
+ transform(code, id) {
904
+ const file = id.split("?")[0];
905
+ if (file.startsWith("\0") || file.includes("node_modules") || !isSourceFile(file)) return null;
906
+ if (!includeDirs.some((d) => file.startsWith(d))) return null;
907
+ const result = transformJsx(code, file);
908
+ if (!result) return null;
909
+ if (config.command === "serve" && site) {
910
+ if (!site.languages.every((l) => result.strings.every((s) => site.exclude.includes(s.trim()) || dicts[l]?.[s]))) scheduleRefresh();
911
+ }
912
+ return {
913
+ code: result.code,
914
+ map: result.map
915
+ };
916
+ },
917
+ closeBundle: {
918
+ order: "post",
919
+ async handler() {
920
+ if (config.command !== "build" || config.build.ssr || rendered) return;
921
+ rendered = true;
922
+ const extra = typeof options.routes === "function" ? await options.routes() : options.routes ?? [];
923
+ const routes = [.../* @__PURE__ */ new Set([...discoverRoutes(root, include), ...extra])];
924
+ await prerender({
925
+ root,
926
+ outDir: path.resolve(root, config.build.outDir),
927
+ site,
928
+ include,
929
+ routes,
930
+ dicts,
931
+ log
932
+ });
933
+ }
934
+ }
935
+ };
936
+ }
937
+ //#endregion
938
+ export { reactAutolocale as default, reactAutolocale, ollama };