@vestra-player/sdk 1.5.48 → 1.5.50

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
+ }
@@ -8,7 +8,7 @@
8
8
  * @packageDocumentation
9
9
  */
10
10
  export { createPlugin } from "./plugin/index.ts";
11
- export type { PluginInstance, PluginManifest } from "./plugin/index.ts";
11
+ export type { PluginInstance, PluginManifest, RuntimeOptions, } from "./plugin/index.ts";
12
12
  export { HostError } from "./wire.ts";
13
13
  export type { Json, Transport } from "./wire.ts";
14
14
  export { API_VERSION, CAPABILITIES, CAPABILITY_INFO, MESSAGE_LIMIT, } from "./capabilities.ts";
@@ -1,5 +1,5 @@
1
1
  /** Menu commands: declaration, state sync (revisions hidden) and dispatch. */
2
- import { failure, Latest, quiet } from "./runtime.js";
2
+ import { failure, Latest, quiet, uncertain } from "./runtime.js";
3
3
  import { checkBindings } from "./bindings.js";
4
4
  import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, MENU_IDS } from "../commands.js";
5
5
  import { ImageHandle } from "./ui.js";
@@ -100,6 +100,10 @@ class CommandHandle {
100
100
  #last = "";
101
101
  #disposed = false;
102
102
  #registered = false;
103
+ /** Its entry in `runtime.commands` (a later handle may own the id). */
104
+ #run = () => { };
105
+ /** The player refused the declaration: no state updates follow. */
106
+ #refused = false;
103
107
  #pump;
104
108
  #label;
105
109
  /** Where the plugin added it, for the player's log. */
@@ -124,10 +128,20 @@ class CommandHandle {
124
128
  // registration itself is revision 1, so every later state follows it.
125
129
  const previous = removing(runtime).get(this.id);
126
130
  this.#pump = new Latest(async ({ state, when }) => {
131
+ if (this.#refused)
132
+ return;
127
133
  const revision = ++this.#revision;
128
134
  if (revision === 1) {
129
135
  await previous;
130
- await runtime.host.commands.register(this.id, 1, declaration, state, when);
136
+ try {
137
+ await runtime.host.commands.register(this.id, 1, declaration, state, when);
138
+ }
139
+ catch (error) {
140
+ // A definite refusal: the player has no such command to update.
141
+ if (!uncertain(error))
142
+ this.#refused = true;
143
+ throw error;
144
+ }
131
145
  this.#registered = true;
132
146
  }
133
147
  else
@@ -136,12 +150,13 @@ class CommandHandle {
136
150
  const initial = this.#state();
137
151
  this.#last = JSON.stringify(initial);
138
152
  this.ready = quiet(runtime
139
- .starting(this.#pump.push(initial), (error) => scope.report(error))
153
+ .declaring(this.#pump.push(initial), `command ${this.id}`, (error) => scope.report(error))
140
154
  .then(() => { }));
141
- runtime.commands.set(this.id, () => {
155
+ this.#run = () => {
142
156
  if (!this.#disposed)
143
157
  scope.run(options.run);
144
- });
158
+ };
159
+ runtime.commands.set(this.id, this.#run);
145
160
  this.#binding = runtime.bind(() => this.#sync());
146
161
  scope.add(this);
147
162
  }
@@ -177,7 +192,7 @@ class CommandHandle {
177
192
  return Object.keys(when).length ? { state, when } : { state };
178
193
  }
179
194
  #sync() {
180
- if (this.#disposed)
195
+ if (this.#disposed || this.#refused)
181
196
  return;
182
197
  const state = this.#state();
183
198
  const encoded = JSON.stringify(state);
@@ -196,7 +211,8 @@ class CommandHandle {
196
211
  this.#disposed = true;
197
212
  this.#scope.forget(this);
198
213
  const runtime = this.#scope.runtime;
199
- runtime.commands.delete(this.id);
214
+ if (runtime.commands.get(this.id) === this.#run)
215
+ runtime.commands.delete(this.id);
200
216
  this.#pump.stop();
201
217
  // Removed from the menu once registered. Recorded before any await, so an
202
218
  // immediate re-add of the same id registers after this removal.
@@ -6,16 +6,23 @@
6
6
  * @internal
7
7
  */
8
8
  import type { Transport } from "../wire.ts";
9
- import type { PluginManifest } from "./runtime.ts";
9
+ import type { PluginManifest, RuntimeOptions } from "./runtime.ts";
10
10
  import type { Plugin } from "./types.ts";
11
11
  export type * from "./types.ts";
12
- export type { PluginManifest };
12
+ export type { PluginManifest, RuntimeOptions };
13
13
  export interface PluginInstance {
14
14
  plugin: Plugin;
15
- /** Wait for registrations started during activation; rejects if one failed. */
15
+ /**
16
+ * Wait for registrations started during activation; rejects if one failed.
17
+ * A command declaration the player refused is not a failure (it is
18
+ * logged, skipped and reported to `onError`) unless
19
+ * `RuntimeOptions.strictDeclarations` is set.
20
+ */
16
21
  activated(): Promise<void>;
17
22
  dispose(): Promise<void>;
18
23
  }
19
24
  export declare function createPlugin(transport: Transport, manifest: PluginManifest, directory: string, dataDirectory?: string,
20
25
  /** `plugin.app` (the runner passes the player's `VESTRA_APP_INFO`). */
21
- app?: unknown): Promise<PluginInstance>;
26
+ app?: unknown,
27
+ /** `TestHost` sets `strictDeclarations`; the player's runner does not. */
28
+ options?: RuntimeOptions): Promise<PluginInstance>;
@@ -5,8 +5,10 @@ import { windowOf } from "./window.js";
5
5
  import { timelineOf } from "./timeline.js";
6
6
  export async function createPlugin(transport, manifest, directory, dataDirectory,
7
7
  /** `plugin.app` (the runner passes the player's `VESTRA_APP_INFO`). */
8
- app) {
9
- const runtime = new Runtime(transport, manifest, directory, dataDirectory, app);
8
+ app,
9
+ /** `TestHost` sets `strictDeclarations`; the player's runner does not. */
10
+ options) {
11
+ const runtime = new Runtime(transport, manifest, directory, dataDirectory, app, options);
10
12
  // Player, window and timeline state are synchronous for plugins: read them first.
11
13
  await Promise.all([
12
14
  playerOf(runtime).init(),