@vestra-player/sdk 1.5.8 → 1.5.11

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 (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +4 -1
  3. package/bin/vestra-plugin.mjs +170 -7
  4. package/dist/commands.d.ts +7 -4
  5. package/dist/i18n-tools.d.ts +67 -0
  6. package/dist/i18n-tools.js +787 -0
  7. package/dist/i18n.d.ts +222 -0
  8. package/dist/i18n.js +972 -0
  9. package/dist/player-runtime.d.ts +2 -0
  10. package/dist/player-runtime.js +2 -0
  11. package/dist/plugin/appearance.js +27 -1
  12. package/dist/plugin/bindings.js +1 -0
  13. package/dist/plugin/commands.js +24 -4
  14. package/dist/plugin/index.js +9 -1
  15. package/dist/plugin/language.d.ts +8 -0
  16. package/dist/plugin/language.js +79 -0
  17. package/dist/plugin/osd.d.ts +11 -0
  18. package/dist/plugin/osd.js +64 -9
  19. package/dist/plugin/packages.js +10 -0
  20. package/dist/plugin/runtime.d.ts +3 -1
  21. package/dist/plugin/runtime.js +6 -0
  22. package/dist/plugin/scope.d.ts +1 -0
  23. package/dist/plugin/scope.js +2 -0
  24. package/dist/plugin/subtitles.js +9 -1
  25. package/dist/plugin/testing.d.ts +184 -5
  26. package/dist/plugin/testing.js +459 -67
  27. package/dist/plugin/timeline.js +3 -1
  28. package/dist/plugin/types.d.ts +523 -54
  29. package/dist/plugin/ui.js +29 -10
  30. package/dist/plugin/values.d.ts +55 -0
  31. package/dist/plugin/values.js +117 -0
  32. package/dist/plugin/window.d.ts +2 -0
  33. package/dist/plugin/window.js +4 -0
  34. package/dist/source-resolvers.d.ts +0 -1
  35. package/dist/source-resolvers.js +9 -1
  36. package/dist/subtitles.d.ts +3 -1
  37. package/dist/wire.d.ts +38 -6
  38. package/dist/wire.js +13 -2
  39. package/llms-full.txt +646 -58
  40. package/llms.txt +44 -40
  41. package/package.json +1 -1
  42. package/slint/vestra/markdown.slint +5 -5
  43. package/slint/vestra/widgets.slint +4 -4
@@ -0,0 +1,787 @@
1
+ /**
2
+ * `vestra-plugin i18n extract` and `vestra-plugin i18n check`, and the
3
+ * catalog step of `vestra-plugin build` (docs/how-to/i18n.md): find the
4
+ * translatable texts of a plugin, keep `locales/messages.pot` and
5
+ * `locales/<lang>.po` up to date, warn about what cannot be translated, and
6
+ * compile the `.po` files the player reads.
7
+ *
8
+ * Translatable texts are the `@tr` calls of the plugin's `.slint` files, the
9
+ * declaration fields and sentences it hands the player (`commands.add`
10
+ * title, path and reason, `Command.set` reason, `settings.page` and
11
+ * `panels.mount` titles, `osd.show`, `OsdPanel.set`, `dialogs.*` texts) and
12
+ * the manifest's display fields. Plugins write no translation code: the
13
+ * player translates these with the plugin's catalog.
14
+ *
15
+ * @internal
16
+ * @module
17
+ */
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
19
+ import { join, relative, sep } from "node:path";
20
+ import ts from "typescript";
21
+ import { compileCatalog, keyOf, LOCALES, parsePo, placeholders, scanSlint, writePo, } from "./i18n.js";
22
+ /** Locales whose plural has one form (`n == 1` and other are the same). */
23
+ const ONE_FORM = new Set(["zh-Hans", "ja", "ko"]);
24
+ export const pluralForms = (language) => ONE_FORM.has(language)
25
+ ? "nplurals=1; plural=0;"
26
+ : "nplurals=2; plural=(n != 1);";
27
+ const slash = (path) => path.split(sep).join("/");
28
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
29
+ const COMPOSED = 'user-visible text composed in TypeScript: pass a template with args (osd.show("Speed {}×", [rate]), plugin.i18n.text("Track {}", [n])) or move it into Slint';
30
+ const DYNAMIC = (what) => `${what} is not a literal; its text cannot be extracted (use a string literal or an \`as const\` table)`;
31
+ const SITES = {
32
+ "Commands.add": { object: 0, fields: ["title", "reason"], path: true },
33
+ "Command.set": { object: 0, fields: ["reason"] },
34
+ "Settings.page": { object: 1, fields: ["title"] },
35
+ "Panels.mount": { object: 2, fields: ["title"] },
36
+ "Osd.show": { argument: 0 },
37
+ "OsdPanel.set": { argument: 0 },
38
+ "Dialogs.confirm": {
39
+ object: 0,
40
+ fields: ["message", "detail", "confirm", "cancel", "checkbox"],
41
+ },
42
+ "Dialogs.openFile": { object: 0, fields: ["title"] },
43
+ "Dialogs.openFiles": { object: 0, fields: ["title"] },
44
+ "Dialogs.openDirectory": { object: 0, fields: ["title"] },
45
+ "Dialogs.saveFile": { object: 0, fields: ["title"] },
46
+ "I18n.text": { argument: 0 },
47
+ "I18n.error": { argument: 0 },
48
+ "I18n.resolve": { argument: 0 },
49
+ };
50
+ /** Without the SDK's types: the module property names in front of the method. */
51
+ const BY_NAME = {
52
+ "commands.add": "Commands.add",
53
+ "settings.page": "Settings.page",
54
+ "panels.mount": "Panels.mount",
55
+ "osd.show": "Osd.show",
56
+ "dialogs.confirm": "Dialogs.confirm",
57
+ "dialogs.openFile": "Dialogs.openFile",
58
+ "dialogs.openFiles": "Dialogs.openFiles",
59
+ "dialogs.openDirectory": "Dialogs.openDirectory",
60
+ "dialogs.saveFile": "Dialogs.saveFile",
61
+ "i18n.text": "I18n.text",
62
+ "i18n.error": "I18n.error",
63
+ "i18n.resolve": "I18n.resolve",
64
+ };
65
+ const none = () => ({ values: [], dynamic: false, composed: false });
66
+ const join2 = (a, b) => ({
67
+ values: [...a.values, ...b.values],
68
+ dynamic: a.dynamic || b.dynamic,
69
+ composed: a.composed || b.composed,
70
+ });
71
+ function notesOf(node, text) {
72
+ const notes = [];
73
+ for (const range of ts.getLeadingCommentRanges(text, node.getFullStart()) ??
74
+ []) {
75
+ const comment = text.slice(range.pos, range.end);
76
+ const at = comment.indexOf("TRANSLATORS:");
77
+ if (at < 0)
78
+ continue;
79
+ const note = comment
80
+ .slice(at + "TRANSLATORS:".length)
81
+ .replace(/\*\/\s*$/, "")
82
+ .split("\n")
83
+ .map((line) => line.replace(/^\s*(\*|\/\/)?\s?/, "").trim())
84
+ .filter(Boolean)
85
+ .join(" ");
86
+ if (note)
87
+ notes.push(note);
88
+ }
89
+ return notes;
90
+ }
91
+ /**
92
+ * An `i18n-ignore` comment before the node: the text is not to be
93
+ * translated (a language's own name, a product name). `extract` leaves it
94
+ * out and `check` does not warn about it.
95
+ */
96
+ function ignored(node, text) {
97
+ // The trivia before the node (getLeadingCommentRanges leaves out a
98
+ // comment on the same line as the previous token).
99
+ return text
100
+ .slice(node.getFullStart(), node.getStart())
101
+ .includes("i18n-ignore");
102
+ }
103
+ /** The statement (or class member) a node belongs to. */
104
+ function statementOf(node) {
105
+ let current = node;
106
+ while (current.parent &&
107
+ !ts.isSourceFile(current.parent) &&
108
+ !ts.isBlock(current.parent))
109
+ current = current.parent;
110
+ return current;
111
+ }
112
+ class Extractor {
113
+ found = [];
114
+ findings = [];
115
+ #checker;
116
+ constructor(checker) {
117
+ this.#checker = checker;
118
+ }
119
+ add(file, line, message) {
120
+ this.found.push({ ...message, file, line, references: [] });
121
+ }
122
+ finding(file, line, kind, message) {
123
+ if (this.findings.some((f) => f.file === file && f.line === line && f.kind === kind))
124
+ return;
125
+ this.findings.push({ file, line, kind, message });
126
+ }
127
+ /** Values of a type: string literals, or a union of them (null and undefined ignored). */
128
+ #fromType(type, depth = 0) {
129
+ const result = none();
130
+ const parts = type.isUnion() ? type.types : [type];
131
+ for (const part of parts) {
132
+ if (part.isStringLiteral())
133
+ result.values.push(part.value);
134
+ // A text value (`plugin.i18n.text(…)`): its source is extracted where
135
+ // it is made.
136
+ else if (part.getProperty("$vestraText") ||
137
+ part.getProperty("$vestraList"))
138
+ continue;
139
+ else if (part.flags &
140
+ (ts.TypeFlags.Null |
141
+ ts.TypeFlags.Undefined |
142
+ ts.TypeFlags.Void |
143
+ ts.TypeFlags.Never))
144
+ continue;
145
+ else if (depth === 0 && part.getCallSignatures().length) {
146
+ for (const signature of part.getCallSignatures())
147
+ Object.assign(result, join2(result, this.#fromType(signature.getReturnType(), 1)));
148
+ }
149
+ else
150
+ result.dynamic = true;
151
+ }
152
+ return result;
153
+ }
154
+ values(node) {
155
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
156
+ return { values: [node.text], dynamic: false, composed: false };
157
+ if (ts.isTemplateExpression(node))
158
+ return { values: [], dynamic: false, composed: true };
159
+ if (ts.isParenthesizedExpression(node) ||
160
+ ts.isAsExpression(node) ||
161
+ ts.isSatisfiesExpression(node) ||
162
+ ts.isNonNullExpression(node) ||
163
+ ts.isTypeAssertionExpression(node))
164
+ return this.values(node.expression);
165
+ if (ts.isConditionalExpression(node))
166
+ return join2(this.values(node.whenTrue), this.values(node.whenFalse));
167
+ if (ts.isBinaryExpression(node)) {
168
+ const op = node.operatorToken.kind;
169
+ if (op === ts.SyntaxKind.QuestionQuestionToken ||
170
+ op === ts.SyntaxKind.BarBarToken)
171
+ return join2(this.values(node.left), this.values(node.right));
172
+ if (op === ts.SyntaxKind.PlusToken) {
173
+ const stringy = (side) => ts.isStringLiteralLike(side) ||
174
+ ts.isTemplateExpression(side) ||
175
+ (this.#checker &&
176
+ this.#checker.getTypeAtLocation(side).flags &
177
+ ts.TypeFlags.StringLike);
178
+ if (stringy(node.left) || stringy(node.right))
179
+ return { values: [], dynamic: false, composed: true };
180
+ }
181
+ }
182
+ if (node.kind === ts.SyntaxKind.NullKeyword ||
183
+ (ts.isIdentifier(node) && node.text === "undefined"))
184
+ return none();
185
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
186
+ if (!ts.isBlock(node.body))
187
+ return this.values(node.body);
188
+ let result = none();
189
+ const visit = (child) => {
190
+ if (ts.isFunctionLike(child))
191
+ return;
192
+ if (ts.isReturnStatement(child) && child.expression)
193
+ result = join2(result, this.values(child.expression));
194
+ ts.forEachChild(child, visit);
195
+ };
196
+ ts.forEachChild(node.body, visit);
197
+ return result;
198
+ }
199
+ if (this.#checker)
200
+ return this.#fromType(this.#checker.getTypeAtLocation(node));
201
+ return { values: [], dynamic: true, composed: false };
202
+ }
203
+ /** The elements of a menu path: each segment's values, the leading `@id` skipped. */
204
+ #path(node, file, source) {
205
+ const add = (values, at, first) => {
206
+ this.#emit(values, at, file, source, "a menu path segment", (value) => !(first && value.startsWith("@")));
207
+ };
208
+ const inner = (expr) => ts.isParenthesizedExpression(expr) ||
209
+ ts.isAsExpression(expr) ||
210
+ ts.isSatisfiesExpression(expr)
211
+ ? inner(expr.expression)
212
+ : expr;
213
+ const array = inner(node);
214
+ if (ts.isArrayLiteralExpression(array)) {
215
+ array.elements.forEach((element, index) => {
216
+ if (ts.isSpreadElement(element)) {
217
+ this.#tuple(element.expression, file, source, index === 0);
218
+ return;
219
+ }
220
+ if (ignored(element, source.text))
221
+ return;
222
+ add(this.values(element), element, index === 0);
223
+ });
224
+ return;
225
+ }
226
+ this.#tuple(array, file, source, true);
227
+ }
228
+ /** A path given as a value: a tuple or array type of string literals. */
229
+ #tuple(node, file, source, first) {
230
+ const checker = this.#checker;
231
+ const values = none();
232
+ if (checker) {
233
+ const type = checker.getTypeAtLocation(node);
234
+ const elements = checker.isTupleType(type) || checker.isArrayType(type)
235
+ ? checker.getTypeArguments(type)
236
+ : undefined;
237
+ if (elements)
238
+ elements.forEach((element, index) => {
239
+ const found = this.#fromType(element);
240
+ found.values = found.values.filter((value) => !(first && index === 0 && value.startsWith("@")));
241
+ Object.assign(values, join2(values, found));
242
+ });
243
+ else
244
+ values.dynamic = true;
245
+ }
246
+ else
247
+ values.dynamic = true;
248
+ this.#emit(values, node, file, source, "a menu path", () => true);
249
+ }
250
+ #emit(values, at, file, source, what, keep, notes = []) {
251
+ const line = source.getLineAndCharacterOfPosition(at.getStart(source)).line + 1;
252
+ for (const value of values.values)
253
+ if (value.trim() !== "" && keep(value))
254
+ this.add(file, line, { id: value, notes });
255
+ if (values.composed)
256
+ this.finding(file, line, "composed", COMPOSED);
257
+ else if (values.dynamic)
258
+ this.finding(file, line, "dynamic", DYNAMIC(what));
259
+ }
260
+ /** `Interface.method` of a call to the SDK, if it is one of `SITES`. */
261
+ #site(call) {
262
+ const callee = call.expression;
263
+ const declaration = this.#checker?.getResolvedSignature(call)?.declaration;
264
+ if (!ts.isPropertyAccessExpression(callee)) {
265
+ // `const { text } = plugin.i18n; text("…")`: by the declaration.
266
+ if (declaration &&
267
+ !ts.isJSDocSignature(declaration) &&
268
+ declaration.parent &&
269
+ ts.isInterfaceDeclaration(declaration.parent) &&
270
+ declaration.name &&
271
+ ts.isIdentifier(declaration.name) &&
272
+ /\/plugin\/types\.(d\.)?ts$/.test(slash(declaration.getSourceFile().fileName))) {
273
+ const name = `${declaration.parent.name.text}.${declaration.name.text}`;
274
+ return name in SITES ? name : undefined;
275
+ }
276
+ return undefined;
277
+ }
278
+ const method = callee.name.text;
279
+ if (declaration &&
280
+ !ts.isJSDocSignature(declaration) &&
281
+ declaration.parent &&
282
+ ts.isInterfaceDeclaration(declaration.parent)) {
283
+ const file = slash(declaration.getSourceFile().fileName);
284
+ if (/\/plugin\/types\.(d\.)?ts$/.test(file)) {
285
+ const name = `${declaration.parent.name.text}.${method}`;
286
+ return name in SITES ? name : undefined;
287
+ }
288
+ }
289
+ // No SDK types (an untyped or broken program): by property names.
290
+ if (declaration)
291
+ return undefined;
292
+ const owner = callee.expression;
293
+ const ownerName = ts.isPropertyAccessExpression(owner)
294
+ ? owner.name.text
295
+ : ts.isIdentifier(owner)
296
+ ? owner.text
297
+ : "";
298
+ return BY_NAME[`${ownerName}.${method}`];
299
+ }
300
+ typescript(file, source) {
301
+ const text = source.text;
302
+ const visit = (node) => {
303
+ if (ts.isCallExpression(node))
304
+ this.#call(node, file, source, text);
305
+ // A text value written as data: `{ $vestraText: "…" }`.
306
+ if (ts.isPropertyAssignment(node) &&
307
+ (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) &&
308
+ node.name.text === "$vestraText" &&
309
+ !ignored(node, text) &&
310
+ !ignored(statementOf(node), text))
311
+ this.#emit(this.values(node.initializer), node.initializer, file, source, "the source of a text", () => true, [...notesOf(statementOf(node), text), ...notesOf(node, text)]);
312
+ if ((ts.isStringLiteral(node) ||
313
+ ts.isNoSubstitutionTemplateLiteral(node)) &&
314
+ CJK.test(node.text) &&
315
+ !ts.isImportDeclaration(node.parent) &&
316
+ !ts.isExportDeclaration(node.parent) &&
317
+ !ignored(node, text) &&
318
+ !ignored(node.parent, text) &&
319
+ !ignored(statementOf(node), text))
320
+ this.finding(file, source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, "not-english", "not English: English is the source text");
321
+ ts.forEachChild(node, visit);
322
+ };
323
+ visit(source);
324
+ }
325
+ #call(call, file, source, text) {
326
+ const name = this.#site(call);
327
+ if (!name)
328
+ return;
329
+ const site = SITES[name];
330
+ if (ignored(statementOf(call), text))
331
+ return;
332
+ const statementNotes = notesOf(statementOf(call), text);
333
+ if ("argument" in site) {
334
+ const argument = call.arguments[site.argument];
335
+ if (!argument || ignored(argument, text))
336
+ return;
337
+ this.#emit(this.values(argument), argument, file, source, `the text of ${name}`, () => true, [...statementNotes, ...notesOf(argument, text)]);
338
+ return;
339
+ }
340
+ const argument = call.arguments[site.object];
341
+ if (!argument)
342
+ return;
343
+ const fields = [...site.fields, ...(site.path ? ["path"] : [])];
344
+ const object = ts.isObjectLiteralExpression(argument) ? argument : null;
345
+ if (!object) {
346
+ // An options object built elsewhere: its fields' types.
347
+ const checker = this.#checker;
348
+ if (!checker)
349
+ return;
350
+ const type = checker.getTypeAtLocation(argument);
351
+ for (const field of fields) {
352
+ const symbol = type.getProperty(field);
353
+ if (!symbol)
354
+ continue;
355
+ const fieldType = checker.getTypeOfSymbolAtLocation(symbol, argument);
356
+ if (field === "path")
357
+ continue;
358
+ this.#emit(this.#fromType(fieldType), argument, file, source, `${field} of ${name}`, () => true, statementNotes);
359
+ }
360
+ return;
361
+ }
362
+ for (const property of object.properties) {
363
+ if (ts.isSpreadAssignment(property)) {
364
+ // `{ ...extra }`: the spread object's fields, by their types; a
365
+ // field that is not a literal (or a text value) is reported.
366
+ const checker = this.#checker;
367
+ const at = source.getLineAndCharacterOfPosition(property.getStart(source)).line +
368
+ 1;
369
+ if (!checker) {
370
+ this.finding(file, at, "dynamic", `fields spread into ${name} cannot be read without types; write ${fields.join(", ")} explicitly`);
371
+ continue;
372
+ }
373
+ if (ignored(property, text))
374
+ continue;
375
+ const type = checker.getTypeAtLocation(property.expression);
376
+ for (const field of fields) {
377
+ const symbol = type.getProperty(field);
378
+ if (!symbol)
379
+ continue;
380
+ if (field === "path") {
381
+ this.finding(file, at, "dynamic", `a path spread into ${name} cannot be extracted; write path explicitly`);
382
+ continue;
383
+ }
384
+ const fieldType = checker.getTypeOfSymbolAtLocation(symbol, property.expression);
385
+ this.#emit(this.#fromType(fieldType), property, file, source, `${field} spread into ${name}`, () => true, statementNotes);
386
+ }
387
+ continue;
388
+ }
389
+ const key = property.name &&
390
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
391
+ ? property.name.text
392
+ : undefined;
393
+ if (!key || !fields.includes(key) || ignored(property, text))
394
+ continue;
395
+ let value;
396
+ if (ts.isPropertyAssignment(property))
397
+ value = property.initializer;
398
+ else if (ts.isShorthandPropertyAssignment(property))
399
+ value = property.name;
400
+ else if (ts.isMethodDeclaration(property) && property.body) {
401
+ // `reason() { return … }`
402
+ const values = none();
403
+ const visit = (child) => {
404
+ if (ts.isFunctionLike(child))
405
+ return;
406
+ if (ts.isReturnStatement(child) && child.expression)
407
+ Object.assign(values, join2(values, this.values(child.expression)));
408
+ ts.forEachChild(child, visit);
409
+ };
410
+ ts.forEachChild(property.body, visit);
411
+ this.#emit(values, property, file, source, `${key} of ${name}`, () => true, [...statementNotes, ...notesOf(property, text)]);
412
+ continue;
413
+ }
414
+ if (!value)
415
+ continue;
416
+ const notes = [...statementNotes, ...notesOf(property, text)];
417
+ if (key === "path") {
418
+ const before = this.found.length;
419
+ this.#path(value, file, source);
420
+ for (const message of this.found.slice(before))
421
+ message.notes = [...message.notes, ...notes];
422
+ continue;
423
+ }
424
+ this.#emit(this.values(value), value, file, source, `${key} of ${name}`, () => true, notes);
425
+ }
426
+ }
427
+ slint(file, text) {
428
+ const lines = text.split("\n");
429
+ for (const call of scanSlint(text)) {
430
+ const above = lines[call.line - 2] ?? "";
431
+ const at = above.indexOf("TRANSLATORS:");
432
+ const notes = at >= 0 && /^\s*\/\//.test(above)
433
+ ? [above.slice(at + "TRANSLATORS:".length).trim()].filter(Boolean)
434
+ : [];
435
+ const message = { id: call.id, notes };
436
+ if (call.context !== undefined)
437
+ message.context = call.context;
438
+ if (call.plural !== undefined)
439
+ message.plural = call.plural;
440
+ this.add(file, call.line, message);
441
+ }
442
+ // Heuristics on the string literals that are not @tr texts.
443
+ const translated = new Set();
444
+ for (const call of scanSlint(text)) {
445
+ translated.add(call.idSpan[0]);
446
+ if (call.pluralSpan)
447
+ translated.add(call.pluralSpan[0]);
448
+ }
449
+ for (const literal of slintLiterals(text)) {
450
+ if (translated.has(literal.start))
451
+ continue;
452
+ const line = text.slice(0, literal.start).split("\n").length;
453
+ const before = text.slice(text.lastIndexOf("\n", literal.start - 1) + 1, literal.start);
454
+ // `@tr("context" => …)` contexts are not texts either.
455
+ if (/@tr\s*\(\s*$/.test(before))
456
+ continue;
457
+ // `// i18n-ignore` on the line or the line above: not to be translated.
458
+ const lineStart = text.lastIndexOf("\n", literal.start - 1) + 1;
459
+ const lineEnd = text.indexOf("\n", literal.start);
460
+ const above = text.slice(text.lastIndexOf("\n", Math.max(0, lineStart - 2)) + 1, lineStart);
461
+ if (text
462
+ .slice(lineStart, lineEnd < 0 ? text.length : lineEnd)
463
+ .includes("i18n-ignore") ||
464
+ /^\s*\/\/.*i18n-ignore/.test(above))
465
+ continue;
466
+ if (CJK.test(literal.value))
467
+ this.finding(file, line, "not-english", "not English: English is the source text");
468
+ else if (/\p{L}/u.test(literal.value) &&
469
+ /(?:^|[\s;{])(text|title|placeholder|tip|label|confirm-text|cancel-text)\s*:\s*$/.test(before))
470
+ this.finding(file, line, "slint-text", `Slint text without @tr: "${literal.value}"`);
471
+ }
472
+ }
473
+ }
474
+ /** The plain string literals of a Slint source (not interpolated ones). */
475
+ function slintLiterals(text) {
476
+ const found = [];
477
+ const templates = [];
478
+ let i = 0;
479
+ const string = (from, start) => {
480
+ let j = from;
481
+ while (j < text.length) {
482
+ if (text[j] === '"') {
483
+ if (start !== null)
484
+ found.push({ start, value: text.slice(start + 1, j) });
485
+ return j + 1;
486
+ }
487
+ if (text[j] === "\\" && text[j + 1] === "{") {
488
+ templates.push(0);
489
+ return j + 2;
490
+ }
491
+ j += text[j] === "\\" ? 2 : 1;
492
+ }
493
+ return text.length;
494
+ };
495
+ while (i < text.length) {
496
+ const c = text[i];
497
+ if (c === "/" && text[i + 1] === "/") {
498
+ const end = text.indexOf("\n", i);
499
+ i = end < 0 ? text.length : end;
500
+ }
501
+ else if (c === "/" && text[i + 1] === "*") {
502
+ let depth = 0;
503
+ i += 2;
504
+ while (i < text.length) {
505
+ if (text.startsWith("/*", i)) {
506
+ depth++;
507
+ i += 2;
508
+ }
509
+ else if (text.startsWith("*/", i)) {
510
+ i += 2;
511
+ if (depth-- === 0)
512
+ break;
513
+ }
514
+ else
515
+ i++;
516
+ }
517
+ }
518
+ else if (c === '"')
519
+ i = string(i + 1, i);
520
+ else if (c === "{" && templates.length) {
521
+ templates[templates.length - 1]++;
522
+ i++;
523
+ }
524
+ else if (c === "}" && templates.length) {
525
+ if (templates[templates.length - 1] === 0) {
526
+ templates.pop();
527
+ i = string(i + 1, null);
528
+ }
529
+ else {
530
+ templates[templates.length - 1]--;
531
+ i++;
532
+ }
533
+ }
534
+ else
535
+ i++;
536
+ }
537
+ return found;
538
+ }
539
+ /** Find every translatable text of a plugin, in reference order. */
540
+ export function extract(options) {
541
+ const { directory } = options;
542
+ let checker;
543
+ let program;
544
+ const files = options.typescript.map((file) => join(directory, file));
545
+ if (files.length) {
546
+ program = ts.createProgram(files, {
547
+ ...(options.compilerOptions ?? {
548
+ target: ts.ScriptTarget.ES2023,
549
+ module: ts.ModuleKind.NodeNext,
550
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
551
+ strict: true,
552
+ allowImportingTsExtensions: true,
553
+ skipLibCheck: true,
554
+ }),
555
+ noEmit: true,
556
+ });
557
+ checker = program.getTypeChecker();
558
+ }
559
+ const extractor = new Extractor(checker);
560
+ for (const file of [...options.typescript].sort()) {
561
+ const path = join(directory, file);
562
+ const source = program?.getSourceFile(path) ??
563
+ ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true);
564
+ extractor.typescript(slash(file), source);
565
+ }
566
+ for (const file of [...options.slint].sort())
567
+ extractor.slint(slash(file), readFileSync(join(directory, file), "utf8"));
568
+ const manifest = options.manifest;
569
+ for (const field of ["name", "punchline", "description"])
570
+ if (typeof manifest[field] === "string" && manifest[field] !== "")
571
+ extractor.add("manifest.json", 0, { id: manifest[field], notes: [] });
572
+ if (Array.isArray(manifest.tags))
573
+ for (const tag of manifest.tags)
574
+ if (typeof tag === "string" && tag !== "")
575
+ extractor.add("manifest.json", 0, { id: tag, notes: [] });
576
+ const occurrences = [...extractor.found].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
577
+ const messages = new Map();
578
+ for (const occurrence of occurrences) {
579
+ const key = keyOf(occurrence.context, occurrence.id);
580
+ const reference = occurrence.line > 0
581
+ ? `${occurrence.file}:${occurrence.line}`
582
+ : occurrence.file;
583
+ let message = messages.get(key);
584
+ if (!message) {
585
+ message = { id: occurrence.id, notes: [], references: [] };
586
+ if (occurrence.context !== undefined)
587
+ message.context = occurrence.context;
588
+ messages.set(key, message);
589
+ }
590
+ if (occurrence.plural !== undefined && message.plural === undefined)
591
+ message.plural = occurrence.plural;
592
+ for (const note of occurrence.notes)
593
+ if (!message.notes.includes(note))
594
+ message.notes.push(note);
595
+ if (!message.references.includes(reference))
596
+ message.references.push(reference);
597
+ }
598
+ const findings = extractor.findings.sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
599
+ return { messages: [...messages.values()], findings };
600
+ }
601
+ const entryOf = (message) => {
602
+ const entry = {
603
+ id: message.id,
604
+ strings: message.plural === undefined ? [""] : ["", ""],
605
+ fuzzy: false,
606
+ notes: [...message.notes],
607
+ references: [...message.references],
608
+ flags: [],
609
+ obsolete: false,
610
+ };
611
+ if (message.context !== undefined)
612
+ entry.context = message.context;
613
+ if (message.plural !== undefined)
614
+ entry.plural = message.plural;
615
+ return entry;
616
+ };
617
+ /** `locales/messages.pot` for these messages. */
618
+ export function template(manifest, messages) {
619
+ const project = [manifest.id, manifest.version]
620
+ .filter((part) => typeof part === "string")
621
+ .join(" ");
622
+ return writePo(`Project-Id-Version: ${project}\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n`, messages.map(entryOf));
623
+ }
624
+ /** The header of a new `<lang>.po`. */
625
+ export const newHeader = (language) => `Language: ${language}\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nPlural-Forms: ${pluralForms(language)}\n`;
626
+ const hasText = (entry) => entry.strings.some((s) => s !== "");
627
+ /**
628
+ * Merge the template into a language's file: translations and fuzzy flags
629
+ * of present messages stay, new ones come untranslated, notes and
630
+ * references follow the template, vanished translated messages become
631
+ * obsolete (`#~`, at the end) and come back with their translation.
632
+ */
633
+ export function merge(existing, language, messages) {
634
+ const parsed = existing === null ? null : parsePo(existing);
635
+ const old = new Map();
636
+ for (const entry of parsed?.entries ?? [])
637
+ old.set(keyOf(entry.context, entry.id), entry);
638
+ const result = [];
639
+ const present = new Set();
640
+ let added = 0;
641
+ let translated = 0;
642
+ for (const message of messages) {
643
+ const key = keyOf(message.context, message.id);
644
+ present.add(key);
645
+ const entry = entryOf(message);
646
+ const before = old.get(key);
647
+ if (before && hasText(before)) {
648
+ entry.strings = [...before.strings];
649
+ if (message.plural === undefined)
650
+ entry.strings = [entry.strings[0]];
651
+ else
652
+ while (entry.strings.length < 2)
653
+ entry.strings.push("");
654
+ entry.fuzzy = before.fuzzy;
655
+ entry.flags = [...before.flags];
656
+ }
657
+ else if (!before)
658
+ added++;
659
+ if (!entry.fuzzy && entry.strings[0] !== "")
660
+ translated++;
661
+ result.push(entry);
662
+ }
663
+ let obsolete = 0;
664
+ for (const [key, entry] of old) {
665
+ if (present.has(key) || !hasText(entry))
666
+ continue;
667
+ result.push({ ...entry, obsolete: true, references: [] });
668
+ obsolete++;
669
+ }
670
+ return {
671
+ text: writePo(parsed?.header ?? newHeader(language), result),
672
+ total: messages.length,
673
+ translated,
674
+ added,
675
+ obsolete,
676
+ };
677
+ }
678
+ /** Problems found by `check` in one plugin's catalogs, as text lines. */
679
+ export function checkCatalogs(directory, messages, findings) {
680
+ const warnings = [];
681
+ const errors = [];
682
+ const locales = join(directory, "locales");
683
+ const pot = join(locales, "messages.pot");
684
+ const keys = (entries) => new Set(entries.map((e) => `${keyOf(e.context, e.id)}\u0000${e.plural ?? ""}`));
685
+ const wanted = keys(messages);
686
+ if (!existsSync(pot))
687
+ warnings.push("locales/messages.pot is missing: run vestra-plugin i18n extract");
688
+ else {
689
+ try {
690
+ const have = keys(parsePo(readFileSync(pot, "utf8")).entries);
691
+ const same = have.size === wanted.size && [...wanted].every((key) => have.has(key));
692
+ if (!same)
693
+ warnings.push("locales/messages.pot is out of date: run vestra-plugin i18n extract");
694
+ }
695
+ catch (error) {
696
+ errors.push(`locales/messages.pot: ${error.message}`);
697
+ }
698
+ }
699
+ const files = existsSync(locales)
700
+ ? readdirSync(locales)
701
+ .filter((name) => name.endsWith(".po"))
702
+ .sort()
703
+ : [];
704
+ for (const name of files) {
705
+ const language = name.slice(0, -3);
706
+ if (language === "en") {
707
+ warnings.push(`locales/${name}: English is the source language; the player never reads en.po`);
708
+ continue;
709
+ }
710
+ if (!LOCALES.includes(language)) {
711
+ warnings.push(`locales/${name}: not a player language (${LOCALES.filter((l) => l !== "en").join(", ")}); the player never reads it`);
712
+ continue;
713
+ }
714
+ let entries;
715
+ try {
716
+ entries = parsePo(readFileSync(join(locales, name), "utf8")).entries;
717
+ }
718
+ catch (error) {
719
+ errors.push(`locales/${name}: ${error.message}`);
720
+ continue;
721
+ }
722
+ const live = entries.filter((entry) => !entry.obsolete);
723
+ const done = live.filter((entry) => !entry.fuzzy &&
724
+ entry.strings[0] !== undefined &&
725
+ entry.strings[0] !== "").length;
726
+ const fuzzy = live.filter((entry) => entry.fuzzy).length;
727
+ const obsolete = entries.length - live.length;
728
+ warnings.push(`${language}: ${done}/${live.length} translated, ${fuzzy} fuzzy, ${obsolete} obsolete`);
729
+ const inFile = keys(live);
730
+ const missing = [...wanted].filter((key) => !inFile.has(key)).length;
731
+ if (missing)
732
+ warnings.push(`locales/${name}: ${missing} ${missing === 1 ? "message is" : "messages are"} not in this file: run vestra-plugin i18n extract`);
733
+ for (const entry of live) {
734
+ entry.strings.forEach((text, form) => {
735
+ if (text === "")
736
+ return;
737
+ const source = form > 0 && entry.plural !== undefined ? entry.plural : entry.id;
738
+ if (placeholders(text).join("\u0000") !==
739
+ placeholders(source).join("\u0000"))
740
+ warnings.push(`locales/${name}: placeholders of ${JSON.stringify(entry.id)}${entry.plural !== undefined ? `[${form}]` : ""} differ from the source (${JSON.stringify(text)}): keep every {…} as written in the msgid`);
741
+ });
742
+ if (entry.plural !== undefined &&
743
+ !ONE_FORM.has(language) &&
744
+ (entry.strings[0] ?? "") !== "" &&
745
+ (entry.strings[1] ?? "") === "")
746
+ warnings.push(`locales/${name}: ${JSON.stringify(entry.id)} has no msgstr[1] (the form for n ≠ 1)`);
747
+ }
748
+ }
749
+ for (const finding of findings)
750
+ warnings.push(`${finding.file}:${finding.line}: ${finding.message}`);
751
+ return { warnings, errors };
752
+ }
753
+ /**
754
+ * Compile every `locales/<lang>.po` of a player language to
755
+ * `<out>/locales/<lang>.json`. Returns the warnings (files the player never
756
+ * reads); throws on a `.po` that does not parse.
757
+ */
758
+ export function compileLocales(directory, out) {
759
+ const locales = join(directory, "locales");
760
+ if (!existsSync(locales))
761
+ return [];
762
+ const warnings = [];
763
+ for (const name of readdirSync(locales).sort()) {
764
+ if (!name.endsWith(".po"))
765
+ continue;
766
+ const language = name.slice(0, -3);
767
+ if (language === "en" ||
768
+ !LOCALES.includes(language)) {
769
+ warnings.push(`locales/${name}: not compiled (not a translation the player reads)`);
770
+ continue;
771
+ }
772
+ let entries;
773
+ try {
774
+ entries = parsePo(readFileSync(join(locales, name), "utf8")).entries;
775
+ }
776
+ catch (error) {
777
+ throw new Error(`locales/${name}: ${error.message}`, {
778
+ cause: error,
779
+ });
780
+ }
781
+ mkdirSync(join(out, "locales"), { recursive: true });
782
+ writeFileSync(join(out, "locales", `${language}.json`), `${JSON.stringify(compileCatalog(language, entries))}\n`);
783
+ }
784
+ return warnings;
785
+ }
786
+ /** The relative path of a file from the plugin directory, forward slashes. */
787
+ export const relativePath = (directory, file) => slash(relative(directory, file));