@rse/specbook 1.3.2 → 1.4.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.
- package/README.md +9 -2
- package/dst/specbook-api.d.ts +5 -1
- package/dst/specbook-api.js +23 -9
- package/dst/specbook-cli.js +8 -5
- package/dst/specbook-cmd-export.js +2 -2
- package/dst/specbook-cmd-init.js +16 -2
- package/dst/specbook-coverage.js +1 -1
- package/dst/specbook-diagram.d.ts +1 -1
- package/dst/specbook-diagram.js +12 -10
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +21 -4
- package/dst/specbook-export-code.d.ts +14 -0
- package/dst/specbook-export-code.js +155 -0
- package/dst/specbook-export-common.d.ts +8 -2
- package/dst/specbook-export-common.js +126 -6
- package/dst/specbook-export-diagram.d.ts +15 -0
- package/dst/specbook-export-diagram.js +211 -0
- package/dst/specbook-export-html-fold.d.ts +20 -0
- package/dst/specbook-export-html-fold.js +458 -0
- package/dst/specbook-export-html-render.d.ts +51 -0
- package/dst/specbook-export-html-render.js +748 -0
- package/dst/specbook-export-html-search.js +6 -5
- package/dst/specbook-export-html-template.d.ts +21 -0
- package/dst/specbook-export-html-template.js +282 -0
- package/dst/specbook-export-html-widget.d.ts +12 -0
- package/dst/specbook-export-html-widget.js +605 -0
- package/dst/specbook-export-html.css +325 -50
- package/dst/specbook-export-html.js +62 -1931
- package/dst/specbook-export-image.d.ts +2 -0
- package/dst/specbook-export-image.js +161 -9
- package/dst/specbook-export-md.js +60 -12
- package/dst/specbook-format-schema.d.ts +1 -1
- package/dst/specbook-format-schema.js +1 -1
- package/dst/specbook-format-spec.d.ts +1 -0
- package/dst/specbook-format-spec.js +1 -0
- package/dst/specbook-format.md +173 -19
- package/dst/specbook-format.yaml +265 -24
- package/dst/specbook-ignore.js +2 -0
- package/dst/specbook-link.js +21 -5
- package/dst/specbook-mcp.js +9 -5
- package/dst/specbook-parse-common.d.ts +24 -0
- package/dst/specbook-parse-common.js +137 -10
- package/dst/specbook-parse-semantic.js +2 -0
- package/dst/specbook-parse-syntax.js +81 -20
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-project.d.ts +1 -0
- package/dst/specbook-project.js +10 -1
- package/dst/specbook-theme.d.ts +1 -0
- package/dst/specbook-theme.js +2 -0
- package/package.json +18 -12
package/dst/specbook-ignore.js
CHANGED
|
@@ -41,6 +41,8 @@ const compileRule = (line, base, nocase) => {
|
|
|
41
41
|
const anchored = pattern.includes("/");
|
|
42
42
|
if (pattern.startsWith("/"))
|
|
43
43
|
pattern = pattern.slice(1);
|
|
44
|
+
if (pattern === "")
|
|
45
|
+
return null;
|
|
44
46
|
/* a negated character class is "[!...]" in the wildmatch of Git,
|
|
45
47
|
while picomatch knows the "[^...]" spelling only */
|
|
46
48
|
pattern = pattern.replace(/\[!/g, "[^");
|
package/dst/specbook-link.js
CHANGED
|
@@ -5,9 +5,24 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/* the Wiki-style reference syntax ("[[xxx]]") */
|
|
7
7
|
export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
|
|
8
|
-
/*
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/* the fenced code blocks of a Markdown text (from the opening fence line
|
|
9
|
+
up to the closing fence line, or to the end of an unclosed one) */
|
|
10
|
+
const fenceRegex = /^[ \t]*(`{3,}|~{3,})[^\n]*(?:\n[\s\S]*?(?:\n[ \t]*\1[`~]*[ \t]*(?=\n|$)|(?![\s\S]))|(?![\s\S]))/gm;
|
|
11
|
+
/* apply a transformation to the parts of a text outside of its fenced
|
|
12
|
+
code blocks, which stay verbatim, as code carries no references */
|
|
13
|
+
const outsideFences = (text, apply) => {
|
|
14
|
+
let result = "";
|
|
15
|
+
let last = 0;
|
|
16
|
+
for (const m of text.matchAll(fenceRegex)) {
|
|
17
|
+
result += apply(text.slice(last, m.index)) + m[0];
|
|
18
|
+
last = m.index + m[0].length;
|
|
19
|
+
}
|
|
20
|
+
return result + apply(text.slice(last));
|
|
21
|
+
};
|
|
22
|
+
/* strip the fenced code blocks of a description and the inline code
|
|
23
|
+
markup of a name, property value, or description (preserved in the
|
|
24
|
+
AST for rendering) for matching and labeling */
|
|
25
|
+
export const plainText = (text) => text.replace(fenceRegex, "").replace(/`/g, "");
|
|
11
26
|
/* provide the lookup structures of an index, built once on first use */
|
|
12
27
|
const lookups = new WeakMap();
|
|
13
28
|
const lookup = (index) => {
|
|
@@ -184,5 +199,6 @@ export const anchorPaths = (index) => {
|
|
|
184
199
|
}
|
|
185
200
|
return paths;
|
|
186
201
|
};
|
|
187
|
-
/* expand all references of a text via a per-reference replacer
|
|
188
|
-
|
|
202
|
+
/* expand all references of a text via a per-reference replacer
|
|
203
|
+
(except inside its fenced code blocks) */
|
|
204
|
+
export const expandReferences = (text, replace) => outsideFences(text, (part) => part.replace(referenceRegex, (_, reference) => replace(reference.trim())));
|
package/dst/specbook-mcp.js
CHANGED
|
@@ -46,14 +46,18 @@ export const serveMcp = async (verbose) => {
|
|
|
46
46
|
server.registerTool("specbook_init", {
|
|
47
47
|
title: "Initialize Specification",
|
|
48
48
|
description: "Initialize the configured specification artifact files below the base directory " +
|
|
49
|
-
"with their frontmatter and artifact heading, skipping already existing files
|
|
49
|
+
"with their frontmatter and artifact heading, skipping already existing files, and " +
|
|
50
|
+
`create the project configuration file "${projectFile}" in the working directory, ` +
|
|
51
|
+
"persisting the effective schema configuration and base directory, unless one already applies.",
|
|
50
52
|
inputSchema: { config, basedir, cwd }
|
|
51
53
|
}, async (args) => {
|
|
52
54
|
try {
|
|
53
|
-
const
|
|
54
|
-
return { content: [{ type: "text", text:
|
|
55
|
-
`initialized artifact file(s): ${
|
|
56
|
-
"no artifact files were created"
|
|
55
|
+
const result = await specbook.init(args);
|
|
56
|
+
return { content: [{ type: "text", text: (result.files.length > 0 ?
|
|
57
|
+
`initialized artifact file(s): ${result.files.join(", ")}` :
|
|
58
|
+
"no artifact files were created") +
|
|
59
|
+
(result.project !== undefined ?
|
|
60
|
+
`\ncreated project configuration file: ${result.project}` : "") }] };
|
|
57
61
|
}
|
|
58
62
|
catch (err) {
|
|
59
63
|
return errorResult(err);
|
|
@@ -31,6 +31,30 @@ export declare const becauseRegex: RegExp;
|
|
|
31
31
|
export declare const embeddingRegex: RegExp;
|
|
32
32
|
export declare const embeddingThemes: readonly ["light", "dark"];
|
|
33
33
|
export declare const embeddingVariants: (reference: string) => string[];
|
|
34
|
+
export declare const pdfType = "application/pdf";
|
|
35
|
+
export declare const codeType = "text/x-code";
|
|
36
|
+
export declare const codeLanguage: (name: string) => string | undefined;
|
|
37
|
+
export declare const markedLines: (mark: string) => {
|
|
38
|
+
has: (line: number) => boolean;
|
|
39
|
+
};
|
|
40
|
+
type FenceInfo = {
|
|
41
|
+
lang: string;
|
|
42
|
+
params: Record<string, string>;
|
|
43
|
+
error?: string;
|
|
44
|
+
};
|
|
45
|
+
export declare const fenceInfo: (info: string | undefined) => FenceInfo;
|
|
46
|
+
export declare const embeddingFragment: (reference: string, type: string) => {
|
|
47
|
+
file: string;
|
|
48
|
+
params: Record<string, string>;
|
|
49
|
+
} | undefined;
|
|
50
|
+
export declare const embeddingFileType: (reference: string) => string | undefined;
|
|
34
51
|
export declare const embeddingMimeType: (reference: string) => string | undefined;
|
|
35
52
|
export declare const embeddingCount: (reference: string | undefined) => number;
|
|
36
53
|
export declare const embeddingDataRegex: RegExp;
|
|
54
|
+
export type DarkMark = "invert" | "none";
|
|
55
|
+
export declare const darkMark: (content: string) => {
|
|
56
|
+
content: string;
|
|
57
|
+
dark?: DarkMark;
|
|
58
|
+
};
|
|
59
|
+
export declare const markDark: (content: string, dark: DarkMark) => string;
|
|
60
|
+
export {};
|
|
@@ -3,6 +3,7 @@
|
|
|
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 { bundledLanguagesInfo } from "shiki/langs";
|
|
6
7
|
/* the state shared between the syntactic and semantic parsing phases */
|
|
7
8
|
export class ParseContext {
|
|
8
9
|
diagnostics = new Array();
|
|
@@ -40,21 +41,129 @@ export const embeddingThemes = ["light", "dark"];
|
|
|
40
41
|
export const embeddingVariants = (reference) => reference.includes("{theme}") ?
|
|
41
42
|
embeddingThemes.map((theme) => reference.replace(/\{theme\}/g, theme)) :
|
|
42
43
|
[reference];
|
|
43
|
-
/* the
|
|
44
|
-
const
|
|
44
|
+
/* the MIME type of a PDF document */
|
|
45
|
+
export const pdfType = "application/pdf";
|
|
46
|
+
/* the MIME type of a source code file, carrying its language as the
|
|
47
|
+
"lang" parameter */
|
|
48
|
+
export const codeType = "text/x-code";
|
|
49
|
+
/* the embeddable file types and their MIME types: the images plus the
|
|
50
|
+
diagram sources (Mermaid and D2) and the documents (PDF), which the
|
|
51
|
+
exports render */
|
|
52
|
+
const fileTypes = {
|
|
45
53
|
svg: "image/svg+xml",
|
|
46
54
|
png: "image/png",
|
|
47
55
|
jpg: "image/jpeg",
|
|
48
56
|
jpeg: "image/jpeg",
|
|
49
|
-
webp: "image/webp"
|
|
57
|
+
webp: "image/webp",
|
|
58
|
+
mmd: "text/vnd.mermaid",
|
|
59
|
+
mermaid: "text/vnd.mermaid",
|
|
60
|
+
d2: "text/vnd.d2",
|
|
61
|
+
pdf: pdfType
|
|
50
62
|
};
|
|
51
|
-
/*
|
|
52
|
-
(
|
|
53
|
-
|
|
63
|
+
/* the code languages of the code listings, keyed by every id and alias
|
|
64
|
+
of the Shiki languages (except those the file types above take) and
|
|
65
|
+
mapped onto the language id */
|
|
66
|
+
const codeLanguages = new Map(bundledLanguagesInfo
|
|
67
|
+
.flatMap((info) => [info.id, ...info.aliases ?? []].map((name) => [name, info.id]))
|
|
68
|
+
.filter(([name]) => fileTypes[name] === undefined));
|
|
69
|
+
/* map a language name (of a fenced code block) onto the language id
|
|
70
|
+
of a code listing (undefined for no or an unknown language) */
|
|
71
|
+
export const codeLanguage = (name) => codeLanguages.get(name.toLowerCase());
|
|
72
|
+
/* the embeddable file types plus the source code files, whose file
|
|
73
|
+
extensions are the language ids and aliases */
|
|
74
|
+
const embeddingTypes = new Map([
|
|
75
|
+
...Array.from(codeLanguages, ([name, id]) => [name, `${codeType};lang=${id}`]),
|
|
76
|
+
...Object.entries(fileTypes)
|
|
77
|
+
]);
|
|
78
|
+
/* the patterns of the parameters: a 1-based line or page number, a
|
|
79
|
+
range of lines, and the hunks of lines to mark */
|
|
80
|
+
const ordinal = /^[1-9]\d*$/;
|
|
81
|
+
const lineRange = /^[1-9]\d*(?:-[1-9]\d*)?$/;
|
|
82
|
+
const lineHunks = /^[1-9]\d*(?:-[1-9]\d*)?(?:,[1-9]\d*(?:-[1-9]\d*)?)*$/;
|
|
83
|
+
/* turn the hunks of a "mark" parameter into a line number membership test
|
|
84
|
+
(checking the ranges instead of expanding them, as a range is unbounded) */
|
|
85
|
+
export const markedLines = (mark) => {
|
|
86
|
+
const hunks = mark.split(",").filter((hunk) => hunk !== "").map((hunk) => {
|
|
87
|
+
const [first, last = first] = hunk.split("-").map(Number);
|
|
88
|
+
return { first, last };
|
|
89
|
+
});
|
|
90
|
+
return { has: (line) => hunks.some((hunk) => line >= hunk.first && line <= hunk.last) };
|
|
91
|
+
};
|
|
92
|
+
/* split the info string of a fenced code block into its language and its
|
|
93
|
+
whitespace-separated listing parameters ("start" and "mark"), plus an
|
|
94
|
+
error message for an unknown or repeated key or an invalid value */
|
|
95
|
+
const fenceParams = { start: ordinal, mark: lineHunks };
|
|
96
|
+
export const fenceInfo = (info) => {
|
|
97
|
+
const [lang = "", ...pairs] = (info ?? "").trim().split(/\s+/);
|
|
98
|
+
const params = {};
|
|
99
|
+
for (const pair of pairs) {
|
|
100
|
+
const m = pair.match(/^([a-z]+)=(.*)$/s);
|
|
101
|
+
if (m === null || params[m[1]] !== undefined || !(fenceParams[m[1]]?.test(m[2]) ?? false))
|
|
102
|
+
return { lang, params, error: `invalid parameter "${pair}" of fenced code block` };
|
|
103
|
+
params[m[1]] = m[2];
|
|
104
|
+
}
|
|
105
|
+
return { lang, params };
|
|
106
|
+
};
|
|
107
|
+
/* the fragment parameters ("#<key>=<value>&...") each file type accepts,
|
|
108
|
+
with the patterns of their values: the treatment on the dark theme
|
|
109
|
+
of an image and a document, the 1-based page of a document, and the
|
|
110
|
+
excerpt and the marked lines of a source code file */
|
|
111
|
+
const darkAuto = /^(?:auto|invert|none)$/;
|
|
112
|
+
const fragmentParams = {
|
|
113
|
+
"image/svg+xml": { dark: darkAuto },
|
|
114
|
+
"image/png": { dark: darkAuto },
|
|
115
|
+
"image/jpeg": { dark: darkAuto },
|
|
116
|
+
"image/webp": { dark: darkAuto },
|
|
117
|
+
[pdfType]: { page: ordinal, dark: darkAuto },
|
|
118
|
+
[codeType]: { lines: lineRange, mark: lineHunks }
|
|
119
|
+
};
|
|
120
|
+
/* split an embedding reference into its file and its fragment parameters
|
|
121
|
+
(URI-decoded and in the canonical order of its type), or undefined for
|
|
122
|
+
a fragment its type does not accept (an unknown or repeated key, or an
|
|
123
|
+
invalid value) */
|
|
124
|
+
export const embeddingFragment = (reference, type) => {
|
|
125
|
+
const hash = reference.indexOf("#", reference.lastIndexOf("/") + 1);
|
|
126
|
+
if (hash < 0)
|
|
127
|
+
return { file: reference, params: {} };
|
|
128
|
+
const accepted = fragmentParams[type.replace(/;.*$/, "")] ?? {};
|
|
129
|
+
const given = new Map();
|
|
130
|
+
for (const pair of reference.slice(hash + 1).split("&")) {
|
|
131
|
+
const m = pair.match(/^([a-z]+)=(.*)$/s);
|
|
132
|
+
if (m === null || given.has(m[1]))
|
|
133
|
+
return undefined;
|
|
134
|
+
let value = m[2];
|
|
135
|
+
try {
|
|
136
|
+
value = decodeURIComponent(value);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* an invalid escape sequence is taken literally */
|
|
140
|
+
}
|
|
141
|
+
if (!Object.hasOwn(accepted, m[1]) || !accepted[m[1]].test(value))
|
|
142
|
+
return undefined;
|
|
143
|
+
given.set(m[1], value);
|
|
144
|
+
}
|
|
145
|
+
const params = {};
|
|
146
|
+
for (const key of Object.keys(accepted)) {
|
|
147
|
+
const value = given.get(key);
|
|
148
|
+
if (value !== undefined)
|
|
149
|
+
params[key] = value;
|
|
150
|
+
}
|
|
151
|
+
return { file: reference.slice(0, hash), params };
|
|
152
|
+
};
|
|
153
|
+
/* map a local image embedding reference onto the MIME type of its file
|
|
154
|
+
(URLs and other file types are not embeddable), regardless of any
|
|
155
|
+
fragment */
|
|
156
|
+
export const embeddingFileType = (reference) => {
|
|
54
157
|
if (/^[a-z][a-z0-9+.-]+:/i.test(reference))
|
|
55
158
|
return undefined;
|
|
56
|
-
const
|
|
57
|
-
return
|
|
159
|
+
const m = reference.match(/^.*?\.([a-z0-9]+)(?:#.*)?$/is);
|
|
160
|
+
return m !== null ? embeddingTypes.get(m[1].toLowerCase()) : undefined;
|
|
161
|
+
};
|
|
162
|
+
/* map a local image embedding reference onto its MIME type, provided its
|
|
163
|
+
fragment (if any) is one its type accepts */
|
|
164
|
+
export const embeddingMimeType = (reference) => {
|
|
165
|
+
const type = embeddingFileType(reference);
|
|
166
|
+
return type !== undefined && embeddingFragment(reference, type) !== undefined ? type : undefined;
|
|
58
167
|
};
|
|
59
168
|
/* the number of embedding entries an image embedding markup occupies,
|
|
60
169
|
given its inline reference: one per variant of an embeddable one, none
|
|
@@ -66,5 +175,23 @@ export const embeddingCount = (reference) => {
|
|
|
66
175
|
return embeddingMimeType(trimmed) !== undefined ? embeddingVariants(trimmed).length : 0;
|
|
67
176
|
};
|
|
68
177
|
/* the image definition ("[label]: data:...") a reference-style image
|
|
69
|
-
embedding refers to: a base64 data: URL of an embeddable image type
|
|
70
|
-
|
|
178
|
+
embedding refers to: a base64 data: URL of an embeddable image type,
|
|
179
|
+
optionally marked with its treatment on the dark theme, or of a source
|
|
180
|
+
code file, carrying its language, start line, and marked lines */
|
|
181
|
+
export const embeddingDataRegex = /^data:(image\/(?:svg\+xml|png|jpeg|webp)|text\/x-code;lang=[^;,]+;start=\d+(?:;mark=[^;,]+)?)(;dark=(?:invert|none))?;base64,([A-Za-z0-9+/=]+)$/;
|
|
182
|
+
/* split an embedded image or document content (an SVG text or a data:
|
|
183
|
+
URL) into its plain content and its explicit treatment on the dark
|
|
184
|
+
theme, which a "#dark=invert" or "#dark=none" fragment (deviating from
|
|
185
|
+
the default "auto") turns into the "dark" data: URL parameter directly
|
|
186
|
+
behind the MIME type (an SVG hence becoming a data: URL, too) */
|
|
187
|
+
export const darkMark = (content) => {
|
|
188
|
+
const m = content.match(/^data:([^;,]+);dark=(invert|none)((?:;[a-z]+=[^;,]*)*);base64,(.*)$/s);
|
|
189
|
+
if (m === null)
|
|
190
|
+
return { content };
|
|
191
|
+
return { dark: m[2], content: m[1] === "image/svg+xml" && m[3] === "" ?
|
|
192
|
+
Buffer.from(m[4], "base64").toString("utf8") : `data:${m[1]}${m[3]};base64,${m[4]}` };
|
|
193
|
+
};
|
|
194
|
+
/* mark a plain embedded image or document content with its explicit
|
|
195
|
+
treatment on the dark theme */
|
|
196
|
+
export const markDark = (content, dark) => content.startsWith("data:") ? content.replace(/^(data:[^;,]+)/, `$1;dark=${dark}`) :
|
|
197
|
+
`data:image/svg+xml;dark=${dark};base64,${Buffer.from(content, "utf8").toString("base64")}`;
|
|
@@ -516,6 +516,8 @@ export const validateReferences = (ctx, specification) => {
|
|
|
516
516
|
check(object, object.description.description, meta.file, meta.line);
|
|
517
517
|
if (object.description.rationale !== undefined)
|
|
518
518
|
check(object, object.description.rationale, meta.file, meta.line);
|
|
519
|
+
if (object.description.elaboration !== undefined)
|
|
520
|
+
check(object, object.description.elaboration, meta.file, meta.line);
|
|
519
521
|
}
|
|
520
522
|
object.children.forEach(walk);
|
|
521
523
|
};
|
|
@@ -6,15 +6,22 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { marked } from "marked";
|
|
9
|
-
import { becauseRegex, embeddingRegex, embeddingDataRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
|
|
10
|
-
/* split a description text into statement and optional rationale
|
|
9
|
+
import { becauseRegex, embeddingRegex, embeddingDataRegex, embeddingMimeType, embeddingFileType, embeddingVariants, embeddingFragment, markDark, pdfType, codeType, codeLanguage, fenceInfo } from "./specbook-parse-common.js";
|
|
10
|
+
/* split a description text into statement and optional rationale, where
|
|
11
|
+
the rationale ends with its paragraph and any blocks following it
|
|
12
|
+
(further paragraphs, images, diagrams) form the elaboration */
|
|
11
13
|
const splitDescription = (text) => {
|
|
12
14
|
const m = text.match(becauseRegex);
|
|
13
15
|
if (m === null || m.index === undefined)
|
|
14
16
|
return { description: text };
|
|
17
|
+
const rest = text.slice(m.index + m[0].length);
|
|
18
|
+
const end = rest.search(/\n[ \t]*\n/);
|
|
19
|
+
if (end < 0)
|
|
20
|
+
return { description: text.slice(0, m.index), rationale: rest };
|
|
15
21
|
return {
|
|
16
22
|
description: text.slice(0, m.index),
|
|
17
|
-
rationale:
|
|
23
|
+
rationale: rest.slice(0, end),
|
|
24
|
+
elaboration: rest.slice(end).trim()
|
|
18
25
|
};
|
|
19
26
|
};
|
|
20
27
|
/* strip the trailing explicit Wiki-style anchor ("{{xxx}}"), the
|
|
@@ -100,7 +107,7 @@ const parseFrontmatter = (text) => {
|
|
|
100
107
|
return { present: false, created: missing, modified: missing, body: text, offset: 0 };
|
|
101
108
|
/* grab a single timestamp key with its raw value and source line */
|
|
102
109
|
const grab = (key) => {
|
|
103
|
-
const km = m[1].match(new RegExp(`^${key}
|
|
110
|
+
const km = m[1].match(new RegExp(`^${key}:[ \\t]*(.+)$`, "m"));
|
|
104
111
|
if (km === null || km.index === undefined)
|
|
105
112
|
return missing;
|
|
106
113
|
const line = 2 + (m[1].slice(0, km.index).match(/\n/g) ?? []).length;
|
|
@@ -116,13 +123,15 @@ const parseFrontmatter = (text) => {
|
|
|
116
123
|
offset: (m[0].match(/\n/g) ?? []).length
|
|
117
124
|
};
|
|
118
125
|
};
|
|
119
|
-
/* recursively load the
|
|
120
|
-
description and the property values of an object (SVG as-is,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
/* recursively load the files embedded via "" into the
|
|
127
|
+
description and the property values of an object (SVG as-is, the
|
|
128
|
+
PNG/JPEG/WebP images, PDF documents, Mermaid/D2 diagram sources, and
|
|
129
|
+
source code excerpts as base64 data: URLs), resolving the references
|
|
130
|
+
relative to the source file and expanding a "{theme}" reference into
|
|
131
|
+
its theme variants, which are loaded into consecutive embedding
|
|
132
|
+
entries (an unreadable file leaves an empty entry, keeping the
|
|
133
|
+
positions aligned), while a reference-style "![alt][label]" takes
|
|
134
|
+
the image of its definition */
|
|
126
135
|
const embed = (ctx, object, file, defs) => {
|
|
127
136
|
const load = (target, text, line) => {
|
|
128
137
|
for (const m of text.matchAll(embeddingRegex)) {
|
|
@@ -137,16 +146,58 @@ const embed = (ctx, object, file, defs) => {
|
|
|
137
146
|
}
|
|
138
147
|
const reference = m[2].trim();
|
|
139
148
|
const type = embeddingMimeType(reference);
|
|
140
|
-
if (type === undefined)
|
|
149
|
+
if (type === undefined) {
|
|
150
|
+
if (embeddingFileType(reference) !== undefined)
|
|
151
|
+
ctx.diagnose(file, line, `invalid fragment of embedding "${reference}"`);
|
|
141
152
|
continue;
|
|
153
|
+
}
|
|
142
154
|
for (const variant of embeddingVariants(reference)) {
|
|
155
|
+
/* the page a fragment of a document selects travels as a
|
|
156
|
+
parameter of its data: URL, while an image or document
|
|
157
|
+
gets marked with an explicit treatment on the dark theme
|
|
158
|
+
deviating from its default ("auto"), unless its "{theme}"
|
|
159
|
+
variants already serve the dark theme */
|
|
143
160
|
target.embedding ??= [];
|
|
144
|
-
const
|
|
161
|
+
const split = embeddingFragment(variant, type) ?? { file: variant, params: {} };
|
|
162
|
+
const image = type.startsWith("image/") || type === pdfType;
|
|
163
|
+
const asset = path.resolve(path.dirname(file), split.file);
|
|
164
|
+
const params = Object.entries(split.params).filter(([key]) => key !== "dark")
|
|
165
|
+
.map(([key, value]) => `;${key}=${encodeURIComponent(value)}`).join("");
|
|
145
166
|
ctx.assets.add(asset);
|
|
167
|
+
if (type.startsWith(`${codeType};`)) {
|
|
168
|
+
/* a source code file is embedded as the excerpt of its
|
|
169
|
+
lines, numbered from its first line, with the hunks
|
|
170
|
+
of its marked lines */
|
|
171
|
+
try {
|
|
172
|
+
const lines = fs.readFileSync(asset, "utf8").replace(/\r?\n$/, "").split(/\r?\n/);
|
|
173
|
+
const range = split.params.lines ?? `1-${lines.length}`;
|
|
174
|
+
const [first, last = first] = range.split("-").map(Number);
|
|
175
|
+
if (first > lines.length || last < first) {
|
|
176
|
+
target.embedding.push("");
|
|
177
|
+
ctx.diagnose(file, line, `empty line range "${range}" of embedding "${variant}"`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const mark = split.params.mark !== undefined ?
|
|
181
|
+
`;mark=${encodeURIComponent(split.params.mark)}` : "";
|
|
182
|
+
const code = lines.slice(first - 1, last).join("\n");
|
|
183
|
+
target.embedding.push(`data:${type};start=${first}${mark};base64,` +
|
|
184
|
+
Buffer.from(code, "utf8").toString("base64"));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
target.embedding.push("");
|
|
188
|
+
ctx.diagnose(file, line, `unreadable embedding file "${variant}": ` +
|
|
189
|
+
(err instanceof Error ? err.message : String(err)));
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
146
193
|
try {
|
|
147
194
|
const data = fs.readFileSync(asset);
|
|
148
|
-
|
|
149
|
-
data.toString("utf8") : `data:${type};base64,${data.toString("base64")}
|
|
195
|
+
const content = type === "image/svg+xml" ?
|
|
196
|
+
data.toString("utf8") : `data:${type}${params};base64,${data.toString("base64")}`;
|
|
197
|
+
const dark = split.params.dark;
|
|
198
|
+
const mark = image && !reference.includes("{theme}")
|
|
199
|
+
&& (dark === "invert" || dark === "none") ? dark : undefined;
|
|
200
|
+
target.embedding.push(mark !== undefined ? markDark(content, mark) : content);
|
|
150
201
|
}
|
|
151
202
|
catch (err) {
|
|
152
203
|
target.embedding.push("");
|
|
@@ -157,8 +208,17 @@ const embed = (ctx, object, file, defs) => {
|
|
|
157
208
|
}
|
|
158
209
|
};
|
|
159
210
|
const line = ctx.metaOf(object).line;
|
|
160
|
-
if (object.description !== undefined)
|
|
211
|
+
if (object.description !== undefined) {
|
|
161
212
|
load(object.description, object.description.description, line);
|
|
213
|
+
load(object.description, object.description.elaboration ?? "", line);
|
|
214
|
+
/* validate the listing parameters of the fenced code blocks */
|
|
215
|
+
for (const text of [object.description.description, object.description.elaboration ?? ""])
|
|
216
|
+
marked.walkTokens(marked.lexer(text), (token) => {
|
|
217
|
+
const info = token.type === "code" ? fenceInfo(token.lang) : undefined;
|
|
218
|
+
if (info?.error !== undefined && codeLanguage(info.lang) !== undefined)
|
|
219
|
+
ctx.diagnose(file, line, info.error);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
162
222
|
for (const property of object.properties)
|
|
163
223
|
load(property, property.value, ctx.propMeta.get(property)?.line ?? line);
|
|
164
224
|
for (const child of object.children)
|
|
@@ -415,20 +475,21 @@ export const parseFile = (ctx, source) => {
|
|
|
415
475
|
}
|
|
416
476
|
else {
|
|
417
477
|
/* an image definition of the Markdown renderer is the one
|
|
418
|
-
supported link definition (its SVG kept as-is again
|
|
478
|
+
supported link definition (its SVG kept as-is again,
|
|
479
|
+
unless marked as inverted on the dark theme) */
|
|
419
480
|
const def = token.type === "def" ? token : undefined;
|
|
420
481
|
const m = def?.href.match(embeddingDataRegex) ?? null;
|
|
421
482
|
const unsupported = unsupportedTokens[token.type];
|
|
422
483
|
if (def !== undefined && m !== null)
|
|
423
|
-
defs.set(def.tag, m[1] === "image/svg+xml" ?
|
|
424
|
-
Buffer.from(m[
|
|
484
|
+
defs.set(def.tag, m[1] === "image/svg+xml" && m[2] === undefined ?
|
|
485
|
+
Buffer.from(m[3], "base64").toString("utf8") : def.href);
|
|
425
486
|
else if (unsupported !== undefined)
|
|
426
487
|
ctx.diagnose(source.file, line, `unsupported ${unsupported} content ignored`);
|
|
427
488
|
}
|
|
428
489
|
line += (token.raw.match(/\n/g) ?? []).length;
|
|
429
490
|
}
|
|
430
491
|
flush();
|
|
431
|
-
/* load the embedded
|
|
492
|
+
/* load the embedded files of all fully parsed objects */
|
|
432
493
|
for (const artifact of state.artifacts)
|
|
433
494
|
for (const object of artifact.objects)
|
|
434
495
|
embed(ctx, object, source.file, defs);
|
package/dst/specbook-parse.js
CHANGED
|
@@ -52,7 +52,7 @@ export const specStatistics = (specification) => {
|
|
|
52
52
|
objects++;
|
|
53
53
|
const texts = [object.name, ...object.properties.map((p) => p.value)];
|
|
54
54
|
if (object.description !== undefined)
|
|
55
|
-
texts.push(object.description.description, object.description.rationale ?? "");
|
|
55
|
+
texts.push(object.description.description, object.description.rationale ?? "", object.description.elaboration ?? "");
|
|
56
56
|
for (const text of texts)
|
|
57
57
|
links += (plainText(text).match(referenceRegex) ?? []).length;
|
|
58
58
|
object.children.forEach(walk);
|
package/dst/specbook-project.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { parseDocument, LineCounter } from "yaml";
|
|
8
|
+
import { parseDocument, stringify, LineCounter } from "yaml";
|
|
9
9
|
import * as v from "valibot";
|
|
10
10
|
import { lineColOfPath } from "./specbook-config.js";
|
|
11
11
|
import { renderDiagnostic } from "./specbook-diagnostic.js";
|
|
@@ -104,3 +104,12 @@ export const loadProject = (dir) => {
|
|
|
104
104
|
basedir: basedir !== undefined ? path.resolve(base, basedir) : undefined
|
|
105
105
|
};
|
|
106
106
|
};
|
|
107
|
+
/* write a new project configuration file into the directory, with the
|
|
108
|
+
schema configuration patterns and the base directory given relative
|
|
109
|
+
to it, never overwriting an existing file */
|
|
110
|
+
export const writeProject = (dir, config, basedir) => {
|
|
111
|
+
const file = path.join(dir, projectFile);
|
|
112
|
+
const text = stringify({ config: config.length === 1 ? config[0] : config, basedir });
|
|
113
|
+
fs.writeFileSync(file, text, { encoding: "utf8", flag: "wx" });
|
|
114
|
+
return file;
|
|
115
|
+
};
|
package/dst/specbook-theme.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type ThemeColors = {
|
|
|
4
4
|
accent: string[];
|
|
5
5
|
signal: string[];
|
|
6
6
|
};
|
|
7
|
+
export declare const themeToneDefault = "#336699";
|
|
7
8
|
export declare const themeColors: (tone: string) => ThemeColors;
|
|
8
9
|
export declare const themeStylesheet: (colors: ThemeColors) => string;
|
|
9
10
|
export type ThemeMapping = {
|
package/dst/specbook-theme.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import { parse, generate } from "@rse/mrcs";
|
|
7
|
+
/* the theme color tone of a document carrying no THEME-TONE */
|
|
8
|
+
export const themeToneDefault = "#336699";
|
|
7
9
|
/* generate the theme color spreads from the theme color tone(s):
|
|
8
10
|
a grey base spread, an accent spread derived from the first tone,
|
|
9
11
|
and a signal spread derived from the second tone (if given) or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rse/specbook",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"description": "Markdown-based Specification Format (API, CLI and MCP service)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [ "dst" ],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"marked": "18.0
|
|
20
|
+
"marked": "18.1.0",
|
|
21
21
|
"marked-smartypants": "1.1.12",
|
|
22
22
|
"yaml": "2.9.1",
|
|
23
23
|
"json5": "2.2.3",
|
|
@@ -27,14 +27,19 @@
|
|
|
27
27
|
"svgo": "4.1.0",
|
|
28
28
|
"@rse/mrcs": "0.9.2",
|
|
29
29
|
"@rse/gradia": "1.4.2",
|
|
30
|
-
"
|
|
30
|
+
"beautiful-mermaid": "1.1.3",
|
|
31
|
+
"@terrastruct/d2": "0.1.33",
|
|
32
|
+
"shiki": "4.5.0",
|
|
33
|
+
"pdfjs-dist": "6.4.299",
|
|
34
|
+
"@napi-rs/canvas": "1.0.10",
|
|
35
|
+
"@toon-format/toon": "4.4.0",
|
|
31
36
|
"valibot": "1.5.0",
|
|
32
37
|
"nunjucks": "3.2.4",
|
|
33
38
|
"@rse/nunjucks-addons": "2.2.0",
|
|
34
39
|
"textframe": "1.2.1",
|
|
35
|
-
"@swc/html": "1.16.
|
|
40
|
+
"@swc/html": "1.16.13",
|
|
36
41
|
"tokenizr": "2.2.1",
|
|
37
|
-
"playwright": "1.
|
|
42
|
+
"playwright": "1.64.0",
|
|
38
43
|
"source-code-error": "1.2.0",
|
|
39
44
|
"commander": "15.0.0",
|
|
40
45
|
"chalk": "6.0.1",
|
|
@@ -43,21 +48,22 @@
|
|
|
43
48
|
"picomatch": "4.0.7",
|
|
44
49
|
"es-toolkit": "1.52.0",
|
|
45
50
|
"fastify": "5.12.5",
|
|
46
|
-
"@fastify/websocket": "11.3.
|
|
47
|
-
"@modelcontextprotocol/sdk": "1.
|
|
51
|
+
"@fastify/websocket": "11.3.3",
|
|
52
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
48
53
|
"zod": "4.6.5"
|
|
49
54
|
},
|
|
50
55
|
"devDependencies": {
|
|
51
56
|
"chokidar-cli": "3.0.0",
|
|
52
57
|
"eslint": "10.9.0",
|
|
53
58
|
"@eslint/js": "10.0.1",
|
|
54
|
-
"typescript-eslint": "8.71.
|
|
59
|
+
"typescript-eslint": "8.71.1",
|
|
55
60
|
"eslint-plugin-promise": "7.3.0",
|
|
56
61
|
"neostandard": "0.14.0-next.1",
|
|
57
|
-
"globals": "17.
|
|
62
|
+
"globals": "17.13.0",
|
|
63
|
+
"eslint-plugin-yml": "3.8.1",
|
|
58
64
|
"markdownlint": "0.41.1",
|
|
59
65
|
"markdownlint-cli2": "0.23.3",
|
|
60
|
-
"@types/node": "26.6.
|
|
66
|
+
"@types/node": "26.6.4",
|
|
61
67
|
"@types/picomatch": "4.0.3",
|
|
62
68
|
"@types/subset-font": "2.3.0",
|
|
63
69
|
"@types/nunjucks": "3.2.6",
|
|
@@ -65,9 +71,9 @@
|
|
|
65
71
|
"typescript": "6.0.3",
|
|
66
72
|
"tsx": "4.23.15",
|
|
67
73
|
"source-sans": "3.52.0",
|
|
68
|
-
"
|
|
74
|
+
"@fontsource/dejavu-mono": "5.3.0",
|
|
69
75
|
"@fontsource/noto-sans-symbols": "5.3.0",
|
|
70
|
-
"postcss": "8.5.
|
|
76
|
+
"postcss": "8.5.29",
|
|
71
77
|
"postcss-cli": "12.0.0",
|
|
72
78
|
"postcss-url": "10.1.4",
|
|
73
79
|
"stylus": "0.64.0",
|