@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
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
+
16
+ ## 1.5.8
17
+
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).
19
+
20
+ - **Removed: `PackageCandidate.trial()`, the `PackageTrial` type, `PluginInfo.origin` `"trial"` and the error code `cleanup`** (only `PackageTrial.stop` used it). The player (0.4.2) no longer answers `packages.trial`, `packages.trialState`, `packages.trialStateByRequest` or `packages.stopTrial`, and never lists a plugin with origin `"trial"`. Trial loads only supported plugins using `commands`, `ui.window` and `events`, listed the plugin under another id (`<id>.trial.<n>`), and `trial()` returned before activation ended, so the feature was removed instead of extended. Check a package with `validate` and install it; `rollback` restores the previous version.
21
+ - **`plugin.resolvers.fileTypes`** (player 0.4.2): the extensions some plugin's file resolver handles, lower case, sorted, each once, without who registered them; `[]` from older players. Reading needs no capability. **`plugin.resolvers.onFileTypesChange(listener)`** follows it when a file resolver comes or goes (needs `events`). For a playlist that accepts ROMs while an emulator plugin runs. `TestHost`: `host.fileTypes()` (this plugin's file resolvers plus `otherResolvers`) and `host.updateFileTypes()` after changing `otherResolvers`.
22
+ - **`vestra-plugin` runs under Node's permission model** (`--permission`, with `--allow-child-process` for the type checker): looking for `node_modules/@types`, `tsconfig.json` and `.git` in parent folders it may not read threw `ERR_ACCESS_DENIED`; such a folder now counts as absent.
23
+ - `Packages.validate`: the player (0.4.2) keeps the 32 most recent candidates and drops the oldest, whose `install` then fails with `stale`; before, the 33rd validation of a run failed with `busy` until the player restarted.
24
+
3
25
  ## 1.5.7
4
26
 
5
27
  **Replaced or closed streams stop with `null`.** A fix; no API change, `apiVersion` stays `vestra/1`, any player.
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
@@ -34,7 +55,7 @@
34
55
  */
35
56
  import { spawnSync } from "node:child_process";
36
57
  import { createHash } from "node:crypto";
37
- import { existsSync } from "node:fs";
58
+ import { existsSync as exists } from "node:fs";
38
59
  import {
39
60
  cp,
40
61
  mkdir,
@@ -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
 
@@ -110,6 +136,20 @@ async function json(path, fallback) {
110
136
  }
111
137
 
112
138
  /** A module from the plugin's project first, else the SDK's own dependencies. */
139
+ /**
140
+ * Whether a path exists. Under Node's permission model (`--permission`)
141
+ * `existsSync` throws for a path outside the allowed ones; such a path
142
+ * counts as absent, as for a user who cannot read it.
143
+ */
144
+ function present(path) {
145
+ try {
146
+ return exists(path);
147
+ } catch (error) {
148
+ if (error?.code === "ERR_ACCESS_DENIED") return false;
149
+ throw error;
150
+ }
151
+ }
152
+
113
153
  function resolveFrom(directory, specifier) {
114
154
  for (const base of [directory, sdkRoot]) {
115
155
  try {
@@ -139,7 +179,7 @@ async function typescript() {
139
179
  async function plugin(path) {
140
180
  const directory = resolve(path);
141
181
  const name = relative(process.cwd(), directory) || basename(directory);
142
- if (!existsSync(join(directory, "manifest.json")))
182
+ if (!present(join(directory, "manifest.json")))
143
183
  fail(`${name}: no manifest.json in ${directory}`);
144
184
  const manifest = await json(join(directory, "manifest.json"));
145
185
  const { problems, hints } = manifestProblems(manifest, directory);
@@ -165,7 +205,7 @@ function displayFiles(target) {
165
205
  const field = name === "README.md" ? "readme" : "changelog";
166
206
  if (
167
207
  target.manifest[field] === undefined &&
168
- existsSync(join(target.directory, name))
208
+ present(join(target.directory, name))
169
209
  )
170
210
  files.add(name);
171
211
  }
@@ -200,7 +240,7 @@ function dependencies(target) {
200
240
  };
201
241
  if (
202
242
  !Object.keys(deps).length ||
203
- existsSync(join(target.directory, "node_modules"))
243
+ present(join(target.directory, "node_modules"))
204
244
  )
205
245
  return;
206
246
  console.log(`${target.name}: installing dependencies (npm ci)`);
@@ -221,7 +261,7 @@ function* upwards(directory) {
221
261
  for (let current = directory; ;) {
222
262
  yield current;
223
263
  const parent = dirname(current);
224
- if (parent === current || existsSync(join(current, ".git"))) return;
264
+ if (parent === current || present(join(current, ".git"))) return;
225
265
  current = parent;
226
266
  }
227
267
  }
@@ -235,13 +275,13 @@ function checkConfig(target, sources) {
235
275
  const roots = [];
236
276
  for (const directory of upwards(target.directory)) {
237
277
  const types = join(directory, "node_modules/@types");
238
- if (existsSync(types)) roots.push(types);
278
+ if (present(types)) roots.push(types);
239
279
  }
240
280
  const node = resolveFrom(target.directory, "@types/node/package.json");
241
281
  if (node) roots.push(dirname(dirname(node)));
242
282
  const base = [...upwards(target.directory)]
243
283
  .map((directory) => join(directory, "tsconfig.json"))
244
- .find((file) => existsSync(file));
284
+ .find((file) => present(file));
245
285
  return {
246
286
  ...(base ? { extends: base } : {}),
247
287
  compilerOptions: {
@@ -262,7 +302,7 @@ async function check(target) {
262
302
  const own = (await files(target.directory)).filter(
263
303
  (file) => isTypeScript(file) || file.endsWith(".mts"),
264
304
  );
265
- const tests = existsSync(join(target.directory, "tests"))
305
+ const tests = present(join(target.directory, "tests"))
266
306
  ? (await readdir(join(target.directory, "tests")))
267
307
  .filter(isTypeScript)
268
308
  .map((file) => join("tests", file))
@@ -285,6 +325,113 @@ async function check(target) {
285
325
  await rm(scratch, { recursive: true, force: true });
286
326
  }
287
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`);
288
435
  }
289
436
 
290
437
  function version(target, flag) {
@@ -308,7 +455,7 @@ function entries(target) {
308
455
  const entry = target.manifest.entry.replace(/^dist\//, "");
309
456
  const candidates = [entry.replace(/\.js$/, ".ts"), entry];
310
457
  const found = candidates.find((file) =>
311
- existsSync(join(target.directory, file)),
458
+ present(join(target.directory, file)),
312
459
  );
313
460
  if (!found)
314
461
  fail(`${target.name}: entry ${target.manifest.entry} has no source file`);
@@ -441,6 +588,14 @@ async function build(target, flag) {
441
588
  entry.built,
442
589
  await readFile(join(out, entry.built), "utf8"),
443
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}`);
444
599
  await writeFile(join(out, "package.json"), '{"type":"module"}\n');
445
600
  await writeFile(
446
601
  join(out, "manifest.json"),
@@ -454,7 +609,7 @@ async function build(target, flag) {
454
609
 
455
610
  async function test(target) {
456
611
  const directory = join(target.directory, "tests");
457
- const tests = existsSync(directory)
612
+ const tests = present(directory)
458
613
  ? (await readdir(directory))
459
614
  .filter((file) => /\.test\.(ts|mjs)$/.test(file))
460
615
  .sort()
@@ -544,8 +699,11 @@ async function pack(target, flag, outDirectory) {
544
699
  );
545
700
  }
546
701
 
547
- const USAGE =
548
- "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");
549
707
 
550
708
  async function main() {
551
709
  const args = process.argv.slice(2);
@@ -561,10 +719,25 @@ async function main() {
561
719
  console.log(USAGE);
562
720
  return;
563
721
  }
564
- 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)) {
565
730
  console.error(USAGE);
566
731
  process.exit(2);
567
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");
568
741
  const flag = option("--version");
569
742
  const out = option("--out");
570
743
  const directories = args.length ? args : ["."];
@@ -574,7 +747,9 @@ async function main() {
574
747
  for (const directory of directories) {
575
748
  try {
576
749
  const target = await plugin(directory);
577
- 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);
578
753
  else if (command === "build") await build(target, flag);
579
754
  else if (command === "pack") await pack(target, flag, out);
580
755
  else await test(target);
@@ -585,7 +760,9 @@ async function main() {
585
760
  }
586
761
  }
587
762
  if (failed.length) {
588
- console.error(`${command} failed for: ${failed.join(", ")}`);
763
+ console.error(
764
+ `${subcommand ? `i18n ${subcommand}` : command} failed for: ${failed.join(", ")}`,
765
+ );
589
766
  process.exit(1);
590
767
  }
591
768
  }
@@ -20,8 +20,7 @@ import type { Transport } from "./wire.ts";
20
20
  */
21
21
  export declare const COMMAND_LIMITS: {
22
22
  /**
23
- * Commands one plugin instance may have at the same time (160). A trial
24
- * package is its own instance.
23
+ * Commands one plugin instance may have at the same time (160).
25
24
  */
26
25
  readonly perPlugin: 160;
27
26
  /** Commands of every plugin plus the player's own, together (1024). */
package/dist/commands.js CHANGED
@@ -18,8 +18,7 @@
18
18
  */
19
19
  export const COMMAND_LIMITS = {
20
20
  /**
21
- * Commands one plugin instance may have at the same time (160). A trial
22
- * package is its own instance.
21
+ * Commands one plugin instance may have at the same time (160).
23
22
  */
24
23
  perPlugin: 160,
25
24
  /** Commands of every plugin plus the player's own, together (1024). */
@@ -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;