@rse/specbook 0.9.5 → 0.9.6
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/README.md +58 -328
- package/dst/specbook-api.d.ts +13 -20
- package/dst/specbook-api.js +28 -25
- package/dst/specbook-cli.js +34 -50
- package/dst/specbook-cmd-describe.d.ts +5 -2
- package/dst/specbook-cmd-describe.js +32 -91
- package/dst/specbook-cmd-export.d.ts +3 -3
- package/dst/specbook-cmd-export.js +4 -3
- package/dst/specbook-cmd-init.d.ts +2 -2
- package/dst/specbook-cmd-init.js +35 -13
- package/dst/specbook-cmd-lint.d.ts +6 -6
- package/dst/specbook-cmd-lint.js +36 -23
- package/dst/specbook-config.d.ts +2 -2
- package/dst/specbook-config.js +2 -2
- package/dst/specbook-diagram.d.ts +4 -4
- package/dst/specbook-diagram.js +21 -15
- package/dst/specbook-export-ast.d.ts +3 -3
- package/dst/specbook-export-common.d.ts +10 -9
- package/dst/specbook-export-common.js +23 -9
- package/dst/specbook-export-html.css +13 -3
- package/dst/specbook-export-html.d.ts +4 -4
- package/dst/specbook-export-html.js +61 -32
- package/dst/specbook-export-logo-dark.svg +35 -0
- package/dst/specbook-export-logo-light.svg +35 -0
- package/dst/specbook-export-md.d.ts +3 -3
- package/dst/{specbook-struct-schema.d.ts → specbook-format-schema.d.ts} +3 -2
- package/dst/{specbook-struct-schema.js → specbook-format-schema.js} +2 -1
- package/dst/specbook-format-spec.d.ts +31 -0
- package/dst/{specbook-struct-spec.js → specbook-format-spec.js} +10 -10
- package/dst/specbook-format.md +734 -0
- package/dst/specbook-link.d.ts +2 -2
- package/dst/specbook-mcp.d.ts +2 -1
- package/dst/specbook-mcp.js +13 -52
- package/dst/specbook-parse-common.d.ts +5 -3
- package/dst/specbook-parse-common.js +8 -0
- package/dst/specbook-parse-semantic.d.ts +4 -4
- package/dst/specbook-parse-semantic.js +4 -65
- package/dst/specbook-parse-syntax.d.ts +2 -2
- package/dst/specbook-parse-syntax.js +18 -14
- package/dst/specbook-parse.d.ts +4 -4
- package/dst/specbook-parse.js +3 -3
- package/dst/specbook-verbose.d.ts +2 -0
- package/dst/specbook-verbose.js +16 -0
- package/package.json +3 -7
- package/dst/specbook-cmd-edit.d.ts +0 -9
- package/dst/specbook-cmd-edit.js +0 -74
- package/dst/specbook-cmd-import.d.ts +0 -9
- package/dst/specbook-cmd-import.js +0 -78
- package/dst/specbook-export-logo.svg +0 -35
- package/dst/specbook-llm.d.ts +0 -13
- package/dst/specbook-llm.js +0 -51
- package/dst/specbook-struct-spec.d.ts +0 -31
package/dst/specbook-cli.js
CHANGED
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import { Command } from "commander";
|
|
9
|
-
import
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, version } from "./specbook-api.js";
|
|
10
11
|
import { serveMcp } from "./specbook-mcp.js";
|
|
11
|
-
/* route verbose messages to stderr, keeping stdout reserved
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if
|
|
15
|
-
|
|
12
|
+
/* route verbose messages to stderr, keeping stdout reserved for the
|
|
13
|
+
command outputs and the MCP protocol, and qualify every message with
|
|
14
|
+
the tool name plus the scope path of the emitting command, where
|
|
15
|
+
Chalk styles the output only if the terminal supports colors */
|
|
16
|
+
const verboseOf = (opts, ...scope) => (cmd, msg) => {
|
|
17
|
+
if (opts.verbose) {
|
|
18
|
+
const scopePath = [...scope, cmd].map((segment) => chalk.bold(segment)).join(": ");
|
|
19
|
+
process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
|
|
20
|
+
}
|
|
16
21
|
};
|
|
17
22
|
/* write a buffer to stdout, awaiting the write callback which only
|
|
18
23
|
fires once the data has been flushed to the underlying pipe, so a
|
|
@@ -28,12 +33,12 @@ const writeStdout = (data) => {
|
|
|
28
33
|
});
|
|
29
34
|
};
|
|
30
35
|
/* write a command result to the output file or stdout */
|
|
31
|
-
const writeOutput = async (output, data, verbose) => {
|
|
36
|
+
const writeOutput = async (output, data, cmd, verbose) => {
|
|
32
37
|
if (output === "-")
|
|
33
38
|
await writeStdout(data);
|
|
34
39
|
else {
|
|
35
40
|
await fs.promises.writeFile(output, data);
|
|
36
|
-
verbose(`wrote "${output}" (${Buffer.byteLength(data)} bytes)`);
|
|
41
|
+
verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`);
|
|
37
42
|
}
|
|
38
43
|
};
|
|
39
44
|
/* determine an option default value, overridable via a
|
|
@@ -45,10 +50,13 @@ const envDefaultFlag = (name, fallback) => {
|
|
|
45
50
|
const value = process.env[`SPECBOOK_${name.toUpperCase().replace(/-/g, "_")}`];
|
|
46
51
|
return value !== undefined ? !(/^(?:|0|false|no|off)$/i).test(value) : fallback;
|
|
47
52
|
};
|
|
48
|
-
/* provide the
|
|
49
|
-
const
|
|
50
|
-
.option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
|
|
51
|
-
|
|
53
|
+
/* provide the verbose option of all sub-commands */
|
|
54
|
+
const withVerboseOption = (command) => command
|
|
55
|
+
.option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false));
|
|
56
|
+
/* provide the common options of the specification processing sub-commands,
|
|
57
|
+
for which the YAML schema configuration is mandatory */
|
|
58
|
+
const withCommonOptions = (command) => withVerboseOption(command)
|
|
59
|
+
.requiredOption("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
|
|
52
60
|
/* parse the command line */
|
|
53
61
|
const program = new Command();
|
|
54
62
|
program.name("specbook")
|
|
@@ -57,12 +65,11 @@ program.name("specbook")
|
|
|
57
65
|
.action(() => {
|
|
58
66
|
program.help();
|
|
59
67
|
});
|
|
60
|
-
program.command("mcp")
|
|
68
|
+
withVerboseOption(program.command("mcp"))
|
|
61
69
|
.description("run as MCP stdio server")
|
|
62
|
-
.option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
|
|
63
70
|
.action(async (opts) => {
|
|
64
|
-
verboseOf(opts)("starting MCP server on stdio");
|
|
65
|
-
await serveMcp(verboseOf(opts));
|
|
71
|
+
verboseOf(opts)("mcp", "starting MCP server on stdio");
|
|
72
|
+
await serveMcp(verboseOf(opts, "mcp"));
|
|
66
73
|
});
|
|
67
74
|
withCommonOptions(program.command("init"))
|
|
68
75
|
.description("initialize the configured specification artifact files below the base directory")
|
|
@@ -87,7 +94,7 @@ withCommonOptions(program.command("lint"))
|
|
|
87
94
|
if (result.diagnostics.length > 0)
|
|
88
95
|
process.exitCode = 1;
|
|
89
96
|
else
|
|
90
|
-
verboseOf(opts)("specification valid");
|
|
97
|
+
verboseOf(opts)("lint", "specification valid");
|
|
91
98
|
});
|
|
92
99
|
withCommonOptions(program.command("export"))
|
|
93
100
|
.description("export the specification Markdown files below the base directory " +
|
|
@@ -104,43 +111,20 @@ withCommonOptions(program.command("export"))
|
|
|
104
111
|
const buffers = await specbook.export({ config: opts.config, basedir: opts.basedir,
|
|
105
112
|
formats: distinct });
|
|
106
113
|
for (const { format, output } of outputs)
|
|
107
|
-
await writeOutput(output, buffers[distinct.indexOf(format)], verboseOf(opts));
|
|
114
|
+
await writeOutput(output, buffers[distinct.indexOf(format)], "export", verboseOf(opts));
|
|
108
115
|
});
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
/* the describe command also describes the generic SpecBook models and
|
|
117
|
+
formats alone, so its YAML schema configuration stays optional */
|
|
118
|
+
withVerboseOption(program.command("describe"))
|
|
119
|
+
.description("describe the SpecBook models and formats as Markdown")
|
|
120
|
+
.option("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"))
|
|
121
|
+
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir"))
|
|
122
|
+
.option("-e, --embed", "embed the YAML schema configuration instead of just referencing it", envDefaultFlag("embed", false))
|
|
111
123
|
.option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
|
|
112
124
|
.action(async (opts) => {
|
|
113
125
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
114
|
-
const text = await specbook.describe({ config: opts.config });
|
|
115
|
-
await writeOutput(opts.output, text, verboseOf(opts));
|
|
116
|
-
});
|
|
117
|
-
withCommonOptions(program.command("import"))
|
|
118
|
-
.description("import foreign sources into the specification artifact files below the base directory")
|
|
119
|
-
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
120
|
-
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
121
|
-
.option("--model <model>", "AI model", envDefault("model"))
|
|
122
|
-
.argument("<input-files...>", "foreign source files to import")
|
|
123
|
-
.action(async (inputs, opts) => {
|
|
124
|
-
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
125
|
-
const written = await specbook.import({ config: opts.config, basedir: opts.basedir,
|
|
126
|
-
inputs, provider: opts.provider, model: opts.model });
|
|
127
|
-
await writeStdout(written.length > 0 ?
|
|
128
|
-
`imported into artifact file(s): ${written.join(", ")}\n` :
|
|
129
|
-
"no artifact files were changed\n");
|
|
130
|
-
});
|
|
131
|
-
withCommonOptions(program.command("edit"))
|
|
132
|
-
.description("apply a free-text edit request to the specification artifact files below the base directory")
|
|
133
|
-
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
134
|
-
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
135
|
-
.option("--model <model>", "AI model", envDefault("model"))
|
|
136
|
-
.argument("<query>", "free-text edit request")
|
|
137
|
-
.action(async (query, opts) => {
|
|
138
|
-
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
139
|
-
const written = await specbook.edit({ config: opts.config, basedir: opts.basedir,
|
|
140
|
-
query, provider: opts.provider, model: opts.model });
|
|
141
|
-
await writeStdout(written.length > 0 ?
|
|
142
|
-
`edited artifact file(s): ${written.join(", ")}\n` :
|
|
143
|
-
"no artifact files were changed\n");
|
|
126
|
+
const text = await specbook.describe({ config: opts.config, basedir: opts.basedir, embed: opts.embed });
|
|
127
|
+
await writeOutput(opts.output, text, "describe", verboseOf(opts));
|
|
144
128
|
});
|
|
145
129
|
try {
|
|
146
130
|
await program.parseAsync();
|
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export declare const describeFormat: (options: {
|
|
2
|
+
config?: string;
|
|
3
|
+
basedir?: string;
|
|
4
|
+
embed?: boolean;
|
|
5
|
+
}) => string;
|
|
@@ -3,97 +3,38 @@
|
|
|
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
|
+
import * as path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
6
9
|
import textframe from "textframe";
|
|
7
|
-
/*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
## Concise Format (used on level 4 and deeper)
|
|
36
|
-
|
|
37
|
-
\`\`\`
|
|
38
|
-
- <kind>: <name>; <key>: <value>; [...]; <statement>, BECAUSE <rationale>.
|
|
39
|
-
\`\`\`
|
|
40
|
-
|
|
41
|
-
Child objects nest as indented list items below their parent item.
|
|
42
|
-
|
|
43
|
-
## Primary Marker
|
|
44
|
-
|
|
45
|
-
In both formats a literal \`(*)\` directly after the \`<name>\` marks the
|
|
46
|
-
object as *primary* (e.g. the primary attribute of an entity). It is
|
|
47
|
-
distinct from an id given as \`(<id>)\` and both may be combined.
|
|
48
|
-
|
|
49
|
-
## Configured Artifacts
|
|
50
|
-
|
|
51
|
-
The following artifacts, object kinds, and properties are configured.
|
|
52
|
-
Object kinds and property names are *case-sensitive* and must be
|
|
53
|
-
written exactly as configured. Properties marked \`required\` must be
|
|
54
|
-
present, and property values must match their given constraint (if
|
|
55
|
-
any, else any value is allowed): \`/xxx/\` requires a match of the
|
|
56
|
-
regular expression, \`[[xxx]]\` requires exactly one Wiki-style link
|
|
57
|
-
reference resolving into the (usually wildcard) reference pattern,
|
|
58
|
-
\`enum(xxx,yyy)\` requires one of the listed members, \`tags(xxx,yyy)\`
|
|
59
|
-
requires a comma-separated set of the listed members (each at most
|
|
60
|
-
once), and \`list(xxx[, ...])\` requires a comma-separated list of
|
|
61
|
-
items, each matching one of the alternative constraints.
|
|
62
|
-
`;
|
|
63
|
-
/* describe a single configured property */
|
|
64
|
-
const describeProperty = (prop, indent) => {
|
|
65
|
-
let line = `${indent}- property \`${prop.name}\` (${prop.optional === true ? "optional" : "required"}`;
|
|
66
|
-
if (prop.value !== undefined)
|
|
67
|
-
line += `, constraint \`${prop.value}\``;
|
|
68
|
-
line += `): ${collapse(prop.desc)}`;
|
|
69
|
-
return line;
|
|
70
|
-
};
|
|
71
|
-
/* describe a single configured object kind (and recursively its childs) */
|
|
72
|
-
const describeObject = (object, level, indent) => {
|
|
73
|
-
const lines = new Array();
|
|
74
|
-
lines.push(`${indent}- object kind \`${object.kind}\` on level ${level}` +
|
|
75
|
-
`${object.optional === true ? " (optional)" : ""}: ${collapse(object.desc)}`);
|
|
76
|
-
for (const prop of object.props ?? [])
|
|
77
|
-
lines.push(describeProperty(prop, `${indent} `));
|
|
78
|
-
for (const child of object.childs ?? [])
|
|
79
|
-
lines.push(describeObject(child, level + 1, `${indent} `));
|
|
80
|
-
return lines.join("\n");
|
|
81
|
-
};
|
|
82
|
-
/* describe the configured specification format as Markdown */
|
|
83
|
-
export const describeConfiguration = (config) => {
|
|
84
|
-
const sections = new Array();
|
|
85
|
-
for (const artifact of config) {
|
|
86
|
-
const lines = new Array();
|
|
87
|
-
lines.push(`### ${artifact.kind}: ${artifact.name ?? ""} (${artifact.kind}-${artifact.id ?? ""})`);
|
|
88
|
-
lines.push("");
|
|
89
|
-
if (artifact.file !== undefined)
|
|
90
|
-
lines.push(`- file: \`${artifact.file}\``);
|
|
91
|
-
lines.push(`- description: ${collapse(artifact.desc)}`);
|
|
92
|
-
for (const prop of artifact.props ?? [])
|
|
93
|
-
lines.push(describeProperty(prop, ""));
|
|
94
|
-
for (const child of artifact.childs ?? [])
|
|
95
|
-
lines.push(describeObject(child, 2, ""));
|
|
96
|
-
sections.push(lines.join("\n"));
|
|
10
|
+
/* provide the build-time bundled description of the generic
|
|
11
|
+
SpecBook models and formats */
|
|
12
|
+
const description = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-format.md"), "utf8");
|
|
13
|
+
/* describe the generic SpecBook models and formats as Markdown,
|
|
14
|
+
optionally extended by the references to (or the embeddings of) the
|
|
15
|
+
artifacts of the particular project instantiation */
|
|
16
|
+
export const describeFormat = (options) => {
|
|
17
|
+
const sections = [description()];
|
|
18
|
+
if (options.config !== undefined || options.basedir !== undefined) {
|
|
19
|
+
sections.push("SpecBook Project Instantiation\n" +
|
|
20
|
+
"==============================\n");
|
|
21
|
+
if (options.config !== undefined && options.embed === true)
|
|
22
|
+
sections.push(textframe(`
|
|
23
|
+
The **SpecBook SCHEMA Model** of this particular project is the following
|
|
24
|
+
YAML schema configuration of the file \`${options.config}\`:
|
|
25
|
+
`) + "\n```yaml\n" +
|
|
26
|
+
fs.readFileSync(options.config, "utf8").replace(/\n*$/, "\n") +
|
|
27
|
+
"```\n");
|
|
28
|
+
else if (options.config !== undefined)
|
|
29
|
+
sections.push(textframe(`
|
|
30
|
+
The **SpecBook SCHEMA Model** of this particular project is the YAML
|
|
31
|
+
schema configuration in the file \`${options.config}\`.
|
|
32
|
+
`));
|
|
33
|
+
if (options.basedir !== undefined)
|
|
34
|
+
sections.push(textframe(`
|
|
35
|
+
The **SpecBook SPEC Model** of this particular project is the set of
|
|
36
|
+
specification Markdown files in the directory \`${options.basedir}\`.
|
|
37
|
+
`));
|
|
97
38
|
}
|
|
98
|
-
return
|
|
39
|
+
return sections.join("\n");
|
|
99
40
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Spec } from "./specbook-format-spec.js";
|
|
2
|
+
import type { Schema } from "./specbook-format-schema.js";
|
|
3
3
|
export declare const formats: readonly ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
|
|
4
4
|
export type ExportFormat = typeof formats[number];
|
|
5
5
|
export declare const parseOutputSpec: (spec: string) => {
|
|
6
6
|
format: ExportFormat;
|
|
7
7
|
output: string;
|
|
8
8
|
};
|
|
9
|
-
export declare const exportSpecification: (specification:
|
|
9
|
+
export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: (msg: string) => void, config?: Schema) => Promise<Buffer>;
|
|
@@ -10,6 +10,7 @@ import { renderAst } from "./specbook-export-ast.js";
|
|
|
10
10
|
import { renderMarkdown } from "./specbook-export-md.js";
|
|
11
11
|
import { renderHtml, htmlOutline } from "./specbook-export-html.js";
|
|
12
12
|
import { htmlToPdf } from "./specbook-export-pdf.js";
|
|
13
|
+
import { literal } from "./specbook-verbose.js";
|
|
13
14
|
/* the supported export formats */
|
|
14
15
|
export const formats = ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
|
|
15
16
|
/* the filename extensions mapping onto the export formats */
|
|
@@ -42,7 +43,7 @@ export const parseOutputSpec = (spec) => {
|
|
|
42
43
|
export const exportSpecification = async (specification, format, verbose, config) => {
|
|
43
44
|
if (!formats.includes(format))
|
|
44
45
|
throw new Error(`unknown export format "${format}"`);
|
|
45
|
-
verbose(`exporting specification as "${format}"`);
|
|
46
|
+
verbose(`exporting specification as "${literal(format)}"`);
|
|
46
47
|
if (format === "json" || format === "json5" || format === "yaml" || format === "toon")
|
|
47
48
|
return renderAst(specification, format, config);
|
|
48
49
|
else if (format === "md")
|
|
@@ -51,11 +52,11 @@ export const exportSpecification = async (specification, format, verbose, config
|
|
|
51
52
|
fonts subsetted to the CHARSET of the specification (if any) */
|
|
52
53
|
const charset = documentCharset(specification);
|
|
53
54
|
if (charset !== undefined)
|
|
54
|
-
verbose(`subsetting embedded fonts to charset "${charset}"`);
|
|
55
|
+
verbose(`subsetting embedded fonts to charset "${literal(charset)}"`);
|
|
55
56
|
/* the theme tone (THEME-TONE) drives the layer-1 color spread
|
|
56
57
|
variables and the PDF decoration colors */
|
|
57
58
|
const tone = documentThemeTone(specification) ?? "#336699";
|
|
58
|
-
verbose(`generating theme color spreads (tone "${tone}")`);
|
|
59
|
+
verbose(`generating theme color spreads (tone "${literal(tone)}")`);
|
|
59
60
|
const colors = themeColors(tone);
|
|
60
61
|
/* the paper size (PAPER-SIZE) drives the PDF page setup and the
|
|
61
62
|
print-time height constraint of the diagrams */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Schema } from "./specbook-format-schema.js";
|
|
2
2
|
export interface InitOptions {
|
|
3
|
-
config:
|
|
3
|
+
config: Schema;
|
|
4
4
|
basedir?: string;
|
|
5
5
|
verbose: (msg: string) => void;
|
|
6
6
|
}
|
package/dst/specbook-cmd-init.js
CHANGED
|
@@ -5,35 +5,57 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import {
|
|
8
|
+
import { literal } from "./specbook-verbose.js";
|
|
9
|
+
/* the current time in the frontmatter timestamp format */
|
|
10
|
+
const timestamp = () => {
|
|
11
|
+
const d = new Date();
|
|
12
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
13
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
|
14
|
+
`${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
15
|
+
};
|
|
9
16
|
/* initialize the configured specification artifact files below the
|
|
10
|
-
base directory with their frontmatter and artifact
|
|
17
|
+
base directory with their frontmatter and artifact headings, where
|
|
18
|
+
all artifacts configured onto the same file reside in it side by
|
|
19
|
+
side, following each other on level 1 */
|
|
11
20
|
export const initSpecification = (options) => {
|
|
12
21
|
const basedir = options.basedir ?? ".";
|
|
13
22
|
fs.mkdirSync(basedir, { recursive: true });
|
|
14
23
|
const now = timestamp();
|
|
15
24
|
const created = new Array();
|
|
25
|
+
/* group the configured artifacts by their file, preserving the
|
|
26
|
+
schema order both of the files and of the artifacts within */
|
|
27
|
+
const groups = new Map();
|
|
16
28
|
for (const artifact of options.config) {
|
|
17
29
|
if (artifact.file === undefined)
|
|
18
30
|
continue;
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
|
|
31
|
+
const group = groups.get(artifact.file);
|
|
32
|
+
if (group === undefined)
|
|
33
|
+
groups.set(artifact.file, [artifact]);
|
|
34
|
+
else
|
|
35
|
+
group.push(artifact);
|
|
36
|
+
}
|
|
37
|
+
for (const [file, artifacts] of groups) {
|
|
38
|
+
const target = path.join(basedir, file);
|
|
39
|
+
if (fs.existsSync(target)) {
|
|
40
|
+
options.verbose(`skipping existing artifact file "${literal(file)}"`);
|
|
22
41
|
continue;
|
|
23
42
|
}
|
|
24
|
-
const
|
|
25
|
-
|
|
43
|
+
const headings = artifacts.map((artifact) => {
|
|
44
|
+
const name = artifact.name ?? "";
|
|
45
|
+
const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
|
|
46
|
+
return `# ${artifact.kind}: ${name}${paren}\n` + "\n";
|
|
47
|
+
}).join("");
|
|
26
48
|
const text = "---\n" +
|
|
27
49
|
`Created: ${now}\n` +
|
|
28
50
|
`Modified: ${now}\n` +
|
|
29
51
|
"---\n" +
|
|
30
52
|
"\n" +
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
fs.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
created.push(
|
|
53
|
+
headings;
|
|
54
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
55
|
+
fs.writeFileSync(target, text, "utf8");
|
|
56
|
+
options.verbose(`created artifact file "${literal(file)}" ` +
|
|
57
|
+
`with ${literal(artifacts.length)} artifact(s)`);
|
|
58
|
+
created.push(file);
|
|
37
59
|
}
|
|
38
60
|
return created;
|
|
39
61
|
};
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { type Diagnostic } from "./specbook-diagnostic.js";
|
|
2
|
-
import { type
|
|
3
|
-
import { type
|
|
2
|
+
import { type Spec } from "./specbook-format-spec.js";
|
|
3
|
+
import { type Schema } from "./specbook-format-schema.js";
|
|
4
4
|
export interface LintOptions {
|
|
5
|
-
config
|
|
5
|
+
config: string;
|
|
6
6
|
basedir: string;
|
|
7
7
|
verbose: (msg: string) => void;
|
|
8
8
|
}
|
|
9
9
|
export interface LintResult {
|
|
10
|
-
specification:
|
|
10
|
+
specification: Spec;
|
|
11
11
|
diagnostics: Diagnostic[];
|
|
12
|
-
config?:
|
|
12
|
+
config?: Schema;
|
|
13
13
|
}
|
|
14
|
-
export declare const
|
|
14
|
+
export declare const schemaFiles: (config: Schema) => Map<string, boolean>;
|
|
15
15
|
export declare const lint: (options: LintOptions) => LintResult;
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -6,34 +6,47 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { loadConfig } from "./specbook-config.js";
|
|
9
|
+
import { literal } from "./specbook-verbose.js";
|
|
9
10
|
import { parseSpecification } from "./specbook-parse.js";
|
|
10
|
-
/*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/* collect the distinct artifact files the schema configuration
|
|
12
|
+
references, in their declaration order, each mapped onto whether
|
|
13
|
+
all of its artifacts are optional and hence the file may be absent */
|
|
14
|
+
export const schemaFiles = (config) => {
|
|
15
|
+
const files = new Map();
|
|
16
|
+
for (const artifact of config) {
|
|
17
|
+
if (artifact.file === undefined)
|
|
18
|
+
continue;
|
|
19
|
+
files.set(artifact.file, (files.get(artifact.file) ?? true) && artifact.optional === true);
|
|
20
|
+
}
|
|
21
|
+
return files;
|
|
22
|
+
};
|
|
15
23
|
/* lint the specification Markdown files below the base directory
|
|
16
|
-
against the
|
|
24
|
+
against the configuration */
|
|
17
25
|
export const lint = (options) => {
|
|
18
26
|
const diagnostics = new Array();
|
|
19
|
-
/* load the
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
message: exists ? "no Markdown files found below base directory" :
|
|
34
|
-
"base directory does not exist" });
|
|
27
|
+
/* load the mandatory YAML schema configuration */
|
|
28
|
+
options.verbose(`loading configuration "${literal(options.config)}"`);
|
|
29
|
+
const loaded = loadConfig(options.config);
|
|
30
|
+
diagnostics.push(...loaded.diagnostics);
|
|
31
|
+
const config = loaded.config ?? undefined;
|
|
32
|
+
/* read the artifact files the configuration references, resolved
|
|
33
|
+
against the base directory and tolerating an absent file if all
|
|
34
|
+
its artifacts are optional */
|
|
35
|
+
const files = config !== undefined ? schemaFiles(config) : new Map();
|
|
36
|
+
options.verbose(`parsing ${literal(files.size)} specification file(s) ` +
|
|
37
|
+
`below "${literal(options.basedir)}"`);
|
|
38
|
+
if (config !== undefined && files.size === 0)
|
|
39
|
+
diagnostics.push({ file: options.config, line: 1, column: 1,
|
|
40
|
+
message: "no artifact files configured" });
|
|
35
41
|
const sources = new Array();
|
|
36
|
-
for (const
|
|
42
|
+
for (const [name, optional] of files) {
|
|
43
|
+
const file = path.join(options.basedir, name);
|
|
44
|
+
if (!fs.existsSync(file)) {
|
|
45
|
+
if (!optional)
|
|
46
|
+
diagnostics.push({ file, line: 1, column: 1,
|
|
47
|
+
message: "missing artifact file" });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
37
50
|
try {
|
|
38
51
|
sources.push({ file, text: fs.readFileSync(file, "utf8") });
|
|
39
52
|
}
|
package/dst/specbook-config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Schema } from "./specbook-format-schema.js";
|
|
2
2
|
import { type Diagnostic } from "./specbook-diagnostic.js";
|
|
3
3
|
export declare const loadConfig: (file: string) => {
|
|
4
|
-
config:
|
|
4
|
+
config: Schema | null;
|
|
5
5
|
diagnostics: Diagnostic[];
|
|
6
6
|
};
|
package/dst/specbook-config.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import { parse as parseYaml, parseDocument, YAMLParseError } from "yaml";
|
|
8
8
|
import * as v from "valibot";
|
|
9
|
-
import {
|
|
9
|
+
import { Schema } from "./specbook-format-schema.js";
|
|
10
10
|
import { compileValueExpr } from "./specbook-parse-value.js";
|
|
11
11
|
/* determine line/column of a YAML document path via the CST */
|
|
12
12
|
const lineColOfPath = (yaml, cst, path) => {
|
|
@@ -58,7 +58,7 @@ export const loadConfig = (file) => {
|
|
|
58
58
|
cst ??= parseDocument(yaml);
|
|
59
59
|
return lineColOfPath(yaml, cst, path);
|
|
60
60
|
};
|
|
61
|
-
const result = v.safeParse(
|
|
61
|
+
const result = v.safeParse(Schema, obj);
|
|
62
62
|
if (!result.success) {
|
|
63
63
|
for (const issue of result.issues) {
|
|
64
64
|
const path = (issue.path ?? []).map((item) => item.key);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Spec, SpecObject } from "./specbook-format-spec.js";
|
|
2
|
+
import type { Schema } from "./specbook-format-schema.js";
|
|
3
3
|
import { type ParseContext } from "./specbook-parse-common.js";
|
|
4
4
|
export interface DiagramResult {
|
|
5
5
|
spec?: string;
|
|
6
6
|
errors: string[];
|
|
7
7
|
}
|
|
8
|
-
export declare const specDiagrams: (specification:
|
|
9
|
-
export declare const validateDiagrams: (ctx: ParseContext, specification:
|
|
8
|
+
export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
|
|
9
|
+
export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -10,9 +10,6 @@ const referenceOnce = /\[\[([^[\]]+)\]\]/;
|
|
|
10
10
|
/* strip the inline code markup of a name or property value
|
|
11
11
|
(preserved in the AST for rendering) for labeling purposes */
|
|
12
12
|
const plainText = (text) => text.replace(/`/g, "");
|
|
13
|
-
/* strip a trailing parenthesized annotation from a
|
|
14
|
-
property key (e.g. "WHEN (Context)") */
|
|
15
|
-
const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
16
13
|
/* render a text as a Gradia atom: a bareword where possible
|
|
17
14
|
(no whitespace, no special characters, no "--"), a quoted
|
|
18
15
|
string otherwise */
|
|
@@ -58,7 +55,7 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
|
|
|
58
55
|
the node set can mix objects of different kinds), with every
|
|
59
56
|
"[[...]]" reference stripped to its target object name */
|
|
60
57
|
for (const key of diagram.properties ?? []) {
|
|
61
|
-
const value = node.properties.find((property) =>
|
|
58
|
+
const value = node.properties.find((property) => property.key === key)?.value;
|
|
62
59
|
if (value !== undefined) {
|
|
63
60
|
const text = value.replace(referenceRegex, (_, reference) => {
|
|
64
61
|
const target = resolveUnique(index, reference.trim()).target;
|
|
@@ -128,8 +125,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
128
125
|
objects (per the "links" selection) and from the edge objects by
|
|
129
126
|
convention (source: parent object, target: first reference in the
|
|
130
127
|
property values, name: object name, arity: "ARITY" property),
|
|
131
|
-
overridable via "edgeTarget"/"edgeArity" (a "grid"
|
|
132
|
-
edge-less by definition, so no edges are derived
|
|
128
|
+
overridable via "edgeSource"/"edgeTarget"/"edgeArity" (a "grid"
|
|
129
|
+
diagram is edge-less by definition, so no edges are derived at all) */
|
|
133
130
|
let edges = new Array();
|
|
134
131
|
if (type !== "grid") {
|
|
135
132
|
for (const node of nodes) {
|
|
@@ -146,16 +143,25 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
146
143
|
edges.push({ source: node, target });
|
|
147
144
|
}
|
|
148
145
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
/* resolve the node an edge object references through a named
|
|
147
|
+
property, or through its first single-reference property when
|
|
148
|
+
no name is configured (skipping the source property) */
|
|
149
|
+
const edgeNode = (edgeObject, key) => {
|
|
150
|
+
const value = key !== undefined ?
|
|
151
|
+
edgeObject.properties.find((property) => property.key === key)?.value :
|
|
152
|
+
edgeObject.properties.filter((property) => property.key !== diagram.edgeSource)
|
|
153
|
+
.map((property) => property.value).find((v) => referenceOnce.test(v));
|
|
155
154
|
const reference = value?.match(referenceOnce)?.[1].trim();
|
|
156
|
-
|
|
155
|
+
return reference !== undefined ? resolveUnique(index, reference).target : undefined;
|
|
156
|
+
};
|
|
157
|
+
for (const edgeObject of edgeObjects) {
|
|
158
|
+
const source = diagram.edgeSource !== undefined ?
|
|
159
|
+
edgeNode(edgeObject, diagram.edgeSource) : parents.get(edgeObject);
|
|
160
|
+
const target = edgeNode(edgeObject, diagram.edgeTarget);
|
|
157
161
|
if (source === undefined) {
|
|
158
|
-
errors.push(
|
|
162
|
+
errors.push(diagram.edgeSource !== undefined ?
|
|
163
|
+
`diagram edge object "${edgeObject.name}" carries no resolvable source reference` :
|
|
164
|
+
`diagram edge object "${edgeObject.name}" has no parent object as source`);
|
|
159
165
|
continue;
|
|
160
166
|
}
|
|
161
167
|
if (target === undefined) {
|
|
@@ -164,7 +170,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
164
170
|
}
|
|
165
171
|
if (!nodeSet.has(source) || !nodeSet.has(target))
|
|
166
172
|
continue;
|
|
167
|
-
const arity = edgeObject.properties.find((property) =>
|
|
173
|
+
const arity = edgeObject.properties.find((property) => property.key === (diagram.edgeArity ?? "ARITY"))?.value;
|
|
168
174
|
edges.push({ source, target, name: plainText(edgeObject.name),
|
|
169
175
|
arity: arity !== undefined ? plainText(arity) : undefined });
|
|
170
176
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Spec } from "./specbook-format-spec.js";
|
|
2
|
+
import type { Schema } from "./specbook-format-schema.js";
|
|
3
3
|
export type AstFormat = "json" | "json5" | "yaml" | "toon";
|
|
4
|
-
export declare const renderAst: (specification:
|
|
4
|
+
export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema) => Promise<Buffer>;
|