@vestra-player/sdk 1.5.49 → 1.5.51

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/dist/docs.js ADDED
@@ -0,0 +1,544 @@
1
+ /**
2
+ * `@vestra-player/sdk/docs`: the SDK's documentation as a tree of small
3
+ * pages for agents, like `go doc`. The same pages and API as the
4
+ * documentation site (https://vestrasys.github.io/vestra-player-sdk/),
5
+ * read by path:
6
+ *
7
+ * ```text
8
+ * docs("/") the top level
9
+ * docs("/API/Media") a category: its symbols
10
+ * docs("/API/Media/Snapshot/dispose") a member: its whole documentation
11
+ * docs("/Guides/How-to/Test a plugin") a guide page
12
+ * searchDocs("snapshot export") matching paths
13
+ * ```
14
+ *
15
+ * Every answer is plain text (Markdown) of at most {@link DOCS_PAGE_BYTES}
16
+ * bytes; a longer one comes in pages (`{ page: 2 }`). The command line
17
+ * gives the same answers: `vestra-plugin docs [path] [--page N]` and
18
+ * `vestra-plugin docs --search <query>` (outside a project that depends on
19
+ * the SDK: `npx -p @vestra-player/sdk vestra-plugin docs …`). The tree is
20
+ * generated from the package's sources when it is built
21
+ * (scripts/docs-tree.mjs) and read on first use.
22
+ *
23
+ * @packageDocumentation
24
+ * @module @vestra-player/sdk/docs
25
+ */
26
+ import { readFileSync } from "node:fs";
27
+ import { gunzipSync } from "node:zlib";
28
+ /**
29
+ * The most bytes (UTF-8) one answer of `docs` or `searchDocs` has.
30
+ *
31
+ * @category Docs
32
+ */
33
+ export const DOCS_PAGE_BYTES = 8192;
34
+ let loaded;
35
+ /** The tree in dist/ (the same path from src/ and from dist/). */
36
+ function tree() {
37
+ if (loaded)
38
+ return loaded;
39
+ const file = new URL("../dist/docs-tree.json.gz", import.meta.url);
40
+ let data;
41
+ try {
42
+ data = JSON.parse(gunzipSync(readFileSync(file)).toString("utf8"));
43
+ }
44
+ catch (error) {
45
+ throw new Error(`the documentation tree is missing (${file.pathname}): build the SDK with npm run build`, { cause: error });
46
+ }
47
+ const nodes = data.nodes.map((stored) => ({
48
+ name: stored.n,
49
+ kind: stored.k,
50
+ summary: stored.s,
51
+ text: stored.t,
52
+ path: "/",
53
+ id: stored.i,
54
+ aliases: stored.a ?? [],
55
+ parent: undefined,
56
+ children: [],
57
+ }));
58
+ data.nodes.forEach((stored, index) => {
59
+ const node = nodes[index];
60
+ for (const child of stored.c ?? []) {
61
+ const made = nodes[child];
62
+ made.parent = node;
63
+ node.children.push(made);
64
+ }
65
+ });
66
+ const assign = (node) => {
67
+ for (const child of node.children) {
68
+ child.path = `${node.path === "/" ? "" : node.path}/${child.name}`;
69
+ assign(child);
70
+ }
71
+ };
72
+ const root = nodes[0];
73
+ assign(root);
74
+ loaded = { version: data.version, root, nodes };
75
+ return loaded;
76
+ }
77
+ const bytes = (text) => Buffer.byteLength(text, "utf8");
78
+ /** Letters and digits only, lower case: "Test a plugin" → "testaplugin". */
79
+ const loose = (text) => text.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
80
+ /** The kinds that are not declarations of the API. */
81
+ const STRUCTURE = new Set(["Root", "Section", "Group", "Page", "Category"]);
82
+ const isDeclaration = (node) => !STRUCTURE.has(node.kind);
83
+ const depth = (node) => node.path.split("/").length;
84
+ /** How a node's name matches a segment: 0 exact, 1 another case or an alias, 2 loosely, or no match. */
85
+ function nameMatch(node, segment) {
86
+ if (node.name === segment || node.aliases.includes(segment))
87
+ return 0;
88
+ const lower = segment.toLowerCase();
89
+ if (node.name.toLowerCase() === lower ||
90
+ node.aliases.some((alias) => alias.toLowerCase() === lower))
91
+ return 1;
92
+ const flat = loose(segment);
93
+ if (flat &&
94
+ (loose(node.name) === flat ||
95
+ node.aliases.some((alias) => loose(alias) === flat)))
96
+ return 2;
97
+ return undefined;
98
+ }
99
+ /** Every node below `node` (any depth) whose name matches `segment`. */
100
+ function below(node, segment) {
101
+ const found = [];
102
+ const visit = (each) => {
103
+ for (const child of each.children) {
104
+ if (nameMatch(child, segment) !== undefined)
105
+ found.push(child);
106
+ visit(child);
107
+ }
108
+ };
109
+ visit(node);
110
+ return found;
111
+ }
112
+ /** Edit distance, for "did you mean". */
113
+ function distance(a, b) {
114
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
115
+ for (let i = 1; i <= a.length; i++) {
116
+ let previous = row[0];
117
+ row[0] = i;
118
+ for (let j = 1; j <= b.length; j++) {
119
+ const current = row[j];
120
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
121
+ previous = current;
122
+ }
123
+ }
124
+ return row[b.length];
125
+ }
126
+ /** A path as typed: its segments and `#anchor`. */
127
+ function parse(path) {
128
+ let text = path.trim().replace(/^["'<]+|["'>]+$/g, "");
129
+ try {
130
+ text = decodeURIComponent(text);
131
+ }
132
+ catch {
133
+ // Not URI-encoded.
134
+ }
135
+ text = text.replace(/\\/g, "/");
136
+ // Git Bash turns "/API" into "C:/Program Files/Git/API".
137
+ text = text.replace(/^[A-Za-z]:\/(?:[^/]*\/)*?Git\//, "/");
138
+ const hash = text.indexOf("#");
139
+ const anchor = hash >= 0 ? text.slice(hash + 1).trim() : undefined;
140
+ if (hash >= 0)
141
+ text = text.slice(0, hash);
142
+ const segments = text
143
+ .split("/")
144
+ .map((segment) => segment.trim().replace(/\.md$/i, ""))
145
+ .filter(Boolean);
146
+ return anchor ? { segments, anchor } : { segments };
147
+ }
148
+ /** Every node the whole of `segments` can lead to from `node`. */
149
+ function resolveAll(node, segments, start) {
150
+ if (!segments.length)
151
+ return [{ ...start, node }];
152
+ const [segment, ...rest] = segments;
153
+ const out = [];
154
+ const direct = node.children.filter((child) => nameMatch(child, segment) !== undefined);
155
+ for (const child of direct)
156
+ out.push(...resolveAll(child, rest, {
157
+ ...start,
158
+ inexact: start.inexact + (nameMatch(child, segment) ? 1 : 0),
159
+ }));
160
+ for (const child of below(node, segment))
161
+ if (!direct.includes(child))
162
+ out.push(...resolveAll(child, rest, {
163
+ ...start,
164
+ jumps: start.jumps + 1,
165
+ inexact: start.inexact + (nameMatch(child, segment) ? 1 : 0),
166
+ }));
167
+ return out;
168
+ }
169
+ const rank = (r) => [
170
+ r.dropped,
171
+ r.jumps,
172
+ r.inexact,
173
+ isDeclaration(r.node) ? 0 : 1,
174
+ depth(r.node),
175
+ ];
176
+ function compare(a, b) {
177
+ const x = rank(a);
178
+ const y = rank(b);
179
+ for (let i = 0; i < x.length; i++)
180
+ if (x[i] !== y[i])
181
+ return x[i] - y[i];
182
+ return 0;
183
+ }
184
+ /**
185
+ * The node a path names: at each level a direct child first, else a name
186
+ * below it; `.` like `/` when `/` alone finds nothing; then one stale
187
+ * segment left out. The best resolution wins (fewest left out, fewest
188
+ * found below their level, exact names, declarations, shallowest);
189
+ * `ambiguous` when several tie.
190
+ */
191
+ function resolve(segments) {
192
+ const { root } = tree();
193
+ const none = { node: root, jumps: 0, inexact: 0, dropped: 0 };
194
+ if (!segments.length)
195
+ return { node: root, jumped: false };
196
+ const attempts = [segments];
197
+ const dotted = segments.flatMap((segment) => segment.split(".").filter(Boolean));
198
+ if (dotted.length !== segments.length)
199
+ attempts.push(dotted);
200
+ let found = [];
201
+ for (const attempt of attempts) {
202
+ found = resolveAll(root, attempt, none);
203
+ if (found.length)
204
+ break;
205
+ }
206
+ if (!found.length)
207
+ for (const attempt of attempts) {
208
+ for (let i = 0; i < attempt.length - 1 && attempt.length > 1; i++)
209
+ found.push(...resolveAll(root, attempt.filter((_, j) => j !== i), { ...none, dropped: 1 }));
210
+ if (found.length)
211
+ break;
212
+ }
213
+ if (!found.length)
214
+ return undefined;
215
+ // One entry per node, with its best resolution.
216
+ const best = new Map();
217
+ for (const each of found) {
218
+ const known = best.get(each.node);
219
+ if (!known || compare(each, known) < 0)
220
+ best.set(each.node, each);
221
+ }
222
+ const sorted = [...best.values()].sort(compare);
223
+ const first = sorted[0];
224
+ const tied = sorted.filter((each) => compare(each, first) === 0);
225
+ if (tied.length > 1)
226
+ return { ambiguous: tied.map((each) => each.node) };
227
+ return {
228
+ node: first.node,
229
+ jumped: first.jumps > 0 || first.dropped > 0,
230
+ };
231
+ }
232
+ const line = (node) => `- ${node.path} (${node.kind.toLowerCase()})${node.summary ? `: ${node.summary}` : ""}`;
233
+ /** GitHub's anchor of a heading: "Acting like the user" → "acting-like-the-user". */
234
+ const slug = (heading) => heading
235
+ .trim()
236
+ .toLowerCase()
237
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
238
+ .replace(/\s/g, "-");
239
+ const FENCE = /^\s*```/;
240
+ const TABLE_RULE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
241
+ /**
242
+ * Lines into pages: the first at most `first` bytes, the others `rest`.
243
+ * A code fence cut by a page break is closed and opened again; a table's
244
+ * header rows are repeated; a short code block moves to the next page
245
+ * whole instead of being cut.
246
+ */
247
+ function paginate(text, first, rest) {
248
+ const lines = text.split("\n");
249
+ const pages = [];
250
+ let current = [];
251
+ let size = 0;
252
+ let fence;
253
+ let header;
254
+ const reserve = 16;
255
+ const budget = () => (pages.length ? rest : first) - reserve;
256
+ const push = (piece) => {
257
+ current.push(piece);
258
+ size += bytes(piece) + 1;
259
+ };
260
+ const flush = () => {
261
+ if (!current.length)
262
+ return;
263
+ if (fence)
264
+ current.push("```");
265
+ pages.push(current.join("\n"));
266
+ current = [];
267
+ size = 0;
268
+ if (fence)
269
+ push(fence);
270
+ if (header)
271
+ header.forEach(push);
272
+ };
273
+ for (let i = 0; i < lines.length; i++) {
274
+ const raw = lines[i];
275
+ if (!fence && FENCE.test(raw)) {
276
+ // A short block whole on the next page rather than cut.
277
+ let end = i + 1;
278
+ while (end < lines.length && !FENCE.test(lines[end]))
279
+ end++;
280
+ const block = bytes(lines.slice(i, end + 1).join("\n")) + 1;
281
+ if (block <= rest / 3 && size + block > budget())
282
+ flush();
283
+ }
284
+ if (!fence && raw.trimStart().startsWith("|")) {
285
+ if (!header && TABLE_RULE.test(lines[i + 1] ?? "")) {
286
+ if (size + bytes(raw) + bytes(lines[i + 1]) + 2 > budget())
287
+ flush();
288
+ push(raw);
289
+ push(lines[i + 1]);
290
+ header = [raw, lines[i + 1]];
291
+ i++;
292
+ continue;
293
+ }
294
+ }
295
+ else if (!fence)
296
+ header = undefined;
297
+ // A line longer than a page is cut.
298
+ const limit = rest - reserve - 2 * bytes(fence ?? "") - 16;
299
+ let piece = raw;
300
+ const pieces = [];
301
+ while (bytes(piece) > limit) {
302
+ let cut = Math.min(piece.length, limit);
303
+ while (bytes(piece.slice(0, cut)) > limit)
304
+ cut = Math.floor(cut * 0.9);
305
+ pieces.push(piece.slice(0, cut));
306
+ piece = piece.slice(cut);
307
+ }
308
+ pieces.push(piece);
309
+ for (const each of pieces) {
310
+ const cost = bytes(each) + 1 + (fence ? 4 : 0);
311
+ if (size + cost > budget() && current.length)
312
+ flush();
313
+ push(each);
314
+ }
315
+ if (FENCE.test(raw))
316
+ fence = fence ? undefined : raw.trim();
317
+ }
318
+ if (current.length) {
319
+ if (fence)
320
+ current.push("```");
321
+ pages.push(current.join("\n"));
322
+ }
323
+ return pages.length ? pages : [""];
324
+ }
325
+ /** The `##` headings of a text, outside code. */
326
+ function headings(text, level) {
327
+ const out = [];
328
+ let fence = false;
329
+ for (const each of text.split("\n")) {
330
+ if (FENCE.test(each))
331
+ fence = !fence;
332
+ const match = /^(#{1,6}) (.+)$/.exec(each);
333
+ if (!fence && match && match[1].length <= level)
334
+ out.push(each);
335
+ }
336
+ return out;
337
+ }
338
+ /** The page asked for, with its header, a section index on page 1 and a continuation line. */
339
+ function answer(header, body, options, more) {
340
+ const longest = `${header} · page 9999 of 9999`;
341
+ const footer = `\n\n(Page 9999 of 9999. ${more(9999)})`;
342
+ const budget = DOCS_PAGE_BYTES - bytes(longest) - bytes(footer) - 4;
343
+ let pages = paginate(body, budget, budget);
344
+ let index = "";
345
+ if (pages.length > 1) {
346
+ // Page 1 lists the sections and the page each starts on.
347
+ const sections = headings(body, 2)
348
+ .filter((each) => each.startsWith("## "))
349
+ .slice(0, 40);
350
+ if (sections.length) {
351
+ const draft = `Sections: ${sections.map((each) => `${each.slice(3)} (p. 99)`).join(" · ")}`;
352
+ pages = paginate(body, budget - bytes(draft) - 2, budget);
353
+ const where = (heading) => pages.findIndex((page) => page.split("\n").includes(heading)) + 1;
354
+ index = `Sections: ${sections.map((each) => `${each.slice(3)} (p. ${where(each)})`).join(" · ")}\n\n`;
355
+ }
356
+ }
357
+ const count = pages.length;
358
+ let wanted = options.page;
359
+ let note = "";
360
+ if (wanted === undefined && options.anchor) {
361
+ const target = slug(options.anchor);
362
+ const found = pages.findIndex((page) => headings(page, 6).some((each) => slug(each.replace(/^#+ /, "")) === target));
363
+ if (found >= 0)
364
+ wanted = found + 1;
365
+ else
366
+ note = `(No section #${options.anchor} here.)\n\n`;
367
+ }
368
+ wanted ??= 1;
369
+ if (!Number.isInteger(wanted) || wanted < 1 || wanted > count)
370
+ return `${header}\n\nThere is no page ${String(options.page)}: this has ${count} page${count === 1 ? "" : "s"}.`;
371
+ const top = count > 1 ? `${header} · page ${wanted} of ${count}` : header;
372
+ const tail = wanted < count
373
+ ? `\n\n(Page ${wanted} of ${count}. ${more(wanted + 1)})`
374
+ : "";
375
+ const lead = `${note}${wanted === 1 ? index : ""}`;
376
+ return `${top}\n\n${lead}${pages[wanted - 1]}${tail}`;
377
+ }
378
+ function show(node, options, note) {
379
+ const { version } = tree();
380
+ const header = node.path === "/"
381
+ ? `/ · Vestra Player SDK ${version} documentation`
382
+ : `${node.path} · ${node.kind}`;
383
+ const parts = [];
384
+ if (note)
385
+ parts.push(note);
386
+ const contents = node.children.length
387
+ ? `## Contents of ${node.path}\n\n${node.children.map(line).join("\n")}`
388
+ : "";
389
+ // A guide group's index page describes the pages listed: the list first.
390
+ if (node.kind === "Group")
391
+ parts.push(contents, node.text);
392
+ else
393
+ parts.push(node.text, contents);
394
+ return answer(header, parts.filter(Boolean).join("\n\n"), options, (next) => `Read on with page ${next} of ${node.path}.`);
395
+ }
396
+ /** The deepest node the path reaches level by level, and the segment it stops at. */
397
+ function reach(segments) {
398
+ let node = tree().root;
399
+ for (const segment of segments) {
400
+ const next = node.children
401
+ .filter((child) => nameMatch(child, segment) !== undefined)
402
+ .sort((a, b) => nameMatch(a, segment) - nameMatch(b, segment))[0];
403
+ if (!next)
404
+ return { node, missing: segment };
405
+ node = next;
406
+ }
407
+ return { node, missing: segments.at(-1) ?? "" };
408
+ }
409
+ function notFound(asked, segments, page) {
410
+ const { node: found, missing } = reach(segments);
411
+ const flat = loose(missing);
412
+ const near = found.children
413
+ .map((child) => ({
414
+ child,
415
+ score: Math.min(distance(loose(child.name), flat), ...child.aliases.map((alias) => distance(loose(alias), flat))),
416
+ }))
417
+ .filter(({ child, score }) => score <= Math.max(2, Math.floor(flat.length / 3)) ||
418
+ loose(child.name).includes(flat) ||
419
+ (flat.length >= 3 && flat.includes(loose(child.name))))
420
+ .sort((a, b) => a.score - b.score)
421
+ .slice(0, 5)
422
+ .map(({ child }) => child);
423
+ const elsewhere = below(tree().root, missing)
424
+ .filter((node) => !near.includes(node))
425
+ .sort((a, b) => depth(a) - depth(b));
426
+ const parts = [`${found.path} has no "${missing}".`];
427
+ const suggestions = [...near, ...elsewhere].slice(0, 10);
428
+ if (suggestions.length)
429
+ parts.push(`Did you mean:\n\n${suggestions.map(line).join("\n")}`);
430
+ parts.push(found.children.length
431
+ ? `## Contents of ${found.path}\n\n${found.children.map(line).join("\n")}`
432
+ : `${found.path} has nothing below it. Search with searchDocs("${missing}") or vestra-plugin docs --search "${missing}".`);
433
+ return answer(`Not found: ${asked}`, parts.join("\n\n"), page === undefined ? {} : { page }, (next) => `Read on with page ${next} of the same request.`);
434
+ }
435
+ /**
436
+ * The documentation at `path`: its text, then the paths one level down,
437
+ * each with a one-line summary.
438
+ *
439
+ * `/` lists Overview, Guides and API. `/Guides/<group>/<page title>` is a
440
+ * page of the documentation site (its file name works too:
441
+ * `/Guides/How-to/testing`); `/API/<category>/<symbol>/<member>` is a
442
+ * declaration with its signature, parameters, return value, errors,
443
+ * capability, player version and examples. Matching ignores case, spaces
444
+ * and punctuation; a leading `/` and a `.md` suffix are optional; `.`
445
+ * works like `/` (`Snapshot.export`); a name that is not at its level is
446
+ * looked up below it (`TestHost`, `/API/Snapshot` finds
447
+ * `/API/Media/Snapshot`), and one stale segment is skipped. A `#heading`
448
+ * opens the page of a long answer that has it. A path that matches
449
+ * nothing answers with the nearest names and the level's contents.
450
+ *
451
+ * @param path - Default `/`.
452
+ * @param options - `page` of a long answer.
453
+ * @returns At most {@link DOCS_PAGE_BYTES} bytes of text; a continued
454
+ * answer ends with the number of the next page.
455
+ *
456
+ * @category Docs
457
+ */
458
+ export function docs(path = "/", options = {}) {
459
+ const { segments, anchor } = parse(path);
460
+ const asked = `/${segments.join("/")}${anchor ? `#${anchor}` : ""}`;
461
+ const found = resolve(segments);
462
+ const wanted = {
463
+ ...(options.page === undefined ? {} : { page: options.page }),
464
+ ...(anchor === undefined ? {} : { anchor }),
465
+ };
466
+ if (!found)
467
+ return notFound(asked, segments, options.page);
468
+ if ("ambiguous" in found)
469
+ return answer(`Several matches: ${asked}`, `Name the one you mean:\n\n${found.ambiguous.map(line).join("\n")}`, options.page === undefined ? {} : { page: options.page }, (next) => `Read on with page ${next} of the same request.`);
470
+ return show(found.node, wanted, found.jumped ? `(Found ${asked} at ${found.node.path}.)` : undefined);
471
+ }
472
+ /** Words a search leaves out (unless the query has nothing else). */
473
+ const STOP_WORDS = new Set("a an and are as at be by can do does for from how i in is it its my of on or the this to what when which with you your".split(" "));
474
+ /**
475
+ * Search the documentation by name and text: the matching paths, best
476
+ * first, each with its one-line summary. Every word of `query` (common
477
+ * words such as "a", "the", "to" left out) must occur, in any case, in
478
+ * the path, the summary or the text; a match in a name ranks first, a
479
+ * declaration's name before a page's.
480
+ *
481
+ * @param query - Words, such as `"snapshot export"` or `"onError"`.
482
+ * @param options - `page` of a long answer.
483
+ * @returns At most {@link DOCS_PAGE_BYTES} bytes of text.
484
+ *
485
+ * @category Docs
486
+ */
487
+ export function searchDocs(query, options = {}) {
488
+ const { nodes } = tree();
489
+ const all = query
490
+ .toLowerCase()
491
+ .split(/[\s/.]+/)
492
+ .filter(Boolean);
493
+ const meaningful = all.filter((word) => !STOP_WORDS.has(word));
494
+ const words = meaningful.length ? meaningful : all;
495
+ const header = `Search: ${query}`;
496
+ if (!words.length)
497
+ return `${header}\n\nGive one or more words to search for.`;
498
+ const whole = query.trim().toLowerCase();
499
+ const scored = [];
500
+ for (const node of nodes) {
501
+ if (!node.parent)
502
+ continue;
503
+ const lower = (node.lower ??= {
504
+ name: node.name.toLowerCase(),
505
+ path: node.path.toLowerCase(),
506
+ summary: node.summary.toLowerCase(),
507
+ text: node.text.toLowerCase(),
508
+ });
509
+ const declaration = isDeclaration(node);
510
+ let score = lower.name === whole ? 200 : 0;
511
+ // A type of the API (an interface, a class…) before its members, and
512
+ // a path that names every word (Snapshot/export for "snapshot export").
513
+ if (declaration && depth(node) === 4)
514
+ score += 20;
515
+ if (words.length > 1 && words.every((word) => lower.path.includes(word)))
516
+ score += 40;
517
+ for (const word of words) {
518
+ if (lower.name === word)
519
+ score += declaration ? 140 : 100;
520
+ else if (lower.name.includes(word))
521
+ score += declaration ? 70 : 50;
522
+ else if (lower.path.includes(word))
523
+ score += 30;
524
+ else if (lower.summary.includes(word))
525
+ score += 15;
526
+ else if (lower.text.includes(word))
527
+ score += 5;
528
+ else {
529
+ score = -1;
530
+ break;
531
+ }
532
+ }
533
+ if (score > 0)
534
+ scored.push({ node, score });
535
+ }
536
+ scored.sort((a, b) => b.score - a.score ||
537
+ depth(a.node) - depth(b.node) ||
538
+ a.node.path.localeCompare(b.node.path));
539
+ if (!scored.length)
540
+ return `${header}\n\nNothing matches. Try fewer or other words, or browse with docs("/").`;
541
+ const left = all.length - words.length;
542
+ const body = `${scored.length} match${scored.length === 1 ? "" : "es"}, best first${left ? ` (common words left out: ${all.filter((word) => !words.includes(word)).join(", ")})` : ""}:\n\n${scored.map(({ node }) => line(node)).join("\n")}`;
543
+ return answer(header, body, options.page === undefined ? {} : { page: options.page }, (next) => `Read on with page ${next} of this search.`);
544
+ }
package/dist/i18n.js CHANGED
@@ -846,6 +846,7 @@ export const MENU_KEYS = {
846
846
  audio: "Audio",
847
847
  screen: "Screen",
848
848
  settings: "Settings",
849
+ reset: "Reset",
849
850
  };
850
851
  /** The player's old Chinese titles of its own menus: parent, title, key. */
851
852
  const LEGACY_MENUS = [
@@ -870,6 +871,7 @@ export const HOST_GROUPS = [
870
871
  "Audio",
871
872
  "Screen",
872
873
  "Settings",
874
+ "Reset",
873
875
  "Playback/Speed",
874
876
  "Playback/Seek",
875
877
  ];
@@ -906,6 +908,7 @@ export const HOST_ZH_HANS = {
906
908
  Audio: "声音",
907
909
  Screen: "屏幕",
908
910
  Settings: "设置",
911
+ Reset: "复位",
909
912
  Speed: "速度",
910
913
  Seek: "跳转",
911
914
  Close: "关闭",
@@ -807,13 +807,15 @@ export interface When {
807
807
  /**
808
808
  * A built-in top-level menu of the player, named in a command path as
809
809
  * `@<id>` or by its English key (see {@link CommandOptions.path}): Open,
810
- * Playback, Subtitles, Video, Audio, Screen, Settings. The list is
811
- * `MENU_IDS` in `@vestra-player/sdk/testing`.
810
+ * Playback, Subtitles, Video, Audio, Screen, Settings, Reset. The list is
811
+ * `MENU_IDS` in `@vestra-player/sdk/testing`. Reset (`@reset`, SDK 1.5.51,
812
+ * player 0.6.3) gathers every command that puts something back to its
813
+ * default; older players refuse `@reset` with `invalid`.
812
814
  *
813
815
  * @since 1.3.0
814
816
  * @category Commands
815
817
  */
816
- export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "screen" | "settings";
818
+ export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "screen" | "settings" | "reset";
817
819
  /**
818
820
  * An icon of the player's V-Line set (`@vestra/icons.slint`), by name: what
819
821
  * a bottom-bar button may show. The list is `BAR_BUTTON_ICONS` in
@@ -904,10 +906,12 @@ export interface CommandButton {
904
906
  * ranges: `open` 100–199, `domain` 200–699 (playback, subtitles, video,
905
907
  * audio…), `screen` 700–799, `windows` 800–899, `exit` 900–999.
906
908
  *
907
- * Built-in menus keep their place. The seven built-in top-level menus
909
+ * Built-in menus keep their place. The built-in top-level menus
908
910
  * ({@link MenuId}) sit at fixed orders, whatever is inside them: Open 110,
909
911
  * Playback 200, Subtitles 300, Video 400, Audio 500, Screen 710, Settings
910
- * 830 (player 0.3.0). This holds whether the path names the menu by id
912
+ * 830 (player 0.3.0), Reset 840 (player 0.6.3; directly below Settings and
913
+ * in its separator group). Like every built-in menu, Reset shows only when
914
+ * a command is in it. This holds whether the path names the menu by id
911
915
  * (`["@video", "Blur"]`) or by its key (`["Video", "Blur"]`), so a plugin's
912
916
  * item never moves the player's menu. Directly inside a built-in menu,
913
917
  * `order` 0 (the default) sorts after every other order: an item without
@@ -943,11 +947,11 @@ export interface CommandOptions {
943
947
  * The player shows each segment and the title in the interface language
944
948
  * from the declaring plugin's catalog (`locales/<lang>.po`, see the
945
949
  * [i18n guide](../../docs/how-to/i18n.md)); the player's own menus
946
- * (Open, Playback, Subtitles, Video, Audio, Screen, Settings, and
950
+ * (Open, Playback, Subtitles, Video, Audio, Screen, Settings, Reset, and
947
951
  * Playback ▸ Speed, Playback ▸ Seek) always show the player's words.
948
952
  * A built-in menu can also be named by its id, `@` plus a {@link MenuId}:
949
- * `@open`, `@playback`, `@subtitles`, `@video`, `@audio`, `@screen` or
950
- * `@settings`. Only the first segment is read as an id; an unknown id, or
953
+ * `@open`, `@playback`, `@subtitles`, `@video`, `@audio`, `@screen`,
954
+ * `@settings` or `@reset` (player 0.6.3). Only the first segment is read as an id; an unknown id, or
951
955
  * an id with nothing after it, fails with `invalid`. Players up to 0.4.2
952
956
  * titled their menus in Chinese (视频…): there `["Video", "Blur"]` is a
953
957
  * menu of its own, so plugins for those players write `@video`; player