@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/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/bin/vestra-plugin.mjs +46 -0
- package/dist/docs-tree.json.gz +0 -0
- package/dist/docs.d.ts +53 -0
- package/dist/docs.js +544 -0
- package/dist/player-runtime.d.ts +1 -1
- package/dist/plugin/commands.js +23 -7
- package/dist/plugin/index.d.ts +11 -4
- package/dist/plugin/index.js +4 -2
- package/dist/plugin/runtime.d.ts +20 -1
- package/dist/plugin/runtime.js +44 -1
- package/dist/plugin/testing.d.ts +5 -2
- package/dist/plugin/testing.js +20 -12
- package/dist/plugin/types.d.ts +23 -5
- package/llms.txt +31 -137
- package/package.json +6 -4
- package/llms-full.txt +0 -20316
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/player-runtime.d.ts
CHANGED
|
@@ -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";
|
package/dist/plugin/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
.
|
|
153
|
+
.declaring(this.#pump.push(initial), `command ${this.id}`, (error) => scope.report(error))
|
|
140
154
|
.then(() => { }));
|
|
141
|
-
|
|
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.
|
|
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.
|
package/dist/plugin/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
26
|
+
app?: unknown,
|
|
27
|
+
/** `TestHost` sets `strictDeclarations`; the player's runner does not. */
|
|
28
|
+
options?: RuntimeOptions): Promise<PluginInstance>;
|
package/dist/plugin/index.js
CHANGED
|
@@ -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
|
-
|
|
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(),
|