@vestra-player/sdk 1.5.7 → 1.5.9

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