@menapo/vi18n 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +49 -0
  2. package/README.md +110 -2
  3. package/dist/apply-Bre-lY9J.js +228 -0
  4. package/dist/captured-Bj8bO6vd.js +1034 -0
  5. package/dist/check-BIKEuu6B.js +201 -0
  6. package/dist/check-HsmmpJZg.js +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +41 -0
  9. package/dist/codec-DuvU2VQy.js +207 -0
  10. package/dist/collect-Cx4_SKOe.js +13 -0
  11. package/dist/collisions-CfpZU8e4.js +217 -0
  12. package/dist/compile-BaDh0cHG.js +245 -0
  13. package/dist/compile-Cc97JaW2.js +2502 -0
  14. package/dist/concurrency-C9UUWuDC.js +96 -0
  15. package/dist/concurrency-DONL7t4V.js +1 -0
  16. package/dist/config.d.ts +21 -0
  17. package/dist/config.js +39 -0
  18. package/dist/dir-zw6bGMKQ.js +290 -0
  19. package/dist/duplicates-yuRljQ6h.js +185 -0
  20. package/dist/engine-C2ENBRJe.js +531 -0
  21. package/dist/engine.d.ts +43 -0
  22. package/dist/engine.js +1241 -0
  23. package/dist/exclude-Du-Zd_qI.js +26 -0
  24. package/dist/export-C9Sy6UWv.js +103 -0
  25. package/dist/extract-C3S8btx0.js +298 -0
  26. package/dist/formats-9unqj_fZ.js +781 -0
  27. package/dist/i18next-9HAoxwUI.js +1 -0
  28. package/dist/i18next-BEJgv_S8.js +1596 -0
  29. package/dist/import-nANvQutw.js +225 -0
  30. package/dist/index.d.ts +156 -0
  31. package/dist/index.js +239 -0
  32. package/dist/init-C2ScuW6a.js +792 -0
  33. package/dist/library-D5CWvTQs.js +1882 -0
  34. package/dist/lingui-zUiCl9Um.js +833 -0
  35. package/dist/lint.d.ts +105 -0
  36. package/dist/lint.js +971 -0
  37. package/dist/load-CQRz4PpM.js +2950 -0
  38. package/dist/load-gW-oJt1Q.js +747 -0
  39. package/dist/locales-DX4MOBFE.js +827 -0
  40. package/dist/lsp-RMMMc88P.js +31 -0
  41. package/dist/match-BGr8EXB3.js +89 -0
  42. package/dist/mcp-CHVwBoRP.js +28 -0
  43. package/dist/merge-driver-BTX87aVm.js +422 -0
  44. package/dist/messages-BfjIExyB.js +166 -0
  45. package/dist/migrate-IpLDMXmq.js +1484 -0
  46. package/dist/migrate-rCQuOGsf.js +1 -0
  47. package/dist/openai-CTaMDcl-.js +346 -0
  48. package/dist/output-DWE3SIUA.js +164 -0
  49. package/dist/parse.d.ts +62 -0
  50. package/dist/parse.js +156 -0
  51. package/dist/pipeline-C9Dbotkh.js +2031 -0
  52. package/dist/pipeline-DbX6rlo5.js +1 -0
  53. package/dist/project-Bw9UPIdx.js +1046 -0
  54. package/dist/project-C4GpaN-n.js +381 -0
  55. package/dist/pseudo-DRv30iN5.js +145 -0
  56. package/dist/raw-C4Y6vyGY.js +4708 -0
  57. package/dist/react-intl-BP0u-D5m.js +519 -0
  58. package/dist/restore-CiYv-jxS.js +175 -0
  59. package/dist/rewrite-EsIw_lsq.js +803 -0
  60. package/dist/rich-DCUKl5HM.js +97 -0
  61. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  62. package/dist/run-BEcnBbu-.js +694 -0
  63. package/dist/runtime-DTMmliT1.js +64 -0
  64. package/dist/scan-worker.d.ts +62 -0
  65. package/dist/scan-worker.js +1 -0
  66. package/dist/scoped-BMYpSthj.js +44 -0
  67. package/dist/server-Da1Ot9IN.js +10485 -0
  68. package/dist/server-deBOq2vq.js +11823 -0
  69. package/dist/server.d.ts +169 -0
  70. package/dist/server.js +1234 -0
  71. package/dist/sitemap-DJXzKoza.js +159 -0
  72. package/dist/sources-D19czDSA.js +2242 -0
  73. package/dist/status-zr4jY59Y.js +149 -0
  74. package/dist/stdio-CVaJfxA7.js +29 -0
  75. package/dist/stdio-FPOKQAgu.js +95 -0
  76. package/dist/sync-B9V2KFwb.js +658 -0
  77. package/dist/t-DC-c9zE5.js +454 -0
  78. package/dist/test.d.ts +116 -0
  79. package/dist/test.js +319 -0
  80. package/dist/translate-CQnJfLjO.js +299 -0
  81. package/dist/ts-BaU5yOf0.js +4863 -0
  82. package/dist/types-CgUCkFU1.d.ts +707 -0
  83. package/dist/types-D3QGM8XO.js +146 -0
  84. package/dist/types-DsdWBG0b.d.ts +558 -0
  85. package/dist/types-DtvQ7UdD.js +1948 -0
  86. package/dist/types-wSh9fCBY.js +11 -0
  87. package/dist/units-CQ5Q99RC.js +150 -0
  88. package/dist/url-CRfCOhUI.js +172 -0
  89. package/dist/url-CsBkRaJy.js +225 -0
  90. package/dist/validate-BxpupdAf.js +2615 -0
  91. package/dist/validate-D6DJepZv.js +365 -0
  92. package/dist/version-Byqi2guX.js +28 -0
  93. package/dist/vite-scan-CT_vsp4g.js +242 -0
  94. package/dist/vite.d.ts +34 -0
  95. package/dist/vite.js +7558 -0
  96. package/dist/vue-i18n-g0iGy6CZ.js +651 -0
  97. package/dist/which-BOemZ5Q8.js +116 -0
  98. package/dist/worker-84U6uYB3.js +1656 -0
  99. package/package.json +97 -3
  100. package/schemas/catalog.json +57 -0
package/dist/lint.js ADDED
@@ -0,0 +1,971 @@
1
+ import { r as DEFAULT_IMPORT_SOURCES } from "./types-D3QGM8XO.js";
2
+ import { f as parseICU, p as quoteStarts, u as normalizeSource } from "./validate-BxpupdAf.js";
3
+ import { t as createCodec } from "./codec-DuvU2VQy.js";
4
+ import { t as VERSION } from "./version-Byqi2guX.js";
5
+ import { a as isIgnored, i as findMarkers, n as sourceSpecifiers, r as scanModule } from "./sources-D19czDSA.js";
6
+ import { t as findCapturedValues } from "./captured-Bj8bO6vd.js";
7
+ import { extname } from "node:path";
8
+ //#region src/lint/analysis.ts
9
+ /**
10
+ * What every `/lint` rule reads of a file, computed once per file: the
11
+ * settings, the marker comments, and the extraction scan itself, run on the
12
+ * linter's tree. Rules that mirror build errors and warnings therefore agree
13
+ * with `vite build` and `vi18n extract` on which calls are messages (import
14
+ * bindings, wrappers, aliases, globals, shadowing, descriptors).
15
+ */
16
+ const FRAMEWORKS = /* @__PURE__ */ new Set([
17
+ "react",
18
+ "preact",
19
+ "vue",
20
+ "svelte",
21
+ "solid"
22
+ ]);
23
+ const SETTINGS_KEYS = /* @__PURE__ */ new Set([
24
+ "importSources",
25
+ "aliases",
26
+ "globals",
27
+ "sourceLocale",
28
+ "root",
29
+ "framework"
30
+ ]);
31
+ /** Callee kinds whose result is text (as `src/core/extract/captured.ts` counts them). */
32
+ const TRANSLATING = /* @__PURE__ */ new Set([
33
+ "t",
34
+ "rich",
35
+ "richHtml",
36
+ "richParts",
37
+ "alias",
38
+ "global",
39
+ "translator"
40
+ ]);
41
+ const codec = createCodec(12);
42
+ function strings$1(value, key) {
43
+ if (value === void 0) return [];
44
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`vi18n lint: settings.vi18n.${key} must be an array of strings`);
45
+ return value;
46
+ }
47
+ const settingsCache = /* @__PURE__ */ new Map();
48
+ function settingsOf(context) {
49
+ const raw = context.settings?.vi18n ?? {};
50
+ const cwd = context.cwd ?? process.cwd();
51
+ const key = `${cwd}\0${JSON.stringify(raw)}`;
52
+ const known = settingsCache.get(key);
53
+ if (known !== void 0) return known;
54
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("vi18n lint: settings.vi18n must be an object");
55
+ const input = raw;
56
+ for (const name of Object.keys(input)) if (!SETTINGS_KEYS.has(name)) throw new Error(`vi18n lint: unknown setting settings.vi18n.${name}; expected one of ${[...SETTINGS_KEYS].join(", ")}`);
57
+ for (const name of [
58
+ "sourceLocale",
59
+ "root",
60
+ "framework"
61
+ ]) if (input[name] !== void 0 && typeof input[name] !== "string") throw new Error(`vi18n lint: settings.vi18n.${name} must be a string`);
62
+ const framework = input.framework ?? null;
63
+ if (framework !== null && !FRAMEWORKS.has(framework)) throw new Error(`vi18n lint: settings.vi18n.framework must be one of ${[...FRAMEWORKS].join(", ")}`);
64
+ const settings = {
65
+ importSources: strings$1(input.importSources, "importSources"),
66
+ aliases: strings$1(input.aliases, "aliases"),
67
+ globals: strings$1(input.globals, "globals"),
68
+ sourceLocale: input.sourceLocale ?? "en",
69
+ root: input.root ?? cwd,
70
+ framework
71
+ };
72
+ settingsCache.set(key, settings);
73
+ return settings;
74
+ }
75
+ const sourceConfigs = /* @__PURE__ */ new WeakMap();
76
+ /** The config slice `sourceSpecifiers` reads (it caches per object, so one per settings). */
77
+ function sourceConfig(settings) {
78
+ let config = sourceConfigs.get(settings);
79
+ if (config === void 0) {
80
+ config = {
81
+ root: settings.root,
82
+ extraction: {
83
+ importSources: [...DEFAULT_IMPORT_SOURCES, ...settings.importSources],
84
+ aliases: settings.aliases,
85
+ globals: settings.globals,
86
+ keep: [],
87
+ dependencies: []
88
+ }
89
+ };
90
+ sourceConfigs.set(settings, config);
91
+ }
92
+ return config;
93
+ }
94
+ /** Keys of linter nodes that the scan must not walk: back links and positions it re-reads as `start`/`end`. */
95
+ const DROPPED = /* @__PURE__ */ new Set([
96
+ "parent",
97
+ "loc",
98
+ "range",
99
+ "start",
100
+ "end",
101
+ "tokens",
102
+ "comments"
103
+ ]);
104
+ /**
105
+ * The linter's tree as the scan reads Oxc's: `start`/`end` from `range`, no
106
+ * `parent` (the scan walks every key of node types it does not list).
107
+ * Iterative, since `a + b + …` nests deeply. String literals are indexed on
108
+ * the way.
109
+ */
110
+ function scanTree(root, literals) {
111
+ const copy = (node) => ({
112
+ type: node.type,
113
+ start: node.range[0],
114
+ end: node.range[1]
115
+ });
116
+ const out = copy(root);
117
+ const stack = [[root, out]];
118
+ for (let entry = stack.pop(); entry !== void 0; entry = stack.pop()) {
119
+ const [node, target] = entry;
120
+ if (node.type === "Literal" && typeof node.value === "string" || node.type === "TemplateLiteral" && node.expressions.length === 0) literals.set(node.range[0], node);
121
+ for (const key in node) {
122
+ if (key === "type" || DROPPED.has(key)) continue;
123
+ const value = node[key];
124
+ if (Array.isArray(value)) target[key] = value.map((item) => {
125
+ if (!isNode(item)) return item;
126
+ const child = copy(item);
127
+ stack.push([item, child]);
128
+ return child;
129
+ });
130
+ else if (isNode(value)) {
131
+ const child = copy(value);
132
+ stack.push([value, child]);
133
+ target[key] = child;
134
+ } else target[key] = value;
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ function isNode(value) {
140
+ return value !== null && typeof value === "object" && typeof value.type === "string" && Array.isArray(value.range);
141
+ }
142
+ function langOf(filename) {
143
+ switch (extname(filename).toLowerCase()) {
144
+ case ".ts":
145
+ case ".mts":
146
+ case ".cts": return "ts";
147
+ case ".tsx": return "tsx";
148
+ case ".jsx": return "jsx";
149
+ default: return "js";
150
+ }
151
+ }
152
+ function commentsOf(context) {
153
+ return context.sourceCode.getAllComments().map((comment) => {
154
+ const range = comment.range ?? [comment.start ?? 0, comment.end ?? 0];
155
+ return {
156
+ type: comment.type,
157
+ value: comment.value,
158
+ start: range[0],
159
+ end: range[1]
160
+ };
161
+ });
162
+ }
163
+ function lineStartsOf(code) {
164
+ const starts = [0];
165
+ for (let i = code.indexOf("\n"); i !== -1; i = code.indexOf("\n", i + 1)) starts.push(i + 1);
166
+ return starts;
167
+ }
168
+ /** Whether a module's top level is module code: an export in an SFC script means `<script>` or `<script module>`. */
169
+ function exports(ast) {
170
+ return ast.body.some((statement) => statement.type === "ExportDefaultDeclaration" || statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration");
171
+ }
172
+ function frameworkOf(settings, extension, specifiers) {
173
+ if (extension === ".astro") return "astro";
174
+ if (extension === ".vue") return "vue";
175
+ if (extension === ".svelte") return "svelte";
176
+ if (settings.framework !== null) return settings.framework;
177
+ const from = (name) => specifiers.some((s) => s === name || s.startsWith(`${name}/`));
178
+ if (from("solid-js")) return "solid";
179
+ if (from("vue")) return "vue";
180
+ if (from("preact")) return "preact";
181
+ return "react";
182
+ }
183
+ const cache = /* @__PURE__ */ new WeakMap();
184
+ function analyse(context) {
185
+ const { sourceCode } = context;
186
+ const known = cache.get(sourceCode.ast);
187
+ if (known !== void 0) return known;
188
+ const code = sourceCode.text;
189
+ const settings = settingsOf(context);
190
+ const comments = code.includes("vi18n-") ? commentsOf(context) : [];
191
+ const markers = findMarkers(code, sourceCode.ast, comments);
192
+ const extension = extname(context.filename).toLowerCase();
193
+ const literals = /* @__PURE__ */ new Map();
194
+ const imports = [];
195
+ for (const statement of sourceCode.ast.body) {
196
+ if (statement.type !== "ImportDeclaration") continue;
197
+ const specifier = statement.source.value;
198
+ if (typeof specifier === "string") imports.push({
199
+ specifier,
200
+ start: 0,
201
+ end: 0,
202
+ bindings: []
203
+ });
204
+ }
205
+ const specifiers = sourceSpecifiers(imports, context.filename, sourceConfig(settings));
206
+ const sfc = extension === ".vue" || extension === ".svelte" || extension === ".astro";
207
+ let scan = null;
208
+ let parsed = null;
209
+ if (specifiers.size > 0 || settings.globals.length > 0) {
210
+ const program = scanTree(sourceCode.ast, literals);
211
+ parsed = {
212
+ code,
213
+ lang: langOf(context.filename),
214
+ program,
215
+ comments
216
+ };
217
+ scan = scanModule(parsed, {
218
+ codec,
219
+ refFile: context.filename,
220
+ sourceSpecifiers: specifiers,
221
+ aliases: settings.aliases,
222
+ globals: settings.globals,
223
+ moduleScope: "warn",
224
+ warnings: true,
225
+ exactPositions: true,
226
+ sourceLocale: settings.sourceLocale,
227
+ ranges: true
228
+ });
229
+ }
230
+ const translating = /* @__PURE__ */ new Map();
231
+ for (const call of scan?.calls ?? []) if (TRANSLATING.has(call.kind) && call.callStart !== void 0 && call.callEnd !== void 0) translating.set(call.callStart, call.callEnd);
232
+ const analysis = {
233
+ code,
234
+ settings,
235
+ markers,
236
+ scan,
237
+ parsed,
238
+ literals,
239
+ translating,
240
+ component: extension === ".astro" || sfc && !exports(sourceCode.ast),
241
+ framework: frameworkOf(settings, extension, imports.map((record) => record.specifier)),
242
+ lineStarts: lineStartsOf(code)
243
+ };
244
+ cache.set(sourceCode.ast, analysis);
245
+ return analysis;
246
+ }
247
+ /** Whether `vi18n-ignore` covers the node. */
248
+ function ignored(analysis, node) {
249
+ return analysis.markers.ignored.length > 0 && isIgnored(analysis.markers, node.range[0]);
250
+ }
251
+ const WRAPPERS = /* @__PURE__ */ new Set([
252
+ "TSAsExpression",
253
+ "TSSatisfiesExpression",
254
+ "TSNonNullExpression",
255
+ "TSTypeAssertion",
256
+ "ChainExpression"
257
+ ]);
258
+ /** The expression under TypeScript-only wrappers and `?.` chains. */
259
+ function unwrap(node) {
260
+ let current = node;
261
+ while (WRAPPERS.has(current.type)) current = current.expression;
262
+ return current;
263
+ }
264
+ /** Whether the node is a call that renders a message (`t`, `rich*`, a translator's or an alias). */
265
+ function isTranslating(analysis, node) {
266
+ const call = unwrap(node);
267
+ return call.type === "CallExpression" && analysis.translating.get(call.range[0]) === call.range[1];
268
+ }
269
+ /** The offset of a 1-based line and column of `analysis.code`. */
270
+ function offsetOf(analysis, line, column) {
271
+ return (analysis.lineStarts[line - 1] ?? 0) + column - 1;
272
+ }
273
+ /** Reports at an offset range of the file. */
274
+ function reportRange(context, range, messageId, data, fix) {
275
+ const { sourceCode } = context;
276
+ context.report({
277
+ loc: {
278
+ start: sourceCode.getLocFromIndex(range[0]),
279
+ end: sourceCode.getLocFromIndex(range[1])
280
+ },
281
+ messageId,
282
+ ...data === void 0 ? {} : { data },
283
+ ...fix === void 0 ? {} : { fix }
284
+ });
285
+ }
286
+ const DOCS = "https://github.com/menapo/kit/blob/main/packages/vi18n/docs/guide/lint.md";
287
+ /** Rule docs link: the guide's section of that rule. */
288
+ function docs(rule, description, recommended = true) {
289
+ return {
290
+ description,
291
+ recommended,
292
+ url: `${DOCS}#${rule}`
293
+ };
294
+ }
295
+ //#endregion
296
+ //#region src/lint/rules/no-concatenation.ts
297
+ /**
298
+ * `no-concatenation`: translated text joined with other text in code, which
299
+ * fixes the word order for every language and splits one sentence into
300
+ * pieces a translator sees apart (PLAN §5.21). Covers `+`, template
301
+ * literals, `concat()`, `[…].join()`, and a translation next to text or
302
+ * another translation in JSX. Tagged templates (`html\`…\``) build markup,
303
+ * not sentences, and are left alone.
304
+ */
305
+ function report(context, analysis, node) {
306
+ if (!ignored(analysis, node)) context.report({
307
+ node,
308
+ messageId: "concatenated"
309
+ });
310
+ }
311
+ /** The operands of a `+` chain. */
312
+ function operands(node, into) {
313
+ const stack = [node];
314
+ for (let next = stack.pop(); next !== void 0; next = stack.pop()) {
315
+ const current = unwrap(next);
316
+ if (current.type === "BinaryExpression" && current.operator === "+") stack.push(current.right, current.left);
317
+ else into.push(current);
318
+ }
319
+ }
320
+ function isPlus(node) {
321
+ return node?.type === "BinaryExpression" && node.operator === "+";
322
+ }
323
+ /** A JSX child that renders as text: non-blank text, or blank text on one line (a space). */
324
+ function textual(child) {
325
+ if (child.type !== "JSXText") return false;
326
+ const value = child.value;
327
+ return value.trim() !== "" || value !== "" && !value.includes("\n");
328
+ }
329
+ function isTranslationChild(analysis, child) {
330
+ return child.type === "JSXExpressionContainer" && child.expression.type !== "JSXEmptyExpression" && isTranslating(analysis, child.expression);
331
+ }
332
+ const noConcatenation = {
333
+ meta: {
334
+ type: "suggestion",
335
+ docs: docs("no-concatenation", "Do not join translated text with other text"),
336
+ schema: [],
337
+ messages: { concatenated: "Translated text is joined with other text here, which fixes the word order for every language: make it one message with ICU arguments, such as t(\"{name} joined {team}\", { name, team })" }
338
+ },
339
+ create(context) {
340
+ const analysis = analyse(context);
341
+ if (analysis.translating.size === 0) return {};
342
+ return {
343
+ BinaryExpression(node) {
344
+ if (node.operator !== "+") return;
345
+ let parent = node.parent;
346
+ while (parent && parent.type.startsWith("TS") && parent.type.endsWith("Expression")) parent = parent.parent;
347
+ if (isPlus(parent)) return;
348
+ const parts = [];
349
+ operands(node, parts);
350
+ if (parts.some((part) => isTranslating(analysis, part))) report(context, analysis, node);
351
+ },
352
+ TemplateLiteral(node) {
353
+ if (node.parent?.type === "TaggedTemplateExpression") return;
354
+ const expressions = node.expressions;
355
+ if (!expressions.some((expression) => isTranslating(analysis, expression))) return;
356
+ const quasis = node.quasis;
357
+ if (expressions.length > 1 || quasis.some((quasi) => quasi.value.raw.trim() !== "")) report(context, analysis, node);
358
+ },
359
+ CallExpression(node) {
360
+ const callee = unwrap(node.callee);
361
+ if (callee.type !== "MemberExpression" || callee.computed === true) return;
362
+ const name = callee.property.name;
363
+ const object = unwrap(callee.object);
364
+ const args = node.arguments;
365
+ if (name === "concat") {
366
+ if ([object, ...args].some((part) => isTranslating(analysis, part))) report(context, analysis, node);
367
+ } else if (name === "join" && object.type === "ArrayExpression") {
368
+ const elements = object.elements.filter((element) => element !== null);
369
+ if (elements.length > 1 && elements.some((element) => isTranslating(analysis, element))) report(context, analysis, node);
370
+ }
371
+ },
372
+ "JSXElement, JSXFragment"(node) {
373
+ const children = node.children.filter((child) => child.type !== "JSXText" || textual(child));
374
+ const joins = (at, step) => {
375
+ const next = children[at + step];
376
+ if (next === void 0) return false;
377
+ if (isTranslationChild(analysis, next)) return true;
378
+ if (next.type !== "JSXText") return false;
379
+ if (next.value.trim() !== "") return true;
380
+ const beyond = children[at + 2 * step];
381
+ return beyond !== void 0 && isTranslationChild(analysis, beyond);
382
+ };
383
+ for (let i = 0; i < children.length; i++) {
384
+ const child = children[i];
385
+ if (isTranslationChild(analysis, child) && (joins(i, -1) || joins(i, 1))) {
386
+ report(context, analysis, child);
387
+ return;
388
+ }
389
+ }
390
+ }
391
+ };
392
+ }
393
+ };
394
+ //#endregion
395
+ //#region src/lint/rules/no-dynamic-message.ts
396
+ const CODES = /* @__PURE__ */ new Set([
397
+ "extract-dynamic-message",
398
+ "extract-non-literal-option",
399
+ "extract-invalid-manual-id"
400
+ ]);
401
+ const noDynamicMessage = {
402
+ meta: {
403
+ type: "problem",
404
+ docs: docs("no-dynamic-message", "Messages must be literals extraction can read"),
405
+ schema: [],
406
+ messages: { dynamic: "{{message}}" }
407
+ },
408
+ create(context) {
409
+ return { Program() {
410
+ for (const diagnostic of analyse(context).scan?.diagnostics ?? []) {
411
+ if (!CODES.has(diagnostic.code) || diagnostic.range === void 0) continue;
412
+ reportRange(context, diagnostic.range, "dynamic", { message: diagnostic.message });
413
+ }
414
+ } };
415
+ }
416
+ };
417
+ //#endregion
418
+ //#region src/lint/jsx.ts
419
+ /** Attributes assistive technology reads or browsers show as text (`translate-a11y-attrs`). */
420
+ const A11Y_ATTRIBUTES = [
421
+ "alt",
422
+ "aria-description",
423
+ "aria-label",
424
+ "aria-placeholder",
425
+ "aria-roledescription",
426
+ "aria-valuetext",
427
+ "placeholder",
428
+ "title"
429
+ ];
430
+ /** Elements whose text is code or markup, not prose (`no-literal-string`). */
431
+ const IGNORED_ELEMENTS = [
432
+ "code",
433
+ "kbd",
434
+ "pre",
435
+ "samp",
436
+ "script",
437
+ "style"
438
+ ];
439
+ /** `<input type="…">` whose `value` is the button's label. */
440
+ const BUTTON_TYPES = /* @__PURE__ */ new Set([
441
+ "button",
442
+ "reset",
443
+ "submit"
444
+ ]);
445
+ const ENTITY = /&(?:#\d+|#x[\da-f]+|[a-z][a-z\d]*);/gi;
446
+ const LETTER = /\p{L}/u;
447
+ /** Whether text holds a letter: punctuation, digits, symbols and entities alone need no translation. */
448
+ function hasLetters(text) {
449
+ return LETTER.test(text.replace(ENTITY, ""));
450
+ }
451
+ /** `name`, `ns:name` or `A.B`. */
452
+ function jsxName(name) {
453
+ if (!name) return null;
454
+ switch (name.type) {
455
+ case "JSXIdentifier": return name.name;
456
+ case "JSXNamespacedName": return `${jsxName(name.namespace)}:${jsxName(name.name)}`;
457
+ case "JSXMemberExpression": return `${jsxName(name.object)}.${jsxName(name.property)}`;
458
+ default: return null;
459
+ }
460
+ }
461
+ /** The string literal value of an attribute (`a="x"`, `a={"x"}`), or null. */
462
+ function attributeText(opening, name) {
463
+ for (const attribute of opening.attributes) {
464
+ if (attribute.type !== "JSXAttribute" || jsxName(attribute.name) !== name) continue;
465
+ let value = attribute.value;
466
+ if (value?.type === "JSXExpressionContainer") value = value.expression;
467
+ return value?.type === "Literal" && typeof value.value === "string" ? value.value : null;
468
+ }
469
+ return null;
470
+ }
471
+ /**
472
+ * Whether the node sits in text that is not for translators: inside an
473
+ * ignored element, or an element (its own included) with HTML's
474
+ * `translate="no"`.
475
+ */
476
+ function untranslatable(node, ignoredElements) {
477
+ for (let current = node.parent; current; current = current.parent) {
478
+ if (current.type !== "JSXElement") continue;
479
+ const opening = current.openingElement;
480
+ const name = jsxName(opening.name);
481
+ if (name !== null && ignoredElements.has(name)) return true;
482
+ if (attributeText(opening, "translate") === "no") return true;
483
+ }
484
+ return false;
485
+ }
486
+ /**
487
+ * The string literals an expression renders as they are: the literal
488
+ * itself, the branches of `? :`, and what `&&`, `||` and `??` can yield as
489
+ * text.
490
+ */
491
+ function renderedStrings(expression) {
492
+ const out = [];
493
+ const stack = [expression];
494
+ for (let next = stack.pop(); next !== void 0; next = stack.pop()) switch (next.type) {
495
+ case "JSXExpressionContainer":
496
+ stack.push(next.expression);
497
+ break;
498
+ case "Literal":
499
+ if (typeof next.value === "string") out.push(next);
500
+ break;
501
+ case "TemplateLiteral":
502
+ if (next.expressions.length === 0) out.push(next);
503
+ break;
504
+ case "ConditionalExpression":
505
+ stack.push(next.alternate, next.consequent);
506
+ break;
507
+ case "LogicalExpression":
508
+ stack.push(next.right);
509
+ if (next.operator !== "&&") stack.push(next.left);
510
+ break;
511
+ case "TSAsExpression":
512
+ case "TSSatisfiesExpression":
513
+ case "TSNonNullExpression": stack.push(next.expression);
514
+ }
515
+ return out;
516
+ }
517
+ /** The text of a literal `renderedStrings` returned. */
518
+ function literalText(node) {
519
+ if (node.type === "Literal") return node.value;
520
+ const quasi = node.quasis[0];
521
+ return quasi?.value.cooked ?? quasi?.value.raw ?? "";
522
+ }
523
+ /** Whether the attribute is the label of an `<input type="button|reset|submit">`. */
524
+ function isButtonValue(attribute) {
525
+ if (jsxName(attribute.name) !== "value") return false;
526
+ const opening = attribute.parent;
527
+ if (!opening || jsxName(opening.name) !== "input") return false;
528
+ const type = attributeText(opening, "type");
529
+ return type !== null && BUTTON_TYPES.has(type.toLowerCase());
530
+ }
531
+ /** Text shortened for a message. */
532
+ function quote(text) {
533
+ const flat = text.trim().replace(/\s+/g, " ");
534
+ return flat.length > 40 ? `${flat.slice(0, 39)}…` : flat;
535
+ }
536
+ //#endregion
537
+ //#region src/lint/rules/no-literal-string.ts
538
+ /**
539
+ * `no-literal-string` (PLAN §4.13): text a JSX component renders without a
540
+ * translation call: JSX text, string children (`{"…"}`, either branch of a
541
+ * `? :`), and the attributes in `attributes` (`label` by default, and the
542
+ * `value` of a button `<input>`). Text without a letter, text inside
543
+ * `ignoreElements` (code, kbd, pre, samp, script, style) or under HTML's
544
+ * `translate="no"`, text matching an `ignore` pattern, and code under
545
+ * `vi18n-ignore` are left alone. The accessibility attributes are
546
+ * `translate-a11y-attrs`'s, so the two rules never report one string twice.
547
+ */
548
+ const strings = {
549
+ type: "array",
550
+ items: { type: "string" },
551
+ uniqueItems: true
552
+ };
553
+ const noLiteralString = {
554
+ meta: {
555
+ type: "suggestion",
556
+ docs: docs("no-literal-string", "Translate the text components render"),
557
+ schema: [{
558
+ type: "object",
559
+ properties: {
560
+ attributes: strings,
561
+ ignoreElements: strings,
562
+ ignore: strings
563
+ },
564
+ additionalProperties: false
565
+ }],
566
+ messages: {
567
+ text: "Text \"{{text}}\" is not translated: render t(\"…\") instead",
568
+ attribute: "Attribute {{name}}=\"{{text}}\" is not translated: pass t(\"…\") instead"
569
+ }
570
+ },
571
+ create(context) {
572
+ const options = context.options[0] ?? {};
573
+ const attributes = new Set(options.attributes ?? ["label"]);
574
+ const elements = new Set(options.ignoreElements ?? IGNORED_ELEMENTS);
575
+ const patterns = (options.ignore ?? []).map((source) => new RegExp(source, "u"));
576
+ const analysis = analyse(context);
577
+ const skip = (node, text) => !hasLetters(text) || patterns.some((pattern) => pattern.test(text.trim())) || ignored(analysis, node) || untranslatable(node, elements);
578
+ return {
579
+ JSXText(node) {
580
+ const text = node.value;
581
+ if (skip(node, text)) return;
582
+ const [start, end] = node.range;
583
+ reportRange(context, [start + (text.length - text.trimStart().length), end - (text.length - text.trimEnd().length)], "text", { text: quote(text) });
584
+ },
585
+ JSXExpressionContainer(node) {
586
+ const parent = node.parent?.type;
587
+ if (parent !== "JSXElement" && parent !== "JSXFragment") return;
588
+ for (const literal of renderedStrings(node)) {
589
+ const text = literalText(literal);
590
+ if (!skip(literal, text)) context.report({
591
+ node: literal,
592
+ messageId: "text",
593
+ data: { text: quote(text) }
594
+ });
595
+ }
596
+ },
597
+ JSXAttribute(node) {
598
+ const name = jsxName(node.name);
599
+ if (name === null || node.value === null) return;
600
+ if (!attributes.has(name) && !isButtonValue(node)) return;
601
+ for (const literal of renderedStrings(node.value)) {
602
+ const text = literalText(literal);
603
+ if (!skip(literal, text)) context.report({
604
+ node: literal,
605
+ messageId: "attribute",
606
+ data: {
607
+ name,
608
+ text: quote(text)
609
+ }
610
+ });
611
+ }
612
+ }
613
+ };
614
+ }
615
+ };
616
+ //#endregion
617
+ //#region src/lint/rules/no-module-scope-t.ts
618
+ /**
619
+ * `no-module-scope-t`: `extract-module-scope`, a `t()` call that runs when
620
+ * the module is imported (PLAN §5.3). The top level of a Vue or Svelte
621
+ * component script and of Astro frontmatter runs per instance or request, so
622
+ * it is not module scope there, as in the compiled code the build scans.
623
+ */
624
+ const noModuleScopeT = {
625
+ meta: {
626
+ type: "problem",
627
+ docs: docs("no-module-scope-t", "Translate inside functions and components, or declare module-level messages with msg()"),
628
+ schema: [],
629
+ messages: { moduleScope: "{{message}}" }
630
+ },
631
+ create(context) {
632
+ return { Program() {
633
+ const analysis = analyse(context);
634
+ if (analysis.component) return;
635
+ for (const diagnostic of analysis.scan?.diagnostics ?? []) {
636
+ if (diagnostic.code !== "extract-module-scope" || diagnostic.range === void 0) continue;
637
+ reportRange(context, diagnostic.range, "moduleScope", { message: diagnostic.message });
638
+ }
639
+ } };
640
+ }
641
+ };
642
+ //#endregion
643
+ //#region src/lint/rules/prefer-full-sentence.ts
644
+ /**
645
+ * `prefer-full-sentence`: a message that reads as part of a sentence, which
646
+ * code then completes with other text or another message. Translators need
647
+ * the whole sentence: word order, agreement and punctuation change around
648
+ * the pieces. Whitespace at either end shows it in any language; for an
649
+ * English `sourceLocale`, so does a message ending on an article,
650
+ * conjunction or preposition, or starting on a lowercase conjunction.
651
+ */
652
+ /** Words a complete English UI string does not end on. Particles of phrasal verbs ("Sign in", "Log out") are left out. */
653
+ const ENDS = /* @__PURE__ */ new Set([
654
+ "a",
655
+ "an",
656
+ "the",
657
+ "and",
658
+ "or",
659
+ "but",
660
+ "nor",
661
+ "of",
662
+ "to",
663
+ "for",
664
+ "with",
665
+ "from",
666
+ "than",
667
+ "as",
668
+ "at",
669
+ "via",
670
+ "per",
671
+ "into",
672
+ "your",
673
+ "my",
674
+ "our",
675
+ "their",
676
+ "its"
677
+ ]);
678
+ /** Lowercase words that continue a sentence begun elsewhere. */
679
+ const STARTS = /* @__PURE__ */ new Set([
680
+ "and",
681
+ "or",
682
+ "but",
683
+ "nor",
684
+ "of",
685
+ "to",
686
+ "than",
687
+ "with",
688
+ "from"
689
+ ]);
690
+ const LAST_WORD = /(?:^|[^\p{L}\p{M}'’-])(\p{L}[\p{L}\p{M}]*)\s*$/u;
691
+ const FIRST_WORD = /^\s*(\p{L}[\p{L}\p{M}]*)(?![\p{L}\p{M}'’-])/u;
692
+ /** The reason a message reads as a fragment, or null. */
693
+ function fragmentOf(source, english) {
694
+ if (source.trim() === "") return null;
695
+ if (source !== source.trim()) return { reason: /^\s/.test(source) ? "starts with whitespace" : "ends with whitespace" };
696
+ if (!english) return null;
697
+ const last = LAST_WORD.exec(source)?.[1];
698
+ if (last !== void 0 && ENDS.has(last.toLowerCase())) return { reason: `ends with "${last}"` };
699
+ const first = FIRST_WORD.exec(source)?.[1];
700
+ if (first !== void 0 && STARTS.has(first)) return { reason: `starts with "${first}"` };
701
+ return null;
702
+ }
703
+ const preferFullSentence = {
704
+ meta: {
705
+ type: "suggestion",
706
+ docs: docs("prefer-full-sentence", "Write whole sentences as one message"),
707
+ schema: [],
708
+ messages: { fragment: "This message {{reason}}, so it reads as part of a sentence: make the whole sentence one message, with ICU arguments for what changes" }
709
+ },
710
+ create(context) {
711
+ return { Program() {
712
+ const analysis = analyse(context);
713
+ const english = /^en(?:-|$)/i.test(analysis.settings.sourceLocale);
714
+ const seen = /* @__PURE__ */ new Set();
715
+ for (const occurrence of analysis.scan?.occurrences ?? []) {
716
+ const data = fragmentOf(occurrence.source, english);
717
+ if (data === null) continue;
718
+ const offset = offsetOf(analysis, occurrence.line, occurrence.column);
719
+ const literal = analysis.literals.get(offset);
720
+ if (literal === void 0 || seen.has(offset) || ignored(analysis, literal)) continue;
721
+ seen.add(offset);
722
+ context.report({
723
+ node: literal,
724
+ messageId: "fragment",
725
+ data
726
+ });
727
+ }
728
+ } };
729
+ }
730
+ };
731
+ //#endregion
732
+ //#region src/lint/rules/t-result-captured.ts
733
+ /**
734
+ * `t-result-captured`: a translation computed once and kept, which an
735
+ * in-place locale switch leaves in the old locale (PLAN §5.15). The same
736
+ * finder as the plugin's dev warning (`extract-captured-value`), on the
737
+ * source: React and Preact hook initialisers, Vue `setup()` and `<script
738
+ * setup>`, Svelte component scripts, Solid component bodies.
739
+ */
740
+ const tResultCaptured = {
741
+ meta: {
742
+ type: "problem",
743
+ docs: docs("t-result-captured", "Do not keep a translation where a locale switch cannot update it"),
744
+ schema: [],
745
+ messages: { captured: "{{message}}" }
746
+ },
747
+ create(context) {
748
+ return { Program() {
749
+ const analysis = analyse(context);
750
+ if (analysis.scan === null || analysis.parsed === null) return;
751
+ const found = findCapturedValues(analysis.parsed, analysis.scan, analysis.framework, {
752
+ topLevel: analysis.component,
753
+ ranges: true
754
+ });
755
+ for (const diagnostic of found) {
756
+ if (diagnostic.range === void 0) continue;
757
+ reportRange(context, diagnostic.range, "captured", { message: diagnostic.message });
758
+ }
759
+ } };
760
+ }
761
+ };
762
+ //#endregion
763
+ //#region src/lint/rules/translate-a11y-attrs.ts
764
+ /**
765
+ * `translate-a11y-attrs` (PLAN §4.16): literal text in the attributes
766
+ * screen readers announce and browsers show (`alt`, `aria-label`,
767
+ * `aria-description`, `aria-placeholder`, `aria-roledescription`,
768
+ * `aria-valuetext`, `placeholder`, `title`, and any in `attributes`).
769
+ * Untranslated, they are the strings a pseudo-locale pass misses, since no
770
+ * one sees them. An empty `alt=""` (a decorative image), text without a
771
+ * letter, HTML's `translate="no"` and `vi18n-ignore` are left alone.
772
+ */
773
+ const NO_ELEMENTS = /* @__PURE__ */ new Set();
774
+ const translateA11yAttrs = {
775
+ meta: {
776
+ type: "suggestion",
777
+ docs: docs("translate-a11y-attrs", "Translate alt, aria-label, title, placeholder and other text attributes"),
778
+ schema: [{
779
+ type: "object",
780
+ properties: { attributes: {
781
+ type: "array",
782
+ items: { type: "string" },
783
+ uniqueItems: true
784
+ } },
785
+ additionalProperties: false
786
+ }],
787
+ messages: { attribute: "Attribute {{name}}=\"{{text}}\" is read by assistive technology but not translated: pass t(\"…\") instead" }
788
+ },
789
+ create(context) {
790
+ const extra = context.options[0]?.attributes ?? [];
791
+ const attributes = /* @__PURE__ */ new Set([...A11Y_ATTRIBUTES, ...extra]);
792
+ const analysis = analyse(context);
793
+ return { JSXAttribute(node) {
794
+ const name = jsxName(node.name);
795
+ if (name === null || node.value === null || !attributes.has(name)) return;
796
+ for (const literal of renderedStrings(node.value)) {
797
+ const text = literalText(literal);
798
+ if (!hasLetters(text) || ignored(analysis, literal) || untranslatable(literal, NO_ELEMENTS)) continue;
799
+ context.report({
800
+ node: literal,
801
+ messageId: "attribute",
802
+ data: {
803
+ name,
804
+ text: quote(text)
805
+ }
806
+ });
807
+ }
808
+ } };
809
+ }
810
+ };
811
+ //#endregion
812
+ //#region src/lint/rules/valid-icu.ts
813
+ /**
814
+ * `valid-icu`: the ICU checks `vite build` and `vi18n extract` run on every
815
+ * extracted message (INTERNALS §2.1.3): syntax, argument styles and
816
+ * skeletons, bidi controls, plural categories of `sourceLocale`, and the
817
+ * apostrophe lint (`icu-apostrophe`, PLAN §4.2), which this rule fixes by
818
+ * doubling the apostrophe: `''` prints the straight apostrophe the author
819
+ * typed, and what follows it stays a placeholder or tag.
820
+ */
821
+ /** The value of a string literal or a template literal without expressions, or null. */
822
+ function valueOf(node) {
823
+ if (node.type === "Literal") return typeof node.value === "string" ? node.value : null;
824
+ const quasi = node.quasis?.[0];
825
+ return typeof quasi?.value.cooked === "string" ? quasi.value.cooked : null;
826
+ }
827
+ /**
828
+ * For each UTF-16 unit of a literal's value, the offset in its body (the
829
+ * text between the quotes) where the unit's character starts, plus the
830
+ * body's length. Escapes it does not follow (legacy octal) make the list
831
+ * longer or shorter than the value, which the caller checks.
832
+ */
833
+ function bodyOffsets(body) {
834
+ const offsets = [];
835
+ for (let i = 0; i < body.length;) {
836
+ if (body[i] !== "\\") {
837
+ offsets.push(i++);
838
+ continue;
839
+ }
840
+ const next = body[i + 1];
841
+ if (next === "\n" || next === "\r" || next === "\u2028" || next === "\u2029") i += next === "\r" && body[i + 2] === "\n" ? 3 : 2;
842
+ else if (next === "x") {
843
+ offsets.push(i);
844
+ i += 4;
845
+ } else if (next === "u" && body[i + 2] === "{") {
846
+ const close = body.indexOf("}", i);
847
+ const code = Number.parseInt(body.slice(i + 3, close), 16);
848
+ offsets.push(i);
849
+ if (code > 65535) offsets.push(i);
850
+ i = close + 1;
851
+ } else if (next === "u") {
852
+ offsets.push(i);
853
+ i += 6;
854
+ } else {
855
+ offsets.push(i);
856
+ i += 2;
857
+ }
858
+ }
859
+ offsets.push(body.length);
860
+ return offsets;
861
+ }
862
+ /**
863
+ * The literal rewritten with every apostrophe that starts quoted text
864
+ * doubled, or null when that would not leave valid ICU without quoted text,
865
+ * or the value's offsets cannot be mapped to the source (NFC or CRLF
866
+ * changes, escapes that do not decode one to one).
867
+ */
868
+ function apostropheFix(node, code) {
869
+ const value = valueOf(node);
870
+ if (value === null || normalizeSource(value) !== value) return null;
871
+ const starts = quoteStarts(value);
872
+ if (starts.length === 0) return null;
873
+ let fixed = value;
874
+ for (let i = starts.length - 1; i >= 0; i--) {
875
+ const at = starts[i];
876
+ fixed = `${fixed.slice(0, at)}'${fixed.slice(at)}`;
877
+ }
878
+ try {
879
+ parseICU(fixed);
880
+ } catch {
881
+ return null;
882
+ }
883
+ if (quoteStarts(fixed).length > 0) return null;
884
+ const raw = code.slice(node.range[0], node.range[1]);
885
+ const quote = raw[0];
886
+ let body = raw.slice(1, -1);
887
+ const offsets = bodyOffsets(body);
888
+ if (offsets.length !== value.length + 1) return null;
889
+ const insert = quote === "'" ? "\\'" : "'";
890
+ for (let i = starts.length - 1; i >= 0; i--) {
891
+ const at = offsets[starts[i]];
892
+ body = `${body.slice(0, at)}${insert}${body.slice(at)}`;
893
+ }
894
+ return `${quote}${body}${quote}`;
895
+ }
896
+ //#endregion
897
+ //#region src/lint/plugin.ts
898
+ /**
899
+ * The `@menapo/vi18n/lint` plugin (PLAN §4.13, §5.21; INTERNALS §3.7): ESLint
900
+ * v9 rules that oxlint loads through `jsPlugins`, so they run inside `vp
901
+ * check`. The rules that mirror build errors are errors in `recommended`;
902
+ * the rest are warnings.
903
+ */
904
+ const rules = {
905
+ "no-literal-string": noLiteralString,
906
+ "no-dynamic-message": noDynamicMessage,
907
+ "no-concatenation": noConcatenation,
908
+ "no-module-scope-t": noModuleScopeT,
909
+ "t-result-captured": tResultCaptured,
910
+ "prefer-full-sentence": preferFullSentence,
911
+ "translate-a11y-attrs": translateA11yAttrs,
912
+ "valid-icu": {
913
+ meta: {
914
+ type: "problem",
915
+ docs: docs("valid-icu", "Messages must be valid ICU, without apostrophes that quote text"),
916
+ fixable: "code",
917
+ schema: [],
918
+ messages: { icu: "{{message}}" }
919
+ },
920
+ create(context) {
921
+ return { Program() {
922
+ const analysis = analyse(context);
923
+ const apostrophes = /* @__PURE__ */ new Map();
924
+ for (const diagnostic of analysis.scan?.diagnostics ?? []) {
925
+ const { range } = diagnostic;
926
+ if (!diagnostic.code.startsWith("icu-") || range === void 0) continue;
927
+ if (diagnostic.code !== "icu-apostrophe") reportRange(context, range, "icu", { message: diagnostic.message });
928
+ else if (!apostrophes.has(range[0])) apostrophes.set(range[0], diagnostic);
929
+ }
930
+ for (const [start, diagnostic] of apostrophes) {
931
+ const literal = analysis.literals.get(start);
932
+ const fixed = literal === void 0 ? null : apostropheFix(literal, analysis.code);
933
+ reportRange(context, diagnostic.range, "icu", { message: diagnostic.message }, fixed === null || literal === void 0 ? void 0 : (fixer) => fixer.replaceTextRange(literal.range, fixed));
934
+ }
935
+ } };
936
+ }
937
+ }
938
+ };
939
+ const recommended = {
940
+ name: "vi18n/recommended",
941
+ plugins: {},
942
+ rules: Object.fromEntries(Object.entries({
943
+ "no-literal-string": "warn",
944
+ "no-dynamic-message": "error",
945
+ "no-concatenation": "warn",
946
+ "no-module-scope-t": "warn",
947
+ "t-result-captured": "warn",
948
+ "prefer-full-sentence": "warn",
949
+ "translate-a11y-attrs": "warn",
950
+ "valid-icu": "error"
951
+ }).map(([rule, severity]) => [`vi18n/${rule}`, severity]))
952
+ };
953
+ const plugin = {
954
+ meta: {
955
+ name: "vi18n",
956
+ version: VERSION
957
+ },
958
+ rules,
959
+ configs: { recommended }
960
+ };
961
+ recommended.plugins.vi18n = plugin;
962
+ //#endregion
963
+ //#region src/lint.ts
964
+ /**
965
+ * `@menapo/vi18n/lint` (INTERNALS §3.7): the lint plugin, as the module's
966
+ * default export because ESLint flat configs and oxlint's `jsPlugins` both
967
+ * load a plugin that way.
968
+ */
969
+ var lint_default = plugin;
970
+ //#endregion
971
+ export { lint_default as default };