@menapo/vi18n 0.0.0-stage → 0.1.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/LICENSE +49 -0
- package/README.md +110 -2
- package/dist/apply-Bre-lY9J.js +228 -0
- package/dist/captured-Bj8bO6vd.js +1034 -0
- package/dist/check-D34P-S--.js +1 -0
- package/dist/check-DyrAbv73.js +201 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +41 -0
- package/dist/codec-DuvU2VQy.js +207 -0
- package/dist/collect-Cx4_SKOe.js +13 -0
- package/dist/collisions-CfpZU8e4.js +217 -0
- package/dist/compile-B5Mz6WJm.js +245 -0
- package/dist/compile-Cc97JaW2.js +2502 -0
- package/dist/concurrency-C9UUWuDC.js +96 -0
- package/dist/concurrency-DONL7t4V.js +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +39 -0
- package/dist/dir-zw6bGMKQ.js +290 -0
- package/dist/duplicates-yuRljQ6h.js +185 -0
- package/dist/engine-B7p40M__.js +531 -0
- package/dist/engine.d.ts +43 -0
- package/dist/engine.js +1241 -0
- package/dist/exclude-Du-Zd_qI.js +26 -0
- package/dist/export-DGsMbcTZ.js +103 -0
- package/dist/extract-Dujlsgnk.js +298 -0
- package/dist/formats-KPIg-08V.js +781 -0
- package/dist/i18next-CrUnKFfh.js +1596 -0
- package/dist/i18next-Rgo-v9l0.js +1 -0
- package/dist/import-Cwdhcywt.js +225 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +239 -0
- package/dist/init-BecCTvKi.js +792 -0
- package/dist/library-D5CWvTQs.js +1882 -0
- package/dist/lingui--PcOOYry.js +833 -0
- package/dist/lint.d.ts +105 -0
- package/dist/lint.js +971 -0
- package/dist/load-CQRz4PpM.js +2950 -0
- package/dist/load-gW-oJt1Q.js +747 -0
- package/dist/locales-DX4MOBFE.js +827 -0
- package/dist/lsp-Cq1Nuu-Q.js +31 -0
- package/dist/match-BGr8EXB3.js +89 -0
- package/dist/mcp-Cf0T7qPW.js +28 -0
- package/dist/merge-driver-CpiiSVIc.js +422 -0
- package/dist/messages-Cxa-5tR0.js +166 -0
- package/dist/migrate-CntqvrW4.js +1 -0
- package/dist/migrate-dPgq9uzy.js +1484 -0
- package/dist/openai-CTaMDcl-.js +346 -0
- package/dist/output-DsaHKHEt.js +164 -0
- package/dist/parse.d.ts +62 -0
- package/dist/parse.js +156 -0
- package/dist/pipeline-C7g11THL.js +2031 -0
- package/dist/pipeline-wxS0SApG.js +1 -0
- package/dist/project-Dwx7iPm6.js +381 -0
- package/dist/project-pXipT8iG.js +1046 -0
- package/dist/pseudo-DRv30iN5.js +145 -0
- package/dist/raw-C4Y6vyGY.js +4708 -0
- package/dist/react-intl-CaBVwJ9u.js +519 -0
- package/dist/restore-Btq66Tid.js +175 -0
- package/dist/rewrite-CiTRAICl.js +803 -0
- package/dist/rich-DCUKl5HM.js +97 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/run-DYD_VXKy.js +694 -0
- package/dist/runtime-DTMmliT1.js +64 -0
- package/dist/scan-worker.d.ts +62 -0
- package/dist/scan-worker.js +1 -0
- package/dist/scoped-BMYpSthj.js +44 -0
- package/dist/server-BDkykMjb.js +11823 -0
- package/dist/server-qv3mz70d.js +10485 -0
- package/dist/server.d.ts +169 -0
- package/dist/server.js +1234 -0
- package/dist/sitemap-DV3o8QD7.js +159 -0
- package/dist/sources-D19czDSA.js +2242 -0
- package/dist/status-DZArW5aE.js +149 -0
- package/dist/stdio-CVaJfxA7.js +29 -0
- package/dist/stdio-FPOKQAgu.js +95 -0
- package/dist/sync-CcVzger_.js +658 -0
- package/dist/t-DC-c9zE5.js +454 -0
- package/dist/test.d.ts +116 -0
- package/dist/test.js +319 -0
- package/dist/translate-DZR3MkSA.js +299 -0
- package/dist/ts-BaU5yOf0.js +4863 -0
- package/dist/types-CgUCkFU1.d.ts +707 -0
- package/dist/types-D3QGM8XO.js +146 -0
- package/dist/types-DsdWBG0b.d.ts +558 -0
- package/dist/types-DtvQ7UdD.js +1948 -0
- package/dist/types-wSh9fCBY.js +11 -0
- package/dist/units-CQ5Q99RC.js +150 -0
- package/dist/url-CRfCOhUI.js +172 -0
- package/dist/url-CsBkRaJy.js +225 -0
- package/dist/validate-BxpupdAf.js +2615 -0
- package/dist/validate-D6DJepZv.js +365 -0
- package/dist/version-BlyLGlOa.js +28 -0
- package/dist/vite-scan-B_YW4X_a.js +242 -0
- package/dist/vite.d.ts +34 -0
- package/dist/vite.js +7558 -0
- package/dist/vue-i18n-CEMnpv7k.js +651 -0
- package/dist/which-C6PFI1Dm.js +116 -0
- package/dist/worker-84U6uYB3.js +1656 -0
- package/package.json +97 -3
- package/schemas/catalog.json +57 -0
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
import { o as isDiagnosticError } from "./types-D3QGM8XO.js";
|
|
2
|
+
import { o as isValidManualId, r as validateTranslation } from "./validate-BxpupdAf.js";
|
|
3
|
+
import { n as printICU } from "./codec-DuvU2VQy.js";
|
|
4
|
+
import { S as entryKey, m as updateCatalogFile, p as serializeCatalog, s as catalogPath, w as sourceMatches } from "./library-D5CWvTQs.js";
|
|
5
|
+
import { i as listProjectFiles, r as isIncluded } from "./project-pXipT8iG.js";
|
|
6
|
+
import { t as VERSION } from "./version-BlyLGlOa.js";
|
|
7
|
+
import { l as langFromId, u as parseModule } from "./worker-84U6uYB3.js";
|
|
8
|
+
import { t as syncCatalog } from "./sync-CcVzger_.js";
|
|
9
|
+
import { i as rootRelative, r as plural } from "./output-DsaHKHEt.js";
|
|
10
|
+
import { t as EXIT } from "./types-wSh9fCBY.js";
|
|
11
|
+
import { a as loadCatalogs, i as hasErrors, n as compareText, o as loadProject, t as CliExit } from "./project-Dwx7iPm6.js";
|
|
12
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
//#region src/cli/commands/migrate.ts
|
|
16
|
+
/**
|
|
17
|
+
* `vi18n migrate --from saas-i18n --catalog <file> [--catalog <l>=<file>]…
|
|
18
|
+
* [--write]` (INTERNALS §2.14, PLAN §11): moves a project from `@saas/i18n`'s
|
|
19
|
+
* key catalogue to vi18n manual ids.
|
|
20
|
+
*
|
|
21
|
+
* - `<x>.t("key"[, values])` with a catalogue key becomes
|
|
22
|
+
* `t({ id: "key", message: "<text>" }[, values])`, importing `t` once; so
|
|
23
|
+
* does a call of a local bound to `<x>.t` (`const t = i18nFor(l).t`,
|
|
24
|
+
* `const { t } = i18n`);
|
|
25
|
+
* - computed keys are not rewritten: every catalogue key the expression can
|
|
26
|
+
* produce becomes a `msg()` descriptor in `messages.vi18n.ts` beside the
|
|
27
|
+
* catalogue, and the call site is reported (`migrate-dynamic-key`); so
|
|
28
|
+
* does a key spelled as a string anywhere else (a route table, a helper's
|
|
29
|
+
* argument), so no key the code names is dropped as unused;
|
|
30
|
+
* - other locales' texts become `translated` manual entries.
|
|
31
|
+
*
|
|
32
|
+
* Catalogues are read with the parser, never executed. Without `--write`
|
|
33
|
+
* nothing is written and the plan is printed; any error writes nothing.
|
|
34
|
+
*/
|
|
35
|
+
const RUNTIME = "@menapo/vi18n";
|
|
36
|
+
const MESSAGES_MODULE = "messages.vi18n.ts";
|
|
37
|
+
/** Extensions parsed as modules; everything else (`.astro`, `.vue`, `.svelte`, `.mdx`) is scanned lexically. */
|
|
38
|
+
const MODULE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
39
|
+
".js",
|
|
40
|
+
".jsx",
|
|
41
|
+
".mjs",
|
|
42
|
+
".cjs",
|
|
43
|
+
".ts",
|
|
44
|
+
".tsx",
|
|
45
|
+
".mts",
|
|
46
|
+
".cts"
|
|
47
|
+
]);
|
|
48
|
+
/** `@saas/i18n` placeholders: `{name}`, `{name:date}`, `{name:number}`, `{name:currency}`. */
|
|
49
|
+
const PLACEHOLDER = /\{(\w+)(?::(date|number|currency))?\}/g;
|
|
50
|
+
function isNode(value) {
|
|
51
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
52
|
+
}
|
|
53
|
+
/** Children in source order (ESTree field order is source order for every node vi18n meets). */
|
|
54
|
+
function* children(node) {
|
|
55
|
+
for (const key in node) {
|
|
56
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
57
|
+
const value = node[key];
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
for (const item of value) if (isNode(item)) yield item;
|
|
60
|
+
} else if (isNode(value)) yield value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function unwrapExpression(node) {
|
|
64
|
+
let current = node;
|
|
65
|
+
for (;;) if (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSNonNullExpression" || current.type === "TSTypeAssertion" || current.type === "ParenthesizedExpression") current = current.expression;
|
|
66
|
+
else return current;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The value of a string-like expression: a string literal, a template
|
|
70
|
+
* without substitutions, or a `+` concatenation of those; null otherwise.
|
|
71
|
+
*/
|
|
72
|
+
function staticString(node) {
|
|
73
|
+
const n = unwrapExpression(node);
|
|
74
|
+
if (n.type === "Literal" && typeof n.value === "string") return n.value;
|
|
75
|
+
if (n.type === "TemplateLiteral" && n.expressions.length === 0) return n.quasis[0].value.cooked;
|
|
76
|
+
if (n.type === "BinaryExpression" && n.operator === "+") {
|
|
77
|
+
const left = staticString(n.left);
|
|
78
|
+
const right = staticString(n.right);
|
|
79
|
+
return left === null || right === null ? null : left + right;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function propertyKey(property) {
|
|
84
|
+
const key = property.key;
|
|
85
|
+
if (property.computed === true) return staticString(key);
|
|
86
|
+
if (key.type === "Identifier") return key.name;
|
|
87
|
+
if (key.type === "Literal") return String(key.value);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The exported object literal of a catalogue module (`export const en = {…}`,
|
|
92
|
+
* with or without a type annotation or `satisfies`, or `export default {…}`),
|
|
93
|
+
* flattened: nested objects join their keys with `.`.
|
|
94
|
+
*/
|
|
95
|
+
function readCatalogue(text, file, locale, display) {
|
|
96
|
+
const diagnostics = [];
|
|
97
|
+
const catalogue = {
|
|
98
|
+
file,
|
|
99
|
+
locale,
|
|
100
|
+
texts: /* @__PURE__ */ new Map()
|
|
101
|
+
};
|
|
102
|
+
const fail = (message, offset = 0) => {
|
|
103
|
+
const diagnostic = {
|
|
104
|
+
code: "catalog-parse",
|
|
105
|
+
severity: "error",
|
|
106
|
+
message,
|
|
107
|
+
file: display
|
|
108
|
+
};
|
|
109
|
+
if (offset > 0) Object.assign(diagnostic, positionOf(text, offset));
|
|
110
|
+
diagnostics.push(diagnostic);
|
|
111
|
+
};
|
|
112
|
+
let program;
|
|
113
|
+
try {
|
|
114
|
+
program = parseModule(text, langFromId(file), file).program;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (!isDiagnosticError(error)) throw error;
|
|
117
|
+
diagnostics.push({
|
|
118
|
+
...error.diagnostic,
|
|
119
|
+
file: display
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
catalogue,
|
|
123
|
+
diagnostics
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const objects = [];
|
|
127
|
+
for (const statement of program.body) if (statement.type === "ExportDefaultDeclaration") {
|
|
128
|
+
const value = unwrapExpression(statement.declaration);
|
|
129
|
+
if (value.type === "ObjectExpression") objects.push(value);
|
|
130
|
+
} else if (statement.type === "ExportNamedDeclaration" && isNode(statement.declaration)) {
|
|
131
|
+
const declaration = statement.declaration;
|
|
132
|
+
if (declaration.type !== "VariableDeclaration") continue;
|
|
133
|
+
for (const declarator of declaration.declarations) {
|
|
134
|
+
if (!isNode(declarator.init)) continue;
|
|
135
|
+
const value = unwrapExpression(declarator.init);
|
|
136
|
+
if (value.type === "ObjectExpression") objects.push(value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (objects.length !== 1) {
|
|
140
|
+
fail(objects.length === 0 ? `${basename(file)} exports no object literal; a saas-i18n catalogue is a module exporting one object of strings` : `${basename(file)} exports ${objects.length} object literals; a saas-i18n catalogue exports exactly one`);
|
|
141
|
+
return {
|
|
142
|
+
catalogue,
|
|
143
|
+
diagnostics
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const walk = (object, prefix) => {
|
|
147
|
+
for (const property of object.properties) {
|
|
148
|
+
if (property.type !== "Property") {
|
|
149
|
+
fail("spread properties cannot be read without executing the catalogue; inline them", property.start);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const key = propertyKey(property);
|
|
153
|
+
if (key === null) {
|
|
154
|
+
fail("a computed key cannot be read without executing the catalogue", property.start);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const value = unwrapExpression(property.value);
|
|
158
|
+
if (value.type === "ObjectExpression") {
|
|
159
|
+
walk(value, prefix + key + ".");
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const textValue = staticString(value);
|
|
163
|
+
if (textValue === null) {
|
|
164
|
+
fail(`the value of "${prefix + key}" is not a string literal`, value.start);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
catalogue.texts.set(prefix + key, textValue);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
walk(objects[0], "");
|
|
171
|
+
return {
|
|
172
|
+
catalogue,
|
|
173
|
+
diagnostics
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A saas-i18n message as ICU text. Placeholders become arguments
|
|
178
|
+
* (`{n:number}` → `{n, number}`, `{d:date}` → `{d, date, medium}`, the ICU
|
|
179
|
+
* style nearest saas's `dateStyle: "medium"`, the same text in English only);
|
|
180
|
+
* everything else is literal text, quoted by the
|
|
181
|
+
* canonical printer where ICU would read it as syntax (`{`, `<b`, `'{`).
|
|
182
|
+
*/
|
|
183
|
+
function saasToIcu(text) {
|
|
184
|
+
const nodes = [];
|
|
185
|
+
const currency = [];
|
|
186
|
+
const literal = (value) => {
|
|
187
|
+
if (value === "") return;
|
|
188
|
+
const last = nodes[nodes.length - 1];
|
|
189
|
+
if (last?.type === "literal") last.value += value;
|
|
190
|
+
else nodes.push({
|
|
191
|
+
type: "literal",
|
|
192
|
+
value
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
let at = 0;
|
|
196
|
+
for (const match of text.matchAll(PLACEHOLDER)) {
|
|
197
|
+
literal(text.slice(at, match.index));
|
|
198
|
+
at = match.index + match[0].length;
|
|
199
|
+
const name = match[1];
|
|
200
|
+
const format = match[2];
|
|
201
|
+
let node;
|
|
202
|
+
if (format === "number") node = {
|
|
203
|
+
type: "number",
|
|
204
|
+
name,
|
|
205
|
+
style: null,
|
|
206
|
+
options: null
|
|
207
|
+
};
|
|
208
|
+
else if (format === "date") node = {
|
|
209
|
+
type: "date",
|
|
210
|
+
name,
|
|
211
|
+
style: {
|
|
212
|
+
kind: "keyword",
|
|
213
|
+
value: "medium"
|
|
214
|
+
},
|
|
215
|
+
options: null
|
|
216
|
+
};
|
|
217
|
+
else {
|
|
218
|
+
if (format === "currency") currency.push(name);
|
|
219
|
+
node = {
|
|
220
|
+
type: "argument",
|
|
221
|
+
name
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
nodes.push(node);
|
|
225
|
+
}
|
|
226
|
+
literal(text.slice(at));
|
|
227
|
+
return {
|
|
228
|
+
icu: printICU(nodes),
|
|
229
|
+
currency
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** Names of plain `{name}` placeholders (no `:date`, `:number`, `:currency`) in a saas text, deduplicated. */
|
|
233
|
+
function plainPlaceholders(text) {
|
|
234
|
+
const names = /* @__PURE__ */ new Set();
|
|
235
|
+
for (const match of text.matchAll(PLACEHOLDER)) if (match[2] === void 0) names.add(match[1]);
|
|
236
|
+
return [...names];
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Whether a name usually holds a date: `date`, `updated`, `expires`, or a
|
|
240
|
+
* camel-case or snake-case suffix (`publishedAt`, `startDate`, `created_on`).
|
|
241
|
+
* Number-like names (`days`, `readingTime`) are left out: saas formatted
|
|
242
|
+
* only `Date` values, so they render the same in vi18n.
|
|
243
|
+
*/
|
|
244
|
+
function isDateName(name) {
|
|
245
|
+
return /^(?:date|datetime|timestamp|when|since|until|deadline|expires|expiry|expiration|created|updated|modified|published|posted|due)$/i.test(name) || /[a-z0-9](?:Date|At|On)$/.test(name) || /^date[A-Z_]/.test(name) || /_(?:date|at|on)$/i.test(name);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The value written for `name` in a call's values object (`{ date: new
|
|
249
|
+
* Date() }`, or `name` itself for `{ date }`); null when the call names no
|
|
250
|
+
* such property. Lexical, so it reads calls in components alike.
|
|
251
|
+
*/
|
|
252
|
+
function valueExpression(call, name) {
|
|
253
|
+
const property = new RegExp(`[{,]\\s*(?:${name}|"${name}"|'${name}')\\s*:\\s*`).exec(call);
|
|
254
|
+
if (property === null) return new RegExp(`[{,]\\s*${name}\\s*[,}]`).test(call) ? name : null;
|
|
255
|
+
const start = property.index + property[0].length;
|
|
256
|
+
for (let i = start; i < call.length; i++) {
|
|
257
|
+
const c = call[i];
|
|
258
|
+
if (c === "," || c === "}" || c === ")") return call.slice(start, i).trim();
|
|
259
|
+
let end = i + 1;
|
|
260
|
+
if (c === "(" || c === "[" || c === "{") end = skipBalanced(call, i);
|
|
261
|
+
else if (c === "\"" || c === "'") end = readString(call, i)?.end ?? null;
|
|
262
|
+
else if (c === "`") end = readTemplate(call, i)?.end ?? null;
|
|
263
|
+
if (end === null) return null;
|
|
264
|
+
i = end - 1;
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
/** Whether a value expression passes a `Date`: `new Date(…)`, or ends in a date-like name (`post.updatedAt`). */
|
|
269
|
+
function looksLikeDate(expression) {
|
|
270
|
+
const constructed = /^new\s+Date\b\s*/.exec(expression);
|
|
271
|
+
if (constructed !== null) {
|
|
272
|
+
const at = constructed[0].length;
|
|
273
|
+
return at === expression.length || skipBalanced(expression, at) === expression.length;
|
|
274
|
+
}
|
|
275
|
+
const last = /([A-Za-z_$][\w$]*)\s*(\(\s*\))?\s*$/.exec(expression.replace(/[!?]+$/, ""));
|
|
276
|
+
if (last === null) return false;
|
|
277
|
+
const name = last[1];
|
|
278
|
+
if (last[2] !== void 0 && DATE_NUMBER_GETTER.test(name)) return false;
|
|
279
|
+
return isDateName(name);
|
|
280
|
+
}
|
|
281
|
+
/** `Date` methods that return numbers, not dates. */
|
|
282
|
+
const DATE_NUMBER_GETTER = /^(?:get(?:UTC)?(?:Date|Day|FullYear|Month|Hours|Minutes|Seconds|Milliseconds)|getTime|getTimezoneOffset|valueOf)$/;
|
|
283
|
+
/** A key as a valid manual id (INTERNALS §2.2.3): invalid characters → `_`, a key without `.` gets `msg.`. */
|
|
284
|
+
function toManualId(key) {
|
|
285
|
+
if (isValidManualId(key)) return key;
|
|
286
|
+
const segments = key.split(".").map((segment) => segment.replace(/[^A-Za-z0-9_:-]/g, "_") || "_");
|
|
287
|
+
if (segments.length < 2) segments.unshift("msg");
|
|
288
|
+
return segments.join(".");
|
|
289
|
+
}
|
|
290
|
+
/** Matches catalogue keys against a template's static parts. */
|
|
291
|
+
function templateKeys(quasis, keys) {
|
|
292
|
+
const found = /* @__PURE__ */ new Set();
|
|
293
|
+
const literal = quasis.join("");
|
|
294
|
+
if (!/[A-Za-z]/.test(literal)) return found;
|
|
295
|
+
const pattern = new RegExp("^" + quasis.map((q) => q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^]+?") + "$");
|
|
296
|
+
for (const key of keys) if (pattern.test(key)) found.add(key);
|
|
297
|
+
return found;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Keys searched for as plain strings outside calls: only nested ones
|
|
301
|
+
* (`nav.home`). A one-word key (`quote`) is as likely an ordinary string of
|
|
302
|
+
* the code, and claiming it would only add noise.
|
|
303
|
+
*/
|
|
304
|
+
function isNamedKey(value, keys) {
|
|
305
|
+
return value.includes(".") && keys.has(value);
|
|
306
|
+
}
|
|
307
|
+
function planModule(file, text, keys, keyList, display) {
|
|
308
|
+
const plan = {
|
|
309
|
+
file,
|
|
310
|
+
text,
|
|
311
|
+
rewrites: [],
|
|
312
|
+
dynamic: [],
|
|
313
|
+
importedT: null,
|
|
314
|
+
program: null,
|
|
315
|
+
diagnostics: []
|
|
316
|
+
};
|
|
317
|
+
let program;
|
|
318
|
+
try {
|
|
319
|
+
program = parseModule(text, langFromId(file), file).program;
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (!isDiagnosticError(error)) throw error;
|
|
322
|
+
plan.diagnostics.push({
|
|
323
|
+
...error.diagnostic,
|
|
324
|
+
file: display
|
|
325
|
+
});
|
|
326
|
+
return plan;
|
|
327
|
+
}
|
|
328
|
+
plan.program = program;
|
|
329
|
+
const runtimeNamespaces = /* @__PURE__ */ new Set();
|
|
330
|
+
for (const statement of program.body) {
|
|
331
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
332
|
+
if (statement.source.value !== RUNTIME) continue;
|
|
333
|
+
for (const specifier of statement.specifiers) {
|
|
334
|
+
const local = specifier.local.name;
|
|
335
|
+
if (specifier.type === "ImportNamespaceSpecifier") runtimeNamespaces.add(local);
|
|
336
|
+
else if (specifier.type === "ImportSpecifier") {
|
|
337
|
+
const imported = specifier.imported;
|
|
338
|
+
if ((imported.name ?? imported.value) === "t" && statement.importKind !== "type") plan.importedT = local;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const bindings = moduleBindings(program, plan.importedT);
|
|
343
|
+
const handled = /* @__PURE__ */ new Set();
|
|
344
|
+
const visit = (node, parent) => {
|
|
345
|
+
if (node.type === "CallExpression") {
|
|
346
|
+
const callee = unwrapExpression(node.callee);
|
|
347
|
+
const args = node.arguments;
|
|
348
|
+
let receiver = null;
|
|
349
|
+
if (callee.type === "MemberExpression" && callee.computed !== true && callee.property.type === "Identifier" && callee.property.name === "t") {
|
|
350
|
+
const object = callee.object;
|
|
351
|
+
if (!(object.type === "Identifier" && runtimeNamespaces.has(object.name))) receiver = text.slice(object.start, object.end).replace(/\s+/g, "");
|
|
352
|
+
} else if (callee.type === "Identifier" && bindings.has(callee.name)) receiver = callee.name;
|
|
353
|
+
const first = args[0];
|
|
354
|
+
if (receiver !== null && first !== void 0 && first.type !== "SpreadElement") {
|
|
355
|
+
const key = staticString(first);
|
|
356
|
+
if (key !== null) {
|
|
357
|
+
if (keys.has(key)) {
|
|
358
|
+
const literal = unwrapExpression(first);
|
|
359
|
+
handled.add(literal.start);
|
|
360
|
+
const quote = text[literal.start] === "'" ? "'" : "\"";
|
|
361
|
+
plan.rewrites.push({
|
|
362
|
+
key,
|
|
363
|
+
receiver,
|
|
364
|
+
callee: {
|
|
365
|
+
start: callee.start,
|
|
366
|
+
end: callee.end,
|
|
367
|
+
text: ""
|
|
368
|
+
},
|
|
369
|
+
argument: {
|
|
370
|
+
start: first.start,
|
|
371
|
+
end: first.end,
|
|
372
|
+
text: quote
|
|
373
|
+
},
|
|
374
|
+
original: text.slice(node.start, node.end),
|
|
375
|
+
markup: null
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
} else plan.dynamic.push({
|
|
379
|
+
offset: first.start,
|
|
380
|
+
expression: text.slice(first.start, first.end),
|
|
381
|
+
keys: expressionKeys(first, keys, keyList, handled),
|
|
382
|
+
receiver
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (node.type === "ObjectExpression") {
|
|
387
|
+
const properties = node.properties.filter((p) => p.type === "Property");
|
|
388
|
+
if (properties.some((p) => propertyKey(p) === "message")) {
|
|
389
|
+
for (const p of properties) if (propertyKey(p) === "id") handled.add(p.value.start);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (node.type === "Literal" && typeof node.value === "string" && isNamedKey(node.value, keys) && !handled.has(node.start) && isKeyPosition(node, parent)) plan.dynamic.push({
|
|
393
|
+
offset: node.start,
|
|
394
|
+
expression: text.slice(node.start, node.end),
|
|
395
|
+
keys: /* @__PURE__ */ new Set([node.value]),
|
|
396
|
+
receiver: null,
|
|
397
|
+
literal: true
|
|
398
|
+
});
|
|
399
|
+
if (node.type === "TemplateLiteral" && !handled.has(node.start)) {
|
|
400
|
+
if (node.expressions.length > 0) {
|
|
401
|
+
const found = templateKeys(node.quasis.map((q) => q.value.cooked ?? ""), keyList);
|
|
402
|
+
if (found.size > 0) plan.dynamic.push({
|
|
403
|
+
offset: node.start,
|
|
404
|
+
expression: text.slice(node.start, node.end),
|
|
405
|
+
keys: found,
|
|
406
|
+
receiver: null
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const child of children(node)) visit(child, node);
|
|
411
|
+
};
|
|
412
|
+
visit(program, null);
|
|
413
|
+
return plan;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Locals bound to a saas `t` (`const t = i18nFor(l).t`, `const { t } =
|
|
417
|
+
* i18n`), whose calls are call sites like `<x>.t(…)`. Scopes are ignored:
|
|
418
|
+
* a call of an unrelated local of the same name only matters when its
|
|
419
|
+
* first argument is a catalogue key.
|
|
420
|
+
*/
|
|
421
|
+
function moduleBindings(program, importedT) {
|
|
422
|
+
const names = /* @__PURE__ */ new Set();
|
|
423
|
+
const visit = (node) => {
|
|
424
|
+
if (node.type === "VariableDeclarator") {
|
|
425
|
+
const id = node.id;
|
|
426
|
+
const init = isNode(node.init) ? unwrapExpression(node.init) : null;
|
|
427
|
+
if (id.type === "Identifier" && init !== null && init.type === "MemberExpression" && init.computed !== true && init.property.name === "t") names.add(id.name);
|
|
428
|
+
else if (id.type === "ObjectPattern") for (const property of id.properties) {
|
|
429
|
+
if (property.type !== "Property" || propertyKey(property) !== "t") continue;
|
|
430
|
+
let value = property.value;
|
|
431
|
+
if (value.type === "AssignmentPattern") value = value.left;
|
|
432
|
+
if (value.type === "Identifier") names.add(value.name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const child of children(node)) visit(child);
|
|
436
|
+
};
|
|
437
|
+
visit(program);
|
|
438
|
+
if (importedT !== null) names.delete(importedT);
|
|
439
|
+
return names;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Whether a string literal equal to a key is a use of that key: not an
|
|
443
|
+
* object key, a module specifier or a type.
|
|
444
|
+
*/
|
|
445
|
+
function isKeyPosition(node, parent) {
|
|
446
|
+
if (parent === null) return true;
|
|
447
|
+
switch (parent.type) {
|
|
448
|
+
case "ImportDeclaration":
|
|
449
|
+
case "ExportNamedDeclaration":
|
|
450
|
+
case "ExportAllDeclaration":
|
|
451
|
+
case "ImportExpression":
|
|
452
|
+
case "TSLiteralType":
|
|
453
|
+
case "TSExternalModuleReference": return false;
|
|
454
|
+
case "Property": return parent.key !== node || parent.computed === true;
|
|
455
|
+
default: return true;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** Catalogue keys an expression can evaluate to: its string literals and templates that match keys. */
|
|
459
|
+
function expressionKeys(node, keys, keyList, handled) {
|
|
460
|
+
const found = /* @__PURE__ */ new Set();
|
|
461
|
+
const visit = (n) => {
|
|
462
|
+
if (n.type === "Literal" && typeof n.value === "string" && keys.has(n.value)) {
|
|
463
|
+
found.add(n.value);
|
|
464
|
+
handled.add(n.start);
|
|
465
|
+
}
|
|
466
|
+
if (n.type === "TemplateLiteral") {
|
|
467
|
+
handled.add(n.start);
|
|
468
|
+
const quasis = n.quasis.map((q) => q.value.cooked ?? "");
|
|
469
|
+
for (const key of templateKeys(quasis, keyList)) found.add(key);
|
|
470
|
+
}
|
|
471
|
+
for (const child of children(n)) visit(child);
|
|
472
|
+
};
|
|
473
|
+
visit(node);
|
|
474
|
+
return found;
|
|
475
|
+
}
|
|
476
|
+
/** `.t(` or `?.t(`; the receiver before it is found by walking back. */
|
|
477
|
+
const MEMBER_T = /\??\.\s*t\s*\(/g;
|
|
478
|
+
/** Words after which `(` opens an expression, not a call: `return (a).t(…)`. */
|
|
479
|
+
const KEYWORDS = /* @__PURE__ */ new Set([
|
|
480
|
+
"return",
|
|
481
|
+
"typeof",
|
|
482
|
+
"await",
|
|
483
|
+
"case",
|
|
484
|
+
"in",
|
|
485
|
+
"of",
|
|
486
|
+
"new",
|
|
487
|
+
"void",
|
|
488
|
+
"delete",
|
|
489
|
+
"yield",
|
|
490
|
+
"else",
|
|
491
|
+
"do"
|
|
492
|
+
]);
|
|
493
|
+
const ESCAPES = {
|
|
494
|
+
n: "\n",
|
|
495
|
+
t: " ",
|
|
496
|
+
r: "\r",
|
|
497
|
+
b: "\b",
|
|
498
|
+
f: "\f",
|
|
499
|
+
v: "\v",
|
|
500
|
+
0: "\0"
|
|
501
|
+
};
|
|
502
|
+
/** A JS string literal starting at `at` (a quote): its value and end, or null when unterminated. */
|
|
503
|
+
function readString(text, at) {
|
|
504
|
+
const quote = text[at];
|
|
505
|
+
let value = "";
|
|
506
|
+
for (let i = at + 1; i < text.length; i++) {
|
|
507
|
+
const c = text[i];
|
|
508
|
+
if (c === quote) return {
|
|
509
|
+
value,
|
|
510
|
+
end: i + 1
|
|
511
|
+
};
|
|
512
|
+
if (c === "\n") return null;
|
|
513
|
+
if (c === "\\") {
|
|
514
|
+
const next = text[++i];
|
|
515
|
+
if (next === void 0) return null;
|
|
516
|
+
if (next === "u" || next === "x") {
|
|
517
|
+
const hex = next === "x" ? text.slice(i + 1, i + 3) : text.slice(i + 1, i + 5);
|
|
518
|
+
value += String.fromCharCode(Number.parseInt(hex, 16));
|
|
519
|
+
i += hex.length;
|
|
520
|
+
} else if (next === "\n") {} else value += ESCAPES[next] ?? next;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
value += c;
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
/** A template literal starting at `at`: cooked static parts, and its end. */
|
|
528
|
+
function readTemplate(text, at) {
|
|
529
|
+
const quasis = [""];
|
|
530
|
+
for (let i = at + 1; i < text.length; i++) {
|
|
531
|
+
const c = text[i];
|
|
532
|
+
if (c === "`") return {
|
|
533
|
+
quasis,
|
|
534
|
+
end: i + 1
|
|
535
|
+
};
|
|
536
|
+
if (c === "\\") {
|
|
537
|
+
quasis[quasis.length - 1] += text[++i] ?? "";
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (c === "$" && text[i + 1] === "{") {
|
|
541
|
+
const end = skipBalanced(text, i + 1);
|
|
542
|
+
if (end === null) return null;
|
|
543
|
+
i = end - 1;
|
|
544
|
+
quasis.push("");
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
quasis[quasis.length - 1] += c;
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
/** From an opening bracket at `at`, the offset after its matching close (strings and templates skipped). */
|
|
552
|
+
function skipBalanced(text, at) {
|
|
553
|
+
let depth = 0;
|
|
554
|
+
for (let i = at; i < text.length; i++) {
|
|
555
|
+
const c = text[i];
|
|
556
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
557
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
558
|
+
depth--;
|
|
559
|
+
if (depth === 0) return i + 1;
|
|
560
|
+
} else if (c === "\"" || c === "'") {
|
|
561
|
+
const s = readString(text, i);
|
|
562
|
+
if (s === null) return null;
|
|
563
|
+
i = s.end - 1;
|
|
564
|
+
} else if (c === "`") {
|
|
565
|
+
const s = readTemplate(text, i);
|
|
566
|
+
if (s === null) return null;
|
|
567
|
+
i = s.end - 1;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
/** The end of a call argument starting at `at`: the next `,` or `)` at depth 0. */
|
|
573
|
+
function argumentEnd(text, at) {
|
|
574
|
+
for (let i = at; i < text.length; i++) {
|
|
575
|
+
const c = text[i];
|
|
576
|
+
if (c === "," || c === ")") return i;
|
|
577
|
+
if (c === "(" || c === "[" || c === "{") {
|
|
578
|
+
const end = skipBalanced(text, i);
|
|
579
|
+
if (end === null) return null;
|
|
580
|
+
i = end - 1;
|
|
581
|
+
} else if (c === "\"" || c === "'") {
|
|
582
|
+
const s = readString(text, i);
|
|
583
|
+
if (s === null) return null;
|
|
584
|
+
i = s.end - 1;
|
|
585
|
+
} else if (c === "`") {
|
|
586
|
+
const s = readTemplate(text, i);
|
|
587
|
+
if (s === null) return null;
|
|
588
|
+
i = s.end - 1;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
function skipSpace(text, at) {
|
|
594
|
+
let i = at;
|
|
595
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
596
|
+
return i;
|
|
597
|
+
}
|
|
598
|
+
/** From a closing bracket at `at`, the offset of its opening one (strings skipped), or null. */
|
|
599
|
+
function matchBack(text, at) {
|
|
600
|
+
let depth = 0;
|
|
601
|
+
for (let i = at; i >= 0; i--) {
|
|
602
|
+
const c = text[i];
|
|
603
|
+
if (c === ")" || c === "]" || c === "}") depth++;
|
|
604
|
+
else if (c === "(" || c === "[" || c === "{") {
|
|
605
|
+
depth--;
|
|
606
|
+
if (depth === 0) return i;
|
|
607
|
+
} else if (c === "\"" || c === "'" || c === "`") {
|
|
608
|
+
let j = i - 1;
|
|
609
|
+
while (j >= 0 && !(text[j] === c && text[j - 1] !== "\\")) j--;
|
|
610
|
+
if (j < 0) return null;
|
|
611
|
+
i = j;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* The start of the receiver ending at `end` (exclusive): identifiers joined
|
|
618
|
+
* by `.`/`?.`, with calls and indexes (`i18nFor(locale).t`, `a[0].t`).
|
|
619
|
+
* Null when there is none (`).t(` after an operator, a number).
|
|
620
|
+
*/
|
|
621
|
+
function receiverStart(text, end) {
|
|
622
|
+
const back = (j) => {
|
|
623
|
+
let k = j;
|
|
624
|
+
while (k > 0 && /\s/.test(text[k - 1])) k--;
|
|
625
|
+
return k;
|
|
626
|
+
};
|
|
627
|
+
let j = back(end);
|
|
628
|
+
let start = null;
|
|
629
|
+
for (;;) {
|
|
630
|
+
const c = text[j - 1];
|
|
631
|
+
if (c === ")" || c === "]") {
|
|
632
|
+
const open = matchBack(text, j - 1);
|
|
633
|
+
if (open === null) return null;
|
|
634
|
+
j = open;
|
|
635
|
+
start = j;
|
|
636
|
+
const before = text[j - 1];
|
|
637
|
+
if (before !== void 0 && /[\w$)\]]/.test(before)) {
|
|
638
|
+
let k = j;
|
|
639
|
+
while (k > 0 && /[\w$]/.test(text[k - 1])) k--;
|
|
640
|
+
if (KEYWORDS.has(text.slice(k, j))) return start;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (before === "." && text[j - 2] === "?") {
|
|
644
|
+
j -= 2;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
return start;
|
|
648
|
+
}
|
|
649
|
+
if (c !== void 0 && /[\w$]/.test(c)) {
|
|
650
|
+
let k = j;
|
|
651
|
+
while (k > 0 && /[\w$]/.test(text[k - 1])) k--;
|
|
652
|
+
if (/[0-9]/.test(text[k])) return null;
|
|
653
|
+
start = k;
|
|
654
|
+
const dot = back(k);
|
|
655
|
+
if (text[dot - 1] !== ".") return start;
|
|
656
|
+
j = text[dot - 2] === "?" ? dot - 2 : dot - 1;
|
|
657
|
+
j = back(j);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
return start;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Locals bound to a saas `t` in a component's script or front matter
|
|
665
|
+
* (`const t = i18nFor(locale).t`, `const { t } = i18n`).
|
|
666
|
+
*/
|
|
667
|
+
function componentBindings(text, importedT) {
|
|
668
|
+
const names = /* @__PURE__ */ new Set();
|
|
669
|
+
const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*/g;
|
|
670
|
+
for (let match = declaration.exec(text); match !== null; match = declaration.exec(text)) {
|
|
671
|
+
const at = match.index + match[0].length;
|
|
672
|
+
let end = at;
|
|
673
|
+
while (end < text.length && !/[;\n,]/.test(text[end])) {
|
|
674
|
+
const c = text[end];
|
|
675
|
+
if (c === "(" || c === "[" || c === "{") {
|
|
676
|
+
const close = skipBalanced(text, end);
|
|
677
|
+
if (close === null) break;
|
|
678
|
+
end = close;
|
|
679
|
+
} else if (c === "\"" || c === "'") {
|
|
680
|
+
const literal = readString(text, end);
|
|
681
|
+
if (literal === null) break;
|
|
682
|
+
end = literal.end;
|
|
683
|
+
} else end++;
|
|
684
|
+
}
|
|
685
|
+
if (/\??\.\s*t\s*$/.test(text.slice(at, end).trimEnd())) names.add(match[1]);
|
|
686
|
+
}
|
|
687
|
+
const destructure = /\b(?:const|let|var)\s*\{([^}]*)\}\s*=/g;
|
|
688
|
+
for (let match = destructure.exec(text); match !== null; match = destructure.exec(text)) for (const part of match[1].split(",")) {
|
|
689
|
+
const m = /^\s*t\s*(?::\s*([A-Za-z_$][\w$]*))?\s*(?:=.*)?$/.exec(part);
|
|
690
|
+
if (m !== null) names.add(m[1] ?? "t");
|
|
691
|
+
}
|
|
692
|
+
if (importedT !== null) names.delete(importedT);
|
|
693
|
+
return names;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* A Vue SFC's quoted attribute values, outside `<script>` and `<style>`
|
|
697
|
+
* blocks, and the contents of those blocks (`code`): what lies in neither is
|
|
698
|
+
* template text.
|
|
699
|
+
*/
|
|
700
|
+
function vueRegions(text) {
|
|
701
|
+
const values = [];
|
|
702
|
+
const code = [];
|
|
703
|
+
let i = 0;
|
|
704
|
+
while (i < text.length) {
|
|
705
|
+
if (text.startsWith("<!--", i)) {
|
|
706
|
+
const end = text.indexOf("-->", i + 4);
|
|
707
|
+
i = end === -1 ? text.length : end + 3;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (text[i] !== "<" || !/[A-Za-z]/.test(text[i + 1] ?? "")) {
|
|
711
|
+
i++;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const name = /^<([A-Za-z][\w-]*)/.exec(text.slice(i, i + 64))?.[1]?.toLowerCase() ?? "";
|
|
715
|
+
i += name.length + 1;
|
|
716
|
+
while (i < text.length && text[i] !== ">") {
|
|
717
|
+
const c = text[i];
|
|
718
|
+
if (c === "\"" || c === "'") {
|
|
719
|
+
const end = text.indexOf(c, i + 1);
|
|
720
|
+
if (end === -1) return {
|
|
721
|
+
attributes: values,
|
|
722
|
+
code
|
|
723
|
+
};
|
|
724
|
+
if (/=\s*$/.test(text.slice(Math.max(0, i - 16), i))) values.push({
|
|
725
|
+
start: i + 1,
|
|
726
|
+
end,
|
|
727
|
+
quote: c
|
|
728
|
+
});
|
|
729
|
+
i = end + 1;
|
|
730
|
+
} else i++;
|
|
731
|
+
}
|
|
732
|
+
i++;
|
|
733
|
+
if (name === "script" || name === "style") {
|
|
734
|
+
const close = text.indexOf(`</${name}`, i);
|
|
735
|
+
const end = close === -1 ? text.length : close;
|
|
736
|
+
code.push({
|
|
737
|
+
start: i,
|
|
738
|
+
end
|
|
739
|
+
});
|
|
740
|
+
i = end;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return {
|
|
744
|
+
attributes: values,
|
|
745
|
+
code
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function planComponent(file, text, keys, keyList) {
|
|
749
|
+
const plan = {
|
|
750
|
+
file,
|
|
751
|
+
text,
|
|
752
|
+
rewrites: [],
|
|
753
|
+
dynamic: [],
|
|
754
|
+
importedT: null,
|
|
755
|
+
program: null,
|
|
756
|
+
diagnostics: []
|
|
757
|
+
};
|
|
758
|
+
const existing = new RegExp(String.raw`import\s*\{([^}]*)\}\s*from\s*["']${RUNTIME.replace("/", "\\/")}["']`).exec(text);
|
|
759
|
+
if (existing !== null) for (const specifier of existing[1].split(",")) {
|
|
760
|
+
const m = /^\s*t(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(specifier);
|
|
761
|
+
if (m !== null) plan.importedT = m[1] ?? "t";
|
|
762
|
+
}
|
|
763
|
+
const regions = extname(file) === ".vue" ? vueRegions(text) : null;
|
|
764
|
+
const markupAt = (offset) => {
|
|
765
|
+
if (regions === null) return null;
|
|
766
|
+
if (regions.code.some((r) => r.start <= offset && offset < r.end)) return null;
|
|
767
|
+
return { quote: regions.attributes.find((a) => a.start <= offset && offset < a.end)?.quote ?? null };
|
|
768
|
+
};
|
|
769
|
+
const templatesSeen = /* @__PURE__ */ new Set();
|
|
770
|
+
/** Ranges already accounted for: rewritten arguments and computed expressions. */
|
|
771
|
+
const covered = [];
|
|
772
|
+
const call = (calleeStart, calleeEnd, open, receiver) => {
|
|
773
|
+
const at = skipSpace(text, open);
|
|
774
|
+
const c = text[at];
|
|
775
|
+
if (c === ")") return;
|
|
776
|
+
const rewrite = (key, end, quote) => {
|
|
777
|
+
const close = skipBalanced(text, open - 1) ?? end + 1;
|
|
778
|
+
covered.push([at, end]);
|
|
779
|
+
plan.rewrites.push({
|
|
780
|
+
key,
|
|
781
|
+
receiver,
|
|
782
|
+
callee: {
|
|
783
|
+
start: calleeStart,
|
|
784
|
+
end: calleeEnd,
|
|
785
|
+
text: ""
|
|
786
|
+
},
|
|
787
|
+
argument: {
|
|
788
|
+
start: at,
|
|
789
|
+
end,
|
|
790
|
+
text: quote
|
|
791
|
+
},
|
|
792
|
+
original: text.slice(calleeStart, close),
|
|
793
|
+
markup: markupAt(at)
|
|
794
|
+
});
|
|
795
|
+
};
|
|
796
|
+
if (c === "\"" || c === "'") {
|
|
797
|
+
const literal = readString(text, at);
|
|
798
|
+
if (literal === null) return;
|
|
799
|
+
const after = skipSpace(text, literal.end);
|
|
800
|
+
if (text[after] !== "," && text[after] !== ")") {
|
|
801
|
+
const end = argumentEnd(text, at);
|
|
802
|
+
if (end !== null) pushDynamic(plan, text, at, end, receiver, keys, keyList, templatesSeen, covered);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (keys.has(literal.value)) rewrite(literal.value, literal.end, c);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const end = argumentEnd(text, at);
|
|
809
|
+
if (end === null) return;
|
|
810
|
+
if (c === "`") {
|
|
811
|
+
const template = readTemplate(text, at);
|
|
812
|
+
if (template !== null && template.quasis.length === 1 && skipSpace(text, template.end) === end) {
|
|
813
|
+
const key = template.quasis[0];
|
|
814
|
+
templatesSeen.add(at);
|
|
815
|
+
if (keys.has(key)) rewrite(key, template.end, "\"");
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
pushDynamic(plan, text, at, end, receiver, keys, keyList, templatesSeen, covered);
|
|
820
|
+
};
|
|
821
|
+
MEMBER_T.lastIndex = 0;
|
|
822
|
+
for (let match = MEMBER_T.exec(text); match !== null; match = MEMBER_T.exec(text)) {
|
|
823
|
+
const start = receiverStart(text, match.index);
|
|
824
|
+
if (start === null) continue;
|
|
825
|
+
const receiver = text.slice(start, match.index).replace(/\s+/g, "");
|
|
826
|
+
const open = match.index + match[0].length;
|
|
827
|
+
call(start, text.lastIndexOf("t", open - 1) + 1, open, receiver);
|
|
828
|
+
}
|
|
829
|
+
for (const name of componentBindings(text, plan.importedT)) {
|
|
830
|
+
const pattern = new RegExp(String.raw`(?<![\w$.])${name.replace(/\$/g, "\\$")}\s*\(`, "g");
|
|
831
|
+
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
832
|
+
if (/\bfunction\s*$/.test(text.slice(Math.max(0, match.index - 12), match.index))) continue;
|
|
833
|
+
call(match.index, match.index + name.length, match.index + match[0].length, name);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
for (let at = text.indexOf("`"); at !== -1; at = text.indexOf("`", at + 1)) {
|
|
837
|
+
if (templatesSeen.has(at)) continue;
|
|
838
|
+
const template = readTemplate(text, at);
|
|
839
|
+
if (template === null) continue;
|
|
840
|
+
if (template.quasis.length > 1) {
|
|
841
|
+
const found = templateKeys(template.quasis, keyList);
|
|
842
|
+
if (found.size > 0) plan.dynamic.push({
|
|
843
|
+
offset: at,
|
|
844
|
+
expression: text.slice(at, template.end),
|
|
845
|
+
keys: found,
|
|
846
|
+
receiver: null
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
at = template.end - 1;
|
|
850
|
+
}
|
|
851
|
+
const inCovered = (offset) => covered.some(([a, b]) => a <= offset && offset < b);
|
|
852
|
+
for (const key of keyList) {
|
|
853
|
+
if (!isNamedKey(key, keys)) continue;
|
|
854
|
+
for (const quote of [
|
|
855
|
+
"\"",
|
|
856
|
+
"'",
|
|
857
|
+
"`"
|
|
858
|
+
]) {
|
|
859
|
+
const needle = quote + key + quote;
|
|
860
|
+
for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + 1)) {
|
|
861
|
+
if (inCovered(at)) continue;
|
|
862
|
+
if (/\bid\s*:\s*$/.test(text.slice(Math.max(0, at - 16), at))) continue;
|
|
863
|
+
plan.dynamic.push({
|
|
864
|
+
offset: at,
|
|
865
|
+
expression: needle,
|
|
866
|
+
keys: /* @__PURE__ */ new Set([key]),
|
|
867
|
+
receiver: null,
|
|
868
|
+
literal: true
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
plan.dynamic.sort((a, b) => a.offset - b.offset);
|
|
874
|
+
plan.rewrites.sort((a, b) => a.callee.start - b.callee.start);
|
|
875
|
+
return plan;
|
|
876
|
+
}
|
|
877
|
+
function pushDynamic(plan, text, start, end, receiver, keys, keyList, templatesSeen, covered) {
|
|
878
|
+
covered.push([start, end]);
|
|
879
|
+
const expression = text.slice(start, end).trim();
|
|
880
|
+
const found = /* @__PURE__ */ new Set();
|
|
881
|
+
for (let i = start; i < end; i++) {
|
|
882
|
+
const c = text[i];
|
|
883
|
+
if (c === "\"" || c === "'") {
|
|
884
|
+
const s = readString(text, i);
|
|
885
|
+
if (s === null) break;
|
|
886
|
+
if (keys.has(s.value)) found.add(s.value);
|
|
887
|
+
i = s.end - 1;
|
|
888
|
+
} else if (c === "`") {
|
|
889
|
+
const s = readTemplate(text, i);
|
|
890
|
+
if (s === null) break;
|
|
891
|
+
templatesSeen.add(i);
|
|
892
|
+
for (const key of templateKeys(s.quasis, keyList)) found.add(key);
|
|
893
|
+
i = s.end - 1;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
plan.dynamic.push({
|
|
897
|
+
offset: start,
|
|
898
|
+
expression,
|
|
899
|
+
keys: found,
|
|
900
|
+
receiver
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
/** A JS string literal in the given quote style (a Vue attribute may need single quotes). */
|
|
904
|
+
function quoteJs(value, quote) {
|
|
905
|
+
if (quote === "\"") return JSON.stringify(value);
|
|
906
|
+
return "'" + JSON.stringify(value).slice(1, -1).replace(/\\"/g, "\"").replace(/'/g, "\\'") + "'";
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* `{ id, message }` in the quote style of the key it replaces, encoded for
|
|
910
|
+
* where it lands. A Vue template decodes entities in attribute values and in
|
|
911
|
+
* `{{ }}` before compiling the expression, so catalogue text there is written
|
|
912
|
+
* as markup: `&` as `&` (else `'` in a message would end its string
|
|
913
|
+
* literal and run the rest as code), an attribute value's own quote as an
|
|
914
|
+
* entity, and in text the `}` of a `}}` as `}`, since `}}` would end the
|
|
915
|
+
* interpolation inside the message (an ICU plural ends with one).
|
|
916
|
+
*/
|
|
917
|
+
function descriptor(info, quote, markup = null) {
|
|
918
|
+
return encodeMarkup(`{ id: ${quoteJs(info.id, quote)}, message: ${quoteJs(info.canonical, quote)} }`, markup);
|
|
919
|
+
}
|
|
920
|
+
/** Code for where it lands in a Vue template (see {@link descriptor}); unchanged in code. */
|
|
921
|
+
function encodeMarkup(code, markup) {
|
|
922
|
+
if (markup === null) return code;
|
|
923
|
+
const html = code.replaceAll("&", "&");
|
|
924
|
+
if (markup.quote === null) return html.replaceAll("}}", "}}");
|
|
925
|
+
return html.replaceAll(markup.quote, markup.quote === "\"" ? """ : "'");
|
|
926
|
+
}
|
|
927
|
+
/** A local name for `t` the file does not already use. */
|
|
928
|
+
function localName(text) {
|
|
929
|
+
for (const name of [
|
|
930
|
+
"t",
|
|
931
|
+
"vt",
|
|
932
|
+
"vi18nT"
|
|
933
|
+
]) if (!new RegExp(String.raw`(?<![.\w$])${name}(?![\w$])`).test(text)) return name;
|
|
934
|
+
return "vi18nT_";
|
|
935
|
+
}
|
|
936
|
+
function importLine(local) {
|
|
937
|
+
return local === "t" ? `import { t } from "${RUNTIME}";` : `import { t as ${local} } from "${RUNTIME}";`;
|
|
938
|
+
}
|
|
939
|
+
/** Where the import goes, per file kind, creating the script block or front matter a component lacks. */
|
|
940
|
+
function importEdit(file, text, local, program) {
|
|
941
|
+
return importLineEdit(file, text, importLine(local), program);
|
|
942
|
+
}
|
|
943
|
+
/** {@link importEdit} for any import line (several names, a type import). */
|
|
944
|
+
function importLineEdit(file, text, line, program) {
|
|
945
|
+
const ext = extname(file);
|
|
946
|
+
if (program !== null) {
|
|
947
|
+
let end = -1;
|
|
948
|
+
for (const statement of program.body) if (statement.type === "ImportDeclaration") end = statement.end;
|
|
949
|
+
if (end !== -1) return {
|
|
950
|
+
start: end,
|
|
951
|
+
end,
|
|
952
|
+
text: "\n" + line
|
|
953
|
+
};
|
|
954
|
+
let at = 0;
|
|
955
|
+
if (text.startsWith("#!")) at = text.indexOf("\n") + 1;
|
|
956
|
+
for (const statement of program.body) if (statement.type === "ExpressionStatement" && typeof statement.directive === "string") at = text.indexOf("\n", statement.end) + 1 || statement.end;
|
|
957
|
+
else break;
|
|
958
|
+
return {
|
|
959
|
+
start: at,
|
|
960
|
+
end: at,
|
|
961
|
+
text: line + "\n"
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
if (ext === ".astro") {
|
|
965
|
+
const fence = /^\s*---[ \t]*\r?\n/.exec(text);
|
|
966
|
+
if (fence !== null) return {
|
|
967
|
+
start: fence[0].length,
|
|
968
|
+
end: fence[0].length,
|
|
969
|
+
text: line + "\n"
|
|
970
|
+
};
|
|
971
|
+
return {
|
|
972
|
+
start: 0,
|
|
973
|
+
end: 0,
|
|
974
|
+
text: `---\n${line}\n---\n`
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
if (ext === ".vue" || ext === ".svelte") {
|
|
978
|
+
const usable = [...text.matchAll(/<script\b([^>]*)>/g)].filter((m) => {
|
|
979
|
+
const attrs = m[1];
|
|
980
|
+
if (/\bsrc\s*=/.test(attrs)) return false;
|
|
981
|
+
if (ext === ".svelte") return !/\bcontext\s*=\s*["']module["']|\bmodule\b/.test(attrs);
|
|
982
|
+
return true;
|
|
983
|
+
});
|
|
984
|
+
const tag = ext === ".vue" ? usable.find((m) => /\bsetup\b/.test(m[1])) : usable[0];
|
|
985
|
+
if (tag !== void 0) {
|
|
986
|
+
const at = tag.index + tag[0].length;
|
|
987
|
+
return {
|
|
988
|
+
start: at,
|
|
989
|
+
end: at,
|
|
990
|
+
text: "\n" + line
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
const options = ext === ".vue" ? usable[0] : void 0;
|
|
994
|
+
if (options !== void 0) {
|
|
995
|
+
const lang = /\blang\s*=\s*["']([^"']+)["']/.exec(options[1])?.[1];
|
|
996
|
+
const close = text.indexOf("<\/script>", options.index);
|
|
997
|
+
if (close !== -1) {
|
|
998
|
+
const at = close + 9;
|
|
999
|
+
return {
|
|
1000
|
+
start: at,
|
|
1001
|
+
end: at,
|
|
1002
|
+
text: `\n${lang === void 0 ? "<script setup>" : `<script setup lang="${lang}">`}\n${line}\n<\/script>`
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
start: 0,
|
|
1008
|
+
end: 0,
|
|
1009
|
+
text: `${ext === ".vue" ? "<script setup>" : "<script>"}\n${line}\n<\/script>\n\n`
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const front = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n/.exec(text);
|
|
1013
|
+
const at = front === null ? 0 : front[0].length;
|
|
1014
|
+
return {
|
|
1015
|
+
start: at,
|
|
1016
|
+
end: at,
|
|
1017
|
+
text: line + "\n\n"
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
function applyEdits(text, edits) {
|
|
1021
|
+
const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
|
|
1022
|
+
let out = text;
|
|
1023
|
+
for (const edit of sorted) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
1024
|
+
return out;
|
|
1025
|
+
}
|
|
1026
|
+
function positionOf(text, offset) {
|
|
1027
|
+
let line = 1;
|
|
1028
|
+
let lineStart = 0;
|
|
1029
|
+
for (let i = text.indexOf("\n"); i !== -1 && i < offset; i = text.indexOf("\n", i + 1)) {
|
|
1030
|
+
line++;
|
|
1031
|
+
lineStart = i + 1;
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
line,
|
|
1035
|
+
column: offset - lineStart + 1
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
async function migrate(ctx, flags) {
|
|
1039
|
+
if (flags.from !== "saas-i18n") {
|
|
1040
|
+
const { migrateLibrary } = await import("./engine-B7p40M__.js");
|
|
1041
|
+
return migrateLibrary(ctx, flags);
|
|
1042
|
+
}
|
|
1043
|
+
const project = await loadProject(ctx);
|
|
1044
|
+
const { config, codec } = project;
|
|
1045
|
+
const rel = (file) => rootRelative(config.root, file);
|
|
1046
|
+
const data = {
|
|
1047
|
+
from: flags.from,
|
|
1048
|
+
write: flags.write,
|
|
1049
|
+
files: [],
|
|
1050
|
+
rewritten: 0,
|
|
1051
|
+
renamed: [],
|
|
1052
|
+
manualIds: [],
|
|
1053
|
+
messagesModule: null,
|
|
1054
|
+
entries: {},
|
|
1055
|
+
edits: [],
|
|
1056
|
+
unused: []
|
|
1057
|
+
};
|
|
1058
|
+
const diagnostics = [];
|
|
1059
|
+
const [first, ...others] = flags.catalogs;
|
|
1060
|
+
if (first.locale !== null && first.locale !== config.sourceLocale) throw usage(`the first --catalog is the source locale's (${config.sourceLocale}), not ${first.locale}`);
|
|
1061
|
+
for (const other of others) if (other.locale === config.sourceLocale || !config.locales.includes(other.locale)) throw usage(`--catalog ${other.locale}=${other.file}: ${other.locale} must be a configured locale other than the source (${config.locales.join(", ")})`);
|
|
1062
|
+
const catalogues = [];
|
|
1063
|
+
for (const entry of flags.catalogs) {
|
|
1064
|
+
const file = resolve(ctx.global.cwd, entry.file);
|
|
1065
|
+
let text;
|
|
1066
|
+
try {
|
|
1067
|
+
text = await readFile(file, "utf8");
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
throw usage(`--catalog ${entry.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1070
|
+
}
|
|
1071
|
+
const read = readCatalogue(text, file, entry.locale ?? config.sourceLocale, rel(file));
|
|
1072
|
+
diagnostics.push(...read.diagnostics);
|
|
1073
|
+
catalogues.push(read.catalogue);
|
|
1074
|
+
}
|
|
1075
|
+
const source = catalogues[0];
|
|
1076
|
+
const keys = /* @__PURE__ */ new Map();
|
|
1077
|
+
const takenIds = /* @__PURE__ */ new Map();
|
|
1078
|
+
for (const key of source.texts.keys()) if (isValidManualId(key)) takenIds.set(key, key);
|
|
1079
|
+
for (const [key, text] of source.texts) {
|
|
1080
|
+
const converted = saasToIcu(text);
|
|
1081
|
+
for (const name of converted.currency) diagnostics.push({
|
|
1082
|
+
code: "icu-unknown-currency",
|
|
1083
|
+
severity: "warning",
|
|
1084
|
+
message: `${key}: {${name}:currency} became {${name}}; format the amount with fmt.currencyMinor and pass the string`,
|
|
1085
|
+
file: rel(source.file),
|
|
1086
|
+
id: key
|
|
1087
|
+
});
|
|
1088
|
+
let canonical;
|
|
1089
|
+
try {
|
|
1090
|
+
canonical = codec.canonicalize(converted.icu);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
if (!isDiagnosticError(error)) throw error;
|
|
1093
|
+
diagnostics.push({
|
|
1094
|
+
...error.diagnostic,
|
|
1095
|
+
file: rel(source.file),
|
|
1096
|
+
id: key
|
|
1097
|
+
});
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
let id = toManualId(key);
|
|
1101
|
+
if (id !== key) {
|
|
1102
|
+
if (takenIds.has(id)) {
|
|
1103
|
+
let n = 2;
|
|
1104
|
+
while (takenIds.has(`${id}_${n}`)) n++;
|
|
1105
|
+
id = `${id}_${n}`;
|
|
1106
|
+
}
|
|
1107
|
+
takenIds.set(id, key);
|
|
1108
|
+
}
|
|
1109
|
+
if (id !== key) data.renamed.push({
|
|
1110
|
+
from: key,
|
|
1111
|
+
to: id
|
|
1112
|
+
});
|
|
1113
|
+
keys.set(key, {
|
|
1114
|
+
key,
|
|
1115
|
+
id,
|
|
1116
|
+
canonical
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
const keyList = [...keys.keys()];
|
|
1120
|
+
const messagesPath = join(dirname(source.file), MESSAGES_MODULE);
|
|
1121
|
+
const skip = /* @__PURE__ */ new Set([messagesPath, ...catalogues.map((c) => c.file)]);
|
|
1122
|
+
const listed = await listProjectFiles(config);
|
|
1123
|
+
const plans = [];
|
|
1124
|
+
for (const file of listed.files.slice().sort(compareText)) {
|
|
1125
|
+
if (skip.has(file)) continue;
|
|
1126
|
+
const text = await readFile(file, "utf8");
|
|
1127
|
+
if (!/\.\s*t\b|\{[^}]*\bt\b[^}]*\}\s*=/.test(text) && !text.includes("`") && !keyList.some((key) => key.includes(".") && text.includes(key))) continue;
|
|
1128
|
+
const plan = MODULE_EXTENSIONS.has(extname(file)) ? planModule(file, text, keys, keyList, rel(file)) : planComponent(file, text, keys, keyList);
|
|
1129
|
+
diagnostics.push(...plan.diagnostics);
|
|
1130
|
+
if (plan.rewrites.length > 0 || plan.dynamic.length > 0) plans.push(plan);
|
|
1131
|
+
}
|
|
1132
|
+
const receivers = /* @__PURE__ */ new Set();
|
|
1133
|
+
for (const plan of plans) for (const r of plan.rewrites) receivers.add(r.receiver);
|
|
1134
|
+
const dynamicKeys = /* @__PURE__ */ new Set();
|
|
1135
|
+
const used = /* @__PURE__ */ new Map();
|
|
1136
|
+
const use = (key, file) => {
|
|
1137
|
+
let files = used.get(key);
|
|
1138
|
+
if (files === void 0) used.set(key, files = /* @__PURE__ */ new Set());
|
|
1139
|
+
files.add(rel(file));
|
|
1140
|
+
};
|
|
1141
|
+
for (const plan of plans) {
|
|
1142
|
+
for (const r of plan.rewrites) use(r.key, plan.file);
|
|
1143
|
+
for (const site of plan.dynamic) {
|
|
1144
|
+
if (site.keys.size === 0 && (site.receiver === null || !receivers.has(site.receiver))) continue;
|
|
1145
|
+
for (const key of site.keys) {
|
|
1146
|
+
dynamicKeys.add(key);
|
|
1147
|
+
use(key, messagesPath);
|
|
1148
|
+
}
|
|
1149
|
+
const where = positionOf(plan.text, site.offset);
|
|
1150
|
+
const listedKeys = [...site.keys].sort(compareText);
|
|
1151
|
+
const example = listedKeys.length > 0 ? listedKeys.slice(0, 3).join(", ") + (listedKeys.length > 3 ? ", …" : "") : "";
|
|
1152
|
+
diagnostics.push({
|
|
1153
|
+
code: "migrate-dynamic-key",
|
|
1154
|
+
severity: "warning",
|
|
1155
|
+
message: site.literal ? `${site.expression} names a catalogue key outside a call vi18n migrate rewrites; it is a msg() descriptor in ${rel(messagesPath)}: import { messages } from it and call t(messages[key])` : site.keys.size > 0 ? `${site.expression} is a computed key and was not rewritten; its ${plural(site.keys.size, "key")} (${example}) are msg() descriptors in ${rel(messagesPath)}: import { messages } from it and call t(messages[key])` : `${site.receiver}.t(${site.expression}) uses a computed key that matches no catalogue key and was not rewritten; pass a msg() descriptor (t(messages[key])) instead`,
|
|
1156
|
+
file: rel(plan.file),
|
|
1157
|
+
line: where.line,
|
|
1158
|
+
column: where.column
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
for (const key of keyList) if (!used.has(key)) data.unused.push(key);
|
|
1163
|
+
diagnostics.push(...datePlaceholders(source, keys, used, plans, rel));
|
|
1164
|
+
const outputs = [];
|
|
1165
|
+
for (const plan of plans) {
|
|
1166
|
+
if (plan.rewrites.length === 0) continue;
|
|
1167
|
+
const local = plan.importedT ?? localName(plan.text);
|
|
1168
|
+
const edits = [];
|
|
1169
|
+
for (const r of plan.rewrites) {
|
|
1170
|
+
const info = keys.get(r.key);
|
|
1171
|
+
edits.push({
|
|
1172
|
+
...r.callee,
|
|
1173
|
+
text: local
|
|
1174
|
+
});
|
|
1175
|
+
const replacement = descriptor(info, r.argument.text, r.markup);
|
|
1176
|
+
edits.push({
|
|
1177
|
+
...r.argument,
|
|
1178
|
+
text: replacement
|
|
1179
|
+
});
|
|
1180
|
+
const where = positionOf(plan.text, r.callee.start);
|
|
1181
|
+
const to = applyEdits(r.original, [{
|
|
1182
|
+
start: 0,
|
|
1183
|
+
end: r.callee.end - r.callee.start,
|
|
1184
|
+
text: local
|
|
1185
|
+
}, {
|
|
1186
|
+
start: r.argument.start - r.callee.start,
|
|
1187
|
+
end: r.argument.end - r.callee.start,
|
|
1188
|
+
text: replacement
|
|
1189
|
+
}]);
|
|
1190
|
+
data.edits.push({
|
|
1191
|
+
file: rel(plan.file),
|
|
1192
|
+
line: where.line,
|
|
1193
|
+
column: where.column,
|
|
1194
|
+
from: r.original,
|
|
1195
|
+
to
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
if (plan.importedT === null) edits.push(importEdit(plan.file, plan.text, local, plan.program));
|
|
1199
|
+
data.rewritten += plan.rewrites.length;
|
|
1200
|
+
outputs.push({
|
|
1201
|
+
file: plan.file,
|
|
1202
|
+
text: applyEdits(plan.text, edits)
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
if (dynamicKeys.size > 0) {
|
|
1206
|
+
outputs.push({
|
|
1207
|
+
file: messagesPath,
|
|
1208
|
+
text: messagesModule(dynamicKeys, keys)
|
|
1209
|
+
});
|
|
1210
|
+
data.messagesModule = rel(messagesPath);
|
|
1211
|
+
if (!isIncluded(messagesPath, config)) diagnostics.push({
|
|
1212
|
+
code: "extract-outside-include",
|
|
1213
|
+
severity: "warning",
|
|
1214
|
+
message: `${rel(messagesPath)} is outside project.include, so vi18n extract will not see its msg() descriptors; add its directory to project.include`,
|
|
1215
|
+
file: rel(messagesPath)
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
const messages = /* @__PURE__ */ new Map();
|
|
1219
|
+
for (const [key, files] of used) {
|
|
1220
|
+
const info = keys.get(key);
|
|
1221
|
+
messages.set(info.id, {
|
|
1222
|
+
id: info.id,
|
|
1223
|
+
manual: true,
|
|
1224
|
+
canonical: info.canonical,
|
|
1225
|
+
sourceHash: codec.sourceHash(info.canonical),
|
|
1226
|
+
label: codec.label(info.canonical),
|
|
1227
|
+
contexts: [],
|
|
1228
|
+
refs: [...files].sort(compareText),
|
|
1229
|
+
sources: [info.canonical],
|
|
1230
|
+
locations: []
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
const translations = /* @__PURE__ */ new Map();
|
|
1234
|
+
for (const catalogue of others.length > 0 ? catalogues.slice(1) : []) {
|
|
1235
|
+
const map = /* @__PURE__ */ new Map();
|
|
1236
|
+
for (const [key, text] of catalogue.texts) {
|
|
1237
|
+
const info = keys.get(key);
|
|
1238
|
+
if (info === void 0 || !used.has(key)) continue;
|
|
1239
|
+
const converted = saasToIcu(text).icu;
|
|
1240
|
+
map.set(info.id, converted);
|
|
1241
|
+
diagnostics.push(...translationProblems(project, catalogue, info, converted, rel(catalogue.file)));
|
|
1242
|
+
}
|
|
1243
|
+
translations.set(catalogue.locale, map);
|
|
1244
|
+
}
|
|
1245
|
+
const loaded = await loadCatalogs(project);
|
|
1246
|
+
const readErrors = loaded.diagnostics.filter((d) => d.code === "catalog-parse" && d.severity === "error");
|
|
1247
|
+
diagnostics.push(...readErrors);
|
|
1248
|
+
const catalogPlans = planCatalogs(project, loaded.set, messages, translations);
|
|
1249
|
+
diagnostics.push(...catalogPlans.diagnostics);
|
|
1250
|
+
for (const [locale, count] of catalogPlans.counts) if (locale !== config.sourceLocale) data.entries[locale] = count;
|
|
1251
|
+
ctx.out.report(diagnostics);
|
|
1252
|
+
const failed = hasErrors(diagnostics);
|
|
1253
|
+
data.files = [...outputs.map((o) => rel(o.file)), ...catalogPlans.changed.map((locale) => rel(catalogPath(config, locale)))];
|
|
1254
|
+
if (flags.write && !failed) {
|
|
1255
|
+
for (const output of outputs) await atomicWrite(output.file, output.text);
|
|
1256
|
+
for (const locale of catalogPlans.changed) {
|
|
1257
|
+
const loadedCatalog = loaded.set.get(locale);
|
|
1258
|
+
await updateCatalogFile(catalogPath(config, locale), loadedCatalog?.hash ?? null, (current) => plannedCatalog(project, locale, current, catalogPlans.sourceCatalog, messages, translations.get(locale)).catalog, {
|
|
1259
|
+
locale,
|
|
1260
|
+
layout: project.layout,
|
|
1261
|
+
isSourceLocale: locale === config.sourceLocale,
|
|
1262
|
+
version: VERSION
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
printPlan(ctx, data, failed);
|
|
1267
|
+
return {
|
|
1268
|
+
exitCode: failed ? EXIT.failure : EXIT.ok,
|
|
1269
|
+
data
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* T defect C: saas formatted a `Date` in any placeholder with `dateStyle:
|
|
1274
|
+
* "medium"`;
|
|
1275
|
+
* `migrate` keeps a plain `{x}` as it is, where a `Date` still renders that
|
|
1276
|
+
* way but a date string never formats, and the catalogue does not say a date
|
|
1277
|
+
* is expected. The value's type is not visible here, so a placeholder is
|
|
1278
|
+
* reported when a call passes it something that looks like a `Date`, or when
|
|
1279
|
+
* its name says it holds one.
|
|
1280
|
+
*/
|
|
1281
|
+
function datePlaceholders(source, keys, used, plans, rel) {
|
|
1282
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1283
|
+
for (const plan of plans) for (const r of plan.rewrites) for (const name of plainPlaceholders(source.texts.get(r.key) ?? "")) {
|
|
1284
|
+
const at = `${r.key}\0${name}`;
|
|
1285
|
+
if (seen.has(at)) continue;
|
|
1286
|
+
const value = valueExpression(r.original.slice(r.argument.end - r.callee.start), name);
|
|
1287
|
+
if (value === null || !looksLikeDate(value)) continue;
|
|
1288
|
+
const { line } = positionOf(plan.text, r.callee.start);
|
|
1289
|
+
seen.set(at, {
|
|
1290
|
+
expression: value,
|
|
1291
|
+
file: rel(plan.file),
|
|
1292
|
+
line
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
const out = [];
|
|
1296
|
+
for (const [key, text] of source.texts) {
|
|
1297
|
+
if (!keys.has(key) || !used.has(key)) continue;
|
|
1298
|
+
for (const name of plainPlaceholders(text)) {
|
|
1299
|
+
const passed = seen.get(`${key}\0${name}`);
|
|
1300
|
+
if (passed === void 0 && !isDateName(name)) continue;
|
|
1301
|
+
const why = passed === void 0 ? "its name says it holds a date" : `${passed.file}:${passed.line} passes ${passed.expression}`;
|
|
1302
|
+
out.push({
|
|
1303
|
+
code: "migrate-date-placeholder",
|
|
1304
|
+
severity: "warning",
|
|
1305
|
+
message: `${key}: {${name}} may receive a Date (${why}); saas formatted it with dateStyle "medium": write {${name}, date, medium} (the same text in English, not in every locale) and pass a Date or epoch milliseconds (new Date(value) for a string), or pass fmt.date(value, { dateStyle: "medium" }) for saas's exact text`,
|
|
1306
|
+
file: rel(source.file),
|
|
1307
|
+
id: key
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return out;
|
|
1312
|
+
}
|
|
1313
|
+
function usage(message) {
|
|
1314
|
+
return new CliExit(EXIT.usage, [{
|
|
1315
|
+
code: "config-invalid",
|
|
1316
|
+
severity: "error",
|
|
1317
|
+
message
|
|
1318
|
+
}]);
|
|
1319
|
+
}
|
|
1320
|
+
/** A translation that does not fit its source is still written (people fix it), but reported now. */
|
|
1321
|
+
function translationProblems(project, catalogue, info, translation, file) {
|
|
1322
|
+
const { codec } = project;
|
|
1323
|
+
try {
|
|
1324
|
+
return validateTranslation(codec.parse(translation), codec.parse(info.canonical), {
|
|
1325
|
+
locale: catalogue.locale,
|
|
1326
|
+
text: translation
|
|
1327
|
+
}).filter((d) => d.severity === "error").map((d) => ({
|
|
1328
|
+
...d,
|
|
1329
|
+
code: "catalog-invalid-translation",
|
|
1330
|
+
severity: "warning",
|
|
1331
|
+
message: `${info.key} (${catalogue.locale}): ${d.message}`,
|
|
1332
|
+
file,
|
|
1333
|
+
locale: catalogue.locale,
|
|
1334
|
+
id: info.id
|
|
1335
|
+
}));
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
if (!isDiagnosticError(error)) throw error;
|
|
1338
|
+
return [{
|
|
1339
|
+
...error.diagnostic,
|
|
1340
|
+
severity: "warning",
|
|
1341
|
+
message: `${info.key} (${catalogue.locale}): ${error.diagnostic.message}`,
|
|
1342
|
+
file,
|
|
1343
|
+
locale: catalogue.locale,
|
|
1344
|
+
id: info.id
|
|
1345
|
+
}];
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* A locale's catalog after migration: the migrated messages added the way
|
|
1350
|
+
* the dev writer adds them (`add-only`, so nothing else changes), then the
|
|
1351
|
+
* catalogue's texts set on entries that have no translation yet — a
|
|
1352
|
+
* translation someone already made in vi18n is never replaced. `notes` are
|
|
1353
|
+
* translator comments for entries left empty (a text the codemod could not
|
|
1354
|
+
* convert, kept for a person to finish).
|
|
1355
|
+
*/
|
|
1356
|
+
function plannedCatalog(project, locale, current, sourceCatalog, messages, translations, notes) {
|
|
1357
|
+
const { config, codec, layout } = project;
|
|
1358
|
+
const synced = syncCatalog({
|
|
1359
|
+
config,
|
|
1360
|
+
codec,
|
|
1361
|
+
messages,
|
|
1362
|
+
locale,
|
|
1363
|
+
catalog: current,
|
|
1364
|
+
sourceCatalog,
|
|
1365
|
+
options: {
|
|
1366
|
+
mode: "add-only",
|
|
1367
|
+
yes: false
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
let translated = 0;
|
|
1371
|
+
let catalog = synced.catalog;
|
|
1372
|
+
if (translations !== void 0 && translations.size > 0 || notes !== void 0 && notes.size > 0) {
|
|
1373
|
+
let changed = false;
|
|
1374
|
+
const entries = catalog.entries.map((entry) => {
|
|
1375
|
+
const key = entryKey(entry, layout, codec);
|
|
1376
|
+
if (key === null) return entry;
|
|
1377
|
+
const message = messages.get(key);
|
|
1378
|
+
if (message === void 0 || entry.manualId !== (message.manual ? key : null)) return entry;
|
|
1379
|
+
if (entry.translation !== "" || !sourceMatches(entry, layout, message, codec)) return entry;
|
|
1380
|
+
const text = translations?.get(key);
|
|
1381
|
+
if (text !== void 0) {
|
|
1382
|
+
translated++;
|
|
1383
|
+
changed = true;
|
|
1384
|
+
return {
|
|
1385
|
+
...entry,
|
|
1386
|
+
translation: text,
|
|
1387
|
+
state: "translated",
|
|
1388
|
+
fuzzy: false
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
const added = (notes?.get(key) ?? []).filter((note) => !entry.comments.includes(note));
|
|
1392
|
+
if (added.length === 0) return entry;
|
|
1393
|
+
changed = true;
|
|
1394
|
+
return {
|
|
1395
|
+
...entry,
|
|
1396
|
+
comments: [...entry.comments, ...added]
|
|
1397
|
+
};
|
|
1398
|
+
});
|
|
1399
|
+
if (changed) catalog = {
|
|
1400
|
+
...catalog,
|
|
1401
|
+
entries
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
return {
|
|
1405
|
+
catalog,
|
|
1406
|
+
added: synced.stats.added,
|
|
1407
|
+
translated,
|
|
1408
|
+
diagnostics: synced.diagnostics
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
function planCatalogs(project, set, messages, translations, notes) {
|
|
1412
|
+
const { config } = project;
|
|
1413
|
+
const sourceCatalog = set.get(config.sourceLocale)?.catalog ?? null;
|
|
1414
|
+
const changed = [];
|
|
1415
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1416
|
+
const diagnostics = [];
|
|
1417
|
+
if (messages.size === 0) return {
|
|
1418
|
+
changed,
|
|
1419
|
+
counts,
|
|
1420
|
+
sourceCatalog,
|
|
1421
|
+
diagnostics
|
|
1422
|
+
};
|
|
1423
|
+
for (const locale of config.locales) {
|
|
1424
|
+
const current = set.get(locale)?.catalog ?? null;
|
|
1425
|
+
const planned = plannedCatalog(project, locale, current, sourceCatalog, messages, translations.get(locale), notes?.get(locale));
|
|
1426
|
+
for (const d of planned.diagnostics) diagnostics.push({
|
|
1427
|
+
...d,
|
|
1428
|
+
locale: d.locale ?? locale,
|
|
1429
|
+
file: d.file ?? rootRelative(config.root, catalogPath(config, locale))
|
|
1430
|
+
});
|
|
1431
|
+
counts.set(locale, planned.translated);
|
|
1432
|
+
if ((current === null ? null : serializeCatalog(current, {
|
|
1433
|
+
isSourceLocale: locale === config.sourceLocale,
|
|
1434
|
+
version: VERSION
|
|
1435
|
+
})) !== serializeCatalog(planned.catalog, {
|
|
1436
|
+
isSourceLocale: locale === config.sourceLocale,
|
|
1437
|
+
version: VERSION
|
|
1438
|
+
})) changed.push(locale);
|
|
1439
|
+
}
|
|
1440
|
+
return {
|
|
1441
|
+
changed,
|
|
1442
|
+
counts,
|
|
1443
|
+
sourceCatalog,
|
|
1444
|
+
diagnostics
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
/** `export const messages = { "<key>": msg({ id, message }) }`, keyed by the catalogue key the code computes. */
|
|
1448
|
+
function messagesModule(dynamicKeys, keys) {
|
|
1449
|
+
const lines = [
|
|
1450
|
+
"// Generated by vi18n migrate --from saas-i18n: catalogue keys the code computes at",
|
|
1451
|
+
"// runtime, as msg() descriptors so vi18n extracts them. Look one up with",
|
|
1452
|
+
"// t(messages[key]); keys stay the catalogue's, ids are vi18n manual ids.",
|
|
1453
|
+
`import { msg } from "${RUNTIME}";`,
|
|
1454
|
+
"",
|
|
1455
|
+
"export const messages = {"
|
|
1456
|
+
];
|
|
1457
|
+
for (const key of [...dynamicKeys].sort(compareText)) {
|
|
1458
|
+
const info = keys.get(key);
|
|
1459
|
+
lines.push(` ${JSON.stringify(key)}: msg(${descriptor(info, "\"")}),`);
|
|
1460
|
+
}
|
|
1461
|
+
lines.push("} as const;", "");
|
|
1462
|
+
return lines.join("\n");
|
|
1463
|
+
}
|
|
1464
|
+
function printPlan(ctx, data, failed) {
|
|
1465
|
+
for (const edit of data.edits) ctx.out.result(`${edit.file}:${edit.line}:${edit.column}\n - ${edit.from}\n + ${edit.to}`);
|
|
1466
|
+
if (data.renamed.length > 0) ctx.out.result(`renamed to valid manual ids: ${data.renamed.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
|
|
1467
|
+
if (data.messagesModule !== null) ctx.out.result(`msg() table for computed keys: ${data.messagesModule}`);
|
|
1468
|
+
for (const [locale, count] of Object.entries(data.entries)) ctx.out.result(`${locale}: ${plural(count, "translation")} for the vi18n catalog`);
|
|
1469
|
+
if (data.unused.length > 0) ctx.out.result(`${plural(data.unused.length, "catalogue key")} no code uses: ${data.unused.join(", ")}`);
|
|
1470
|
+
const verb = data.write && !failed ? "rewrote" : "would rewrite";
|
|
1471
|
+
ctx.out.result(`${verb} ${plural(data.rewritten, "call")} in ${plural(data.files.length, "file")}` + (data.write ? failed ? " (nothing written: fix the errors first)" : "" : "; run with --write to apply"));
|
|
1472
|
+
}
|
|
1473
|
+
async function atomicWrite(path, content) {
|
|
1474
|
+
const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1475
|
+
try {
|
|
1476
|
+
await writeFile(temp, content);
|
|
1477
|
+
await rename(temp, path);
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
await rm(temp, { force: true });
|
|
1480
|
+
throw error;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
//#endregion
|
|
1484
|
+
export { toManualId as _, encodeMarkup as a, migrate as c, positionOf as d, readString as f, skipSpace as g, skipBalanced as h, atomicWrite as i, planCatalogs as l, saasToIcu as m, applyEdits as n, importLineEdit as o, readTemplate as p, argumentEnd as r, isDateName as s, MESSAGES_MODULE as t, plannedCatalog as u, usage as v, vueRegions as y };
|