@rse/specbook 1.2.8 → 1.2.10
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 +1 -0
- package/dst/specbook-api.d.ts +4 -0
- package/dst/specbook-api.js +9 -5
- package/dst/specbook-cli.js +17 -7
- package/dst/specbook-cmd-lint.d.ts +1 -0
- package/dst/specbook-cmd-lint.js +13 -0
- package/dst/specbook-export-html-search.js +17 -0
- package/dst/specbook-export-html.css +82 -0
- package/dst/specbook-export-html.js +106 -0
- package/dst/specbook-format.md +1 -0
- package/dst/specbook-format.yaml +28 -1
- package/dst/specbook-ignore.d.ts +2 -0
- package/dst/specbook-ignore.js +173 -0
- package/dst/specbook-mcp.js +9 -3
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -86,6 +86,7 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
|
|
|
86
86
|
reference coverage tables (e.g. the share of the use cases, scenarios,
|
|
87
87
|
requirements, and rules the test cases verify), a
|
|
88
88
|
scroll progress meter (which also scrolls back to the top on click),
|
|
89
|
+
diagram maximization (into the viewport or the fullscreen),
|
|
89
90
|
description popups (which show the schema and corpus description of
|
|
90
91
|
an object kind, property, or object instance on hover), and a
|
|
91
92
|
light/dark theme toggle. It is intended for the day-to-day online
|
package/dst/specbook-api.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export declare class SpecBook {
|
|
|
34
34
|
lint(options: {
|
|
35
35
|
config?: string[];
|
|
36
36
|
basedir?: string;
|
|
37
|
+
gitignore?: boolean;
|
|
37
38
|
}): Promise<LintResult>;
|
|
38
39
|
private renderFormats;
|
|
39
40
|
export(options: {
|
|
@@ -41,6 +42,7 @@ export declare class SpecBook {
|
|
|
41
42
|
basedir?: string;
|
|
42
43
|
formats?: ExportFormat[];
|
|
43
44
|
realtime?: boolean;
|
|
45
|
+
gitignore?: boolean;
|
|
44
46
|
}): Promise<Buffer[]>;
|
|
45
47
|
private observe;
|
|
46
48
|
watch(options: {
|
|
@@ -48,6 +50,7 @@ export declare class SpecBook {
|
|
|
48
50
|
basedir?: string;
|
|
49
51
|
formats?: ExportFormat[];
|
|
50
52
|
realtime?: boolean;
|
|
53
|
+
gitignore?: boolean;
|
|
51
54
|
outputs?: string[];
|
|
52
55
|
onExport: (buffers: Buffer[]) => void | Promise<void>;
|
|
53
56
|
}): Promise<void>;
|
|
@@ -56,6 +59,7 @@ export declare class SpecBook {
|
|
|
56
59
|
basedir?: string;
|
|
57
60
|
addr?: string;
|
|
58
61
|
port?: number;
|
|
62
|
+
gitignore?: boolean;
|
|
59
63
|
}): Promise<void>;
|
|
60
64
|
describe(options: {
|
|
61
65
|
config?: string[];
|
package/dst/specbook-api.js
CHANGED
|
@@ -81,10 +81,13 @@ export class SpecBook {
|
|
|
81
81
|
return initSpecification({ config: this.requireConfig(await this.configFiles(options.config), verbose),
|
|
82
82
|
basedir: options.basedir ?? ".", verbose });
|
|
83
83
|
}
|
|
84
|
-
/* lint the specification Markdown files below the base directory
|
|
84
|
+
/* lint the specification Markdown files below the base directory,
|
|
85
|
+
where "gitignore" treats an artifact file Git excludes from its
|
|
86
|
+
project exactly like an absent one */
|
|
85
87
|
async lint(options) {
|
|
86
88
|
return lint({ config: await this.configFiles(options.config),
|
|
87
|
-
basedir: options.basedir ?? ".",
|
|
89
|
+
basedir: options.basedir ?? ".", gitignore: options.gitignore === true,
|
|
90
|
+
verbose: this.verboseOf("lint") });
|
|
88
91
|
}
|
|
89
92
|
/* render an already parsed specification into the requested formats
|
|
90
93
|
(strict: any error diagnostic prevents the export, as a partial or
|
|
@@ -116,7 +119,7 @@ export class SpecBook {
|
|
|
116
119
|
if (requested.includes("pdf"))
|
|
117
120
|
await requireBrowser(verbose);
|
|
118
121
|
const result = lint({ config: await this.configFiles(options.config),
|
|
119
|
-
basedir: options.basedir ?? ".", verbose });
|
|
122
|
+
basedir: options.basedir ?? ".", gitignore: options.gitignore === true, verbose });
|
|
120
123
|
return this.renderFormats(result, requested, verbose, options.realtime === true);
|
|
121
124
|
}
|
|
122
125
|
/* keep an export in sync with its sources (the shared core of
|
|
@@ -135,7 +138,8 @@ export class SpecBook {
|
|
|
135
138
|
invalid specification, and the configuration files are
|
|
136
139
|
observed, too, so a failing export (even one due to an
|
|
137
140
|
invalid configuration) still keeps the observe loop fed */
|
|
138
|
-
const result = lint({ config, basedir: options.basedir ?? ".",
|
|
141
|
+
const result = lint({ config, basedir: options.basedir ?? ".",
|
|
142
|
+
gitignore: options.gitignore === true, verbose });
|
|
139
143
|
const files = [...config.map((file) => path.resolve(file)), ...result.files];
|
|
140
144
|
/* an output which is itself an observed source would re-trigger
|
|
141
145
|
the observation with its own write and hence feed an endless
|
|
@@ -176,7 +180,7 @@ export class SpecBook {
|
|
|
176
180
|
const server = await servePreview({ addr: options.addr ?? previewAddr,
|
|
177
181
|
port: options.port ?? previewPort, verbose });
|
|
178
182
|
return this.observe({ config: options.config, basedir: options.basedir,
|
|
179
|
-
formats: ["html"], realtime: true,
|
|
183
|
+
formats: ["html"], realtime: true, gitignore: options.gitignore,
|
|
180
184
|
onExport: ([html]) => server.update(html) }, verbose);
|
|
181
185
|
}
|
|
182
186
|
/* describe the generic SpecBook models and formats as Markdown (or
|
package/dst/specbook-cli.js
CHANGED
|
@@ -80,6 +80,13 @@ const configOf = (opts) => opts.config.length > 0 ? opts.config : envDefault("co
|
|
|
80
80
|
for which the YAML schema configuration falls back onto the standard one */
|
|
81
81
|
const withCommonOptions = (command) => withConfigOption(withVerboseOption(command), "the bundled standard schema configuration")
|
|
82
82
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."));
|
|
83
|
+
/* provide the Git exclude option of the specification *reading*
|
|
84
|
+
sub-commands (not of "init", which creates artifact files instead of
|
|
85
|
+
reading them), which treats an artifact file Git excludes from its
|
|
86
|
+
project exactly like an absent one */
|
|
87
|
+
const withGitignoreOption = (command) => command
|
|
88
|
+
.option("-g, --gitignore", "skip the artifact files excluded by the Git exclude rules " +
|
|
89
|
+
"(the \".gitignore\" files, \"info/exclude\", and the global excludes file)", envDefaultFlag("gitignore", false));
|
|
83
90
|
/* the help, version, and usage-error output Commander produces, which
|
|
84
91
|
is collected instead of written directly, as Commander writes it
|
|
85
92
|
synchronously and then terminates the process, truncating a piped
|
|
@@ -118,11 +125,12 @@ withCommonOptions(program.command("init"))
|
|
|
118
125
|
"no artifact files were created\n");
|
|
119
126
|
});
|
|
120
127
|
/* the lint command reports all diagnostics and fails on any error */
|
|
121
|
-
withCommonOptions(program.command("lint"))
|
|
128
|
+
withGitignoreOption(withCommonOptions(program.command("lint")))
|
|
122
129
|
.description("lint the specification Markdown files below the base directory")
|
|
123
130
|
.action(async (opts) => {
|
|
124
131
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
125
|
-
const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir
|
|
132
|
+
const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir,
|
|
133
|
+
gitignore: opts.gitignore });
|
|
126
134
|
for (const diagnostic of result.diagnostics)
|
|
127
135
|
await writeStdout(parseVerbosity(opts.verbose) > 0 ?
|
|
128
136
|
renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
|
|
@@ -133,7 +141,7 @@ withCommonOptions(program.command("lint"))
|
|
|
133
141
|
verboseOf(opts)("lint", "specification valid", "notice");
|
|
134
142
|
});
|
|
135
143
|
/* the export command parses the input once and writes every output */
|
|
136
|
-
withCommonOptions(program.command("export"))
|
|
144
|
+
withGitignoreOption(withCommonOptions(program.command("export")))
|
|
137
145
|
.description("export the specification Markdown files below the base directory " +
|
|
138
146
|
"as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
|
|
139
147
|
.option("-w, --watch", "keep the outputs in sync by re-exporting on every source change", envDefaultFlag("watch", false))
|
|
@@ -156,13 +164,14 @@ withCommonOptions(program.command("export"))
|
|
|
156
164
|
};
|
|
157
165
|
if (opts.watch)
|
|
158
166
|
await specbook.watch({ config: configOf(opts), basedir: opts.basedir,
|
|
159
|
-
formats: distinct,
|
|
167
|
+
formats: distinct, gitignore: opts.gitignore,
|
|
168
|
+
outputs: outputs.map(({ output }) => output), onExport: write });
|
|
160
169
|
else
|
|
161
170
|
await write(await specbook.export({ config: configOf(opts), basedir: opts.basedir,
|
|
162
|
-
formats: distinct }));
|
|
171
|
+
formats: distinct, gitignore: opts.gitignore }));
|
|
163
172
|
});
|
|
164
173
|
/* the preview command serves the HTML export live in the browser */
|
|
165
|
-
withCommonOptions(program.command("preview"))
|
|
174
|
+
withGitignoreOption(withCommonOptions(program.command("preview")))
|
|
166
175
|
.description("serve the HTML export of the specification Markdown files below the base " +
|
|
167
176
|
"directory as a live preview, re-exported and reloaded on every source change")
|
|
168
177
|
.option("-a, --addr <ip-addr>", "IP address to listen on", envDefault("addr", previewAddr))
|
|
@@ -172,7 +181,8 @@ withCommonOptions(program.command("preview"))
|
|
|
172
181
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
173
182
|
throw new Error(`invalid TCP port "${opts.port}"`);
|
|
174
183
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
175
|
-
await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port
|
|
184
|
+
await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port,
|
|
185
|
+
gitignore: opts.gitignore });
|
|
176
186
|
});
|
|
177
187
|
/* the describe command also describes the generic SpecBook models and
|
|
178
188
|
formats alone, so its YAML schema configuration stays optional */
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -10,6 +10,7 @@ import { literal } from "./specbook-verbose.js";
|
|
|
10
10
|
import { parseSpecification, specStatistics, resolveArtifact, collectSchemas } from "./specbook-parse.js";
|
|
11
11
|
import { buildLinkIndex, chainOf, plainText } from "./specbook-link.js";
|
|
12
12
|
import { referencedCoverage, specCoverage, coverageRatio } from "./specbook-coverage.js";
|
|
13
|
+
import { excluder } from "./specbook-ignore.js";
|
|
13
14
|
/* collect the distinct artifact files the schema configuration
|
|
14
15
|
references, in their declaration order, each mapped onto whether
|
|
15
16
|
all of its artifacts are optional and hence the file may be absent */
|
|
@@ -44,12 +45,24 @@ export const lint = (options) => {
|
|
|
44
45
|
if (config !== undefined && files.size === 0)
|
|
45
46
|
diagnostics.push({ file: options.config[0], line: 1, column: 1, severity: "error",
|
|
46
47
|
message: "no artifact files configured" });
|
|
48
|
+
/* an artifact file Git excludes from its project is treated exactly
|
|
49
|
+
like an absent one -- it is no part of the project and hence no
|
|
50
|
+
part of its specification */
|
|
51
|
+
const excluded = options.gitignore ? excluder(options.basedir) : () => false;
|
|
47
52
|
const sources = new Array();
|
|
48
53
|
const present = new Set();
|
|
49
54
|
const watched = new Array();
|
|
50
55
|
for (const [name, optional] of files) {
|
|
51
56
|
const file = path.join(options.basedir, name);
|
|
52
57
|
watched.push(path.resolve(file));
|
|
58
|
+
if (excluded(file)) {
|
|
59
|
+
if (optional)
|
|
60
|
+
options.verbose(`skipping artifact file "${literal(file)}" excluded by Git`);
|
|
61
|
+
else
|
|
62
|
+
diagnostics.push({ file, line: 1, column: 1, severity: "error",
|
|
63
|
+
message: "artifact file excluded by Git" });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
53
66
|
if (!fs.existsSync(file)) {
|
|
54
67
|
if (!optional)
|
|
55
68
|
diagnostics.push({ file, line: 1, column: 1, severity: "error",
|
|
@@ -74,6 +74,23 @@
|
|
|
74
74
|
units.push({ el, text, words, keep: false })
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
|
+
|
|
78
|
+
/* let a hyperlink whose target the filtering hid leave the
|
|
79
|
+
search mode (like the Escape key) before it jumps: the click
|
|
80
|
+
is intercepted, the document revealed again, and the click
|
|
81
|
+
re-issued, so the browser performs the regular jump */
|
|
82
|
+
document.querySelectorAll("a[href^='#']").forEach((link) => {
|
|
83
|
+
link.addEventListener("click", (event) => {
|
|
84
|
+
const target = document.getElementById(
|
|
85
|
+
decodeURIComponent((link.getAttribute("href") ?? "").slice(1)))
|
|
86
|
+
if (target === null || target.closest(".search-hide") === null)
|
|
87
|
+
return
|
|
88
|
+
event.preventDefault()
|
|
89
|
+
reset()
|
|
90
|
+
slide(false)
|
|
91
|
+
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
|
|
92
|
+
})
|
|
93
|
+
})
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
/* the Levenshtein edit distance of two words */
|
|
@@ -574,6 +574,87 @@ span.fold-label svg {
|
|
|
574
574
|
display: none;
|
|
575
575
|
}
|
|
576
576
|
}
|
|
577
|
+
/* the maximization of the diagrams (screen-only): every diagram
|
|
578
|
+
carries two controls at its top right corner (drawn like the fold
|
|
579
|
+
chevron of an over-long cell, in rounded boxes on the page
|
|
580
|
+
background), faded in while the diagram is hovered, which maximize
|
|
581
|
+
the diagram temporarily in the shared overlay covering the whole
|
|
582
|
+
viewport (or, taken into the browser fullscreen, the whole screen)
|
|
583
|
+
on the page background, where the cloned diagram scales to fit the
|
|
584
|
+
padded area (aspect ratio kept, centered) and a close mark sits at
|
|
585
|
+
the top right corner, while the document beneath stops scrolling */
|
|
586
|
+
span.maximize-controls {
|
|
587
|
+
position: absolute;
|
|
588
|
+
top: 0.35rem;
|
|
589
|
+
right: 0.35rem;
|
|
590
|
+
display: flex;
|
|
591
|
+
gap: 0.25rem;
|
|
592
|
+
line-height: 0;
|
|
593
|
+
opacity: 0;
|
|
594
|
+
transition: opacity 0.2s ease;
|
|
595
|
+
}
|
|
596
|
+
div.diagram:hover span.maximize-controls {
|
|
597
|
+
opacity: 1;
|
|
598
|
+
}
|
|
599
|
+
span.maximize-controls > span,
|
|
600
|
+
span.maximize-close {
|
|
601
|
+
display: inline-block;
|
|
602
|
+
padding: 0.2rem;
|
|
603
|
+
line-height: 0;
|
|
604
|
+
color: var(--theme-color-specbook-symbol);
|
|
605
|
+
background-color: var(--theme-color-specbook-background);
|
|
606
|
+
border: 1px solid var(--theme-color-specbook-border);
|
|
607
|
+
border-radius: 0.25rem;
|
|
608
|
+
cursor: pointer;
|
|
609
|
+
-webkit-user-select: none;
|
|
610
|
+
user-select: none;
|
|
611
|
+
}
|
|
612
|
+
span.maximize-controls > span:hover,
|
|
613
|
+
span.maximize-close:hover {
|
|
614
|
+
color: var(--theme-color-specbook-accent);
|
|
615
|
+
border-color: var(--theme-color-specbook-accent);
|
|
616
|
+
}
|
|
617
|
+
span.maximize-controls svg,
|
|
618
|
+
span.maximize-close svg {
|
|
619
|
+
width: 1rem;
|
|
620
|
+
height: 1rem;
|
|
621
|
+
}
|
|
622
|
+
div.maximize {
|
|
623
|
+
display: none;
|
|
624
|
+
position: fixed;
|
|
625
|
+
inset: 0;
|
|
626
|
+
z-index: 40;
|
|
627
|
+
box-sizing: border-box;
|
|
628
|
+
padding: 2rem;
|
|
629
|
+
background-color: var(--theme-color-specbook-background);
|
|
630
|
+
}
|
|
631
|
+
div.maximize.open {
|
|
632
|
+
display: block;
|
|
633
|
+
}
|
|
634
|
+
div.maximize span.maximize-close {
|
|
635
|
+
position: absolute;
|
|
636
|
+
top: 0.6rem;
|
|
637
|
+
right: 0.6rem;
|
|
638
|
+
}
|
|
639
|
+
div.maximize div.maximize-content {
|
|
640
|
+
width: 100%;
|
|
641
|
+
height: 100%;
|
|
642
|
+
}
|
|
643
|
+
div.maximize div.maximize-content svg {
|
|
644
|
+
display: block;
|
|
645
|
+
width: 100%;
|
|
646
|
+
height: 100%;
|
|
647
|
+
max-width: none;
|
|
648
|
+
}
|
|
649
|
+
html.maximized {
|
|
650
|
+
overflow: hidden;
|
|
651
|
+
}
|
|
652
|
+
@media print {
|
|
653
|
+
span.maximize-controls,
|
|
654
|
+
div.maximize {
|
|
655
|
+
display: none;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
577
658
|
/* the connection status tab of the live preview (screen-only): the
|
|
578
659
|
bottommost tab riding right of the brand bar below the table of
|
|
579
660
|
contents tab (sized like the search tab, fixed independent of the
|
|
@@ -1204,6 +1285,7 @@ span.theme-dark-only {
|
|
|
1204
1285
|
}
|
|
1205
1286
|
/* the diagrams, rationales, keywords, and code spans */
|
|
1206
1287
|
div.diagram {
|
|
1288
|
+
position: relative;
|
|
1207
1289
|
margin: 1rem 0;
|
|
1208
1290
|
}
|
|
1209
1291
|
div.diagram svg {
|
|
@@ -60,6 +60,7 @@ const templates = {
|
|
|
60
60
|
{% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
|
|
61
61
|
<script>{{ Document.progress }}</script>
|
|
62
62
|
<script>{{ Document.fold }}</script>
|
|
63
|
+
<script>{{ Document.maximize }}</script>
|
|
63
64
|
{% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
|
|
64
65
|
{% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
|
|
65
66
|
{% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
|
|
@@ -654,6 +655,110 @@ const foldScript = textframe `
|
|
|
654
655
|
|
|
655
656
|
})()
|
|
656
657
|
`;
|
|
658
|
+
/* the client-side script of the diagram maximization: it attaches two
|
|
659
|
+
controls to the top right corner of every diagram, revealed by the
|
|
660
|
+
stylesheet while the diagram is hovered, which maximize the diagram
|
|
661
|
+
temporarily in a single shared overlay covering the viewport (the
|
|
662
|
+
SVG cloned into it and fitted by the stylesheet), the second one
|
|
663
|
+
additionally taking the overlay into the browser fullscreen (a
|
|
664
|
+
refused request leaves the viewport overlay), where the close mark
|
|
665
|
+
at the top right corner, "Escape", a click beside the diagram, and
|
|
666
|
+
a jump through a node hyperlink close the overlay again (a browser
|
|
667
|
+
leaving the fullscreen on its own closes it, too). The script runs
|
|
668
|
+
at the end of the body, as the diagrams it decorates have to exist
|
|
669
|
+
already, and a live preview body swap replaces the overlay and the
|
|
670
|
+
body-bound listeners along with the body, while the document-bound
|
|
671
|
+
fullscreen listener retires itself */
|
|
672
|
+
const maximizeScript = textframe `
|
|
673
|
+
(function () {
|
|
674
|
+
/* an icon control drawn like the tab icons */
|
|
675
|
+
const svgNS = "http://www.w3.org/2000/svg"
|
|
676
|
+
const mark = (name, title, paths) => {
|
|
677
|
+
const span = document.createElement("span")
|
|
678
|
+
span.className = name
|
|
679
|
+
span.title = title
|
|
680
|
+
const svg = document.createElementNS(svgNS, "svg")
|
|
681
|
+
svg.setAttribute("viewBox", "0 0 24 24")
|
|
682
|
+
svg.setAttribute("fill", "none")
|
|
683
|
+
svg.setAttribute("stroke", "currentColor")
|
|
684
|
+
svg.setAttribute("stroke-width", "2.5")
|
|
685
|
+
svg.setAttribute("stroke-linecap", "round")
|
|
686
|
+
svg.setAttribute("stroke-linejoin", "round")
|
|
687
|
+
for (const d of paths) {
|
|
688
|
+
const path = document.createElementNS(svgNS, "path")
|
|
689
|
+
path.setAttribute("d", d)
|
|
690
|
+
svg.appendChild(path)
|
|
691
|
+
}
|
|
692
|
+
span.appendChild(svg)
|
|
693
|
+
return span
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/* the shared overlay: the close mark plus the content holding
|
|
697
|
+
the clone of the maximized diagram */
|
|
698
|
+
const overlay = document.createElement("div")
|
|
699
|
+
overlay.className = "maximize"
|
|
700
|
+
const close = mark("maximize-close", "close the maximized diagram",
|
|
701
|
+
[ "M18 6 6 18", "M6 6l12 12" ])
|
|
702
|
+
const content = document.createElement("div")
|
|
703
|
+
content.className = "maximize-content"
|
|
704
|
+
overlay.append(close, content)
|
|
705
|
+
document.body.appendChild(overlay)
|
|
706
|
+
|
|
707
|
+
/* open the overlay onto a diagram, optionally in the browser
|
|
708
|
+
fullscreen, and close it again (leaving the fullscreen, too),
|
|
709
|
+
with the document scrolling locked while it is open */
|
|
710
|
+
const open = (diagram, fullscreen) => {
|
|
711
|
+
const svg = diagram.querySelector("svg").cloneNode(true)
|
|
712
|
+
svg.removeAttribute("style")
|
|
713
|
+
content.replaceChildren(svg)
|
|
714
|
+
overlay.classList.add("open")
|
|
715
|
+
document.documentElement.classList.add("maximized")
|
|
716
|
+
if (fullscreen)
|
|
717
|
+
overlay.requestFullscreen().catch(() => {})
|
|
718
|
+
}
|
|
719
|
+
const shut = () => {
|
|
720
|
+
overlay.classList.remove("open")
|
|
721
|
+
document.documentElement.classList.remove("maximized")
|
|
722
|
+
content.replaceChildren()
|
|
723
|
+
if (document.fullscreenElement === overlay)
|
|
724
|
+
document.exitFullscreen().catch(() => {})
|
|
725
|
+
}
|
|
726
|
+
close.addEventListener("click", shut)
|
|
727
|
+
overlay.addEventListener("click", (event) => {
|
|
728
|
+
/* a node hyperlink navigates on its own, the overlay just
|
|
729
|
+
gets out of its way, while a click beside the diagram
|
|
730
|
+
closes the overlay alone */
|
|
731
|
+
if (event.target.closest("a") !== null || event.target.closest("svg") === null)
|
|
732
|
+
shut()
|
|
733
|
+
})
|
|
734
|
+
document.body.addEventListener("keydown", (event) => {
|
|
735
|
+
if (event.key === "Escape" && overlay.classList.contains("open"))
|
|
736
|
+
shut()
|
|
737
|
+
})
|
|
738
|
+
const left = () => {
|
|
739
|
+
if (!overlay.isConnected)
|
|
740
|
+
document.removeEventListener("fullscreenchange", left)
|
|
741
|
+
else if (document.fullscreenElement === null && overlay.classList.contains("open"))
|
|
742
|
+
shut()
|
|
743
|
+
}
|
|
744
|
+
document.addEventListener("fullscreenchange", left)
|
|
745
|
+
|
|
746
|
+
/* attach the two controls to every diagram */
|
|
747
|
+
document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((diagram) => {
|
|
748
|
+
const controls = document.createElement("span")
|
|
749
|
+
controls.className = "maximize-controls"
|
|
750
|
+
const view = mark("maximize-view", "maximize this diagram in the viewport",
|
|
751
|
+
[ "M15 3h6v6", "M21 3l-7 7", "M9 21H3v-6", "M3 21l7-7" ])
|
|
752
|
+
const full = mark("maximize-full", "maximize this diagram in fullscreen",
|
|
753
|
+
[ "M3 7V5a2 2 0 0 1 2-2h2", "M17 3h2a2 2 0 0 1 2 2v2",
|
|
754
|
+
"M21 17v2a2 2 0 0 1-2 2h-2", "M7 21H5a2 2 0 0 1-2-2v-2" ])
|
|
755
|
+
view.addEventListener("click", () => { open(diagram, false) })
|
|
756
|
+
full.addEventListener("click", () => { open(diagram, true) })
|
|
757
|
+
controls.append(view, full)
|
|
758
|
+
diagram.appendChild(controls)
|
|
759
|
+
})
|
|
760
|
+
})()
|
|
761
|
+
`;
|
|
657
762
|
/* the client-side script of the table of contents side panel: it
|
|
658
763
|
slides the panel out of and back into the viewport edge (closing it
|
|
659
764
|
also on a jump, on "Escape", and on a click outside), remembers the
|
|
@@ -1661,6 +1766,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
|
|
|
1661
1766
|
search: safe(searchScript()),
|
|
1662
1767
|
progress: safe(scrollProgressScript),
|
|
1663
1768
|
fold: safe(foldScript),
|
|
1769
|
+
maximize: safe(maximizeScript),
|
|
1664
1770
|
info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
|
|
1665
1771
|
realtime: realtime ? safe(realtimeScript) : "",
|
|
1666
1772
|
toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
|
package/dst/specbook-format.md
CHANGED
package/dst/specbook-format.yaml
CHANGED
|
@@ -1239,7 +1239,27 @@
|
|
|
1239
1239
|
type: graph
|
|
1240
1240
|
nodes: "[[DM.ENTITY:*]]"
|
|
1241
1241
|
edges: "[[RELATION:*]]"
|
|
1242
|
+
nest:
|
|
1243
|
+
properties:
|
|
1244
|
+
- GROUP
|
|
1245
|
+
config:
|
|
1246
|
+
graph-columns-max: 2
|
|
1242
1247
|
children:
|
|
1248
|
+
- kind: GROUP
|
|
1249
|
+
name: "[A-Z][a-zA-Z0-9]*"
|
|
1250
|
+
desc: >
|
|
1251
|
+
A named group of strongly related entities, carrying a Pascal-cased, 1-3 word long,
|
|
1252
|
+
per-artifact unique name, distinct from the names of its entities (e.g. `Statistics`
|
|
1253
|
+
for the snapshot entities, `Messages` for a message and its texts), and described by
|
|
1254
|
+
a 1-sentence description ("what") plus a 1-sentence rationale ("why") of the
|
|
1255
|
+
cohesion. A group is either an aggregate (a root entity with the part entities it
|
|
1256
|
+
owns) or a subject area (entities sharing one concern), affects the overview diagram
|
|
1257
|
+
only, where its entities are drawn next to each other inside a box, and carries no
|
|
1258
|
+
semantics of its own: the relations between its entities stay explicit. The groups
|
|
1259
|
+
are listed before the entities, and every entity belongs to at most one group.
|
|
1260
|
+
optional: true
|
|
1261
|
+
referenced:
|
|
1262
|
+
- "[[DM.ENTITY:*]]"
|
|
1243
1263
|
- kind: ENTITY
|
|
1244
1264
|
name: "[A-Z][a-zA-Z0-9]*"
|
|
1245
1265
|
desc: >
|
|
@@ -1281,6 +1301,13 @@
|
|
|
1281
1301
|
is anchored in the ubiquitous language and every term shows its data.
|
|
1282
1302
|
value: list([[TERM:*]])
|
|
1283
1303
|
optional: true
|
|
1304
|
+
- name: GROUP
|
|
1305
|
+
desc: >
|
|
1306
|
+
Reference to the DATA-MODEL group the entity belongs to, placing it next to
|
|
1307
|
+
the other entities of the group in the overview diagram; omitted for an
|
|
1308
|
+
entity standing on its own.
|
|
1309
|
+
value: "[[DM.GROUP:*]]"
|
|
1310
|
+
optional: true
|
|
1284
1311
|
children:
|
|
1285
1312
|
- kind: ATTRIBUTE
|
|
1286
1313
|
name: "[a-z][a-zA-Z0-9]*"
|
|
@@ -2872,7 +2899,7 @@
|
|
|
2872
2899
|
- PRODUCT
|
|
2873
2900
|
config:
|
|
2874
2901
|
size-node-width-max: 440
|
|
2875
|
-
grid-columns-max:
|
|
2902
|
+
grid-columns-max: 2
|
|
2876
2903
|
children:
|
|
2877
2904
|
- kind: TIER
|
|
2878
2905
|
desc: |
|
|
@@ -0,0 +1,173 @@
|
|
|
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 { execFileSync } from "node:child_process";
|
|
10
|
+
import picomatch from "picomatch";
|
|
11
|
+
/* translate a single Git exclude line into a picomatch-backed rule,
|
|
12
|
+
honoring the anchored-vs-floating, directory-only, and negation
|
|
13
|
+
semantics of the pattern; an anchored pattern resolves relative to
|
|
14
|
+
the "base" directory its rule file governs, given as a POSIX path
|
|
15
|
+
relative to the working tree root */
|
|
16
|
+
const compileRule = (line, base) => {
|
|
17
|
+
let pattern = line.trim();
|
|
18
|
+
if (pattern === "" || pattern.startsWith("#"))
|
|
19
|
+
return null;
|
|
20
|
+
let negated = false;
|
|
21
|
+
if (pattern.startsWith("!")) {
|
|
22
|
+
negated = true;
|
|
23
|
+
pattern = pattern.slice(1);
|
|
24
|
+
}
|
|
25
|
+
let dirOnly = false;
|
|
26
|
+
if (pattern.endsWith("/")) {
|
|
27
|
+
dirOnly = true;
|
|
28
|
+
pattern = pattern.slice(0, -1);
|
|
29
|
+
}
|
|
30
|
+
/* a pattern carrying a slash anywhere but at its end is anchored at
|
|
31
|
+
the directory of its rule file, while every other one floats and
|
|
32
|
+
hence matches at any depth below it */
|
|
33
|
+
const anchored = pattern.includes("/");
|
|
34
|
+
if (pattern.startsWith("/"))
|
|
35
|
+
pattern = pattern.slice(1);
|
|
36
|
+
const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
|
|
37
|
+
const isMatch = picomatch(glob, { dot: true });
|
|
38
|
+
return { matcher: (p) => isMatch(p), negated, dirOnly };
|
|
39
|
+
};
|
|
40
|
+
/* load the rules of a single exclude file, given as an absolute "file"
|
|
41
|
+
and the working-tree-relative "base" its anchored patterns resolve
|
|
42
|
+
against; an absent or unreadable file contributes no rules */
|
|
43
|
+
const loadRules = (file, base) => {
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
text = fs.readFileSync(file, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const rules = new Array();
|
|
52
|
+
for (const line of text.split(/\r?\n/)) {
|
|
53
|
+
const rule = compileRule(line, base);
|
|
54
|
+
if (rule !== null)
|
|
55
|
+
rules.push(rule);
|
|
56
|
+
}
|
|
57
|
+
return rules;
|
|
58
|
+
};
|
|
59
|
+
/* locate the Git working tree containing a directory, i.e. the nearest
|
|
60
|
+
ancestor-or-self carrying a ".git" directory (a regular working
|
|
61
|
+
tree) or a ".git" file (a linked worktree or a submodule) */
|
|
62
|
+
const workingTree = (dir) => {
|
|
63
|
+
let current = path.resolve(dir);
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (fs.existsSync(path.join(current, ".git")))
|
|
66
|
+
return current;
|
|
67
|
+
const parent = path.dirname(current);
|
|
68
|
+
if (parent === current)
|
|
69
|
+
return null;
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/* resolve the global Git excludes file: the configured
|
|
74
|
+
"core.excludesFile", or else the XDG location Git falls back onto
|
|
75
|
+
when that configuration value is unset */
|
|
76
|
+
const globalFile = () => {
|
|
77
|
+
let file = "";
|
|
78
|
+
try {
|
|
79
|
+
file = execFileSync("git", ["config", "--get", "core.excludesFile"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* no such configuration value, or no Git at all */
|
|
83
|
+
}
|
|
84
|
+
if (file === "") {
|
|
85
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
86
|
+
return xdg !== undefined && xdg !== "" ?
|
|
87
|
+
path.join(xdg, "git", "ignore") :
|
|
88
|
+
path.join(os.homedir(), ".config", "git", "ignore");
|
|
89
|
+
}
|
|
90
|
+
if (file.startsWith("~/"))
|
|
91
|
+
return path.join(os.homedir(), file.slice(2));
|
|
92
|
+
return file;
|
|
93
|
+
};
|
|
94
|
+
/* resolve the repository-local exclude file of a working tree, held by
|
|
95
|
+
the Git *common* directory: a regular working tree carries ".git" as
|
|
96
|
+
that very directory, while a linked worktree carries it as a file
|
|
97
|
+
pointing at its own administrative directory, whose "commondir" in
|
|
98
|
+
turn points at the shared one */
|
|
99
|
+
const infoFile = (root) => {
|
|
100
|
+
const dot = path.join(root, ".git");
|
|
101
|
+
let dir = dot;
|
|
102
|
+
if (fs.statSync(dot, { throwIfNoEntry: false })?.isFile() === true) {
|
|
103
|
+
const pointer = (/^gitdir:\s*(.+?)\s*$/m).exec(fs.readFileSync(dot, "utf8"));
|
|
104
|
+
if (pointer === null)
|
|
105
|
+
return "";
|
|
106
|
+
dir = path.resolve(root, pointer[1]);
|
|
107
|
+
const common = path.join(dir, "commondir");
|
|
108
|
+
if (fs.existsSync(common))
|
|
109
|
+
dir = path.resolve(dir, fs.readFileSync(common, "utf8").trim());
|
|
110
|
+
}
|
|
111
|
+
return path.join(dir, "info", "exclude");
|
|
112
|
+
};
|
|
113
|
+
/* create the predicate deciding whether an absolute file path is
|
|
114
|
+
excluded from the Git working tree containing "dir", honoring the
|
|
115
|
+
same three rule sources and the same precedence order as Git itself
|
|
116
|
+
-- the global excludes file, the repository-local "info/exclude",
|
|
117
|
+
and the ".gitignore" files from the working tree root down to the
|
|
118
|
+
file. A path outside any Git working tree is never excluded, as
|
|
119
|
+
there is no project to be excluded from */
|
|
120
|
+
export const excluder = (dir) => {
|
|
121
|
+
const root = workingTree(dir);
|
|
122
|
+
if (root === null)
|
|
123
|
+
return () => false;
|
|
124
|
+
/* the rules which no directory of the working tree owns and which
|
|
125
|
+
hence govern it as a whole, in ascending Git precedence */
|
|
126
|
+
const base = [...loadRules(globalFile(), ""), ...loadRules(infoFile(root), "")];
|
|
127
|
+
/* the per-directory ".gitignore" rules, loaded on first use, as one
|
|
128
|
+
run queries many files sharing the very same directories */
|
|
129
|
+
const cache = new Map();
|
|
130
|
+
const rulesOf = (relDir) => {
|
|
131
|
+
let rules = cache.get(relDir);
|
|
132
|
+
if (rules === undefined) {
|
|
133
|
+
rules = loadRules(path.join(root, relDir, ".gitignore"), relDir);
|
|
134
|
+
cache.set(relDir, rules);
|
|
135
|
+
}
|
|
136
|
+
return rules;
|
|
137
|
+
};
|
|
138
|
+
return (file) => {
|
|
139
|
+
const rel = path.relative(root, path.resolve(file)).replace(/\\/g, "/");
|
|
140
|
+
if (rel === "" || rel.startsWith("../"))
|
|
141
|
+
return false;
|
|
142
|
+
const segments = rel.split("/");
|
|
143
|
+
/* walk the directory chain from the working tree root down to
|
|
144
|
+
the file, picking up the ".gitignore" rules on the way and
|
|
145
|
+
deciding every segment against the rules known at its level,
|
|
146
|
+
where the last matching rule wins; an excluded ancestor
|
|
147
|
+
directory excludes the file itself, as Git never descends
|
|
148
|
+
into it -- and, for the same reason, a rule below such an
|
|
149
|
+
ancestor can never re-include the file */
|
|
150
|
+
let rules = base;
|
|
151
|
+
let relDir = "";
|
|
152
|
+
for (let i = 0; i < segments.length; i++) {
|
|
153
|
+
rules = rules.concat(rulesOf(relDir));
|
|
154
|
+
const current = relDir === "" ? segments[i] : `${relDir}/${segments[i]}`;
|
|
155
|
+
/* every segment but the last one is a directory by
|
|
156
|
+
construction, while the last one is whatever the file
|
|
157
|
+
system says it is */
|
|
158
|
+
const isDir = i < segments.length - 1
|
|
159
|
+
|| fs.statSync(path.join(root, current), { throwIfNoEntry: false })?.isDirectory() === true;
|
|
160
|
+
let excluded = false;
|
|
161
|
+
for (const rule of rules) {
|
|
162
|
+
if (rule.dirOnly && !isDir)
|
|
163
|
+
continue;
|
|
164
|
+
if (rule.matcher(current))
|
|
165
|
+
excluded = !rule.negated;
|
|
166
|
+
}
|
|
167
|
+
if (excluded)
|
|
168
|
+
return true;
|
|
169
|
+
relDir = current;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
};
|
|
173
|
+
};
|
package/dst/specbook-mcp.js
CHANGED
|
@@ -51,7 +51,10 @@ export const serveMcp = async (verbose) => {
|
|
|
51
51
|
config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
|
|
52
52
|
"patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
|
|
53
53
|
"default: the bundled standard schema configuration)"),
|
|
54
|
-
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
|
|
54
|
+
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
55
|
+
gitignore: z.boolean().optional().describe("skip the artifact files excluded by the Git " +
|
|
56
|
+
"exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
|
|
57
|
+
"file), treating such a file exactly like an absent one (default: false)")
|
|
55
58
|
}
|
|
56
59
|
}, async (args) => {
|
|
57
60
|
try {
|
|
@@ -78,7 +81,10 @@ export const serveMcp = async (verbose) => {
|
|
|
78
81
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
79
82
|
format: z.enum(formats).optional().describe("output format (default: inferred from the " +
|
|
80
83
|
"output file extension, else json)"),
|
|
81
|
-
output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)")
|
|
84
|
+
output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)"),
|
|
85
|
+
gitignore: z.boolean().optional().describe("skip the artifact files excluded by the Git " +
|
|
86
|
+
"exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
|
|
87
|
+
"file), treating such a file exactly like an absent one (default: false)")
|
|
82
88
|
}
|
|
83
89
|
}, async (args) => {
|
|
84
90
|
try {
|
|
@@ -89,7 +95,7 @@ export const serveMcp = async (verbose) => {
|
|
|
89
95
|
{ format: args.format ?? "json", output: args.output } :
|
|
90
96
|
parseOutputSpec(args.output);
|
|
91
97
|
const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
|
|
92
|
-
formats: [spec.format] });
|
|
98
|
+
formats: [spec.format], gitignore: args.gitignore });
|
|
93
99
|
if (spec.output !== undefined && spec.output !== "-") {
|
|
94
100
|
await fs.promises.writeFile(spec.output, data);
|
|
95
101
|
return { content: [{ type: "text", text: `exported specification into "${spec.output}" (${data.length} bytes)` }] };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rse/specbook",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.10",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"description": "Markdown-based Specification Format (API, CLI and MCP service)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [ "dst" ],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"marked": "18.0.
|
|
20
|
+
"marked": "18.0.12",
|
|
21
21
|
"marked-smartypants": "1.1.12",
|
|
22
22
|
"yaml": "2.9.0",
|
|
23
23
|
"json5": "2.2.3",
|
|
24
24
|
"pdf-lib": "1.17.1",
|
|
25
25
|
"subset-font": "2.7.0",
|
|
26
26
|
"@rse/mrcs": "0.9.2",
|
|
27
|
-
"@rse/gradia": "1.2.
|
|
27
|
+
"@rse/gradia": "1.2.3",
|
|
28
28
|
"@toon-format/toon": "4.1.1",
|
|
29
|
-
"valibot": "1.
|
|
29
|
+
"valibot": "1.5.0",
|
|
30
30
|
"nunjucks": "3.2.4",
|
|
31
31
|
"@rse/nunjucks-addons": "2.2.0",
|
|
32
32
|
"textframe": "1.2.1",
|
|
@@ -38,23 +38,25 @@
|
|
|
38
38
|
"chalk": "6.0.0",
|
|
39
39
|
"chokidar5": "npm:chokidar@5.0.0",
|
|
40
40
|
"glob": "13.0.6",
|
|
41
|
+
"picomatch": "4.0.7",
|
|
41
42
|
"es-toolkit": "1.52.0",
|
|
42
43
|
"fastify": "5.12.3",
|
|
43
44
|
"@fastify/websocket": "11.3.0",
|
|
44
45
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
45
|
-
"zod": "4.
|
|
46
|
+
"zod": "4.6.1"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"chokidar-cli": "3.0.0",
|
|
49
50
|
"eslint": "10.9.0",
|
|
50
51
|
"@eslint/js": "10.0.1",
|
|
51
|
-
"typescript-eslint": "8.
|
|
52
|
+
"typescript-eslint": "8.70.0",
|
|
52
53
|
"eslint-plugin-promise": "7.3.0",
|
|
53
54
|
"neostandard": "0.14.0-next.1",
|
|
54
55
|
"globals": "17.12.0",
|
|
55
56
|
"markdownlint": "0.41.1",
|
|
56
57
|
"markdownlint-cli2": "0.23.2",
|
|
57
58
|
"@types/node": "26.5.0",
|
|
59
|
+
"@types/picomatch": "4.0.3",
|
|
58
60
|
"@types/subset-font": "2.3.0",
|
|
59
61
|
"@types/nunjucks": "3.2.6",
|
|
60
62
|
"@types/ws": "8.18.1",
|
|
@@ -67,7 +69,7 @@
|
|
|
67
69
|
"postcss-cli": "12.0.0",
|
|
68
70
|
"postcss-url": "10.1.4",
|
|
69
71
|
"stylus": "0.64.0",
|
|
70
|
-
"@rse/stx": "1.1.
|
|
72
|
+
"@rse/stx": "1.1.7",
|
|
71
73
|
"shx": "0.4.0",
|
|
72
74
|
"nodemon": "3.1.14"
|
|
73
75
|
},
|