@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
@@ -3,16 +3,17 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import * as fs from "node:fs";
7
6
  import * as path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { glob } from "glob";
10
7
  import { loadConfig, configStatistics } from "./specbook-config.js";
11
- import { loadProject, writeProject, projectFile } from "./specbook-project.js";
8
+ import { loadProject, writeProject, projectFile, isRemote } from "./specbook-project.js";
9
+ import { version, standardConfig, metaConfig, pmConfig, isBundled, cacheDir, isCached, expandPattern, resolveConfig, reportOverlaps, search, fetchModes, parseFetchMode } from "./specbook-remote.js";
12
10
  import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
13
11
  import { initSpecification } from "./specbook-cmd-init.js";
14
12
  import { lint } from "./specbook-cmd-lint.js";
13
+ import { isBelow } from "./specbook-files.js";
15
14
  import { exportSpecification, watchSpecification, parseOutputSpec, formats, requireBrowser, parseOmit, omitAspects } from "./specbook-cmd-export.js";
15
+ import { lintSchema } from "./specbook-cmd-export-schema.js";
16
+ import { SpecBookDocument, renderNodes, queryFormats, parseQueryOutput } from "./specbook-cmd-query.js";
16
17
  import { servePreview, previewAddr, previewPort } from "./specbook-cmd-preview.js";
17
18
  import { describeFormat, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel } from "./specbook-cmd-describe.js";
18
19
  import { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity } from "./specbook-verbose.js";
@@ -24,13 +25,8 @@ export { projectFile };
24
25
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
25
26
  export { compressLevels, parseCompressLevel };
26
27
  export { renderDiagnostic, renderDiagnosticVerbose };
27
- /* our own version, taken from the package manifest, which resides one
28
- level above both the source and the compiled module directory */
29
- export const version = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
30
- /* the bundled standard YAML schema configuration, which resides
31
- alongside the compiled module and is used whenever no particular
32
- schema configuration is given */
33
- export const standardConfig = fileURLToPath(new URL("specbook-format.yaml", import.meta.url));
28
+ export { SpecBookDocument, renderNodes, queryFormats, parseQueryOutput };
29
+ export { version, standardConfig, metaConfig, pmConfig, cacheDir, fetchModes, parseFetchMode };
34
30
  /* the SpecBook API */
35
31
  export class SpecBook {
36
32
  verbose;
@@ -64,32 +60,39 @@ export class SpecBook {
64
60
  return {
65
61
  file: project?.file,
66
62
  config: config !== undefined && config.length > 0 ?
67
- config.map((pattern) => pattern === "std" ? pattern : given(pattern)) :
68
- project?.config?.map((pattern) => pattern === "std" ? pattern : derived(pattern)),
63
+ config.map((pattern) => isRemote(pattern) ? pattern : given(pattern)) :
64
+ project?.config?.map((pattern) => isRemote(pattern) ? pattern : derived(pattern)),
69
65
  basedir: basedir !== undefined ? given(basedir) :
70
66
  project?.basedir !== undefined ? derived(project.basedir) :
71
67
  options.cwd !== undefined ? cwd : undefined
72
68
  };
73
69
  }
70
+ /* the options of the remote resolution of a command */
71
+ remoteOptions(options, verbose) {
72
+ return { fetch: parseFetchMode(options.fetch ?? "auto"),
73
+ cwd: path.resolve(options.cwd ?? "."), verbose };
74
+ }
74
75
  /* determine the files of the YAML schema configuration out of the
75
- glob patterns, in the order of the patterns and alphabetically
76
- within a pattern, where the literal "std" names the bundled
77
- standard schema configuration, which is also the fallback if no
78
- pattern is given at all */
79
- async configFiles(patterns) {
80
- if (patterns === undefined || patterns.length === 0)
76
+ patterns, in the order of the patterns and alphabetically within
77
+ a pattern, where a remote reference (the literals "std", "meta",
78
+ and "pm" included, naming the bundled schema configurations, the
79
+ standard one being also the fallback if no pattern is given at all) is
80
+ resolved into the files of the download cache, and optionally
81
+ collect the display label of every file: the remote reference it
82
+ stems from (plus its base name, if the reference yields several
83
+ files), or its local path */
84
+ async configFiles(patterns, access, labels) {
85
+ if (patterns === undefined || patterns.length === 0) {
86
+ labels?.set(standardConfig, "std");
81
87
  return [standardConfig];
88
+ }
82
89
  const files = new Array();
83
90
  for (const pattern of patterns) {
84
- if (pattern === "std") {
85
- files.push(standardConfig);
86
- continue;
87
- }
88
- const matches = await glob(pattern, { nodir: true,
89
- windowsPathsNoEscape: process.platform === "win32" });
90
- if (matches.length === 0)
91
- throw new Error(`no configuration file matches "${pattern}"`);
92
- files.push(...matches.sort());
91
+ const matches = isRemote(pattern) ? await resolveConfig(pattern, access) : await expandPattern(pattern);
92
+ for (const file of matches)
93
+ labels?.set(file, !isRemote(pattern) ? file :
94
+ matches.length === 1 ? pattern : `${pattern} (${path.basename(file)})`);
95
+ files.push(...matches);
93
96
  }
94
97
  return files;
95
98
  }
@@ -97,31 +100,34 @@ export class SpecBook {
97
100
  requireConfig(files, verbose) {
98
101
  verbose("loading schema configuration " +
99
102
  files.map((file) => `"${literal(file)}"`).join(", "));
100
- const { config, diagnostics } = loadConfig(files);
103
+ const { config, diagnostics, overlaps } = loadConfig(files);
101
104
  if (config === undefined)
102
105
  throw new Error("invalid configuration:\n" +
103
106
  diagnostics.map(renderDiagnostic).join("\n"));
107
+ reportOverlaps(overlaps, verbose);
104
108
  const stats = configStatistics(config);
105
109
  verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
106
110
  `and ${literal(stats.links)} link relationship(s)`);
107
111
  return config;
108
112
  }
109
113
  /* initialize the configured specification artifact files below the
110
- base directory and persist the effective project options into a
111
- new project configuration file in the working directory, unless
112
- one already applies (as a new one would shadow it) */
114
+ base directory (where "sample" scaffolds a sample object per
115
+ allowed object kind into them) and persist the effective project
116
+ options into a new project configuration file in the working
117
+ directory, unless one already applies (as a new one would shadow it) */
113
118
  async init(options) {
114
119
  const verbose = this.verboseOf("init");
115
120
  const project = this.project(options, verbose);
116
- const files = initSpecification({ config: this.requireConfig(await this.configFiles(project.config), verbose),
117
- basedir: project.basedir ?? ".", verbose });
121
+ const config = await this.configFiles(project.config, this.remoteOptions(options, verbose));
122
+ const files = initSpecification({ config: this.requireConfig(config, verbose),
123
+ basedir: project.basedir ?? ".", sample: options.sample === true, verbose });
118
124
  if (project.file !== undefined) {
119
125
  verbose(`skipping existing project configuration "${literal(project.file)}"`);
120
126
  return { files };
121
127
  }
122
128
  const cwd = path.resolve(options.cwd ?? ".");
123
129
  const relative = (value) => path.relative(cwd, path.resolve(cwd, value)) || ".";
124
- const file = writeProject(cwd, project.config?.map((pattern) => pattern === "std" ? pattern : relative(pattern)) ?? ["std"], relative(project.basedir ?? "."));
130
+ const file = writeProject(cwd, project.config?.map((pattern) => isRemote(pattern) ? pattern : relative(pattern)) ?? ["std"], relative(project.basedir ?? "."));
125
131
  verbose(`created project configuration "${literal(file)}"`);
126
132
  return { files, project: options.cwd !== undefined ? file : path.relative(process.cwd(), file) };
127
133
  }
@@ -131,9 +137,39 @@ export class SpecBook {
131
137
  async lint(options) {
132
138
  const verbose = this.verboseOf("lint");
133
139
  const project = this.project(options, verbose);
134
- return lint({ config: await this.configFiles(project.config),
140
+ return lint({ config: await this.configFiles(project.config, this.remoteOptions(options, verbose)),
135
141
  basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
136
142
  }
143
+ /* load the specification Markdown files below the base directory (or,
144
+ under "schema", the effective schema configuration itself, described
145
+ as a specification of the bundled meta schema configuration) as a
146
+ document queryable through ASTq selectors, strictly like "export":
147
+ any error diagnostic prevents the loading, as a partial or invalid
148
+ specification must never be consumed, while the warnings are just
149
+ surfaced as unconditional verbose messages */
150
+ async load(options) {
151
+ const verbose = this.verboseOf("load");
152
+ if (options.schema === true && options.basedir !== undefined)
153
+ throw new Error("the base directory is not applicable to the export of the schema configuration");
154
+ const project = this.project(options, verbose);
155
+ const labels = new Map();
156
+ const config = await this.configFiles(project.config, this.remoteOptions(options, verbose), labels);
157
+ const result = this.parseSource(options, config, labels, project.basedir ?? ".", verbose);
158
+ if (result.config === undefined || result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
159
+ throw new Error("invalid specification:\n" +
160
+ result.diagnostics.map(renderDiagnostic).join("\n"));
161
+ for (const diagnostic of result.diagnostics)
162
+ verbose(renderDiagnostic(diagnostic), "none");
163
+ return new SpecBookDocument(result.specification, result.config, result.diagnostics, result.origins);
164
+ }
165
+ /* search the bundled schema configurations, the npm
166
+ registry, and GitHub for the shareable schema configurations
167
+ matching a term */
168
+ async search(options) {
169
+ const verbose = this.verboseOf("search");
170
+ verbose(`searching for schema configurations matching "${literal(options.term ?? "")}"`);
171
+ return search(options.term ?? "", this.remoteOptions(options, verbose));
172
+ }
137
173
  /* render an already parsed specification into the requested formats
138
174
  (strict: any error diagnostic prevents the export, as a partial or
139
175
  invalid specification must never be emitted, while the warnings
@@ -163,26 +199,54 @@ export class SpecBook {
163
199
  const cwd = options.cwd;
164
200
  return cwd === undefined ? options.rebase : options.rebase?.map((dir) => dir !== undefined ? path.resolve(cwd, dir) : dir);
165
201
  }
202
+ /* refuse a normalized Markdown output below the base directory (told
203
+ by its re-basing directory), as every ".md" file below the base
204
+ directory is a specification file and hence the output would be
205
+ parsed as one on the next run */
206
+ guardRebase(formats, rebase, basedir) {
207
+ for (const [i, format] of formats.entries()) {
208
+ const dir = rebase?.[i];
209
+ if (format === "md" && dir !== undefined && isBelow(dir, basedir))
210
+ throw new Error(`the Markdown output directory "${dir}" is at or below the base directory ` +
211
+ `"${basedir}", so the output would be parsed as a specification file`);
212
+ }
213
+ }
214
+ /* parse the source of an export: the specification Markdown files
215
+ below the base directory, or, under "schema", the specification
216
+ generated out of the effective schema configuration itself (to
217
+ which no base directory applies) */
218
+ parseSource(options, config, labels, basedir, verbose) {
219
+ if (options.schema === true)
220
+ return lintSchema({ config, labels, verbose });
221
+ return lint({ config, basedir, gitignore: options.gitignore === true, verbose });
222
+ }
166
223
  /* export the specification Markdown files below the base directory
167
- as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown,
168
- parsing the input just once and returning one buffer per
169
- requested format, where "rebase" names, per requested format, the
170
- directory of the output file, onto which the image references of
171
- the normalized Markdown are re-based (an absent one keeps them),
172
- and "omit" names the (comma-separated) aspects the HTML, the PDF,
173
- and (the diagrams) the AST leave out (see "omitAspects") */
224
+ (or, under "schema", the effective schema configuration itself,
225
+ described as a specification of the bundled meta schema
226
+ configuration) as JSON, JSON5, YAML, TOON, HTML, PDF, or
227
+ normalized Markdown, parsing the input just once and returning
228
+ one buffer per requested format, where "rebase" names, per
229
+ requested format, the directory of the output file, onto which
230
+ the image references of the normalized Markdown are re-based (an
231
+ absent one keeps them), and "omit" names the (comma-separated)
232
+ aspects the HTML, the PDF, and (the diagrams) the AST leave out
233
+ (see "omitAspects") */
174
234
  async export(options) {
175
235
  const verbose = this.verboseOf("export");
176
236
  const requested = options.formats ?? ["json"];
177
237
  const omit = parseOmit(options.omit);
238
+ if (options.schema === true && options.basedir !== undefined)
239
+ throw new Error("the base directory is not applicable to the export of the schema configuration");
178
240
  const project = this.project(options, verbose);
179
- const config = await this.configFiles(project.config);
241
+ if (options.schema !== true)
242
+ this.guardRebase(requested, this.rebaseOf(options), project.basedir ?? ".");
243
+ const labels = new Map();
244
+ const config = await this.configFiles(project.config, this.remoteOptions(options, verbose), labels);
180
245
  /* a missing browser is an environment problem, so let the PDF
181
246
  export fail before the specification is even parsed */
182
247
  if (requested.includes("pdf"))
183
248
  await requireBrowser(verbose);
184
- const result = lint({ config,
185
- basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
249
+ const result = this.parseSource(options, config, labels, project.basedir ?? ".", verbose);
186
250
  return this.renderFormats(result, requested, verbose, { realtime: options.realtime === true, omit }, this.rebaseOf(options));
187
251
  }
188
252
  /* keep an export in sync with its sources (the shared core of
@@ -196,8 +260,13 @@ export class SpecBook {
196
260
  process alive afterwards */
197
261
  async observe(options, verbose) {
198
262
  const omit = parseOmit(options.omit);
263
+ if (options.schema === true && options.basedir !== undefined)
264
+ throw new Error("the base directory is not applicable to the export of the schema configuration");
199
265
  const project = this.project(options, verbose);
200
- const config = await this.configFiles(project.config);
266
+ if (options.schema !== true)
267
+ this.guardRebase(options.formats, this.rebaseOf(options), project.basedir ?? ".");
268
+ const labels = new Map();
269
+ const config = await this.configFiles(project.config, this.remoteOptions(options, verbose), labels);
201
270
  /* a missing browser is an environment problem, so let the PDF
202
271
  export fail before the specification is even parsed, but
203
272
  after the usage problems (exactly like "export") */
@@ -206,11 +275,13 @@ export class SpecBook {
206
275
  return watchSpecification(async () => {
207
276
  /* the lint result carries the files to observe even for an
208
277
  invalid specification, and the configuration files are
209
- observed, too, so a failing export (even one due to an
210
- invalid configuration) still keeps the observe loop fed */
211
- const result = lint({ config, basedir: project.basedir ?? ".",
212
- gitignore: options.gitignore === true, verbose });
213
- const files = [...config.map((file) => path.resolve(file)), ...result.files];
278
+ observed, too (the only sources of the schema export), so
279
+ a failing export (even one due to an invalid configuration)
280
+ still keeps the observe loop fed, while the files of the
281
+ download cache never change */
282
+ const result = this.parseSource(options, config, labels, project.basedir ?? ".", verbose);
283
+ const files = [...config.map((file) => path.resolve(file)), ...result.files]
284
+ .filter((file) => !isCached(file));
214
285
  /* an output which is itself an observed source would re-trigger
215
286
  the observation with its own write and hence feed an endless
216
287
  re-export loop, so refuse it before it is ever written */
@@ -241,7 +312,7 @@ export class SpecBook {
241
312
  sources (see "observe") and every fresh export is pushed to the
242
313
  connected browsers as an in-place document update, through the
243
314
  client-side script the "realtime" export injects into the HTML,
244
- where "omit" is the one of "export" */
315
+ where "omit" and "schema" are the ones of "export" */
245
316
  async preview(options) {
246
317
  const verbose = this.verboseOf("preview");
247
318
  /* an invalid omit aspect is a usage problem, so let the preview
@@ -255,7 +326,7 @@ export class SpecBook {
255
326
  try {
256
327
  return await this.observe({ config: options.config, basedir: options.basedir, cwd: options.cwd,
257
328
  formats: ["html"], realtime: true, omit: options.omit, gitignore: options.gitignore,
258
- onExport: ([html]) => server.update(html) }, verbose);
329
+ schema: options.schema, onExport: ([html]) => server.update(html) }, verbose);
259
330
  }
260
331
  catch (err) {
261
332
  /* a failing release must not mask the primary error */
@@ -275,13 +346,16 @@ export class SpecBook {
275
346
  const project = this.project(options, verbose);
276
347
  /* the schema-bearing parts require a schema configuration, so
277
348
  they fall back onto the bundled standard one, which is always
278
- embedded (also when named explicitly through "std"), as its
279
- bundled file is no meaningful reference */
349
+ embedded (also when named explicitly through "std", and so are
350
+ the bundled meta and project management ones named through
351
+ "meta" and "pm"), as its bundled file is no meaningful
352
+ reference, and so is a remote one, as its download cache file
353
+ is none either */
280
354
  const given = project.config !== undefined;
281
355
  const config = given || part === "all" || part === "schema" ?
282
- await this.configFiles(project.config) : undefined;
283
- const standard = config !== undefined && config.every((file) => file === standardConfig);
284
- const embed = options.embed === true || standard;
356
+ await this.configFiles(project.config, this.remoteOptions(options, verbose)) : undefined;
357
+ const standard = config !== undefined && config.every(isBundled);
358
+ const embed = options.embed === true || standard || (config?.some(isCached) ?? false);
285
359
  const basedir = project.basedir ?? (part === "spec" ? "." : undefined);
286
360
  const schema = config !== undefined ? this.requireConfig(config, verbose) : undefined;
287
361
  verbose(`describing the "${literal(part)}" part of the SpecBook models and formats ` +
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { Command, CommanderError } from "commander";
10
10
  import chalk from "chalk";
11
- import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, parseVerbosity, verbosityOf, projectFile, omitAspects, version } from "./specbook-api.js";
11
+ import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, parseVerbosity, verbosityOf, projectFile, omitAspects, parseFetchMode, version, renderNodes, queryFormats, parseQueryOutput } from "./specbook-api.js";
12
12
  import { serveMcp } from "./specbook-mcp.js";
13
13
  /* route verbose messages to stderr, keeping stdout reserved for the
14
14
  command outputs and the MCP protocol, and qualify every message with
@@ -67,14 +67,17 @@ const withVerboseOption = (command) => command
67
67
  .option("-v, --verbose [level]", "print verbose processing information to stderr " +
68
68
  "(0: none, 1: notices, 2: also the details like the coverage ratios, " +
69
69
  "3: also the traces like the unreferenced objects; default: 0, bare flag: 1)", envDefault("verbose", "0"));
70
- /* provide the repeatable schema configuration option, whose files or
71
- glob patterns are merged in order (with "std" naming the bundled
72
- standard one), and determine its value, where an absent one is left
73
- to the API, which resolves it through the environment variable
74
- SPECBOOK_CONFIG and the project configuration file */
70
+ /* provide the repeatable schema configuration option, whose files,
71
+ glob patterns, or remote references are merged in order (with "std",
72
+ "meta", and "pm" naming the bundled ones), and determine its value, where
73
+ an absent one is left to the API, which resolves it through the
74
+ environment variable SPECBOOK_CONFIG and the project configuration file */
75
75
  const withConfigOption = (command, fallback) => command
76
- .option("-c, --config <yaml-file>", "YAML schema configuration file or glob pattern " +
77
- "(repeatable, merged in order, \"std\" for the bundled standard schema configuration; " +
76
+ .option("-c, --config <yaml-file>", "YAML schema configuration file, glob pattern, or remote reference " +
77
+ "(\"npm:<pkg>[@<range>][/<path>]\", \"github:<user>/<repo>[/<path>][#<ref>]\", " +
78
+ "or an \"http[s]://...\" URL) " +
79
+ "(repeatable, merged in order, \"std\" for the bundled standard schema configuration, " +
80
+ "\"meta\" for the bundled meta one, \"pm\" for the bundled project management one; " +
78
81
  `default: SPECBOOK_CONFIG, else the "config" entry of "${projectFile}", else ${fallback})`, (value, previous) => previous.concat(value), new Array());
79
82
  const configOf = (opts) => opts.config.length > 0 ? opts.config : undefined;
80
83
  /* provide the base directory option, where an absent one is left to
@@ -83,9 +86,16 @@ const configOf = (opts) => opts.config.length > 0 ? opts.config : undefined;
83
86
  const withBasedirOption = (command, fallback) => command
84
87
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files " +
85
88
  `(default: SPECBOOK_BASEDIR, else the "basedir" entry of "${projectFile}", else ${fallback})`);
89
+ /* provide the fetch option, which selects when the remote references
90
+ are fetched through the network, and determine its value */
91
+ const withFetchOption = (command) => command
92
+ .option("-F, --fetch <mode>", "when to fetch the remote references through the network (\"never\": " +
93
+ "the download cache only, \"auto\": whenever the download cache lacks them or is no longer fresh, " +
94
+ "\"always\": ignoring the download cache; default: auto)", envDefault("fetch", "auto"));
95
+ const fetchOf = (opts) => parseFetchMode(opts.fetch);
86
96
  /* provide the common options of the specification processing sub-commands,
87
97
  for which the YAML schema configuration falls back onto the standard one */
88
- const withCommonOptions = (command) => withBasedirOption(withConfigOption(withVerboseOption(command), "the bundled standard schema configuration"), "\".\"");
98
+ const withCommonOptions = (command) => withFetchOption(withBasedirOption(withConfigOption(withVerboseOption(command), "the bundled standard schema configuration"), "\".\""));
89
99
  /* provide the Git exclude option of the specification *reading*
90
100
  sub-commands (not of "init", which creates artifact files instead of
91
101
  reading them), which treats an artifact file Git excludes from its
@@ -93,6 +103,14 @@ const withCommonOptions = (command) => withBasedirOption(withConfigOption(withVe
93
103
  const withGitignoreOption = (command) => command
94
104
  .option("-g, --gitignore", "skip the artifact files excluded by the Git exclude rules " +
95
105
  "(the \".gitignore\" files, \"info/exclude\", and the global excludes file)", envDefaultFlag("gitignore", false));
106
+ /* provide the schema option of the HTML-rendering sub-commands, which
107
+ exports the effective schema configuration itself (described as a
108
+ specification of the bundled meta schema configuration) instead of
109
+ the specification below the base directory */
110
+ const withSchemaOption = (command) => command
111
+ .option("-S, --schema", "export the schema configuration itself as a SpecBook document " +
112
+ "(described through the bundled meta schema configuration) instead of the specification " +
113
+ "(the base directory is not applicable then)", envDefaultFlag("schema", false));
96
114
  /* provide the repeatable omit option of the HTML-rendering sub-commands,
97
115
  whose aspects are left out of the output instead of being foldable,
98
116
  and determine its value (validated by the API) */
@@ -130,13 +148,17 @@ withVerboseOption(program.command("mcp"))
130
148
  verboseOf(opts)("mcp", "starting MCP server on stdio", "notice");
131
149
  await serveMcp(verboseOf(opts, "mcp"));
132
150
  });
133
- /* the init command creates the configured artifact files and,
134
- unless one already applies, the project configuration file */
151
+ /* the init command creates the configured artifact files (optionally
152
+ with a sample object per allowed object kind) and, unless one already
153
+ applies, the project configuration file */
135
154
  withCommonOptions(program.command("init"))
136
155
  .description("initialize the configured specification artifact files below the base directory")
156
+ .option("-s, --sample", "scaffold a sample object per allowed object kind into the artifact files, " +
157
+ "with placeholder values satisfying the schema configuration", envDefaultFlag("sample", false))
137
158
  .action(async (opts) => {
138
159
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
139
- const result = await specbook.init({ config: configOf(opts), basedir: opts.basedir });
160
+ const result = await specbook.init({ config: configOf(opts), basedir: opts.basedir,
161
+ fetch: fetchOf(opts), sample: opts.sample });
140
162
  await writeStdout((result.files.length > 0 ?
141
163
  `initialized artifact file(s): ${result.files.join(", ")}\n` :
142
164
  "no artifact files were created\n") +
@@ -150,7 +172,7 @@ withGitignoreOption(withCommonOptions(program.command("lint")))
150
172
  const verbose = verboseOf(opts);
151
173
  const specbook = new SpecBook({ verbose });
152
174
  const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir,
153
- gitignore: opts.gitignore });
175
+ fetch: fetchOf(opts), gitignore: opts.gitignore });
154
176
  const detailed = parseVerbosity(opts.verbose) > 0;
155
177
  for (const diagnostic of result.diagnostics)
156
178
  await writeStdout(detailed ?
@@ -162,9 +184,9 @@ withGitignoreOption(withCommonOptions(program.command("lint")))
162
184
  verbose("lint", "specification valid", "notice");
163
185
  });
164
186
  /* the export command parses the input once and writes every output */
165
- withOmitOption(withGitignoreOption(withCommonOptions(program.command("export"))))
187
+ withSchemaOption(withOmitOption(withGitignoreOption(withCommonOptions(program.command("export")))))
166
188
  .description("export the specification Markdown files below the base directory " +
167
- "as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
189
+ "(or the schema configuration itself) as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
168
190
  .option("-w, --watch", "keep the outputs in sync by re-exporting on every source change", envDefaultFlag("watch", false))
169
191
  .option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
170
192
  "from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), new Array())
@@ -195,17 +217,18 @@ withOmitOption(withGitignoreOption(withCommonOptions(program.command("export")))
195
217
  const formats = distinct.map(({ format }) => format);
196
218
  const rebase = distinct.map(rebaseOf);
197
219
  if (opts.watch)
198
- await specbook.watch({ config: configOf(opts), basedir: opts.basedir,
199
- formats, rebase, omit: omitOf(opts), gitignore: opts.gitignore,
220
+ await specbook.watch({ config: configOf(opts), basedir: opts.basedir, fetch: fetchOf(opts),
221
+ formats, rebase, omit: omitOf(opts), gitignore: opts.gitignore, schema: opts.schema,
200
222
  outputs: outputs.map(({ output }) => output), onExport: write });
201
223
  else
202
- await write(await specbook.export({ config: configOf(opts), basedir: opts.basedir,
203
- formats, rebase, omit: omitOf(opts), gitignore: opts.gitignore }));
224
+ await write(await specbook.export({ config: configOf(opts), basedir: opts.basedir, fetch: fetchOf(opts),
225
+ formats, rebase, omit: omitOf(opts), gitignore: opts.gitignore, schema: opts.schema }));
204
226
  });
205
227
  /* the preview command serves the HTML export live in the browser */
206
- withOmitOption(withGitignoreOption(withCommonOptions(program.command("preview"))))
228
+ withSchemaOption(withOmitOption(withGitignoreOption(withCommonOptions(program.command("preview")))))
207
229
  .description("serve the HTML export of the specification Markdown files below the base " +
208
- "directory as a live preview, re-exported and reloaded on every source change")
230
+ "directory (or of the schema configuration itself) as a live preview, re-exported and " +
231
+ "reloaded on every source change")
209
232
  .option("-a, --addr <ip-addr>", "IP address to listen on", envDefault("addr", previewAddr))
210
233
  .option("-p, --port <tcp-port>", "TCP port to listen on", envDefault("port", String(previewPort)))
211
234
  .action(async (opts) => {
@@ -213,17 +236,45 @@ withOmitOption(withGitignoreOption(withCommonOptions(program.command("preview"))
213
236
  if (!Number.isInteger(port) || port < 1 || port > 65535)
214
237
  throw new Error(`invalid TCP port "${opts.port}"`);
215
238
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
216
- await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port,
217
- omit: omitOf(opts), gitignore: opts.gitignore });
239
+ await specbook.preview({ config: configOf(opts), basedir: opts.basedir, fetch: fetchOf(opts),
240
+ addr: opts.addr, port, omit: omitOf(opts), gitignore: opts.gitignore, schema: opts.schema });
241
+ });
242
+ /* the query command loads the specification and writes the
243
+ nodes an ASTq selector matches as JSON, JSON5, YAML, or TOON */
244
+ withSchemaOption(withGitignoreOption(withCommonOptions(program.command("query"))))
245
+ .description("query the specification Markdown files below the base directory " +
246
+ "(or the schema configuration itself) with an ASTq selector, writing the matched nodes " +
247
+ "as JSON, JSON5, YAML, or TOON")
248
+ .argument("<selector>", "the ASTq selector of the nodes to write")
249
+ .option("-P, --param <name>=<value>", "value of a \"{name}\" parameter of the selector (repeatable)", (value, previous) => previous.concat(value), new Array())
250
+ .option("-f, --format <format>", `output format (${queryFormats.join(", ")}; ` +
251
+ "default: inferred from the output file like for \"export\", else json)", envDefault("format"))
252
+ .option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
253
+ .action(async (selector, opts) => {
254
+ const verbose = verboseOf(opts);
255
+ const output = parseQueryOutput(opts.output, opts.format);
256
+ const fallback = envDefault("param");
257
+ const given = opts.param.length > 0 ? opts.param : (fallback !== undefined ? [fallback] : []);
258
+ const params = {};
259
+ for (const param of given) {
260
+ const m = (/^([^=]+)=(.*)$/s).exec(param);
261
+ if (m === null)
262
+ throw new Error(`invalid query parameter "${param}" (expected "<name>=<value>")`);
263
+ params[m[1]] = m[2];
264
+ }
265
+ const specbook = new SpecBook({ verbose });
266
+ const document = await specbook.load({ config: configOf(opts), basedir: opts.basedir,
267
+ fetch: fetchOf(opts), gitignore: opts.gitignore, schema: opts.schema });
268
+ await writeOutput(output.output, renderNodes(document.query(selector, params), output.format), "query", verbose);
218
269
  });
219
270
  /* the describe command also describes the generic SpecBook models and
220
271
  formats alone, so its YAML schema configuration stays optional */
221
- withBasedirOption(withConfigOption(withVerboseOption(program.command("describe")), "the bundled standard schema configuration, embedded"), "none")
272
+ withFetchOption(withBasedirOption(withConfigOption(withVerboseOption(program.command("describe")), "the bundled standard schema configuration, embedded"), "none"))
222
273
  .description("describe the SpecBook models and formats as Markdown")
223
274
  .option("-e, --embed", "embed the given YAML schema configuration instead of just referencing it", envDefaultFlag("embed", false))
224
275
  .option("-z, --compress [level]", "compression level of the emitted YAML schema configuration " +
225
276
  "(0: verbatim, 1: re-emitted with 2-space indentation and without comments, " +
226
- "2: also without \"refs\", \"diagram\", and \"format\" fields, " +
277
+ "2: also without \"refs\", \"badge\", \"multiple\", \"diagram\", and \"format\" fields, " +
227
278
  "3: also without \"desc\" fields; default and bare flag: 1)", envDefault("compress", "1"))
228
279
  .option("-f, --format <format>", `output format (${describeFormats.join(", ")})`, envDefault("format", "md"))
229
280
  .option("-p, --part <part>", `document part (${describeParts.join(", ")})`, envDefault("part", "all"))
@@ -232,10 +283,26 @@ withBasedirOption(withConfigOption(withVerboseOption(program.command("describe")
232
283
  const verbose = verboseOf(opts);
233
284
  const specbook = new SpecBook({ verbose });
234
285
  const text = await specbook.describe({ config: configOf(opts), basedir: opts.basedir,
235
- embed: opts.embed, compress: parseCompressLevel(opts.compress),
286
+ fetch: fetchOf(opts), embed: opts.embed, compress: parseCompressLevel(opts.compress),
236
287
  format: parseDescribeFormat(opts.format), part: parseDescribePart(opts.part) });
237
288
  await writeOutput(opts.output, text, "describe", verbose);
238
289
  });
290
+ /* the search command finds the bundled schema configurations
291
+ and the shareable ones of the npm registry and GitHub */
292
+ withFetchOption(withVerboseOption(program.command("search")))
293
+ .description("search the bundled schema configurations, the npm registry, " +
294
+ "and GitHub for shareable schema configurations")
295
+ .argument("[term...]", "the words a matching schema configuration has to carry")
296
+ .action(async (term, opts) => {
297
+ const specbook = new SpecBook({ verbose: verboseOf(opts) });
298
+ const hits = await specbook.search({ term: term.join(" "), fetch: fetchOf(opts) });
299
+ if (hits.length === 0)
300
+ await writeStdout("no matching schema configurations found\n");
301
+ const width = Math.max(0, ...hits.map((hit) => hit.ref.length));
302
+ for (const hit of hits)
303
+ await writeStdout((`${hit.ref.padEnd(width)} ` +
304
+ `${(hit.version ?? "").padEnd(8)} ${hit.description ?? ""}`).trimEnd() + "\n");
305
+ });
239
306
  /* run the command line program */
240
307
  try {
241
308
  await program.parseAsync();
@@ -21,9 +21,9 @@ export const parseDescribeFormat = (value) => parseChoice(describeFormats, "form
21
21
  export const parseDescribePart = (value) => parseChoice(describeParts, "part", value);
22
22
  /* the supported compression levels of the emitted YAML schema
23
23
  configuration: verbatim (0), re-emitted with 2-space indentation and
24
- without comments (1), additionally without its "refs", "diagram", and
25
- "format" fields (2), and additionally without its "desc" fields of
26
- objects and properties (3) */
24
+ without comments (1), additionally without its "refs", "badge",
25
+ "multiple", "diagram", and "format" fields (2), and additionally
26
+ without its "desc" fields of objects and properties (3) */
27
27
  export const compressLevels = [0, 1, 2, 3];
28
28
  /* parse and validate a compression level specification,
29
29
  where a bare flag (or a boolean word) selects the plain re-emitting */
@@ -48,7 +48,7 @@ const compressYaml = (yaml, level) => {
48
48
  if (level === 0)
49
49
  return yaml;
50
50
  const doc = parseDocument(yaml);
51
- const drop = [["refs", "diagram", "format"], ["desc"]].slice(0, level - 1).flat();
51
+ const drop = [["refs", "badge", "multiple", "diagram", "format"], ["desc"]].slice(0, level - 1).flat();
52
52
  doc.commentBefore = null;
53
53
  doc.comment = null;
54
54
  visit(doc, {
@@ -0,0 +1,8 @@
1
+ import { type Verbose } from "./specbook-verbose.js";
2
+ import { type LintResult } from "./specbook-cmd-lint.js";
3
+ export interface SchemaLintOptions {
4
+ config: string[];
5
+ labels: Map<string, string>;
6
+ verbose: Verbose;
7
+ }
8
+ export declare const lintSchema: (options: SchemaLintOptions) => LintResult;