@rse/specbook 1.5.2 → 1.6.1

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.
Files changed (63) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +288 -43
  3. package/dst/specbook-api.d.ts +23 -3
  4. package/dst/specbook-api.js +133 -59
  5. package/dst/specbook-cli.js +93 -26
  6. package/dst/specbook-cmd-describe.js +4 -4
  7. package/dst/specbook-cmd-export-schema.d.ts +8 -0
  8. package/dst/specbook-cmd-export-schema.js +441 -0
  9. package/dst/specbook-cmd-export.js +23 -6
  10. package/dst/specbook-cmd-init-sample.d.ts +4 -0
  11. package/dst/specbook-cmd-init-sample.js +322 -0
  12. package/dst/specbook-cmd-init.d.ts +1 -0
  13. package/dst/specbook-cmd-init.js +42 -10
  14. package/dst/specbook-cmd-lint.js +46 -48
  15. package/dst/specbook-cmd-query.d.ts +30 -0
  16. package/dst/specbook-cmd-query.js +165 -0
  17. package/dst/specbook-config.d.ts +9 -1
  18. package/dst/specbook-config.js +135 -10
  19. package/dst/specbook-coverage.js +2 -2
  20. package/dst/specbook-diagram.d.ts +7 -5
  21. package/dst/specbook-diagram.js +546 -143
  22. package/dst/specbook-export-ast.d.ts +1 -1
  23. package/dst/specbook-export-ast.js +11 -8
  24. package/dst/specbook-export-code.js +3 -3
  25. package/dst/specbook-export-common.d.ts +1 -1
  26. package/dst/specbook-export-common.js +3 -2
  27. package/dst/specbook-export-diagram.d.ts +1 -1
  28. package/dst/specbook-export-diagram.js +25 -7
  29. package/dst/specbook-export-html-fold.d.ts +16 -6
  30. package/dst/specbook-export-html-fold.js +65 -44
  31. package/dst/specbook-export-html-render.js +107 -26
  32. package/dst/specbook-export-html-search.js +18 -20
  33. package/dst/specbook-export-html-template.js +38 -9
  34. package/dst/specbook-export-html-widget.d.ts +2 -0
  35. package/dst/specbook-export-html-widget.js +219 -19
  36. package/dst/specbook-export-html.css +208 -38
  37. package/dst/specbook-export-html.js +96 -53
  38. package/dst/specbook-export-md.d.ts +2 -1
  39. package/dst/specbook-export-md.js +7 -8
  40. package/dst/specbook-export-pdf.js +1 -1
  41. package/dst/specbook-files.d.ts +2 -0
  42. package/dst/specbook-files.js +41 -0
  43. package/dst/specbook-format-meta.yaml +401 -0
  44. package/dst/specbook-format-pm.yaml +1240 -0
  45. package/dst/specbook-format-schema.d.ts +33 -3
  46. package/dst/specbook-format-schema.js +49 -3
  47. package/dst/specbook-format.md +427 -47
  48. package/dst/specbook-format.yaml +524 -228
  49. package/dst/specbook-ignore.js +2 -2
  50. package/dst/specbook-link.d.ts +4 -0
  51. package/dst/specbook-link.js +61 -6
  52. package/dst/specbook-mcp.js +103 -19
  53. package/dst/specbook-parse-semantic.d.ts +1 -0
  54. package/dst/specbook-parse-semantic.js +11 -4
  55. package/dst/specbook-parse-syntax.d.ts +1 -0
  56. package/dst/specbook-parse-syntax.js +1 -1
  57. package/dst/specbook-parse.d.ts +1 -1
  58. package/dst/specbook-parse.js +3 -3
  59. package/dst/specbook-project.d.ts +2 -0
  60. package/dst/specbook-project.js +15 -5
  61. package/dst/specbook-remote.d.ts +28 -0
  62. package/dst/specbook-remote.js +429 -0
  63. package/package.json +32 -20
@@ -0,0 +1,429 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { createHash } from "node:crypto";
10
+ import { Readable } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { fileURLToPath } from "node:url";
13
+ import { glob } from "glob";
14
+ import { parse as parseIni } from "ini";
15
+ import pacote from "pacote";
16
+ import npmFetch from "npm-registry-fetch";
17
+ import { satisfies, valid } from "semver";
18
+ import { extract as extractTar } from "tar";
19
+ import { loadProjectFile, isRemote, projectFile } from "./specbook-project.js";
20
+ import { isBelow } from "./specbook-files.js";
21
+ import { literal } from "./specbook-verbose.js";
22
+ /* our own version, taken from the package manifest, which resides one
23
+ level above both the source and the compiled module directory */
24
+ export const version = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
25
+ /* the bundled standard YAML schema configuration, which resides
26
+ alongside the compiled module and is used whenever no particular
27
+ schema configuration is given */
28
+ export const standardConfig = fileURLToPath(new URL("specbook-format.yaml", import.meta.url));
29
+ /* the bundled meta YAML schema configuration, which resides alongside
30
+ the compiled module, too, and describes a schema configuration
31
+ itself (the one of the generated corpus of the schema export) */
32
+ export const metaConfig = fileURLToPath(new URL("specbook-format-meta.yaml", import.meta.url));
33
+ /* the bundled project management YAML schema configuration, which
34
+ resides alongside the compiled module, too, and plans the delivery
35
+ of a solution in a handbook beside its specification */
36
+ export const pmConfig = fileURLToPath(new URL("specbook-format-pm.yaml", import.meta.url));
37
+ /* whether a file is one of the bundled schema configurations */
38
+ export const isBundled = (file) => file === standardConfig || file === metaConfig || file === pmConfig;
39
+ /* the directory of the download cache: SPECBOOK_CACHE, else the native
40
+ cache location of the platform */
41
+ export const cacheDir = (() => {
42
+ const env = process.env.SPECBOOK_CACHE;
43
+ if (env !== undefined && env !== "")
44
+ return path.resolve(env);
45
+ const home = os.homedir();
46
+ if (process.platform === "darwin")
47
+ return path.join(home, "Library", "Caches", "specbook");
48
+ else if (process.platform === "win32")
49
+ return path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "specbook", "Cache");
50
+ else
51
+ return path.join(process.env.XDG_CACHE_HOME || path.join(home, ".cache"), "specbook");
52
+ })();
53
+ /* whether a file resides in the download cache (and hence never changes) */
54
+ export const isCached = (file) => path.resolve(file).startsWith(cacheDir + path.sep);
55
+ /* report the artifacts several schema configuration files define (and
56
+ hence merge): as a detail, or unconditionally if two remote files
57
+ collide, as such a merge is hardly intended */
58
+ export const reportOverlaps = (overlaps, verbose) => {
59
+ for (const { artifact, files } of overlaps)
60
+ verbose(`artifact "${literal(artifact)}" defined by ` +
61
+ files.map((file) => `"${literal(file)}"`).join(", ") + " (merged in order)", files.filter(isCached).length > 1 ? "none" : "detail");
62
+ };
63
+ /* the time a floating reference (a range, a branch, a tag, or a URL)
64
+ stays fresh in the cache before it is fetched again,
65
+ mirroring the five minutes the npm registry grants its packuments */
66
+ const floatingTtl = 5 * 60 * 1000;
67
+ /* the fetch modes of the remote resolution: never fetch, i.e., the
68
+ download cache only ("never"), fetch whenever the download cache
69
+ lacks the entry or it is no longer fresh ("auto", the default), and
70
+ always fetch, ignoring the download cache ("always") */
71
+ export const fetchModes = ["never", "auto", "always"];
72
+ /* parse and validate a fetch mode specification */
73
+ export const parseFetchMode = (value) => {
74
+ const mode = fetchModes.find((known) => known === value);
75
+ if (mode === undefined)
76
+ throw new Error(`unknown fetch mode "${value}" ` +
77
+ `(supported: ${fetchModes.join(", ")})`);
78
+ return mode;
79
+ };
80
+ /* parse a remote reference: "npm:<pkg>[@<range>][/<path>]" (the package
81
+ perhaps scoped), "github:<user>/<repo>[/<path>][#<ref>]" (the ref a
82
+ branch, tag, or commit, default "HEAD"), or an "http:" or "https:"
83
+ URL, where a path never leaves its package */
84
+ const parseRef = (ref) => {
85
+ let m;
86
+ if ((m = (/^npm:((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(?:\/(.+))?$/).exec(ref)) !== null)
87
+ return { kind: "npm", name: m[1], range: m[2], path: safePath(ref, m[3]) };
88
+ else if ((m = (/^github:([^/#]+)\/([^/#]+)(?:\/([^#]+))?(?:#(.+))?$/).exec(ref)) !== null
89
+ && ![m[1], m[2], m[4]].some((segment) => segment === "." || segment === ".."))
90
+ return { kind: "github", user: m[1], repo: m[2], path: safePath(ref, m[3]), ref: m[4] ?? "HEAD" };
91
+ else if ((/^https?:\/\/[^/]/).test(ref) && URL.canParse(ref))
92
+ return { kind: "url", url: ref };
93
+ throw new Error(`invalid remote reference "${ref}"`);
94
+ };
95
+ /* validate a path inside a package, which has to stay inside it (no
96
+ absolute path, no parent directory segments), as its segments */
97
+ const safeSegments = (ref, p) => {
98
+ const segments = p.replace(/\/+$/, "").split("/");
99
+ if (segments.some((segment) => segment === "" || segment === ".."))
100
+ throw new Error(`invalid path in remote reference "${ref}"`);
101
+ return segments;
102
+ };
103
+ const safePath = (ref, p) => p !== undefined ? safeSegments(ref, p).join("/") : undefined;
104
+ /* materialize a cache entry through "fill" into a temporary sibling,
105
+ which then replaces the entry atomically (a stale one dropped), so an
106
+ aborted download never leaves a half-written entry behind */
107
+ const materialize = async (target, fill) => {
108
+ const tmp = `${target}.tmp-${process.pid}`;
109
+ fs.rmSync(tmp, { recursive: true, force: true });
110
+ fs.mkdirSync(tmp, { recursive: true });
111
+ try {
112
+ await fill(tmp);
113
+ fs.rmSync(target, { recursive: true, force: true });
114
+ fs.renameSync(tmp, target);
115
+ const now = new Date();
116
+ fs.utimesSync(target, now, now);
117
+ }
118
+ catch (err) {
119
+ fs.rmSync(tmp, { recursive: true, force: true });
120
+ throw err;
121
+ }
122
+ };
123
+ /* provide a cache entry: an existing one is taken as long as it is
124
+ fresh (an immutable one always, a floating one within its TTL) or the
125
+ fetch mode is "never", but never under "always", else it is fetched
126
+ (again), which under "never" is an error, while a concurrent request
127
+ for an entry being fetched just awaits that very fetch */
128
+ const pending = new Map();
129
+ const ensure = async (target, floating, label, options, fill) => {
130
+ const running = pending.get(target);
131
+ if (running !== undefined)
132
+ return running;
133
+ const stat = fs.statSync(target, { throwIfNoEntry: false });
134
+ const fresh = !floating || Date.now() - (stat?.mtimeMs ?? 0) < floatingTtl;
135
+ if (stat !== undefined && (options.fetch === "never" || (options.fetch === "auto" && fresh)))
136
+ return;
137
+ if (options.fetch === "never")
138
+ throw new Error(`"${label}" is not cached (fetch mode "never")`);
139
+ options.verbose(`fetching "${literal(label)}"`);
140
+ const job = materialize(target, fill).finally(() => pending.delete(target));
141
+ pending.set(target, job);
142
+ await job;
143
+ };
144
+ /* download a URL, failing on any HTTP error (with the message of a
145
+ JSON error body, like the rate limit notice of GitHub) */
146
+ const download = async (url, headers = {}) => {
147
+ const response = await fetch(url, { headers, redirect: "follow" });
148
+ if (!response.ok || response.body === null) {
149
+ let detail = "";
150
+ try {
151
+ const body = JSON.parse(await response.text());
152
+ if (typeof body.message === "string")
153
+ detail = `: ${body.message}`;
154
+ }
155
+ catch {
156
+ /* no JSON error body */
157
+ }
158
+ throw new Error(`cannot download "${url}": HTTP ${response.status} ${response.statusText}${detail}`);
159
+ }
160
+ return response;
161
+ };
162
+ /* the npm configuration the registry client honors ("registry", the
163
+ scoped "@scope:registry", and the "//host/:_authToken"-style
164
+ credentials): the user file (NPM_CONFIG_USERCONFIG, else "~/.npmrc"),
165
+ overridden by the project file in the working directory and by the
166
+ "npm_config_*" environment variables, with the "${VAR}" references
167
+ of the values expanded like npm does */
168
+ const npmConfig = (cwd) => {
169
+ const config = {};
170
+ const files = [process.env.NPM_CONFIG_USERCONFIG || path.join(os.homedir(), ".npmrc"), path.join(cwd, ".npmrc")];
171
+ for (const file of files) {
172
+ let text;
173
+ try {
174
+ text = fs.readFileSync(file, "utf8");
175
+ }
176
+ catch {
177
+ continue;
178
+ }
179
+ for (const [key, value] of Object.entries(parseIni(text)))
180
+ if (typeof value === "string")
181
+ config[key] = value.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
182
+ else if (typeof value === "boolean")
183
+ config[key] = value;
184
+ }
185
+ for (const [name, value] of Object.entries(process.env))
186
+ if (name.startsWith("npm_config_") && value !== undefined)
187
+ config[name.slice("npm_config_".length).replace(/_/g, "-")] = value;
188
+ return config;
189
+ };
190
+ /* the options of the npm registry client: the npm configuration, our
191
+ own cache (never the one of npm), the fetch mode (the cache only,
192
+ resp. always revalidated), and a packument cache spanning the
193
+ process, as a packument hardly changes within it */
194
+ const packumentCache = new Map();
195
+ const npmOptions = (options) => ({
196
+ ...npmConfig(options.cwd),
197
+ cache: path.join(cacheDir, "npm", "_cacache"),
198
+ offline: options.fetch === "never",
199
+ preferOnline: options.fetch === "always",
200
+ packumentCache
201
+ });
202
+ /* fetch an npm package: its version resolved out of the range (default:
203
+ the "latest" tag) through the registry, its tarball extracted (never
204
+ running any of its scripts) into the cache, where an exact version is
205
+ immutable and hence stays forever (an already extracted one not even
206
+ consulting the registry again, unless the fetch mode is "always") */
207
+ const fetchNpm = async (ref, options) => {
208
+ const opts = npmOptions(options);
209
+ const dirOf = (v) => path.join(cacheDir, "npm", ref.name, v);
210
+ let exact = ref.range !== undefined && valid(ref.range) !== null ? ref.range : undefined;
211
+ if (exact === undefined || !fs.existsSync(dirOf(exact)) || options.fetch === "always")
212
+ exact = (await pacote.manifest(`${ref.name}@${ref.range ?? "latest"}`, opts)).version;
213
+ const spec = `${ref.name}@${exact}`;
214
+ const root = dirOf(exact);
215
+ await ensure(root, false, `npm:${spec}`, options, async (tmp) => {
216
+ await pacote.extract(spec, tmp, opts);
217
+ });
218
+ return { root, resolved: `npm:${spec}` };
219
+ };
220
+ /* the request headers of the GitHub requests (which GitHub refuses
221
+ without a user agent), authenticated through GITHUB_TOKEN (for the
222
+ private repositories and a higher rate limit) */
223
+ const githubHeaders = () => {
224
+ const token = process.env.SPECBOOK_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
225
+ return { "User-Agent": `specbook/${version}`,
226
+ ...(token !== undefined && token !== "" ? { Authorization: `Bearer ${token}` } : {}) };
227
+ };
228
+ /* fetch a GitHub repository at a ref: its tarball (whose single top-level
229
+ directory is stripped) extracted into the cache, where a commit hash
230
+ is immutable and every other ref floats */
231
+ const fetchGithub = async (ref, options) => {
232
+ const floating = !(/^[0-9a-f]{40}$/).test(ref.ref);
233
+ const spec = `${ref.user}/${ref.repo}#${ref.ref}`;
234
+ const root = path.join(cacheDir, "github", ref.user, ref.repo, encodeURIComponent(ref.ref));
235
+ await ensure(root, floating, `github:${spec}`, options, async (tmp) => {
236
+ const url = `https://codeload.github.com/${ref.user}/${ref.repo}/tar.gz/${ref.ref}`;
237
+ const response = await download(url, githubHeaders());
238
+ await pipeline(Readable.fromWeb(response.body), extractTar({ cwd: tmp, strip: 1 }));
239
+ });
240
+ return { root, resolved: `github:${spec}` };
241
+ };
242
+ /* fetch a single file by URL into the cache, which always floats */
243
+ const fetchUrl = async (ref, options) => {
244
+ const url = new URL(ref.url);
245
+ const name = path.posix.basename(url.pathname) || "index.yaml";
246
+ const root = path.join(cacheDir, "url", createHash("sha256").update(ref.url).digest("hex").slice(0, 32));
247
+ await ensure(root, true, ref.url, options, async (tmp) => {
248
+ const response = await download(ref.url);
249
+ fs.writeFileSync(path.join(tmp, name), Buffer.from(await response.arrayBuffer()));
250
+ });
251
+ return path.join(root, name);
252
+ };
253
+ /* read the manifest of a package directory, if any, checking the
254
+ SpecBook versions it supports ("engines.specbook") */
255
+ const manifestOf = (dir, label) => {
256
+ const file = path.join(dir, "package.json");
257
+ if (!fs.existsSync(file))
258
+ return undefined;
259
+ let manifest;
260
+ try {
261
+ manifest = JSON.parse(fs.readFileSync(file, "utf8"));
262
+ }
263
+ catch (err) {
264
+ throw new Error(`invalid "package.json" in "${label}"`, { cause: err });
265
+ }
266
+ const range = manifest.engines?.specbook;
267
+ if (range !== undefined && !satisfies(version, range, { includePrerelease: true }))
268
+ throw new Error(`"${label}" requires SpecBook ${range}, but this is version ${version}`);
269
+ return manifest;
270
+ };
271
+ /* read the project configuration file of a package directory, if any */
272
+ const projectOf = (dir) => {
273
+ const file = path.join(dir, projectFile);
274
+ return fs.existsSync(file) ? loadProjectFile(file) : undefined;
275
+ };
276
+ /* fetch the package of an npm or GitHub reference, whose manifest has
277
+ to support this SpecBook version */
278
+ const fetchPackage = async (ref, label, options) => {
279
+ const pkg = await (ref.kind === "npm" ? fetchNpm(ref, options) : fetchGithub(ref, options));
280
+ return { ...pkg, manifest: manifestOf(pkg.root, label) };
281
+ };
282
+ /* the directory (or file) inside a fetched package a reference names,
283
+ which has to exist, where a nested directory carrying a manifest of
284
+ its own has to support this SpecBook version, too */
285
+ const packageDir = (pkg, ref, label) => {
286
+ if (ref.path === undefined)
287
+ return pkg.root;
288
+ const dir = path.join(pkg.root, ...ref.path.split("/"));
289
+ if (!fs.existsSync(dir))
290
+ throw new Error(`"${ref.path}" does not exist in "${pkg.resolved}"`);
291
+ if (fs.statSync(dir).isDirectory())
292
+ manifestOf(dir, label);
293
+ return dir;
294
+ };
295
+ /* expand a local schema configuration pattern into its files, in
296
+ alphabetical order, where a pattern matching no file is an error */
297
+ export const expandPattern = async (pattern) => {
298
+ const matches = await glob(pattern, { nodir: true,
299
+ windowsPathsNoEscape: process.platform === "win32" });
300
+ if (matches.length === 0)
301
+ throw new Error(`no configuration file matches "${pattern}"`);
302
+ return matches.sort();
303
+ };
304
+ /* resolve a remote reference of a schema configuration into its local
305
+ files: the bundled standard one for "std" (the bundled meta one for
306
+ "meta", and the bundled project management one for "pm"), the single
307
+ file of a URL, and for a package the file or glob pattern its path
308
+ names inside the package (or the named building block of the
309
+ "specbook.schema" map of its manifest), while a directory (the
310
+ package itself, or a nested one) offers its schema configuration
311
+ through the "specbook.schema" file of its manifest, the "config"
312
+ entries of its own project configuration file (its remote ones
313
+ resolved recursively, a cycle being an error), or its "index.yaml" */
314
+ export const resolveConfig = async (ref, options, chain = []) => {
315
+ if (ref === "std")
316
+ return [standardConfig];
317
+ else if (ref === "meta")
318
+ return [metaConfig];
319
+ else if (ref === "pm")
320
+ return [pmConfig];
321
+ const parsed = parseRef(ref);
322
+ if (parsed.kind === "url")
323
+ return [await fetchUrl(parsed, options)];
324
+ const pkg = await fetchPackage(parsed, ref, options);
325
+ if (chain.includes(pkg.resolved))
326
+ throw new Error(`cyclic schema configuration reference "${ref}"`);
327
+ /* the path names a building block of the manifest, or a glob pattern inside the package */
328
+ const blocks = pkg.manifest?.specbook?.schema;
329
+ const block = typeof blocks === "object" && parsed.path !== undefined && Object.hasOwn(blocks, parsed.path) ?
330
+ blocks[parsed.path] : undefined;
331
+ if (block !== undefined)
332
+ return [path.join(pkg.root, ...safeSegments(ref, block))];
333
+ else if (parsed.path !== undefined && (/[*?[{]/).test(parsed.path))
334
+ return expandPattern(path.join(pkg.root, ...parsed.path.split("/")));
335
+ /* the path names a file inside the package */
336
+ const dir = packageDir(pkg, parsed, ref);
337
+ if (!fs.statSync(dir).isDirectory())
338
+ return [dir];
339
+ /* the manifest names the schema configuration (or offers the named building blocks) */
340
+ const schema = (dir === pkg.root ? pkg.manifest : manifestOf(dir, ref))?.specbook?.schema;
341
+ if (typeof schema === "string")
342
+ return [path.join(dir, ...safeSegments(ref, schema))];
343
+ else if (typeof schema === "object")
344
+ throw new Error(`"${ref}" offers the named schema configurations ` +
345
+ `${Object.keys(schema).map((name) => `"${name}"`).join(", ")} (append one as its path)`);
346
+ /* the project configuration file names the schema configuration */
347
+ const project = projectOf(dir);
348
+ if (project?.config !== undefined) {
349
+ const files = new Array();
350
+ for (const pattern of project.config) {
351
+ if (!isRemote(pattern) && !isBelow(pattern, pkg.root))
352
+ throw new Error(`"${projectFile}" of "${ref}" references "${pattern}" outside of its package`);
353
+ files.push(...(isRemote(pattern) ?
354
+ await resolveConfig(pattern, options, [...chain, pkg.resolved]) :
355
+ await expandPattern(pattern)));
356
+ }
357
+ return files;
358
+ }
359
+ /* the conventional index file */
360
+ const index = path.join(dir, "index.yaml");
361
+ if (fs.existsSync(index))
362
+ return [index];
363
+ throw new Error(`no schema configuration found in "${ref}" (neither a "specbook.schema" ` +
364
+ `entry in "package.json", nor a "${projectFile}", nor an "index.yaml")`);
365
+ };
366
+ /* the search hits of the bundled schema configurations */
367
+ const bundledHits = [{
368
+ ref: "std",
369
+ name: "standard",
370
+ version,
371
+ description: "SpecBook Standard Schema",
372
+ source: "builtin"
373
+ }, {
374
+ ref: "meta",
375
+ name: "meta",
376
+ version,
377
+ description: "SpecBook Meta Schema (describing a schema configuration itself)",
378
+ source: "builtin"
379
+ }, {
380
+ ref: "pm",
381
+ name: "pm",
382
+ version,
383
+ description: "SpecBook Project Management Schema (planning the delivery of a specification)",
384
+ source: "builtin"
385
+ }];
386
+ /* search the bundled schema configurations (matched locally by all
387
+ words of the term), the npm registry (the packages
388
+ carrying the keyword "specbook-schema"), and GitHub (the repositories
389
+ carrying the same topic) for the shareable schema configurations
390
+ matching a term, where a failing source is reported but does not fail
391
+ the search, and the fetch mode "never" searches the bundled ones only */
392
+ export const search = async (term, options) => {
393
+ const words = term.toLowerCase().split(/\s+/).filter((word) => word !== "");
394
+ const hits = bundledHits.filter((hit) => {
395
+ const text = `${hit.ref} ${hit.name} ${hit.description}`.toLowerCase();
396
+ return words.every((word) => text.includes(word));
397
+ });
398
+ if (options.fetch === "never") {
399
+ options.verbose("searching the bundled schema configurations only (fetch mode \"never\")");
400
+ return hits;
401
+ }
402
+ const guard = async (source, action) => {
403
+ try {
404
+ await action();
405
+ }
406
+ catch (err) {
407
+ options.verbose(`search of ${source} failed: ` +
408
+ (err instanceof Error ? err.message : String(err)), "none");
409
+ }
410
+ };
411
+ /* the npm registry, searched by keyword */
412
+ await guard("the npm registry", async () => {
413
+ const result = await npmFetch.json("/-/v1/search", { ...npmOptions(options),
414
+ query: { text: `keywords:specbook-schema ${term}`.trim(), size: 50 } });
415
+ for (const { package: pkg } of result.objects)
416
+ hits.push({ ref: `npm:${pkg.name}`, name: pkg.name, version: pkg.version,
417
+ description: pkg.description, source: "npm" });
418
+ });
419
+ /* GitHub, searched by topic */
420
+ await guard("GitHub", async () => {
421
+ const query = encodeURIComponent(`topic:specbook-schema ${term}`.trim());
422
+ const response = await download(`https://api.github.com/search/repositories?q=${query}&per_page=50`, { ...githubHeaders(), Accept: "application/vnd.github+json" });
423
+ const result = await response.json();
424
+ for (const item of result.items)
425
+ hits.push({ ref: `github:${item.full_name}`, name: item.full_name,
426
+ description: item.description ?? undefined, source: "github" });
427
+ });
428
+ return hits.filter((hit, i) => hits.findIndex((other) => other.ref === hit.ref) === i);
429
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.5.2",
3
+ "version": "1.6.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "files": [ "dst" ],
19
19
  "dependencies": {
20
- "marked": "18.1.0",
20
+ "marked": "18.1.2",
21
21
  "marked-smartypants": "1.1.12",
22
22
  "yaml": "2.9.1",
23
23
  "json5": "2.2.3",
@@ -26,45 +26,57 @@
26
26
  "sharp": "0.35.5",
27
27
  "svgo": "4.1.0",
28
28
  "@rse/mrcs": "0.9.2",
29
- "@rse/gradia": "1.4.3",
29
+ "@rse/gradia": "2.0.1",
30
30
  "beautiful-mermaid": "1.1.3",
31
31
  "@d2lang/d2": "0.1.34",
32
32
  "shiki": "4.5.0",
33
- "pdfjs-dist": "6.4.299",
34
- "@napi-rs/canvas": "1.0.11",
33
+ "pdfjs-dist": "6.5.438",
34
+ "@napi-rs/canvas": "1.1.0",
35
35
  "@toon-format/toon": "4.4.0",
36
36
  "valibot": "1.5.0",
37
37
  "nunjucks": "3.2.4",
38
38
  "@rse/nunjucks-addons": "2.2.0",
39
39
  "textframe": "1.2.1",
40
- "@swc/html": "1.16.13",
40
+ "@swc/html": "1.16.21",
41
41
  "tokenizr": "2.2.1",
42
- "playwright": "1.64.0",
42
+ "astq": "2.8.3",
43
+ "randexp": "0.6.1",
44
+ "wrap-ansi": "10.0.2",
45
+ "playwright": "1.64.1",
43
46
  "source-code-error": "1.2.0",
44
47
  "commander": "15.0.0",
45
48
  "chalk": "6.0.1",
46
49
  "chokidar5": "npm:chokidar@5.0.0",
47
- "glob": "13.0.6",
50
+ "glob": "14.0.3",
48
51
  "picomatch": "4.0.7",
49
- "es-toolkit": "1.52.0",
50
- "fastify": "5.12.5",
51
- "@fastify/websocket": "11.3.4",
52
- "@modelcontextprotocol/sdk": "1.32.1",
53
- "zod": "4.6.5"
52
+ "pacote": "22.0.1",
53
+ "npm-registry-fetch": "20.0.1",
54
+ "tar": "7.5.22",
55
+ "ini": "7.0.0",
56
+ "semver": "7.8.5",
57
+ "es-toolkit": "1.53.1",
58
+ "fastify": "5.12.7",
59
+ "@fastify/websocket": "11.3.6",
60
+ "@modelcontextprotocol/sdk": "1.32.2",
61
+ "zod": "4.6.7"
54
62
  },
55
63
  "devDependencies": {
56
64
  "chokidar-cli": "3.0.0",
57
65
  "eslint": "10.9.0",
58
66
  "@eslint/js": "10.0.1",
59
- "typescript-eslint": "8.71.1",
67
+ "typescript-eslint": "8.72.1",
60
68
  "eslint-plugin-promise": "7.3.0",
61
- "neostandard": "0.14.0",
62
- "globals": "17.13.0",
69
+ "neostandard": "0.14.1",
70
+ "globals": "17.14.0",
63
71
  "eslint-plugin-yml": "3.9.0",
64
- "markdownlint": "0.41.1",
65
- "markdownlint-cli2": "0.23.3",
66
- "@types/node": "26.6.5",
72
+ "markdownlint": "0.42.0",
73
+ "markdownlint-cli2": "0.24.0",
74
+ "@types/node": "26.6.6",
67
75
  "@types/picomatch": "4.0.3",
76
+ "@types/pacote": "11.1.8",
77
+ "@types/npm-registry-fetch": "8.0.9",
78
+ "@types/ini": "4.1.1",
79
+ "@types/semver": "7.8.0",
68
80
  "@types/subset-font": "2.3.0",
69
81
  "@types/nunjucks": "3.2.6",
70
82
  "@types/ws": "8.18.2",
@@ -73,7 +85,7 @@
73
85
  "source-sans": "3.52.0",
74
86
  "@fontsource/dejavu-mono": "5.3.0",
75
87
  "@fontsource/noto-sans-symbols": "5.3.0",
76
- "postcss": "8.5.29",
88
+ "postcss": "8.5.32",
77
89
  "postcss-cli": "12.0.0",
78
90
  "postcss-url": "10.1.4",
79
91
  "stylus": "0.64.0",