@vestra-player/sdk 1.5.9 → 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.
- package/CHANGELOG.md +26 -0
- package/dist/commands.d.ts +7 -4
- package/dist/i18n-tools.js +59 -3
- package/dist/i18n.d.ts +92 -0
- package/dist/i18n.js +299 -0
- package/dist/player-runtime.d.ts +2 -1
- package/dist/player-runtime.js +2 -1
- package/dist/plugin/bindings.js +1 -0
- package/dist/plugin/commands.js +24 -4
- package/dist/plugin/index.js +9 -1
- package/dist/plugin/language.d.ts +6 -2
- package/dist/plugin/language.js +74 -0
- package/dist/plugin/osd.d.ts +10 -2
- package/dist/plugin/osd.js +35 -16
- package/dist/plugin/subtitles.js +9 -1
- package/dist/plugin/testing.d.ts +54 -5
- package/dist/plugin/testing.js +199 -49
- package/dist/plugin/timeline.js +3 -1
- package/dist/plugin/types.d.ts +338 -43
- package/dist/plugin/ui.js +26 -10
- package/dist/plugin/values.d.ts +55 -0
- package/dist/plugin/values.js +117 -0
- package/dist/source-resolvers.d.ts +0 -1
- package/dist/source-resolvers.js +9 -1
- package/dist/subtitles.d.ts +3 -1
- package/dist/wire.d.ts +17 -9
- package/dist/wire.js +2 -1
- package/llms-full.txt +295 -47
- package/llms.txt +41 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.11
|
|
4
|
+
|
|
5
|
+
**Text values: words mixed with data, translated by the player** (player 0.4.3). Additive; `apiVersion` stays `vestra/1`, and plugins that worked need no change. 1.5.9 and 1.5.10 were not published to npm; this release includes them. Round-2 i18n requests R2-audio-1, R2-video-1, R2-subtitles-1/2, R2-playlist-1/2, R2-store-1, R2-workshop-1, R2-nes-1, capture #1, P-2, the info and hotkeys export texts, timeline and subtitle titles: one mechanism. Guide: [Translate a plugin ▸ Text that mixes words and data](docs/how-to/i18n.md#text-that-mixes-words-and-data).
|
|
6
|
+
|
|
7
|
+
- **`plugin.i18n.text(source, args?)`** returns a `Text`: plain, frozen data (`{ $vestraText, args }`), not a translation. The player translates `source` with the plugin's catalog and fills in `args` wherever it shows it. Arguments are data, or text values that are translated too, so a sentence can be built from translated parts without composing it in TypeScript.
|
|
8
|
+
- **`plugin.i18n.list(items, style?)`** returns a `TextList`: items joined with the interface language's separator (`ListStyle`: `"list"` `, ` / `、`, `"clauses"` `, ` / `,`, `"semicolons"`, `"sentences"`, `"lines"`; `LIST_SEPARATORS` in `@vestra-player/sdk/testing`).
|
|
9
|
+
- **Accepted wherever the player shows a plugin's text**: command `title`, `path` segments (the menu merges by the English rendering) and `reason`; `osd.show` and `OsdPanel.set` as the text or an argument (`TemplateArgs` now takes `TextArg`); every `dialogs.confirm` text; file dialog `title` and `saveFile`'s `fileName` (a string `fileName` stays data); property values where Slint has a `string`, at any depth (`PropData` takes `Text` and `TextList`), so a ComboBox model can mix fixed words and data; `TimelineLayer.set` mark titles; `subtitles.load({ title })`.
|
|
10
|
+
- **`plugin.i18n.error(source, args?)`**: an `Error` (`TextError`) whose words the player shows translated when a file or web resolver's `resolve` throws it (also a stream failing while it starts): the open's failure and `player.state.error`. `message` is the English rendering.
|
|
11
|
+
- **`plugin.i18n.resolve(value)`**: the translated string, for text that leaves the player only (clipboard, files). Never for the interface.
|
|
12
|
+
- **`OsdPanel.set(text, args, { maxColumns })`** (`OsdPanelOptions`): the player cuts each line after translation (a wide character is two columns).
|
|
13
|
+
- **`BindingInfo.key`**: the menu path's English keys, the same in every language (P-2). Find a command by its place in the menu with it, not with `path`.
|
|
14
|
+
- **Limits** (`TEXT_LIMITS` in `@vestra-player/sdk/testing`): nesting 8, 1024 values in one text, 16 arguments per text, 256 items per list, 4096 bytes per source or string argument (template string arguments were 1024 bytes). A malformed value throws `invalid` where it is made, and the player refuses it with `invalid`. A property value's limits count a text value as the string it becomes.
|
|
15
|
+
- **`vestra-plugin i18n extract`** collects the source of every `plugin.i18n.text(…)`, `error(…)` and `resolve("…")` call (also destructured: `const { text } = plugin.i18n`) and of a `{ $vestraText: "…" }` literal. Every source is collected, also one made only of placeholders and punctuation (`"{} ({})"`, which Chinese writes `"{}({})"`). It now reads fields spread into a call's options (`commands.add({ ...extra })`) by their types and reports the ones it cannot extract; before, it skipped them without a word. A field typed as a text value is not reported as "not a literal".
|
|
16
|
+
- **Type note**: the option fields that now take a text value (`ConfirmOptions.message` and the other dialog texts, `CommandOptions.title`/`path`/`reason`, `CommandState.reason`, file dialog `title`/`fileName`, `TemplateArgs`) are wider types. Code that only passes them is unchanged; code that reads such a field back as a `string` (a helper that cuts `options.message`) needs a `typeof … === "string"` check.
|
|
17
|
+
- **`TestHost`** shows text values as the player does: `FakeCommand.display` (and the non-enumerable `display.reason`), `host.osd`, `host.osdPanel` (cut to `maxColumns`), `host.confirmTexts`, new `host.fileDialogs` (`FileDialogText`), window, panel and settings page `props` (text values replaced by their strings; `host.sent(...)` keeps what the plugin sent), timeline titles, subtitle titles, a resolver's `TextError`, and `BindingInfo.key`. `host.translate` takes a text value too. It refuses malformed text values with the player's messages.
|
|
18
|
+
|
|
19
|
+
## 1.5.10
|
|
20
|
+
|
|
21
|
+
**Property value limits: documented, higher, enforced by `TestHost`, reported loudly** (player 0.4.3). A fix; `apiVersion` stays `vestra/1`, and plugins that worked need no change. 1.5.9 was not published to npm; this release includes its changes.
|
|
22
|
+
|
|
23
|
+
- **`UI_VALUE_LIMITS`** in `@vestra-player/sdk/testing`: the player's limits on one property value, which used to appear only in the `PropValue` description. Raised in player 0.4.3 (older players in brackets): nesting depth 8, **16384 values** (4096), **4096 rows per array** (1024), 64 KiB per string or field name, 1 MiB of text. A list of 8-field rows is 9 values per row, so about 1800 rows fit (about 450 before). The player's tests check that the constant agrees with the player.
|
|
24
|
+
- **`TestHost` enforces them** on `ui.open`, `ui.set`, `ui.mount`, `ui.setMount`, `settings.register` and `settings.set`, with the player's code and message. Before, it took any value, so a plugin whose list outgrew the limit passed its tests and showed a stale list in the player (D1: a 3000-row playlist). A test that writes a value over the limits now sees the failure in `onError` (or, for a tool window's initial property, a rejected `Window.show`).
|
|
25
|
+
- **Failures say what they are.** A value over the limits fails with code **`limit`** (was `host`) and the message names the property (`rows: UI array exceeds 4096 rows`). A panel's or settings page's initial property over the limits is still left out (the panel mounts without it; a failed mount during activation would fail the plugin), but the player now reports it to `onError` instead of only writing its log.
|
|
26
|
+
- **`ui.error` reaches `onError`.** The player reports failures nobody asked about: a callback whose arguments it could not convert, a property it could not show again when it rebuilt a panel or the Options window (an image file removed since), a detached sidebar it could not prepare. The SDK used to drop these; it now reports them to the root scope's `onError` with the player's code (`ui` from older players).
|
|
27
|
+
- The player's runner (player 0.4.3) refuses a request longer than the player's 1 MiB message with `limit`; before, the player stopped the plugin.
|
|
28
|
+
|
|
3
29
|
## 1.5.9
|
|
4
30
|
|
|
5
31
|
**Translations** (player 0.4.3). Additive; `apiVersion` stays `vestra/1`, and plugins written for 1.5.8 need no change. One test-visible change: a core action's `declaration.path` in `TestHost` is now the player's English key (read `display` for what the menu shows). The player now shows its interface in English, Simplified Chinese, Japanese, Korean, German, French or Spanish, chosen at start-up; a plugin writes every text in English and ships one gettext file per language. Plugins write no translation code: the player translates. Guides: [Translate a plugin](docs/how-to/i18n.md), [Migrate a plugin to English source text](docs/how-to/i18n-migration.md); example: `examples/i18n`.
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Finite candidate command registry. Host authenticates owner; no plugin supplies an owner id. */
|
|
2
2
|
import type { Transport } from "./wire.ts";
|
|
3
|
+
import type { ListJson, TextJson } from "./i18n.ts";
|
|
3
4
|
/**
|
|
4
5
|
* The player's command registry caps, the same values as the player's own
|
|
5
6
|
* table (the player's tests check that they agree). They are runaway guards,
|
|
@@ -47,12 +48,14 @@ export type CommandState = {
|
|
|
47
48
|
enabled: boolean;
|
|
48
49
|
checked: boolean | null;
|
|
49
50
|
busy: boolean;
|
|
50
|
-
|
|
51
|
+
/** Source text, or a text value (player 0.4.3). */
|
|
52
|
+
reason: string | TextJson | ListJson | null;
|
|
51
53
|
};
|
|
52
54
|
export type CommandDeclaration = {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
path
|
|
55
|
+
/** Source text, or a text value (player 0.4.3). */
|
|
56
|
+
title: string | TextJson;
|
|
57
|
+
/** Complete primary menu path INCLUDING its leaf; empty = not in the main menu. Segments may be text values (player 0.4.3). */
|
|
58
|
+
path: (string | TextJson)[];
|
|
56
59
|
/** Sibling order, ascending; a submenu takes its first command's order (a built-in menu keeps a fixed one). */
|
|
57
60
|
order: number;
|
|
58
61
|
/** Separator label: adjacent siblings of different groups get a line. */
|
package/dist/i18n-tools.js
CHANGED
|
@@ -26,7 +26,7 @@ export const pluralForms = (language) => ONE_FORM.has(language)
|
|
|
26
26
|
: "nplurals=2; plural=(n != 1);";
|
|
27
27
|
const slash = (path) => path.split(sep).join("/");
|
|
28
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';
|
|
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
30
|
const DYNAMIC = (what) => `${what} is not a literal; its text cannot be extracted (use a string literal or an \`as const\` table)`;
|
|
31
31
|
const SITES = {
|
|
32
32
|
"Commands.add": { object: 0, fields: ["title", "reason"], path: true },
|
|
@@ -43,6 +43,9 @@ const SITES = {
|
|
|
43
43
|
"Dialogs.openFiles": { object: 0, fields: ["title"] },
|
|
44
44
|
"Dialogs.openDirectory": { object: 0, fields: ["title"] },
|
|
45
45
|
"Dialogs.saveFile": { object: 0, fields: ["title"] },
|
|
46
|
+
"I18n.text": { argument: 0 },
|
|
47
|
+
"I18n.error": { argument: 0 },
|
|
48
|
+
"I18n.resolve": { argument: 0 },
|
|
46
49
|
};
|
|
47
50
|
/** Without the SDK's types: the module property names in front of the method. */
|
|
48
51
|
const BY_NAME = {
|
|
@@ -55,6 +58,9 @@ const BY_NAME = {
|
|
|
55
58
|
"dialogs.openFiles": "Dialogs.openFiles",
|
|
56
59
|
"dialogs.openDirectory": "Dialogs.openDirectory",
|
|
57
60
|
"dialogs.saveFile": "Dialogs.saveFile",
|
|
61
|
+
"i18n.text": "I18n.text",
|
|
62
|
+
"i18n.error": "I18n.error",
|
|
63
|
+
"i18n.resolve": "I18n.resolve",
|
|
58
64
|
};
|
|
59
65
|
const none = () => ({ values: [], dynamic: false, composed: false });
|
|
60
66
|
const join2 = (a, b) => ({
|
|
@@ -125,6 +131,11 @@ class Extractor {
|
|
|
125
131
|
for (const part of parts) {
|
|
126
132
|
if (part.isStringLiteral())
|
|
127
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;
|
|
128
139
|
else if (part.flags &
|
|
129
140
|
(ts.TypeFlags.Null |
|
|
130
141
|
ts.TypeFlags.Undefined |
|
|
@@ -249,10 +260,22 @@ class Extractor {
|
|
|
249
260
|
/** `Interface.method` of a call to the SDK, if it is one of `SITES`. */
|
|
250
261
|
#site(call) {
|
|
251
262
|
const callee = call.expression;
|
|
252
|
-
|
|
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
|
+
}
|
|
253
276
|
return undefined;
|
|
277
|
+
}
|
|
254
278
|
const method = callee.name.text;
|
|
255
|
-
const declaration = this.#checker?.getResolvedSignature(call)?.declaration;
|
|
256
279
|
if (declaration &&
|
|
257
280
|
!ts.isJSDocSignature(declaration) &&
|
|
258
281
|
declaration.parent &&
|
|
@@ -279,6 +302,13 @@ class Extractor {
|
|
|
279
302
|
const visit = (node) => {
|
|
280
303
|
if (ts.isCallExpression(node))
|
|
281
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)]);
|
|
282
312
|
if ((ts.isStringLiteral(node) ||
|
|
283
313
|
ts.isNoSubstitutionTemplateLiteral(node)) &&
|
|
284
314
|
CJK.test(node.text) &&
|
|
@@ -330,6 +360,32 @@ class Extractor {
|
|
|
330
360
|
return;
|
|
331
361
|
}
|
|
332
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
|
+
}
|
|
333
389
|
const key = property.name &&
|
|
334
390
|
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
|
|
335
391
|
? property.name.text
|
package/dist/i18n.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ListStyle, Locale } from "./plugin/types.ts";
|
|
1
2
|
/**
|
|
2
3
|
* The player's interface languages: English (the source language) first,
|
|
3
4
|
* then the translations, as the player's own table (its tests check that
|
|
@@ -78,6 +79,83 @@ export declare function format(template: string, args: TemplateArgsValue): strin
|
|
|
78
79
|
* number (`{n}` of a Slint plural is `"n"`). Translations must keep them.
|
|
79
80
|
*/
|
|
80
81
|
export declare function placeholders(text: string): string[];
|
|
82
|
+
/**
|
|
83
|
+
* The ways a `TextList` is joined (`ListStyle`), as the player
|
|
84
|
+
* joins them in each interface language (`LIST_SEPARATORS`).
|
|
85
|
+
*/
|
|
86
|
+
export declare const LIST_STYLES: readonly ListStyle[];
|
|
87
|
+
/**
|
|
88
|
+
* The separator of each list style per interface language, as the player's
|
|
89
|
+
* table (`i18n::text::SEPARATORS`; its tests check that they agree).
|
|
90
|
+
*/
|
|
91
|
+
export declare const LIST_SEPARATORS: Readonly<Record<Locale, Readonly<Record<ListStyle, string>>>>;
|
|
92
|
+
/**
|
|
93
|
+
* The player's limits on one text value (a
|
|
94
|
+
* {@link @vestra-player/sdk!Text | Text} or
|
|
95
|
+
* {@link @vestra-player/sdk!TextList | TextList} with everything nested in it), as the player's
|
|
96
|
+
* `i18n::text` (its tests check that they agree).
|
|
97
|
+
*/
|
|
98
|
+
export declare const TEXT_LIMITS: {
|
|
99
|
+
/** Nesting depth of texts and lists; the value itself is depth 0 (8). */
|
|
100
|
+
readonly depth: 8;
|
|
101
|
+
/** Texts, lists and arguments in all, the value itself included (1024). */
|
|
102
|
+
readonly nodes: 1024;
|
|
103
|
+
/** Arguments of one text (16). */
|
|
104
|
+
readonly args: 16;
|
|
105
|
+
/** Items of one list (256). */
|
|
106
|
+
readonly items: 256;
|
|
107
|
+
/** UTF-8 bytes of a source text or a string argument (4096). */
|
|
108
|
+
readonly stringBytes: 4096;
|
|
109
|
+
};
|
|
110
|
+
/** A translatable text on the wire: English source text and its arguments. */
|
|
111
|
+
export type TextJson = {
|
|
112
|
+
$vestraText: string;
|
|
113
|
+
args?: ArgJson[] | {
|
|
114
|
+
[name: string]: ArgJson;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** A list on the wire: items joined the interface language's way. */
|
|
118
|
+
export type ListJson = {
|
|
119
|
+
$vestraList: ArgJson[];
|
|
120
|
+
style?: ListStyle;
|
|
121
|
+
};
|
|
122
|
+
/** An argument of a text: data (strings and numbers) or another text. */
|
|
123
|
+
export type ArgJson = string | number | TextJson | ListJson;
|
|
124
|
+
/** A text or list value (by its marker key). */
|
|
125
|
+
export declare function isTextValue(value: unknown): value is TextJson | ListJson;
|
|
126
|
+
/**
|
|
127
|
+
* Why the player would refuse a text value or template arguments, or
|
|
128
|
+
* `null`. `args` mode checks template arguments (an array or object of
|
|
129
|
+
* arguments) instead of one value. The same rules and messages as the
|
|
130
|
+
* player's `i18n::text::check`.
|
|
131
|
+
*/
|
|
132
|
+
export declare function textProblem(value: unknown, mode?: "value" | "args"): string | null;
|
|
133
|
+
type TemplateArgsLike = readonly ArgJson[] | Readonly<Record<string, ArgJson>> | null;
|
|
134
|
+
/**
|
|
135
|
+
* A text value as the player shows it: the source translated with the
|
|
136
|
+
* plugin's catalog and formatted with its (rendered) arguments; a list's
|
|
137
|
+
* items joined with the locale's separator.
|
|
138
|
+
*/
|
|
139
|
+
export declare function renderText(value: TextJson | ListJson, catalog: Catalog, locale: string): string;
|
|
140
|
+
/**
|
|
141
|
+
* Template arguments with their texts rendered (for `format`): what the
|
|
142
|
+
* player fills a template with.
|
|
143
|
+
*/
|
|
144
|
+
export declare function renderTemplateArgs(args: TemplateArgsLike | undefined, catalog: Catalog, locale: string): TemplateArgsValue | undefined;
|
|
145
|
+
/** The English rendering of a text value: the key a menu merges by. */
|
|
146
|
+
export declare function textKey(value: TextJson | ListJson): string;
|
|
147
|
+
/**
|
|
148
|
+
* A property value with every text value in it replaced by its shown
|
|
149
|
+
* string, as the player does before it hands the value to Slint. Returns
|
|
150
|
+
* a problem (`textProblem`) instead when a text value is malformed.
|
|
151
|
+
*/
|
|
152
|
+
export declare function resolveTexts<T>(value: T, catalog: Catalog, locale: string): {
|
|
153
|
+
value: T;
|
|
154
|
+
} | {
|
|
155
|
+
problem: string;
|
|
156
|
+
};
|
|
157
|
+
/** Whether a value holds a text value anywhere (a cheap pre-check). */
|
|
158
|
+
export declare function hasTexts(value: unknown): boolean;
|
|
81
159
|
/** One `@tr("context" => "id" | "plural" % n, args…)` call. */
|
|
82
160
|
export interface TrCall {
|
|
83
161
|
context?: string;
|
|
@@ -128,3 +206,17 @@ export declare function menuKeys(path: readonly string[]): string[];
|
|
|
128
206
|
* agree).
|
|
129
207
|
*/
|
|
130
208
|
export declare const HOST_ZH_HANS: Readonly<Record<string, string>>;
|
|
209
|
+
/**
|
|
210
|
+
* The catalog of a package directory for `locale`, as the player loads it:
|
|
211
|
+
* the compiled `locales/<lang>.json` of a built package, else the source
|
|
212
|
+
* `locales/<lang>.po`. Empty for English and without a file; a broken file
|
|
213
|
+
* throws.
|
|
214
|
+
*/
|
|
215
|
+
export declare function loadPackageCatalog(directory: string, locale: string): Catalog;
|
|
216
|
+
/**
|
|
217
|
+
* Each line of `text` cut to `max` columns, ending with `…` when cut, as
|
|
218
|
+
* the player cuts an OSD panel's lines (`OsdPanelOptions.maxColumns`; the
|
|
219
|
+
* player measures with Unicode's East Asian Width, this is close to it).
|
|
220
|
+
*/
|
|
221
|
+
export declare function fitColumns(text: string, max: number): string;
|
|
222
|
+
export {};
|
package/dist/i18n.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* Internal: the player's runner and tests reach it through
|
|
9
9
|
* `@vestra-player/sdk/runtime`, `LOCALES` through `./testing`.
|
|
10
10
|
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
11
13
|
/**
|
|
12
14
|
* The player's interface languages: English (the source language) first,
|
|
13
15
|
* then the translations, as the player's own table (its tests check that
|
|
@@ -326,6 +328,254 @@ export function placeholders(text) {
|
|
|
326
328
|
found.push(match[1]);
|
|
327
329
|
return found.sort();
|
|
328
330
|
}
|
|
331
|
+
// ---------------------------------------------------------------- texts
|
|
332
|
+
/**
|
|
333
|
+
* The ways a `TextList` is joined (`ListStyle`), as the player
|
|
334
|
+
* joins them in each interface language (`LIST_SEPARATORS`).
|
|
335
|
+
*/
|
|
336
|
+
export const LIST_STYLES = [
|
|
337
|
+
"list",
|
|
338
|
+
"clauses",
|
|
339
|
+
"semicolons",
|
|
340
|
+
"sentences",
|
|
341
|
+
"lines",
|
|
342
|
+
];
|
|
343
|
+
/**
|
|
344
|
+
* The separator of each list style per interface language, as the player's
|
|
345
|
+
* table (`i18n::text::SEPARATORS`; its tests check that they agree).
|
|
346
|
+
*/
|
|
347
|
+
export const LIST_SEPARATORS = {
|
|
348
|
+
en: {
|
|
349
|
+
list: ", ",
|
|
350
|
+
clauses: ", ",
|
|
351
|
+
semicolons: "; ",
|
|
352
|
+
sentences: ". ",
|
|
353
|
+
lines: "\n",
|
|
354
|
+
},
|
|
355
|
+
"zh-Hans": {
|
|
356
|
+
list: "、",
|
|
357
|
+
clauses: ",",
|
|
358
|
+
semicolons: ";",
|
|
359
|
+
sentences: "。",
|
|
360
|
+
lines: "\n",
|
|
361
|
+
},
|
|
362
|
+
ja: {
|
|
363
|
+
list: "、",
|
|
364
|
+
clauses: "、",
|
|
365
|
+
semicolons: ";",
|
|
366
|
+
sentences: "。",
|
|
367
|
+
lines: "\n",
|
|
368
|
+
},
|
|
369
|
+
ko: {
|
|
370
|
+
list: ", ",
|
|
371
|
+
clauses: ", ",
|
|
372
|
+
semicolons: "; ",
|
|
373
|
+
sentences: ". ",
|
|
374
|
+
lines: "\n",
|
|
375
|
+
},
|
|
376
|
+
de: {
|
|
377
|
+
list: ", ",
|
|
378
|
+
clauses: ", ",
|
|
379
|
+
semicolons: "; ",
|
|
380
|
+
sentences: ". ",
|
|
381
|
+
lines: "\n",
|
|
382
|
+
},
|
|
383
|
+
fr: {
|
|
384
|
+
list: ", ",
|
|
385
|
+
clauses: ", ",
|
|
386
|
+
semicolons: " ; ",
|
|
387
|
+
sentences: ". ",
|
|
388
|
+
lines: "\n",
|
|
389
|
+
},
|
|
390
|
+
es: {
|
|
391
|
+
list: ", ",
|
|
392
|
+
clauses: ", ",
|
|
393
|
+
semicolons: "; ",
|
|
394
|
+
sentences: ". ",
|
|
395
|
+
lines: "\n",
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* The player's limits on one text value (a
|
|
400
|
+
* {@link @vestra-player/sdk!Text | Text} or
|
|
401
|
+
* {@link @vestra-player/sdk!TextList | TextList} with everything nested in it), as the player's
|
|
402
|
+
* `i18n::text` (its tests check that they agree).
|
|
403
|
+
*/
|
|
404
|
+
export const TEXT_LIMITS = {
|
|
405
|
+
/** Nesting depth of texts and lists; the value itself is depth 0 (8). */
|
|
406
|
+
depth: 8,
|
|
407
|
+
/** Texts, lists and arguments in all, the value itself included (1024). */
|
|
408
|
+
nodes: 1024,
|
|
409
|
+
/** Arguments of one text (16). */
|
|
410
|
+
args: 16,
|
|
411
|
+
/** Items of one list (256). */
|
|
412
|
+
items: 256,
|
|
413
|
+
/** UTF-8 bytes of a source text or a string argument (4096). */
|
|
414
|
+
stringBytes: 4096,
|
|
415
|
+
};
|
|
416
|
+
const plainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
417
|
+
/** A text or list value (by its marker key). */
|
|
418
|
+
export function isTextValue(value) {
|
|
419
|
+
return (plainObject(value) && ("$vestraText" in value || "$vestraList" in value));
|
|
420
|
+
}
|
|
421
|
+
const utf8 = (text) => new TextEncoder().encode(text).length;
|
|
422
|
+
/**
|
|
423
|
+
* Why the player would refuse a text value or template arguments, or
|
|
424
|
+
* `null`. `args` mode checks template arguments (an array or object of
|
|
425
|
+
* arguments) instead of one value. The same rules and messages as the
|
|
426
|
+
* player's `i18n::text::check`.
|
|
427
|
+
*/
|
|
428
|
+
export function textProblem(value, mode = "value") {
|
|
429
|
+
let nodes = 0;
|
|
430
|
+
const L = TEXT_LIMITS;
|
|
431
|
+
const args = (list, depth) => {
|
|
432
|
+
const values = Array.isArray(list)
|
|
433
|
+
? list
|
|
434
|
+
: plainObject(list)
|
|
435
|
+
? Object.values(list)
|
|
436
|
+
: null;
|
|
437
|
+
if (!values)
|
|
438
|
+
return "args is an array or an object";
|
|
439
|
+
if (values.length > L.args)
|
|
440
|
+
return `args holds at most ${L.args} values`;
|
|
441
|
+
for (const item of values) {
|
|
442
|
+
const problem = arg(item, depth);
|
|
443
|
+
if (problem)
|
|
444
|
+
return problem;
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
};
|
|
448
|
+
const arg = (item, depth) => {
|
|
449
|
+
if (typeof item === "number")
|
|
450
|
+
return Number.isFinite(item) ? count() : "a number argument is finite";
|
|
451
|
+
if (typeof item === "string")
|
|
452
|
+
return utf8(item) > L.stringBytes
|
|
453
|
+
? `a string argument is at most ${L.stringBytes} bytes`
|
|
454
|
+
: count();
|
|
455
|
+
if (isTextValue(item))
|
|
456
|
+
return text(item, depth + 1);
|
|
457
|
+
return "args are strings, numbers, texts and lists";
|
|
458
|
+
};
|
|
459
|
+
const count = () => ++nodes > L.nodes ? `a text holds at most ${L.nodes} values` : null;
|
|
460
|
+
const text = (item, depth) => {
|
|
461
|
+
if (depth > L.depth)
|
|
462
|
+
return `texts nest at most ${L.depth} deep`;
|
|
463
|
+
if (!plainObject(item))
|
|
464
|
+
return "a text is a $vestraText or $vestraList object";
|
|
465
|
+
const over = count();
|
|
466
|
+
if (over)
|
|
467
|
+
return over;
|
|
468
|
+
if ("$vestraText" in item) {
|
|
469
|
+
const keys = Object.keys(item);
|
|
470
|
+
if (keys.some((key) => key !== "$vestraText" && key !== "args"))
|
|
471
|
+
return "a text has only $vestraText and args";
|
|
472
|
+
const source = item.$vestraText;
|
|
473
|
+
if (typeof source !== "string" || source === "")
|
|
474
|
+
return "a text's source is a non-empty string";
|
|
475
|
+
if (utf8(source) > L.stringBytes)
|
|
476
|
+
return `a text's source is at most ${L.stringBytes} bytes`;
|
|
477
|
+
return item.args === undefined ? null : args(item.args, depth);
|
|
478
|
+
}
|
|
479
|
+
const keys = Object.keys(item);
|
|
480
|
+
if (keys.some((key) => key !== "$vestraList" && key !== "style"))
|
|
481
|
+
return "a list has only $vestraList and style";
|
|
482
|
+
const items = item.$vestraList;
|
|
483
|
+
if (!Array.isArray(items))
|
|
484
|
+
return "a list's items are an array";
|
|
485
|
+
if (items.length > L.items)
|
|
486
|
+
return `a list holds at most ${L.items} items`;
|
|
487
|
+
if (item.style !== undefined &&
|
|
488
|
+
!LIST_STYLES.includes(item.style))
|
|
489
|
+
return `a list's style is one of ${LIST_STYLES.join(", ")}`;
|
|
490
|
+
for (const entry of items) {
|
|
491
|
+
const problem = arg(entry, depth);
|
|
492
|
+
if (problem)
|
|
493
|
+
return problem;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
};
|
|
497
|
+
return mode === "args" ? args(value, 0) : text(value, 0);
|
|
498
|
+
}
|
|
499
|
+
function renderArg(arg, r) {
|
|
500
|
+
return isTextValue(arg) ? renderValue(arg, r) : arg;
|
|
501
|
+
}
|
|
502
|
+
function renderValue(value, r) {
|
|
503
|
+
if ("$vestraText" in value) {
|
|
504
|
+
const template = r.catalog.text(value.$vestraText);
|
|
505
|
+
return format(template, renderArgs(value.args, r) ?? []);
|
|
506
|
+
}
|
|
507
|
+
const locale = LOCALES.includes(r.locale)
|
|
508
|
+
? r.locale
|
|
509
|
+
: "en";
|
|
510
|
+
const separator = LIST_SEPARATORS[locale][value.style ?? "list"];
|
|
511
|
+
return value.$vestraList
|
|
512
|
+
.map((item) => String(argumentText(renderArg(item, r))))
|
|
513
|
+
.join(separator);
|
|
514
|
+
}
|
|
515
|
+
const argumentText = (value) => typeof value === "number" ? format("{}", [value]) : value;
|
|
516
|
+
function renderArgs(args, r) {
|
|
517
|
+
if (args === undefined || args === null)
|
|
518
|
+
return undefined;
|
|
519
|
+
if (Array.isArray(args))
|
|
520
|
+
return args.map((arg) => renderArg(arg, r));
|
|
521
|
+
return Object.fromEntries(Object.entries(args).map(([name, arg]) => [name, renderArg(arg, r)]));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* A text value as the player shows it: the source translated with the
|
|
525
|
+
* plugin's catalog and formatted with its (rendered) arguments; a list's
|
|
526
|
+
* items joined with the locale's separator.
|
|
527
|
+
*/
|
|
528
|
+
export function renderText(value, catalog, locale) {
|
|
529
|
+
return renderValue(value, { catalog, locale });
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Template arguments with their texts rendered (for `format`): what the
|
|
533
|
+
* player fills a template with.
|
|
534
|
+
*/
|
|
535
|
+
export function renderTemplateArgs(args, catalog, locale) {
|
|
536
|
+
return renderArgs(args ?? undefined, { catalog, locale });
|
|
537
|
+
}
|
|
538
|
+
/** The English rendering of a text value: the key a menu merges by. */
|
|
539
|
+
export function textKey(value) {
|
|
540
|
+
return renderValue(value, { catalog: new Catalog(), locale: "en" });
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* A property value with every text value in it replaced by its shown
|
|
544
|
+
* string, as the player does before it hands the value to Slint. Returns
|
|
545
|
+
* a problem (`textProblem`) instead when a text value is malformed.
|
|
546
|
+
*/
|
|
547
|
+
export function resolveTexts(value, catalog, locale) {
|
|
548
|
+
let problem = null;
|
|
549
|
+
const walk = (item) => {
|
|
550
|
+
if (problem)
|
|
551
|
+
return item;
|
|
552
|
+
if (isTextValue(item)) {
|
|
553
|
+
problem = textProblem(item);
|
|
554
|
+
return problem ? item : renderText(item, catalog, locale);
|
|
555
|
+
}
|
|
556
|
+
if (Array.isArray(item))
|
|
557
|
+
return item.map(walk);
|
|
558
|
+
if (plainObject(item)) {
|
|
559
|
+
// A runtime image or Markdown document stays as it is.
|
|
560
|
+
if ("$vestraImage" in item || "$vestraMarkdown" in item)
|
|
561
|
+
return item;
|
|
562
|
+
return Object.fromEntries(Object.entries(item).map(([key, entry]) => [key, walk(entry)]));
|
|
563
|
+
}
|
|
564
|
+
return item;
|
|
565
|
+
};
|
|
566
|
+
const out = walk(value);
|
|
567
|
+
return problem ? { problem } : { value: out };
|
|
568
|
+
}
|
|
569
|
+
/** Whether a value holds a text value anywhere (a cheap pre-check). */
|
|
570
|
+
export function hasTexts(value) {
|
|
571
|
+
if (isTextValue(value))
|
|
572
|
+
return true;
|
|
573
|
+
if (Array.isArray(value))
|
|
574
|
+
return value.some(hasTexts);
|
|
575
|
+
if (plainObject(value))
|
|
576
|
+
return Object.values(value).some(hasTexts);
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
329
579
|
const lineEnd = (s, i) => {
|
|
330
580
|
const found = s.slice(i).search(/[\n\r]/);
|
|
331
581
|
return found < 0 ? s.length : i + found;
|
|
@@ -671,3 +921,52 @@ export const HOST_ZH_HANS = {
|
|
|
671
921
|
"Not available in fullscreen": "全屏时不可用",
|
|
672
922
|
"Picture-in-picture is not supported on this system": "此系统不支持画中画",
|
|
673
923
|
};
|
|
924
|
+
// ---------------------------------------------------------------- packages
|
|
925
|
+
/**
|
|
926
|
+
* The catalog of a package directory for `locale`, as the player loads it:
|
|
927
|
+
* the compiled `locales/<lang>.json` of a built package, else the source
|
|
928
|
+
* `locales/<lang>.po`. Empty for English and without a file; a broken file
|
|
929
|
+
* throws.
|
|
930
|
+
*/
|
|
931
|
+
export function loadPackageCatalog(directory, locale) {
|
|
932
|
+
if (locale === "en")
|
|
933
|
+
return new Catalog();
|
|
934
|
+
const base = join(directory, "locales", locale);
|
|
935
|
+
if (existsSync(`${base}.json`)) {
|
|
936
|
+
const compiled = JSON.parse(readFileSync(`${base}.json`, "utf8"));
|
|
937
|
+
return new Catalog(compiled.messages ?? {});
|
|
938
|
+
}
|
|
939
|
+
if (existsSync(`${base}.po`))
|
|
940
|
+
return new Catalog(compileCatalog(locale, parsePo(readFileSync(`${base}.po`, "utf8")).entries).messages);
|
|
941
|
+
return new Catalog();
|
|
942
|
+
}
|
|
943
|
+
// ---------------------------------------------------------------- columns
|
|
944
|
+
const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]/u;
|
|
945
|
+
const ZERO = /[\p{Mn}\p{Me}-]/u;
|
|
946
|
+
/** Columns of one character: 2 wide (East Asian), 0 combining, else 1. */
|
|
947
|
+
const columnsOf = (c) => (ZERO.test(c) ? 0 : WIDE.test(c) ? 2 : 1);
|
|
948
|
+
/**
|
|
949
|
+
* Each line of `text` cut to `max` columns, ending with `…` when cut, as
|
|
950
|
+
* the player cuts an OSD panel's lines (`OsdPanelOptions.maxColumns`; the
|
|
951
|
+
* player measures with Unicode's East Asian Width, this is close to it).
|
|
952
|
+
*/
|
|
953
|
+
export function fitColumns(text, max) {
|
|
954
|
+
return text
|
|
955
|
+
.split("\n")
|
|
956
|
+
.map((line) => {
|
|
957
|
+
let used = 0;
|
|
958
|
+
const chars = [...line];
|
|
959
|
+
if (chars.reduce((n, c) => n + columnsOf(c), 0) <= max)
|
|
960
|
+
return line;
|
|
961
|
+
let out = "";
|
|
962
|
+
for (const c of chars) {
|
|
963
|
+
const width = columnsOf(c);
|
|
964
|
+
if (used + width > max - 1)
|
|
965
|
+
break;
|
|
966
|
+
out += c;
|
|
967
|
+
used += width;
|
|
968
|
+
}
|
|
969
|
+
return `${out}…`;
|
|
970
|
+
})
|
|
971
|
+
.join("\n");
|
|
972
|
+
}
|
package/dist/player-runtime.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ 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
|
+
export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.ts";
|
|
17
17
|
export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
|
|
18
18
|
export { MARKDOWN_LIMITS } from "./plugin/markdown.ts";
|
|
19
|
+
export { UI_VALUE_LIMITS } from "./plugin/values.ts";
|
package/dist/player-runtime.js
CHANGED
|
@@ -11,6 +11,7 @@ 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
|
+
export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.js";
|
|
15
15
|
export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
|
|
16
16
|
export { MARKDOWN_LIMITS } from "./plugin/markdown.js";
|
|
17
|
+
export { UI_VALUE_LIMITS } from "./plugin/values.js";
|
package/dist/plugin/bindings.js
CHANGED