@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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.9
4
+
5
+ **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`.
6
+
7
+ - **`plugin.i18n.locale`** (new `I18n`, `Locale`): the interface language of this run, `"en"`, `"zh-Hans"`, `"ja"`, `"ko"`, `"de"`, `"fr"` or `"es"`; it never changes while the player runs. `"en"` from older players.
8
+ - **Templates with arguments**: `osd.show(text, args?)`, `OsdPanel.set(text, args?)` and `ConfirmOptions.args` (new `TemplateArgs`: an array for `{}` / `{0}`, an object for `{name}`). The player translates the template with the plugin's catalog, then fills it in. Without `args` the text shows as it is, so existing calls are unchanged; older players show the template unfilled.
9
+ - **Declarations are translated by the player** from the plugin's catalog: command titles, path segments and `reason`, settings page and panel titles, and the manifest's display fields. Menu paths are English source text and menus merge by them; the player's own menus have the English keys `Open`, `Playback`, `Subtitles`, `Video`, `Audio`, `Screen`, `Settings` (the `@video`-style ids still work, and the player still accepts the old Chinese titles of its own menus). New `PluginInfo.display` (`PluginDisplay`): a package's name, punchline, description and tags as shown.
10
+ - **`appearance.language`** (`LanguagePreference`: `"system"` or a locale) and **`appearance.setLanguage(language)`** (needs `ui.theme`): the saved choice, used from the next start.
11
+ - **Catalogs**: `locales/messages.pot` and `locales/<lang>.po` in the plugin's folder. New commands **`vestra-plugin i18n extract`** (finds `@tr` in `.slint` files, the texts of `commands.add`, `osd.show`, dialogs, settings and panel titles through the TypeScript type checker, and the manifest's display fields; merges into every `.po`, keeping translations; `--locale <lang>` starts one) and **`vestra-plugin i18n check`** (coverage, placeholder and plural problems, a stale template, sentences composed in TypeScript, non-literal values, non-English source text, Slint text without `@tr`; warnings only). `vestra-plugin check` prints the same warnings when the plugin has `locales/`. `vestra-plugin build` compiles each `.po` into `dist/locales/<lang>.json` and no longer packs `.po` files.
12
+ - **`TestHost`**: `locale` (also `startPlugin({ locale })`) loads the plugin's catalog for that language; `FakeCommand.display` is the menu as shown, `host.osd` and `osdPanel` the shown text (the template and args stay in `host.sent(...)`), new `host.confirmTexts`, `host.translate(text, args)`, `host.loadCatalog(directory)`, `host.language` / `setLanguage(...)`, settings page and panel titles as shown. The core actions' declarations use the player's English keys (`["Playback", "Seek", "Forward 5 s"]`); their `display` and the binding table show the player's words. While `locale` is not set, the player's own messages (core actions, refusals) stay in the Chinese of 1.5.8, so existing tests keep passing; with a `locale` they are the player's English (or its Chinese for `"zh-Hans"`).
13
+ - `LOCALES` in `@vestra-player/sdk/testing`.
14
+ - The Slint library copy (`slint/vestra/widgets.slint`, `markdown.slint`) uses `@tr` for its own texts (`UrlInputRow`'s default buttons, the code block's Copy button, the image placeholder).
15
+
3
16
  ## 1.5.8
4
17
 
5
18
  **Trial loading is removed; `resolvers.fileTypes`; the CLI under `--permission`.** `apiVersion` stays `vestra/1`. The removal breaks code that called `PackageCandidate.trial()`: only Vestra Player AI did, and it moves to installing (an install can be rolled back).
package/README.md CHANGED
@@ -72,6 +72,7 @@ Complete, tested plugins in [`examples/`](https://github.com/vestrasys/vestra-pl
72
72
  | [menus](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/menus) | Radio items, separator groups and a menu item whose title changes. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Menus.html) |
73
73
  | [theme-widgets](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/theme-widgets) | A tool window drawn with the player's theme and widgets. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Theme_widgets.html) |
74
74
  | [markdown](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/markdown) | A window that shows a Markdown document. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Markdown.html) |
75
+ | [i18n](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/i18n) | A plugin in the player's language: English source text, a translation. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Counter__i18n_.html) |
75
76
 
76
77
  ## Command line
77
78
 
@@ -81,6 +82,8 @@ Complete, tested plugins in [`examples/`](https://github.com/vestrasys/vestra-pl
81
82
  | `vestra-plugin test [dir...]` | Runs `node --test` on `tests/*.test.{ts,mjs}`. |
82
83
  | `vestra-plugin build [dir...] [--version x.y.z]` | Writes `dist/`: types stripped, files copied, manifest versioned. |
83
84
  | `vestra-plugin pack [dir...] [--version x.y.z] [--out dir]` | Builds, then packs `dist/` as `<id>-<version>.vxp`. |
85
+ | `vestra-plugin i18n extract [dir...] [--locale lang]` | Collects the texts to translate into `locales/`, keeping translations. |
86
+ | `vestra-plugin i18n check [dir...]` | Translation coverage and untranslatable text (warnings only). |
84
87
 
85
88
  ## Compatibility
86
89
 
@@ -91,7 +94,7 @@ Complete, tested plugins in [`examples/`](https://github.com/vestrasys/vestra-pl
91
94
  | 1.2 | `vestra/1` | 0.2 or newer | 26 (shipped with the player) |
92
95
  | 1.3 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions (menu ids, `Theme.danger`, empty video chains) | 26 (shipped with the player) |
93
96
  | 1.4 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions. `ThemeId` is `"vestra"` only (see the CHANGELOG) | 26 (shipped with the player) |
94
- | 1.5 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions; 0.4 for the 1.5 additions (`vestra-light`, `@vestra/icons.slint`) | 26 (shipped with the player) |
97
+ | 1.5 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions; 0.4 for the 1.5 additions (`vestra-light`, `@vestra/icons.slint`); 0.4.3 for translations (1.5.9) | 26 (shipped with the player) |
95
98
 
96
99
  Minor releases only add backwards-compatible API; a breaking change gets a new `apiVersion` and a new major version. `@vestra-player/sdk/runtime` is for the player only and not covered by semver.
97
100
 
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * vestra-plugin: check, build, test and pack a Vestra Player plugin.
3
+ * vestra-plugin: check, build, test and pack a Vestra Player plugin, and
4
+ * keep its translations up to date.
4
5
  *
5
6
  * vestra-plugin <check|build|test|pack> [plugin-dir...] [--version x.y.z] [--out dir]
7
+ * vestra-plugin i18n extract [plugin-dir...] [--locale <lang>]...
8
+ * vestra-plugin i18n check [plugin-dir...]
6
9
  *
7
10
  * A plugin directory holds manifest.json; the default is the current
8
11
  * directory. Works in any project that depends on @vestra-player/sdk.
@@ -14,6 +17,24 @@
14
17
  * pack build, then dist/ → <out>/<id>-<version>.vxp (a zip; default
15
18
  * out: the current directory) with its sha256 and size
16
19
  *
20
+ * Translations (docs/how-to/i18n.md). English is the source text; each
21
+ * language is a gettext file locales/<lang>.po:
22
+ *
23
+ * i18n extract find the texts to translate (@tr in .slint files, the
24
+ * texts handed to commands.add, osd.show, dialogs, settings
25
+ * and panel titles, the manifest's display fields), write
26
+ * locales/messages.pot and merge it into every
27
+ * locales/<lang>.po (translations kept, vanished ones marked
28
+ * obsolete); --locale <lang> starts a new language
29
+ * i18n check coverage per language, placeholder and plural problems, a
30
+ * stale messages.pot, and texts that cannot be translated
31
+ * (composed in TypeScript, not literals, not English, Slint
32
+ * text without @tr); warnings only, an unreadable .po fails
33
+ *
34
+ * `check` prints the same i18n warnings when the plugin has locales/;
35
+ * `build` compiles locales/<lang>.po to dist/locales/<lang>.json (the .po
36
+ * files themselves are not packed).
37
+ *
17
38
  * Every command first checks manifest.json against the player's rules
18
39
  * (display fields, paths inside the package, unknown fields); `check` also
19
40
  * prints hints, such as an id without the `<owner>.` prefix the official
@@ -62,6 +83,10 @@ const sdkRoot = resolve(import.meta.dirname, "..");
62
83
  const { manifestProblems } = await import(
63
84
  pathToFileURL(join(sdkRoot, "dist/manifest.js")).href
64
85
  );
86
+ const i18n = await import(pathToFileURL(join(sdkRoot, "dist/i18n.js")).href);
87
+ const i18nTools = await import(
88
+ pathToFileURL(join(sdkRoot, "dist/i18n-tools.js")).href
89
+ );
65
90
  const SDK = "@vestra-player/sdk";
66
91
 
67
92
  /** Never part of a production package. */
@@ -79,7 +104,8 @@ const SKIP_FILES = new Set([
79
104
  "package.json",
80
105
  "package-lock.json",
81
106
  ]);
82
- const SKIP_EXTENSIONS = new Set([".md", ".log", ".zip", ".vxp"]);
107
+ // Translation sources: build packs the compiled dist/locales/<lang>.json.
108
+ const SKIP_EXTENSIONS = new Set([".md", ".log", ".zip", ".vxp", ".po", ".pot"]);
83
109
  const isTypeScript = (file) => file.endsWith(".ts") && !file.endsWith(".d.ts");
84
110
  const isDeclaration = (file) => /\.d\.(m|c)?ts$/.test(file);
85
111
 
@@ -299,6 +325,113 @@ async function check(target) {
299
325
  await rm(scratch, { recursive: true, force: true });
300
326
  }
301
327
  console.log(`${target.name}: type check passed (${sources.length} files)`);
328
+ // Translation warnings never fail the check.
329
+ if (present(join(target.directory, "locales"))) {
330
+ try {
331
+ const { warnings, errors } = await i18nReport(target);
332
+ for (const line of [...warnings, ...errors])
333
+ console.log(`${target.name}: i18n: ${line}`);
334
+ } catch (error) {
335
+ console.log(`${target.name}: i18n: ${error.message}`);
336
+ }
337
+ }
338
+ }
339
+
340
+ /** The plugin's own production .ts / .mts and .slint files. */
341
+ async function sourceFiles(target) {
342
+ const all = await files(target.directory);
343
+ return {
344
+ typescript: all.filter(
345
+ (file) =>
346
+ (isTypeScript(file) || file.endsWith(".mts")) && !isDeclaration(file),
347
+ ),
348
+ slint: all.filter((file) => file.endsWith(".slint")),
349
+ };
350
+ }
351
+
352
+ /** Compiler options of the type check (its tsconfig), for extraction. */
353
+ async function extraction(target) {
354
+ const ts = await typescript();
355
+ const { typescript: sources, slint } = await sourceFiles(target);
356
+ const config = checkConfig(
357
+ target,
358
+ sources.map((file) => join(target.directory, file)),
359
+ );
360
+ const parsed = ts.parseJsonConfigFileContent(
361
+ config,
362
+ ts.sys,
363
+ target.directory,
364
+ );
365
+ return i18nTools.extract({
366
+ directory: target.directory,
367
+ manifest: target.manifest,
368
+ slint,
369
+ typescript: sources,
370
+ compilerOptions: parsed.options,
371
+ });
372
+ }
373
+
374
+ async function i18nReport(target) {
375
+ const { messages, findings } = await extraction(target);
376
+ return i18nTools.checkCatalogs(target.directory, messages, findings);
377
+ }
378
+
379
+ async function i18nExtract(target, locales) {
380
+ for (const locale of locales) {
381
+ if (locale === "en")
382
+ fail("--locale en: English is the source language, it has no .po file");
383
+ if (!i18n.LOCALES.includes(locale))
384
+ fail(
385
+ `--locale ${locale}: the player's languages are ${i18n.LOCALES.filter((l) => l !== "en").join(", ")}`,
386
+ );
387
+ }
388
+ dependencies(target);
389
+ const { messages, findings } = await extraction(target);
390
+ const directory = join(target.directory, "locales");
391
+ await mkdir(directory, { recursive: true });
392
+ await writeFile(
393
+ join(directory, "messages.pot"),
394
+ i18nTools.template(target.manifest, messages),
395
+ );
396
+ console.log(
397
+ `${target.name}: locales/messages.pot: ${messages.length} messages`,
398
+ );
399
+ const languages = new Set(locales);
400
+ for (const name of await readdir(directory))
401
+ if (name.endsWith(".po")) languages.add(name.slice(0, -3));
402
+ for (const language of [...languages].sort()) {
403
+ if (language === "en" || !i18n.LOCALES.includes(language)) {
404
+ console.log(
405
+ `${target.name}: locales/${language}.po: skipped (not a player language)`,
406
+ );
407
+ continue;
408
+ }
409
+ const file = join(directory, `${language}.po`);
410
+ let existing = null;
411
+ if (present(file)) existing = await readFile(file, "utf8");
412
+ let merged;
413
+ try {
414
+ merged = i18nTools.merge(existing, language, messages);
415
+ } catch (error) {
416
+ fail(`${target.name}: locales/${language}.po: ${error.message}`);
417
+ }
418
+ await writeFile(file, merged.text);
419
+ console.log(
420
+ `${target.name}: locales/${language}.po: ${merged.translated}/${merged.total} translated, ${merged.added} new, ${merged.obsolete} obsolete${existing === null ? " (created)" : ""}`,
421
+ );
422
+ }
423
+ for (const finding of findings)
424
+ console.log(
425
+ `${target.name}: i18n: ${finding.file}:${finding.line}: ${finding.message}`,
426
+ );
427
+ }
428
+
429
+ async function i18nCheck(target) {
430
+ dependencies(target);
431
+ const { warnings, errors } = await i18nReport(target);
432
+ for (const line of warnings) console.log(`${target.name}: i18n: ${line}`);
433
+ if (errors.length) fail(`${target.name}: ${errors.join("; ")}`);
434
+ if (!warnings.length) console.log(`${target.name}: i18n: no problems`);
302
435
  }
303
436
 
304
437
  function version(target, flag) {
@@ -455,6 +588,14 @@ async function build(target, flag) {
455
588
  entry.built,
456
589
  await readFile(join(out, entry.built), "utf8"),
457
590
  );
591
+ let localeWarnings;
592
+ try {
593
+ localeWarnings = i18nTools.compileLocales(target.directory, out);
594
+ } catch (error) {
595
+ fail(`${target.name}: ${error.message}`);
596
+ }
597
+ for (const line of localeWarnings)
598
+ console.log(`${target.name}: warning: ${line}`);
458
599
  await writeFile(join(out, "package.json"), '{"type":"module"}\n');
459
600
  await writeFile(
460
601
  join(out, "manifest.json"),
@@ -558,8 +699,11 @@ async function pack(target, flag, outDirectory) {
558
699
  );
559
700
  }
560
701
 
561
- const USAGE =
562
- "usage: vestra-plugin <check|build|test|pack> [plugin-dir...] [--version x.y.z] [--out dir]";
702
+ const USAGE = [
703
+ "usage: vestra-plugin <check|build|test|pack> [plugin-dir...] [--version x.y.z] [--out dir]",
704
+ " vestra-plugin i18n extract [plugin-dir...] [--locale <lang>]...",
705
+ " vestra-plugin i18n check [plugin-dir...]",
706
+ ].join("\n");
563
707
 
564
708
  async function main() {
565
709
  const args = process.argv.slice(2);
@@ -575,10 +719,25 @@ async function main() {
575
719
  console.log(USAGE);
576
720
  return;
577
721
  }
578
- if (!["check", "build", "test", "pack"].includes(command)) {
722
+ let subcommand = null;
723
+ if (command === "i18n") {
724
+ subcommand = args.shift();
725
+ if (!["extract", "check"].includes(subcommand)) {
726
+ console.error(USAGE);
727
+ process.exit(2);
728
+ }
729
+ } else if (!["check", "build", "test", "pack"].includes(command)) {
579
730
  console.error(USAGE);
580
731
  process.exit(2);
581
732
  }
733
+ const locales = [];
734
+ for (let index; (index = args.indexOf("--locale")) >= 0;) {
735
+ const [, value] = args.splice(index, 2);
736
+ if (value === undefined) fail("--locale needs a value");
737
+ locales.push(value);
738
+ }
739
+ if (locales.length && subcommand !== "extract")
740
+ fail("--locale applies to i18n extract");
582
741
  const flag = option("--version");
583
742
  const out = option("--out");
584
743
  const directories = args.length ? args : ["."];
@@ -588,7 +747,9 @@ async function main() {
588
747
  for (const directory of directories) {
589
748
  try {
590
749
  const target = await plugin(directory);
591
- if (command === "check") await check(target);
750
+ if (subcommand === "extract") await i18nExtract(target, locales);
751
+ else if (subcommand === "check") await i18nCheck(target);
752
+ else if (command === "check") await check(target);
592
753
  else if (command === "build") await build(target, flag);
593
754
  else if (command === "pack") await pack(target, flag, out);
594
755
  else await test(target);
@@ -599,7 +760,9 @@ async function main() {
599
760
  }
600
761
  }
601
762
  if (failed.length) {
602
- console.error(`${command} failed for: ${failed.join(", ")}`);
763
+ console.error(
764
+ `${subcommand ? `i18n ${subcommand}` : command} failed for: ${failed.join(", ")}`,
765
+ );
603
766
  process.exit(1);
604
767
  }
605
768
  }
@@ -0,0 +1,67 @@
1
+ import ts from "typescript";
2
+ /** A translatable text found in the sources. */
3
+ export interface Message {
4
+ context?: string;
5
+ id: string;
6
+ plural?: string;
7
+ /** `#.` notes (from `TRANSLATORS:` comments). */
8
+ notes: string[];
9
+ /** `path:line` (or `manifest.json`), relative to the plugin, sorted. */
10
+ references: string[];
11
+ }
12
+ /** Something extraction or the heuristics want the author to look at. */
13
+ export interface Finding {
14
+ /** Relative path, forward slashes. */
15
+ file: string;
16
+ line: number;
17
+ kind: "dynamic" | "composed" | "not-english" | "slint-text";
18
+ message: string;
19
+ }
20
+ export interface ExtractOptions {
21
+ /** The plugin directory. */
22
+ directory: string;
23
+ /** The plugin's `manifest.json` (display fields are extracted). */
24
+ manifest: Record<string, unknown>;
25
+ /** The plugin's `.slint` files, relative to `directory`. */
26
+ slint: readonly string[];
27
+ /** The plugin's production `.ts` / `.mts` files, relative to `directory`. */
28
+ typescript: readonly string[];
29
+ /** Compiler options for a program over those files (as `check` uses). */
30
+ compilerOptions?: ts.CompilerOptions;
31
+ }
32
+ export declare const pluralForms: (language: string) => "nplurals=1; plural=0;" | "nplurals=2; plural=(n != 1);";
33
+ /** Find every translatable text of a plugin, in reference order. */
34
+ export declare function extract(options: ExtractOptions): {
35
+ messages: Message[];
36
+ findings: Finding[];
37
+ };
38
+ /** `locales/messages.pot` for these messages. */
39
+ export declare function template(manifest: Record<string, unknown>, messages: readonly Message[]): string;
40
+ /** The header of a new `<lang>.po`. */
41
+ export declare const newHeader: (language: string) => string;
42
+ /**
43
+ * Merge the template into a language's file: translations and fuzzy flags
44
+ * of present messages stay, new ones come untranslated, notes and
45
+ * references follow the template, vanished translated messages become
46
+ * obsolete (`#~`, at the end) and come back with their translation.
47
+ */
48
+ export declare function merge(existing: string | null, language: string, messages: readonly Message[]): {
49
+ text: string;
50
+ total: number;
51
+ translated: number;
52
+ added: number;
53
+ obsolete: number;
54
+ };
55
+ /** Problems found by `check` in one plugin's catalogs, as text lines. */
56
+ export declare function checkCatalogs(directory: string, messages: readonly Message[], findings: readonly Finding[]): {
57
+ warnings: string[];
58
+ errors: string[];
59
+ };
60
+ /**
61
+ * Compile every `locales/<lang>.po` of a player language to
62
+ * `<out>/locales/<lang>.json`. Returns the warnings (files the player never
63
+ * reads); throws on a `.po` that does not parse.
64
+ */
65
+ export declare function compileLocales(directory: string, out: string): string[];
66
+ /** The relative path of a file from the plugin directory, forward slashes. */
67
+ export declare const relativePath: (directory: string, file: string) => string;