@vestra-player/sdk 1.5.8 → 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.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -1
- package/bin/vestra-plugin.mjs +170 -7
- package/dist/i18n-tools.d.ts +67 -0
- package/dist/i18n-tools.js +731 -0
- package/dist/i18n.d.ts +130 -0
- package/dist/i18n.js +673 -0
- package/dist/player-runtime.d.ts +1 -0
- package/dist/player-runtime.js +1 -0
- package/dist/plugin/appearance.js +27 -1
- package/dist/plugin/language.d.ts +4 -0
- package/dist/plugin/language.js +5 -0
- package/dist/plugin/osd.d.ts +3 -0
- package/dist/plugin/osd.js +45 -9
- package/dist/plugin/packages.js +10 -0
- package/dist/plugin/runtime.d.ts +3 -1
- package/dist/plugin/runtime.js +6 -0
- package/dist/plugin/scope.d.ts +1 -0
- package/dist/plugin/scope.js +2 -0
- package/dist/plugin/testing.d.ts +133 -3
- package/dist/plugin/testing.js +289 -47
- package/dist/plugin/types.d.ts +194 -20
- package/dist/plugin/ui.js +4 -1
- package/dist/plugin/window.d.ts +2 -0
- package/dist/plugin/window.js +4 -0
- package/dist/wire.d.ts +26 -2
- package/dist/wire.js +12 -2
- package/llms-full.txt +361 -21
- package/llms.txt +43 -39
- package/package.json +1 -1
- package/slint/vestra/markdown.slint +5 -5
- package/slint/vestra/widgets.slint +4 -4
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Translation catalogs, the same way the player reads them (the player's
|
|
3
|
+
* `src/host/src/i18n/`): gettext `.po` files, the compiled
|
|
4
|
+
* `locales/<lang>.json` a package carries, template formatting and the
|
|
5
|
+
* Slint `@tr` scanner. Used by `vestra-plugin i18n`, `vestra-plugin build`
|
|
6
|
+
* and {@link TestHost}; not part of the plugin API (plugins write no
|
|
7
|
+
* translation code: see docs/how-to/i18n.md).
|
|
8
|
+
* Internal: the player's runner and tests reach it through
|
|
9
|
+
* `@vestra-player/sdk/runtime`, `LOCALES` through `./testing`.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The player's interface languages: English (the source language) first,
|
|
13
|
+
* then the translations, as the player's own table (its tests check that
|
|
14
|
+
* they agree).
|
|
15
|
+
*/
|
|
16
|
+
export const LOCALES = ["en", "zh-Hans", "ja", "ko", "de", "fr", "es"];
|
|
17
|
+
/** The catalog key: `msgid`, or `msgctxt` U+0004 `msgid`. */
|
|
18
|
+
export const keyOf = (context, id) => context === undefined ? id : `${context}\u0004${id}`;
|
|
19
|
+
const translated = (entry) => !entry.fuzzy && !entry.obsolete && (entry.strings[0] ?? "") !== "";
|
|
20
|
+
/** Parse a `.po` (or `.pot`) file. Throws with the line number on errors. */
|
|
21
|
+
export function parsePo(text) {
|
|
22
|
+
const entries = [];
|
|
23
|
+
let header = "";
|
|
24
|
+
const blank = () => ({
|
|
25
|
+
id: "",
|
|
26
|
+
strings: [],
|
|
27
|
+
fuzzy: false,
|
|
28
|
+
notes: [],
|
|
29
|
+
references: [],
|
|
30
|
+
flags: [],
|
|
31
|
+
obsolete: false,
|
|
32
|
+
});
|
|
33
|
+
let entry = blank();
|
|
34
|
+
let started = false;
|
|
35
|
+
let field = "none";
|
|
36
|
+
// Comments seen before the entry's first keyword.
|
|
37
|
+
let pending = blank();
|
|
38
|
+
const finish = () => {
|
|
39
|
+
if (started) {
|
|
40
|
+
if (entry.id === "" && entry.context === undefined)
|
|
41
|
+
header = entry.strings[0] ?? "";
|
|
42
|
+
else
|
|
43
|
+
entries.push(entry);
|
|
44
|
+
}
|
|
45
|
+
entry = blank();
|
|
46
|
+
started = false;
|
|
47
|
+
field = "none";
|
|
48
|
+
};
|
|
49
|
+
const lines = text.split(/\r?\n/);
|
|
50
|
+
for (let index = 0; index < lines.length; index++) {
|
|
51
|
+
let line = lines[index].trim();
|
|
52
|
+
const at = (message) => new Error(`line ${index + 1}: ${message}`);
|
|
53
|
+
if (line === "") {
|
|
54
|
+
finish();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let obsolete = false;
|
|
58
|
+
if (line.startsWith("#~")) {
|
|
59
|
+
obsolete = true;
|
|
60
|
+
line = line.slice(2).trim();
|
|
61
|
+
if (line === "")
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
else if (line.startsWith("#")) {
|
|
65
|
+
if (started)
|
|
66
|
+
finish();
|
|
67
|
+
const body = line.slice(2).trim();
|
|
68
|
+
if (line.startsWith("#."))
|
|
69
|
+
pending.notes.push(body);
|
|
70
|
+
else if (line.startsWith("#:"))
|
|
71
|
+
pending.references.push(...body.split(/\s+/).filter(Boolean));
|
|
72
|
+
else if (line.startsWith("#,"))
|
|
73
|
+
for (const flag of body.split(",").map((f) => f.trim()))
|
|
74
|
+
if (flag === "fuzzy")
|
|
75
|
+
pending.fuzzy = true;
|
|
76
|
+
else if (flag)
|
|
77
|
+
pending.flags.push(flag);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let keyword = "";
|
|
81
|
+
let rest = line;
|
|
82
|
+
if (!line.startsWith('"')) {
|
|
83
|
+
const split = line.search(/\s/);
|
|
84
|
+
if (split < 0)
|
|
85
|
+
throw at("a keyword without a string");
|
|
86
|
+
keyword = line.slice(0, split);
|
|
87
|
+
rest = line.slice(split).trim();
|
|
88
|
+
}
|
|
89
|
+
let next;
|
|
90
|
+
if (keyword === "") {
|
|
91
|
+
if (field === "none")
|
|
92
|
+
throw at("a string without a keyword");
|
|
93
|
+
next = field;
|
|
94
|
+
}
|
|
95
|
+
else if (keyword === "msgctxt")
|
|
96
|
+
next = "context";
|
|
97
|
+
else if (keyword === "msgid")
|
|
98
|
+
next = "id";
|
|
99
|
+
else if (keyword === "msgid_plural")
|
|
100
|
+
next = "plural";
|
|
101
|
+
else if (keyword === "msgstr")
|
|
102
|
+
next = 0;
|
|
103
|
+
else {
|
|
104
|
+
const match = /^msgstr\[(\d+)\]$/.exec(keyword);
|
|
105
|
+
if (!match || Number(match[1]) > 16)
|
|
106
|
+
throw at(`unknown keyword ${keyword}`);
|
|
107
|
+
next = Number(match[1]);
|
|
108
|
+
}
|
|
109
|
+
if (keyword !== "") {
|
|
110
|
+
const opens = next === "context" || (next === "id" && field !== "context");
|
|
111
|
+
if (opens && started)
|
|
112
|
+
finish();
|
|
113
|
+
if (!started) {
|
|
114
|
+
if (!opens)
|
|
115
|
+
throw at("an entry starts with msgctxt or msgid");
|
|
116
|
+
started = true;
|
|
117
|
+
entry = { ...pending, obsolete };
|
|
118
|
+
pending = blank();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const value = unquote(rest, at);
|
|
122
|
+
if (next === "context")
|
|
123
|
+
entry.context = (entry.context ?? "") + value;
|
|
124
|
+
else if (next === "id")
|
|
125
|
+
entry.id += value;
|
|
126
|
+
else if (next === "plural")
|
|
127
|
+
entry.plural = (entry.plural ?? "") + value;
|
|
128
|
+
else if (typeof next === "number") {
|
|
129
|
+
while (entry.strings.length <= next)
|
|
130
|
+
entry.strings.push("");
|
|
131
|
+
entry.strings[next] += value;
|
|
132
|
+
}
|
|
133
|
+
field = next;
|
|
134
|
+
}
|
|
135
|
+
finish();
|
|
136
|
+
return { header, entries };
|
|
137
|
+
}
|
|
138
|
+
function unquote(text, at) {
|
|
139
|
+
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"'))
|
|
140
|
+
throw at("expected a quoted string");
|
|
141
|
+
let out = "";
|
|
142
|
+
const inner = text.slice(1, -1);
|
|
143
|
+
for (let i = 0; i < inner.length; i++) {
|
|
144
|
+
const c = inner[i];
|
|
145
|
+
if (c !== "\\") {
|
|
146
|
+
out += c;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const n = inner[++i];
|
|
150
|
+
if (n === "n")
|
|
151
|
+
out += "\n";
|
|
152
|
+
else if (n === "t")
|
|
153
|
+
out += "\t";
|
|
154
|
+
else if (n === "r")
|
|
155
|
+
out += "\r";
|
|
156
|
+
else if (n === '"')
|
|
157
|
+
out += '"';
|
|
158
|
+
else if (n === "\\")
|
|
159
|
+
out += "\\";
|
|
160
|
+
else
|
|
161
|
+
throw at(`unknown escape \\${n ?? ""}`);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
const poString = (text) => `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\t", "\\t").replaceAll("\r", "\\r").replaceAll("\n", "\\n")}"`;
|
|
166
|
+
/** A `.po` string field: one line, or `""` then one line per `\n`. */
|
|
167
|
+
function field(keyword, text) {
|
|
168
|
+
const parts = text.split(/(?<=\n)/);
|
|
169
|
+
if (parts.length <= 1)
|
|
170
|
+
return `${keyword} ${poString(text)}`;
|
|
171
|
+
return [`${keyword} ""`, ...parts.map(poString)].join("\n");
|
|
172
|
+
}
|
|
173
|
+
/** Write a `.po` file: the header, then the entries in the given order. */
|
|
174
|
+
export function writePo(header, entries) {
|
|
175
|
+
const blocks = [`msgid ""\n${field("msgstr", header)}`];
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const prefix = entry.obsolete ? "#~ " : "";
|
|
178
|
+
const lines = [];
|
|
179
|
+
for (const note of entry.notes)
|
|
180
|
+
lines.push(`#. ${note}`);
|
|
181
|
+
if (entry.references.length && !entry.obsolete)
|
|
182
|
+
lines.push(`#: ${entry.references.join(" ")}`);
|
|
183
|
+
const flags = [...(entry.fuzzy ? ["fuzzy"] : []), ...entry.flags];
|
|
184
|
+
if (flags.length)
|
|
185
|
+
lines.push(`#, ${flags.join(", ")}`);
|
|
186
|
+
const body = [];
|
|
187
|
+
if (entry.context !== undefined)
|
|
188
|
+
body.push(field("msgctxt", entry.context));
|
|
189
|
+
body.push(field("msgid", entry.id));
|
|
190
|
+
if (entry.plural !== undefined) {
|
|
191
|
+
body.push(field("msgid_plural", entry.plural));
|
|
192
|
+
const strings = entry.strings.length ? entry.strings : ["", ""];
|
|
193
|
+
strings.forEach((text, n) => body.push(field(`msgstr[${n}]`, text)));
|
|
194
|
+
}
|
|
195
|
+
else
|
|
196
|
+
body.push(field("msgstr", entry.strings[0] ?? ""));
|
|
197
|
+
for (const text of body)
|
|
198
|
+
for (const line of text.split("\n"))
|
|
199
|
+
lines.push(prefix + line);
|
|
200
|
+
blocks.push(lines.join("\n"));
|
|
201
|
+
}
|
|
202
|
+
return `${blocks.join("\n\n")}\n`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The compiled catalog of one language (`locales/<lang>.json`): translated
|
|
206
|
+
* entries only (not fuzzy, not obsolete, not empty), keys sorted. Plurals
|
|
207
|
+
* have two forms, as the player shows them: `msgstr[0]` for `n == 1` and
|
|
208
|
+
* `msgstr[1]` (else `msgstr[0]`) for every other `n`.
|
|
209
|
+
*/
|
|
210
|
+
export function compileCatalog(language, entries) {
|
|
211
|
+
const messages = {};
|
|
212
|
+
for (const entry of [...entries].sort((a, b) => keyOf(a.context, a.id) < keyOf(b.context, b.id) ? -1 : 1)) {
|
|
213
|
+
if (!translated(entry))
|
|
214
|
+
continue;
|
|
215
|
+
const key = keyOf(entry.context, entry.id);
|
|
216
|
+
if (key in messages)
|
|
217
|
+
continue;
|
|
218
|
+
const one = entry.strings[0];
|
|
219
|
+
messages[key] =
|
|
220
|
+
entry.plural === undefined ? one : [one, entry.strings[1] || one];
|
|
221
|
+
}
|
|
222
|
+
return { language, messages };
|
|
223
|
+
}
|
|
224
|
+
/** Lookups in one language's messages, as the player does them. */
|
|
225
|
+
export class Catalog {
|
|
226
|
+
messages;
|
|
227
|
+
constructor(messages = {}) {
|
|
228
|
+
this.messages = new Map(Object.entries(messages));
|
|
229
|
+
}
|
|
230
|
+
/** From parsed `.po` entries. */
|
|
231
|
+
static fromEntries(entries) {
|
|
232
|
+
return new Catalog(compileCatalog("", entries).messages);
|
|
233
|
+
}
|
|
234
|
+
/** The translation of `id` (the one form of a plural), or `undefined`. */
|
|
235
|
+
lookup(id, context) {
|
|
236
|
+
const message = this.messages.get(keyOf(context, id));
|
|
237
|
+
return Array.isArray(message) ? message[0] : message;
|
|
238
|
+
}
|
|
239
|
+
/** The translation of `id`, or `id` itself. */
|
|
240
|
+
text(id) {
|
|
241
|
+
return this.lookup(id) ?? id;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A plugin sentence (`osd.show(template, args)`, dialogs): translated,
|
|
245
|
+
* then formatted with `args` when given; without `args` the text shows
|
|
246
|
+
* as it is, braces included.
|
|
247
|
+
*/
|
|
248
|
+
template(text, args) {
|
|
249
|
+
const template = this.text(text);
|
|
250
|
+
return args === undefined || args === null
|
|
251
|
+
? template
|
|
252
|
+
: format(template, args);
|
|
253
|
+
}
|
|
254
|
+
/** What an `@tr` call becomes, `undefined` when untranslated. */
|
|
255
|
+
slint(call) {
|
|
256
|
+
const message = this.messages.get(keyOf(call.context, call.id));
|
|
257
|
+
if (message === undefined)
|
|
258
|
+
return undefined;
|
|
259
|
+
if (Array.isArray(message) !== (call.plural !== undefined))
|
|
260
|
+
return undefined;
|
|
261
|
+
return Array.isArray(message)
|
|
262
|
+
? [message[0], message[1]]
|
|
263
|
+
: [message, undefined];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const argument = (value) => typeof value === "number"
|
|
267
|
+
? Number.isInteger(value)
|
|
268
|
+
? value.toFixed(0)
|
|
269
|
+
: String(value)
|
|
270
|
+
: (value ?? "");
|
|
271
|
+
/**
|
|
272
|
+
* Format a template the way Slint's `@tr` and the player do: `{}` takes
|
|
273
|
+
* the next argument, `{0}` the first, `{name}` a field of an object; `{{`
|
|
274
|
+
* and `}}` are braces. A placeholder without an argument stays as written.
|
|
275
|
+
*/
|
|
276
|
+
export function format(template, args) {
|
|
277
|
+
let out = "";
|
|
278
|
+
let next = 0;
|
|
279
|
+
let rest = template;
|
|
280
|
+
for (;;) {
|
|
281
|
+
const at = rest.search(/[{}]/);
|
|
282
|
+
if (at < 0)
|
|
283
|
+
break;
|
|
284
|
+
out += rest.slice(0, at);
|
|
285
|
+
const tail = rest.slice(at);
|
|
286
|
+
if (tail.startsWith("{{") || tail.startsWith("}}")) {
|
|
287
|
+
out += tail[0];
|
|
288
|
+
rest = tail.slice(2);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (tail.startsWith("}")) {
|
|
292
|
+
out += "}";
|
|
293
|
+
rest = tail.slice(1);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const close = tail.indexOf("}");
|
|
297
|
+
if (close < 0) {
|
|
298
|
+
out += tail;
|
|
299
|
+
rest = "";
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
const name = tail.slice(1, close);
|
|
303
|
+
let value;
|
|
304
|
+
const list = Array.isArray(args)
|
|
305
|
+
? args
|
|
306
|
+
: null;
|
|
307
|
+
if (name === "")
|
|
308
|
+
value = list?.[next++];
|
|
309
|
+
else if (/^\d+$/.test(name))
|
|
310
|
+
value = list?.[Number(name)];
|
|
311
|
+
else if (args && !list)
|
|
312
|
+
value = args[name];
|
|
313
|
+
out += value === undefined ? tail.slice(0, close + 1) : argument(value);
|
|
314
|
+
rest = tail.slice(close + 1);
|
|
315
|
+
}
|
|
316
|
+
return out + rest;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The placeholders of a template, sorted: `""` per `{}`, else the name or
|
|
320
|
+
* number (`{n}` of a Slint plural is `"n"`). Translations must keep them.
|
|
321
|
+
*/
|
|
322
|
+
export function placeholders(text) {
|
|
323
|
+
const found = [];
|
|
324
|
+
const plain = text.replaceAll("{{", "").replaceAll("}}", "");
|
|
325
|
+
for (const match of plain.matchAll(/\{([^{}]*)\}/g))
|
|
326
|
+
found.push(match[1]);
|
|
327
|
+
return found.sort();
|
|
328
|
+
}
|
|
329
|
+
const lineEnd = (s, i) => {
|
|
330
|
+
const found = s.slice(i).search(/[\n\r]/);
|
|
331
|
+
return found < 0 ? s.length : i + found;
|
|
332
|
+
};
|
|
333
|
+
function commentEnd(s, from) {
|
|
334
|
+
let depth = 0;
|
|
335
|
+
let i = from + 2;
|
|
336
|
+
while (i + 1 < s.length) {
|
|
337
|
+
if (s[i] === "/" && s[i + 1] === "*") {
|
|
338
|
+
depth++;
|
|
339
|
+
i += 2;
|
|
340
|
+
}
|
|
341
|
+
else if (s[i] === "*" && s[i + 1] === "/") {
|
|
342
|
+
if (depth === 0)
|
|
343
|
+
return i + 2;
|
|
344
|
+
depth--;
|
|
345
|
+
i += 2;
|
|
346
|
+
}
|
|
347
|
+
else
|
|
348
|
+
i++;
|
|
349
|
+
}
|
|
350
|
+
return s.length;
|
|
351
|
+
}
|
|
352
|
+
function stringEnd(s, from, templates) {
|
|
353
|
+
let i = from;
|
|
354
|
+
while (i < s.length) {
|
|
355
|
+
if (s[i] === '"')
|
|
356
|
+
return i + 1;
|
|
357
|
+
if (s[i] === "\\" && s[i + 1] === "{") {
|
|
358
|
+
templates.push(0);
|
|
359
|
+
return i + 2;
|
|
360
|
+
}
|
|
361
|
+
i += s[i] === "\\" ? 2 : 1;
|
|
362
|
+
}
|
|
363
|
+
return s.length;
|
|
364
|
+
}
|
|
365
|
+
function skip(s, i) {
|
|
366
|
+
for (;;) {
|
|
367
|
+
if (/\s/.test(s[i] ?? ""))
|
|
368
|
+
i++;
|
|
369
|
+
else if (s[i] === "/" && s[i + 1] === "/")
|
|
370
|
+
i = lineEnd(s, i);
|
|
371
|
+
else if (s[i] === "/" && s[i + 1] === "*")
|
|
372
|
+
i = commentEnd(s, i);
|
|
373
|
+
else
|
|
374
|
+
return i;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** A plain string literal at `i`: its span and value (no interpolation). */
|
|
378
|
+
function literal(s, i) {
|
|
379
|
+
if (s[i] !== '"')
|
|
380
|
+
return null;
|
|
381
|
+
let j = i + 1;
|
|
382
|
+
let value = "";
|
|
383
|
+
for (;;) {
|
|
384
|
+
const c = s[j];
|
|
385
|
+
if (c === undefined)
|
|
386
|
+
return null;
|
|
387
|
+
if (c === '"')
|
|
388
|
+
return [[i, j + 1], value];
|
|
389
|
+
if (c !== "\\") {
|
|
390
|
+
value += c;
|
|
391
|
+
j++;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const n = s[j + 1];
|
|
395
|
+
if (n === '"' || n === "\\")
|
|
396
|
+
value += n;
|
|
397
|
+
else if (n === "n")
|
|
398
|
+
value += "\n";
|
|
399
|
+
else if (n === "u") {
|
|
400
|
+
if (s[j + 2] !== "{")
|
|
401
|
+
return null;
|
|
402
|
+
const close = s.indexOf("}", j + 3);
|
|
403
|
+
if (close < 0)
|
|
404
|
+
return null;
|
|
405
|
+
const code = Number.parseInt(s.slice(j + 3, close), 16);
|
|
406
|
+
if (!/^[0-9a-fA-F]+$/.test(s.slice(j + 3, close)) || code > 0x10ffff)
|
|
407
|
+
return null;
|
|
408
|
+
value += String.fromCodePoint(code);
|
|
409
|
+
j = close + 1;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
else
|
|
413
|
+
return null;
|
|
414
|
+
j += 2;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function callAt(s, at) {
|
|
418
|
+
let i = skip(s, at + 1);
|
|
419
|
+
if (!s.startsWith("tr", i))
|
|
420
|
+
return null;
|
|
421
|
+
i += 2;
|
|
422
|
+
if (/[A-Za-z0-9_-]/.test(s[i] ?? ""))
|
|
423
|
+
return null;
|
|
424
|
+
i = skip(s, i);
|
|
425
|
+
if (s[i] !== "(")
|
|
426
|
+
return null;
|
|
427
|
+
let found = literal(s, skip(s, i + 1));
|
|
428
|
+
if (!found)
|
|
429
|
+
return null;
|
|
430
|
+
let [span, text] = found;
|
|
431
|
+
let context;
|
|
432
|
+
let end = skip(s, span[1]);
|
|
433
|
+
if (s.startsWith("=>", end)) {
|
|
434
|
+
context = text;
|
|
435
|
+
found = literal(s, skip(s, end + 2));
|
|
436
|
+
if (!found)
|
|
437
|
+
return null;
|
|
438
|
+
[span, text] = found;
|
|
439
|
+
end = skip(s, span[1]);
|
|
440
|
+
}
|
|
441
|
+
let resume = span[1];
|
|
442
|
+
const call = {
|
|
443
|
+
id: text,
|
|
444
|
+
line: s.slice(0, at).split("\n").length,
|
|
445
|
+
idSpan: span,
|
|
446
|
+
};
|
|
447
|
+
if (context !== undefined)
|
|
448
|
+
call.context = context;
|
|
449
|
+
if (s[end] === "|") {
|
|
450
|
+
const other = literal(s, skip(s, end + 1));
|
|
451
|
+
if (!other)
|
|
452
|
+
return null;
|
|
453
|
+
call.plural = other[1];
|
|
454
|
+
call.pluralSpan = other[0];
|
|
455
|
+
resume = other[0][1];
|
|
456
|
+
}
|
|
457
|
+
return [call, resume];
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Every `@tr` call of a Slint source, in order; the same scanner as the
|
|
461
|
+
* player's (both run the shared cases of the player's
|
|
462
|
+
* `src/host/tests/fixtures/slint-tr.json`). Comments, strings and string
|
|
463
|
+
* interpolation are skipped; a malformed call is left out.
|
|
464
|
+
*/
|
|
465
|
+
export function scanSlint(source) {
|
|
466
|
+
const calls = [];
|
|
467
|
+
const templates = [];
|
|
468
|
+
let i = 0;
|
|
469
|
+
while (i < source.length) {
|
|
470
|
+
const c = source[i];
|
|
471
|
+
if (c === "/" && source[i + 1] === "/")
|
|
472
|
+
i = lineEnd(source, i);
|
|
473
|
+
else if (c === "/" && source[i + 1] === "*")
|
|
474
|
+
i = commentEnd(source, i);
|
|
475
|
+
else if (c === '"')
|
|
476
|
+
i = stringEnd(source, i + 1, templates);
|
|
477
|
+
else if (c === "{" && templates.length) {
|
|
478
|
+
templates[templates.length - 1]++;
|
|
479
|
+
i++;
|
|
480
|
+
}
|
|
481
|
+
else if (c === "}" && templates.length) {
|
|
482
|
+
if (templates[templates.length - 1] === 0) {
|
|
483
|
+
templates.pop();
|
|
484
|
+
i = stringEnd(source, i + 1, templates);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
templates[templates.length - 1]--;
|
|
488
|
+
i++;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else if (c === "@") {
|
|
492
|
+
const found = callAt(source, i);
|
|
493
|
+
if (found) {
|
|
494
|
+
calls.push(found[0]);
|
|
495
|
+
i = found[1];
|
|
496
|
+
}
|
|
497
|
+
else
|
|
498
|
+
i++;
|
|
499
|
+
}
|
|
500
|
+
else
|
|
501
|
+
i++;
|
|
502
|
+
}
|
|
503
|
+
return calls;
|
|
504
|
+
}
|
|
505
|
+
/** A Slint string literal for `text`. */
|
|
506
|
+
export function slintQuote(text) {
|
|
507
|
+
let out = '"';
|
|
508
|
+
for (const c of text) {
|
|
509
|
+
if (c === '"')
|
|
510
|
+
out += '\\"';
|
|
511
|
+
else if (c === "\\")
|
|
512
|
+
out += "\\\\";
|
|
513
|
+
else if (c === "\n")
|
|
514
|
+
out += "\\n";
|
|
515
|
+
else if (/\p{Cc}/u.test(c))
|
|
516
|
+
out += `\\u{${c.codePointAt(0).toString(16)}}`;
|
|
517
|
+
else
|
|
518
|
+
out += c;
|
|
519
|
+
}
|
|
520
|
+
return `${out}"`;
|
|
521
|
+
}
|
|
522
|
+
/** `source` with its `@tr` literals translated (what the player compiles). */
|
|
523
|
+
export function rewriteSlint(source, catalog) {
|
|
524
|
+
let out = "";
|
|
525
|
+
let copied = 0;
|
|
526
|
+
for (const call of scanSlint(source)) {
|
|
527
|
+
const translation = catalog.slint(call);
|
|
528
|
+
if (!translation)
|
|
529
|
+
continue;
|
|
530
|
+
out += source.slice(copied, call.idSpan[0]) + slintQuote(translation[0]);
|
|
531
|
+
copied = call.idSpan[1];
|
|
532
|
+
if (call.pluralSpan && translation[1] !== undefined) {
|
|
533
|
+
out +=
|
|
534
|
+
source.slice(copied, call.pluralSpan[0]) + slintQuote(translation[1]);
|
|
535
|
+
copied = call.pluralSpan[1];
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return out + source.slice(copied);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The supported locale for a BCP 47 tag, as the player maps the system's
|
|
542
|
+
* languages: Simplified Chinese is `zh-Hans`; Traditional Chinese (`zh-Hant`,
|
|
543
|
+
* or Taiwan, Hong Kong, Macao) is English; other languages by their primary
|
|
544
|
+
* subtag; `undefined` for a language the player does not have.
|
|
545
|
+
*/
|
|
546
|
+
export function matchLocale(tag) {
|
|
547
|
+
const parts = tag
|
|
548
|
+
.replaceAll("_", "-")
|
|
549
|
+
.toLowerCase()
|
|
550
|
+
.split(/[.@]/)[0]
|
|
551
|
+
.split("-");
|
|
552
|
+
const [language, ...rest] = parts;
|
|
553
|
+
if (language === "zh") {
|
|
554
|
+
const script = rest.find((part) => part.length === 4);
|
|
555
|
+
if (script)
|
|
556
|
+
return script === "hans" ? "zh-Hans" : "en";
|
|
557
|
+
return rest.some((r) => ["tw", "hk", "mo"].includes(r)) ? "en" : "zh-Hans";
|
|
558
|
+
}
|
|
559
|
+
return LOCALES.find((locale) => !locale.includes("-") && locale === language);
|
|
560
|
+
}
|
|
561
|
+
// ---------------------------------------------------------------- menus
|
|
562
|
+
/**
|
|
563
|
+
* The player's built-in top-level menus: id → key (the English text paths
|
|
564
|
+
* merge by), as the player's `MENU_ANCHORS`.
|
|
565
|
+
*/
|
|
566
|
+
export const MENU_KEYS = {
|
|
567
|
+
open: "Open",
|
|
568
|
+
playback: "Playback",
|
|
569
|
+
subtitles: "Subtitles",
|
|
570
|
+
video: "Video",
|
|
571
|
+
audio: "Audio",
|
|
572
|
+
screen: "Screen",
|
|
573
|
+
settings: "Settings",
|
|
574
|
+
};
|
|
575
|
+
/** The player's old Chinese titles of its own menus: parent, title, key. */
|
|
576
|
+
const LEGACY_MENUS = [
|
|
577
|
+
[[], "打开", "Open"],
|
|
578
|
+
[[], "播放", "Playback"],
|
|
579
|
+
[[], "字幕", "Subtitles"],
|
|
580
|
+
[[], "视频", "Video"],
|
|
581
|
+
[[], "声音", "Audio"],
|
|
582
|
+
[[], "屏幕", "Screen"],
|
|
583
|
+
[[], "设置", "Settings"],
|
|
584
|
+
[["Playback"], "速度", "Speed"],
|
|
585
|
+
[["Playback"], "跳转", "Seek"],
|
|
586
|
+
// Shared with a migrated built-in plugin (until all are migrated).
|
|
587
|
+
[["Settings"], "外观", "Appearance"],
|
|
588
|
+
];
|
|
589
|
+
/** The player's own submenus, by key path (their labels are the player's). */
|
|
590
|
+
export const HOST_GROUPS = [
|
|
591
|
+
"Open",
|
|
592
|
+
"Playback",
|
|
593
|
+
"Subtitles",
|
|
594
|
+
"Video",
|
|
595
|
+
"Audio",
|
|
596
|
+
"Screen",
|
|
597
|
+
"Settings",
|
|
598
|
+
"Playback/Speed",
|
|
599
|
+
"Playback/Seek",
|
|
600
|
+
];
|
|
601
|
+
/**
|
|
602
|
+
* The keys a path merges by, as the player normalizes it: `@id` becomes the
|
|
603
|
+
* built-in menu's key, the player's old Chinese titles of its own submenus
|
|
604
|
+
* their keys.
|
|
605
|
+
*/
|
|
606
|
+
export function menuKeys(path) {
|
|
607
|
+
const keys = [...path];
|
|
608
|
+
const id = keys[0]?.startsWith("@") ? keys[0].slice(1) : null;
|
|
609
|
+
if (id !== null && MENU_KEYS[id])
|
|
610
|
+
keys[0] = MENU_KEYS[id];
|
|
611
|
+
for (const [parent, old, key] of LEGACY_MENUS) {
|
|
612
|
+
const depth = parent.length;
|
|
613
|
+
if (keys.length > depth + 1 &&
|
|
614
|
+
parent.every((segment, i) => keys[i] === segment) &&
|
|
615
|
+
keys[depth] === old)
|
|
616
|
+
keys[depth] = key;
|
|
617
|
+
}
|
|
618
|
+
return keys;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* The player's own texts that the test host repeats (core actions, its
|
|
622
|
+
* refusals, its menus), English → the player's Simplified Chinese
|
|
623
|
+
* (`src/host/locales/zh-Hans.po` in the player; its tests check that they
|
|
624
|
+
* agree).
|
|
625
|
+
*/
|
|
626
|
+
export const HOST_ZH_HANS = {
|
|
627
|
+
Open: "打开",
|
|
628
|
+
Playback: "播放",
|
|
629
|
+
Subtitles: "字幕",
|
|
630
|
+
Video: "视频",
|
|
631
|
+
Audio: "声音",
|
|
632
|
+
Screen: "屏幕",
|
|
633
|
+
Settings: "设置",
|
|
634
|
+
Speed: "速度",
|
|
635
|
+
Seek: "跳转",
|
|
636
|
+
Close: "关闭",
|
|
637
|
+
Cancel: "取消",
|
|
638
|
+
"Play/Pause": "播放/暂停",
|
|
639
|
+
Previous: "上一个",
|
|
640
|
+
Next: "下一个",
|
|
641
|
+
"Previous Frame": "上一帧",
|
|
642
|
+
"Next Frame": "下一帧",
|
|
643
|
+
"Back {} s": "后退{}秒",
|
|
644
|
+
"Forward {} s": "前进{}秒",
|
|
645
|
+
Normal: "正常",
|
|
646
|
+
"Slower 0.1": "减速 0.1",
|
|
647
|
+
"Faster 0.1": "加速 0.1",
|
|
648
|
+
"Volume +": "音量 +",
|
|
649
|
+
"Volume −": "音量 −",
|
|
650
|
+
Mute: "静音",
|
|
651
|
+
"Not supported by the current source": "当前来源不支持此操作",
|
|
652
|
+
"The current source cannot step back a frame": "当前来源不支持逐帧后退",
|
|
653
|
+
"No video": "没有视频",
|
|
654
|
+
"No previous item": "没有上一个",
|
|
655
|
+
"No next item": "没有下一个",
|
|
656
|
+
Paused: "已暂停",
|
|
657
|
+
Playing: "播放",
|
|
658
|
+
"Back {} s: {} / {}": "后退 {}秒: {} / {}",
|
|
659
|
+
"Forward {} s: {} / {}": "前进 {}秒: {} / {}",
|
|
660
|
+
"Speed: {}x": "播放速度: {}x",
|
|
661
|
+
"Volume: {}%": "音量: {}%",
|
|
662
|
+
"Mute: On": "静音: 开",
|
|
663
|
+
"Mute: Off": "静音: 关",
|
|
664
|
+
"bottom bar": "底栏",
|
|
665
|
+
sidebar: "侧栏",
|
|
666
|
+
"video backdrop": "视频背景",
|
|
667
|
+
"The {} is used by {}": "{}已被 {} 使用",
|
|
668
|
+
"The sidebar holds at most {} tabs": "侧栏最多 {} 个页签",
|
|
669
|
+
"{} is resolved by {}": "{} 已由 {} 解析",
|
|
670
|
+
"File type .{}": "文件类型 .{}",
|
|
671
|
+
"Not available in fullscreen": "全屏时不可用",
|
|
672
|
+
"Picture-in-picture is not supported on this system": "此系统不支持画中画",
|
|
673
|
+
};
|
package/dist/player-runtime.d.ts
CHANGED
|
@@ -13,5 +13,6 @@ export { HostError } from "./wire.ts";
|
|
|
13
13
|
export type { Json, Transport } from "./wire.ts";
|
|
14
14
|
export { API_VERSION, CAPABILITIES } from "./capabilities.ts";
|
|
15
15
|
export { COMMAND_LIMITS, MENU_IDS } from "./commands.ts";
|
|
16
|
+
export { Catalog, HOST_ZH_HANS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, scanSlint, } from "./i18n.ts";
|
|
16
17
|
export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
|
|
17
18
|
export { MARKDOWN_LIMITS } from "./plugin/markdown.ts";
|
package/dist/player-runtime.js
CHANGED
|
@@ -11,5 +11,6 @@ export { createPlugin } from "./plugin/index.js";
|
|
|
11
11
|
export { HostError } from "./wire.js";
|
|
12
12
|
export { API_VERSION, CAPABILITIES } from "./capabilities.js";
|
|
13
13
|
export { COMMAND_LIMITS, MENU_IDS } from "./commands.js";
|
|
14
|
+
export { Catalog, HOST_ZH_HANS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, scanSlint, } from "./i18n.js";
|
|
14
15
|
export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
|
|
15
16
|
export { MARKDOWN_LIMITS } from "./plugin/markdown.js";
|