@rse/specbook 0.9.7 → 0.9.9

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 CHANGED
@@ -44,7 +44,8 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
44
44
  The YAML schema configuration strictly defines the allowed object
45
45
  kinds, hierarchies, and properties, whose values are constrained by an
46
46
  expression language (regex, enum, tags, list, and reference). Violations
47
- are reported as file- and line-precise diagnostics.
47
+ are reported as file- and line-precise diagnostics. A bundled standard
48
+ schema configuration applies if no particular one is given.
48
49
 
49
50
  - **Object Model Diagram Visualisation**:
50
51
  Object kinds can declare "graph", "hub", or "grid" diagrams in the
@@ -82,7 +83,9 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
82
83
  Overview Poster
83
84
  ---------------
84
85
 
85
- [![poster](etc/poster.png)](etc/poster.pdf)
86
+ [![poster-1](etc/poster-1.png)](etc/poster-1.pdf)
87
+
88
+ [![poster-2](etc/poster-2.png)](etc/poster-2.pdf)
86
89
 
87
90
  Rendering Example
88
91
  -----------------
@@ -105,17 +108,18 @@ Usage
105
108
  ### CLI
106
109
 
107
110
  ```
108
- $ specbook init [-v] -c <yaml-file> [-b <basedir>]
109
- $ specbook lint [-v] -c <yaml-file> [-b <basedir>]
110
- $ specbook export [-v] -c <yaml-file> [-b <basedir>] [-o [<format>:]<output-file>] [...]
111
+ $ specbook init [-v] [-c <yaml-file>] [-b <basedir>]
112
+ $ specbook lint [-v] [-c <yaml-file>] [-b <basedir>]
113
+ $ specbook export [-v] [-c <yaml-file>] [-b <basedir>] [-o [<format>:]<output-file>] [...]
111
114
  $ specbook describe [-v] [-c <yaml-file>] [-b <basedir>] [-e] [-o <markdown-file>]
112
115
  $ specbook mcp [-v]
113
116
  ```
114
117
 
115
- The YAML schema configuration `-c`/`--config` is mandatory for `init`,
116
- `lint`, and `export`, as it determines the specification: exactly the
117
- artifact files its `file` fields reference are loaded and parsed, all
118
- other Markdown files below the base directory are ignored. A referenced
118
+ The YAML schema configuration `-c`/`--config` (default: the bundled
119
+ standard schema configuration `specbook-format.yaml`) determines the
120
+ specification: exactly the artifact files its `file` fields reference
121
+ are loaded and parsed, all other Markdown files below the base
122
+ directory are ignored. A referenced
119
123
  file which is absent is reported, unless all of its artifacts are
120
124
  `optional`, and so is an artifact absent from its present file, unless
121
125
  it is `optional` itself. Both `lint` and `export` report all
@@ -132,6 +136,11 @@ occur multiple times. The format (`json`, `json5`, `yaml`, `toon`,
132
136
  it is explicitly given as a `<format>:` prefix, and plain `-` (stdout)
133
137
  defaults to JSON.
134
138
 
139
+ The `md` format normalizes the entire corpus into a *single* Markdown
140
+ document, whose single frontmatter block carries the earliest `Created:`
141
+ and the latest `Modified:` timestamp of all artifacts, so the
142
+ per-artifact timestamps do not survive this round-trip.
143
+
135
144
  The `describe` command outputs the description of the SpecBook models
136
145
  and formats. If the configuration file `-c`/`--config` or the base
137
146
  directory `-b`/`--basedir` is given, it additionally appends a *SpecBook
@@ -144,9 +153,7 @@ its own.
144
153
  The default value of every CLI option `--xxx` can be overridden by a
145
154
  corresponding `SPECBOOK_XXX` environment variable (e.g.
146
155
  `SPECBOOK_BASEDIR`, `SPECBOOK_CONFIG`, `SPECBOOK_OUTPUT`,
147
- `SPECBOOK_VERBOSE`), while an explicitly supplied option always wins. A
148
- `SPECBOOK_CONFIG` value also satisfies the otherwise mandatory
149
- `-c`/`--config` option.
156
+ `SPECBOOK_VERBOSE`), while an explicitly supplied option always wins.
150
157
 
151
158
  ### API
152
159
 
@@ -157,7 +164,6 @@ const specbook = new SpecBook({
157
164
  verbose: (cmd, msg) => console.error(`specbook: ${cmd}: ${renderVerbose(msg)}`)
158
165
  })
159
166
  const result = await specbook.lint({
160
- config: "smp/ase.specbook.yaml",
161
167
  basedir: "smp/broadcast"
162
168
  })
163
169
  ```
@@ -9,6 +9,7 @@ export { type LintResult };
9
9
  export type { Spec, SpecArtifact, SpecObject, SpecDescription, SpecProperty } from "./specbook-format-spec.js";
10
10
  export type { Schema, SchemaObject, SchemaProperty } from "./specbook-format-schema.js";
11
11
  export declare const version: string;
12
+ export declare const standardConfig: string;
12
13
  export type VerboseSink = (cmd: string, msg: string) => void;
13
14
  export interface SpecBookOptions {
14
15
  verbose?: VerboseSink;
@@ -17,18 +18,18 @@ export declare class SpecBook {
17
18
  private verbose;
18
19
  constructor(options?: SpecBookOptions);
19
20
  private verboseOf;
20
- private requireConfigFile;
21
+ private configFile;
21
22
  private requireConfig;
22
23
  init(options: {
23
- config: string;
24
+ config?: string;
24
25
  basedir?: string;
25
26
  }): Promise<string[]>;
26
27
  lint(options: {
27
- config: string;
28
+ config?: string;
28
29
  basedir?: string;
29
30
  }): Promise<LintResult>;
30
31
  export(options: {
31
- config: string;
32
+ config?: string;
32
33
  basedir?: string;
33
34
  formats?: ExportFormat[];
34
35
  }): Promise<Buffer[]>;
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import * as fs from "node:fs";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { loadConfig } from "./specbook-config.js";
8
9
  import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
9
10
  import { initSpecification } from "./specbook-cmd-init.js";
@@ -18,6 +19,10 @@ export { renderDiagnostic, renderDiagnosticVerbose };
18
19
  /* our own version, taken from the package manifest, which resides one
19
20
  level above both the source and the compiled module directory */
20
21
  export const version = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
22
+ /* the bundled standard YAML schema configuration, which resides
23
+ alongside the compiled module and is used whenever no particular
24
+ schema configuration is given */
25
+ export const standardConfig = fileURLToPath(new URL("specbook-format.yaml", import.meta.url));
21
26
  /* the SpecBook API */
22
27
  export class SpecBook {
23
28
  verbose;
@@ -28,16 +33,15 @@ export class SpecBook {
28
33
  verboseOf(cmd) {
29
34
  return (msg) => this.verbose(cmd, msg);
30
35
  }
31
- /* require the file of the mandatory YAML schema configuration */
32
- requireConfigFile(file) {
33
- if (file === undefined)
34
- throw new Error("YAML schema configuration required");
35
- return file;
36
+ /* determine the file of the YAML schema configuration, falling
37
+ back onto the bundled standard schema configuration */
38
+ configFile(file) {
39
+ return file ?? standardConfig;
36
40
  }
37
- /* load a mandatory YAML schema configuration, failing on any problem */
41
+ /* load a YAML schema configuration, failing on any problem */
38
42
  requireConfig(file, verbose) {
39
- file = this.requireConfigFile(file);
40
- verbose(`loading configuration "${literal(file)}"`);
43
+ file = this.configFile(file);
44
+ verbose(`loading schema configuration "${literal(file)}"`);
41
45
  const { config, diagnostics } = loadConfig(file);
42
46
  if (config === undefined)
43
47
  throw new Error("invalid configuration:\n" +
@@ -53,7 +57,7 @@ export class SpecBook {
53
57
  }
54
58
  /* lint the specification Markdown files below the base directory */
55
59
  async lint(options) {
56
- return lint({ config: this.requireConfigFile(options.config),
60
+ return lint({ config: this.configFile(options.config),
57
61
  basedir: options.basedir ?? ".", verbose: this.verboseOf("lint") });
58
62
  }
59
63
  /* export the specification Markdown files below the base directory
@@ -63,7 +67,7 @@ export class SpecBook {
63
67
  as a partial or invalid specification must never be emitted) */
64
68
  async export(options) {
65
69
  const verbose = this.verboseOf("export");
66
- const result = lint({ config: this.requireConfigFile(options.config),
70
+ const result = lint({ config: this.configFile(options.config),
67
71
  basedir: options.basedir ?? ".", verbose });
68
72
  if (result.diagnostics.length > 0)
69
73
  throw new Error("invalid specification:\n" +
@@ -54,9 +54,10 @@ const envDefaultFlag = (name, fallback) => {
54
54
  const withVerboseOption = (command) => command
55
55
  .option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false));
56
56
  /* provide the common options of the specification processing sub-commands,
57
- for which the YAML schema configuration is mandatory */
57
+ for which the YAML schema configuration falls back onto the standard one */
58
58
  const withCommonOptions = (command) => withVerboseOption(command)
59
- .requiredOption("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"))
59
+ .option("-c, --config <yaml-file>", "YAML schema configuration file " +
60
+ "(default: the bundled standard schema configuration)", envDefault("config"))
60
61
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."));
61
62
  /* parse the command line */
62
63
  const program = new Command();
@@ -18,20 +18,17 @@ export const describeFormat = (options) => {
18
18
  "==============================\n");
19
19
  if (options.config !== undefined && options.embed === true)
20
20
  sections.push(textframe(`
21
- The **SpecBook SCHEMA Model** of this particular project is the following
22
- YAML schema configuration of the file \`${options.config}\`:
21
+ The **SpecBook SCHEMA Model** is the following YAML schema configuration:
23
22
  `) + "\n```yaml\n" +
24
23
  fs.readFileSync(options.config, "utf8").replace(/\n*$/, "\n") +
25
24
  "```\n");
26
25
  else if (options.config !== undefined)
27
26
  sections.push(textframe(`
28
- The **SpecBook SCHEMA Model** of this particular project is the YAML
29
- schema configuration in the file \`${options.config}\`.
27
+ The **SpecBook SCHEMA Model** is the YAML schema configuration in file \`${options.config}\`.
30
28
  `));
31
29
  if (options.basedir !== undefined)
32
30
  sections.push(textframe(`
33
- The **SpecBook SPEC Model** of this particular project is the set of
34
- specification Markdown files in the directory \`${options.basedir}\`.
31
+ The **SpecBook SPEC Model** is the set of Markdown specification files in directory \`${options.basedir}\`.
35
32
  `));
36
33
  }
37
34
  return sections.join("\n");
@@ -27,11 +27,15 @@ const extensions = new Map([
27
27
  format from the filename extension when not explicitly given
28
28
  (plain "-" for stdout defaults to JSON) */
29
29
  export const parseOutputSpec = (spec) => {
30
- const prefixed = spec.match(/^([a-zA-Z0-9]+):(.+)$/);
30
+ /* a single-character prefix is never a format name, so a Windows
31
+ drive letter still falls through to the extension inference */
32
+ const prefixed = spec.match(/^([a-zA-Z0-9]{2,}):(.+)$/);
31
33
  if (prefixed !== null) {
32
34
  const explicit = extensions.get(prefixed[1].toLowerCase());
33
- if (explicit !== undefined)
34
- return { format: explicit, output: prefixed[2] };
35
+ if (explicit === undefined)
36
+ throw new Error(`unknown export format "${prefixed[1]}" in output "${spec}" ` +
37
+ `(supported: ${Array.from(extensions.keys()).join(", ")})`);
38
+ return { format: explicit, output: prefixed[2] };
35
39
  }
36
40
  if (spec === "-")
37
41
  return { format: "json", output: spec };
@@ -25,7 +25,7 @@ const schemaFiles = (config) => {
25
25
  export const lint = (options) => {
26
26
  const diagnostics = new Array();
27
27
  /* load the mandatory YAML schema configuration */
28
- options.verbose(`loading configuration "${literal(options.config)}"`);
28
+ options.verbose(`loading schema configuration "${literal(options.config)}"`);
29
29
  const loaded = loadConfig(options.config);
30
30
  diagnostics.push(...loaded.diagnostics);
31
31
  const config = loaded.config;
@@ -22,10 +22,11 @@ const lineColOfPath = (doc, lines, path) => {
22
22
  return { line: 1, column: 1 };
23
23
  };
24
24
  /* check the constraints of a structurally valid configuration which
25
- are beyond its schema: "file" fields are allowed on the first
26
- (artifact) level only, property value expressions have to be
27
- syntactically valid, and the names of non-artifact objects have to
28
- be valid patterns (as they are compiled into regular expressions) */
25
+ are beyond its schema: sibling objects have to stay distinctly
26
+ resolvable, "file" fields are allowed on the first (artifact) level
27
+ only, property value expressions have to be syntactically valid, and
28
+ the names of non-artifact objects have to be valid patterns (as they
29
+ are compiled into regular expressions) */
29
30
  const checkConstraints = (config, file, posOfPath) => {
30
31
  const diagnostics = new Array();
31
32
  const diagnose = (path, message) => {
@@ -33,8 +34,17 @@ const checkConstraints = (config, file, posOfPath) => {
33
34
  diagnostics.push({ file, line: pos.line, column: pos.column, message });
34
35
  };
35
36
  const check = (objects, path, depth) => {
37
+ /* the sibling objects are resolved by kind and id on the first
38
+ (artifact) level and by kind alone below it, so a sibling
39
+ colliding on this key stays unreachable dead configuration */
40
+ const seen = new Set();
36
41
  for (const [i, object] of objects.entries()) {
37
42
  const at = [...path, i];
43
+ const ident = depth > 1 ? object.kind : `${object.kind}:${object.id ?? object.name ?? ""}`;
44
+ if (seen.has(ident))
45
+ diagnose([...at, "kind"], `object "${ident}" collides with a preceding sibling object (the later one is unreachable)`);
46
+ else
47
+ seen.add(ident);
38
48
  if (depth > 1 && object.file !== undefined)
39
49
  diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
40
50
  for (const [j, prop] of (object.props ?? []).entries()) {
@@ -1,10 +1,14 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
2
  import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
3
3
  import type { ParseContext } from "./specbook-parse-common.js";
4
+ export interface DiagramError {
5
+ reason: string;
6
+ object?: SpecObject;
7
+ }
4
8
  export interface DiagramResult {
5
9
  spec?: string;
6
10
  config?: SchemaDiagram["config"];
7
- errors: string[];
11
+ errors: DiagramError[];
8
12
  }
9
13
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
10
14
  export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -110,13 +110,13 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
110
110
  edgeNode(edgeObject, diagram.edgeSource) : parents.get(edgeObject);
111
111
  const target = edgeNode(edgeObject, diagram.edgeTarget);
112
112
  if (source === undefined) {
113
- errors.push(diagram.edgeSource !== undefined ?
114
- `diagram edge object "${edgeObject.name}" carries no resolvable source reference` :
115
- `diagram edge object "${edgeObject.name}" has no parent object as source`);
113
+ errors.push({ object: edgeObject, reason: diagram.edgeSource !== undefined ?
114
+ "carries no resolvable source reference" :
115
+ "has no parent object as source" });
116
116
  continue;
117
117
  }
118
118
  if (target === undefined) {
119
- errors.push(`diagram edge object "${edgeObject.name}" carries no resolvable target reference`);
119
+ errors.push({ object: edgeObject, reason: "carries no resolvable target reference" });
120
120
  continue;
121
121
  }
122
122
  if (!nodeSet.has(source) || !nodeSet.has(target))
@@ -136,11 +136,11 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
136
136
  }
137
137
  else {
138
138
  if (diagram.edges !== undefined)
139
- errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
139
+ errors.push({ reason: "\"grid\" diagram cannot carry an \"edges\" configuration" });
140
140
  if (diagram.hierarchy === true)
141
- errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
141
+ errors.push({ reason: "\"grid\" diagram cannot carry a \"hierarchy\" configuration" });
142
142
  if (diagram.onlyConnected === true)
143
- errors.push("\"grid\" diagram cannot carry an \"onlyConnected\" configuration");
143
+ errors.push({ reason: "\"grid\" diagram cannot carry an \"onlyConnected\" configuration" });
144
144
  }
145
145
  /* deduplicate the edges (the same reference can occur in
146
146
  multiple texts of the same node object) */
@@ -167,7 +167,7 @@ const resolvePatterns = (index, errors, value, field) => {
167
167
  const pattern = m[1].trim();
168
168
  const set = resolveSet(index, pattern);
169
169
  if (set.length === 0)
170
- errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
170
+ errors.push({ reason: `unresolvable diagram "${field}" pattern "[[${pattern}]]"` });
171
171
  for (const match of set) {
172
172
  if (!seen.has(match)) {
173
173
  seen.add(match);
@@ -176,7 +176,7 @@ const resolvePatterns = (index, errors, value, field) => {
176
176
  }
177
177
  }
178
178
  if (!found)
179
- errors.push(`diagram "${field}" carries no "[[...]]" reference pattern`);
179
+ errors.push({ reason: `diagram "${field}" carries no "[[...]]" reference pattern` });
180
180
  return matches;
181
181
  };
182
182
  /* derive the Gradia spec of a single object from its "diagram:"
@@ -210,13 +210,13 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
210
210
  resolved object of an explicit "[[...]]" reference otherwise */
211
211
  let center = object;
212
212
  if (type !== "hub" && diagram.center !== undefined)
213
- errors.push(`"${type}" diagram cannot carry a "center" configuration`);
213
+ errors.push({ reason: `"${type}" diagram cannot carry a "center" configuration` });
214
214
  if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
215
215
  const reference = diagram.center.match(referenceOnce)?.[1].trim();
216
216
  const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
217
217
  if (resolved?.target === undefined) {
218
- errors.push((resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
219
- ` diagram "center" reference "${diagram.center}"`);
218
+ errors.push({ reason: (resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
219
+ ` diagram "center" reference "${diagram.center}"` });
220
220
  return { errors };
221
221
  }
222
222
  center = resolved.target;
@@ -227,7 +227,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
227
227
  Gradia requires exactly this constrained topology */
228
228
  if (type === "hub") {
229
229
  if (!nodeSet.has(center))
230
- errors.push(`"hub" diagram center "${center.name}" is not part of the node set`);
230
+ errors.push({ reason: `"hub" diagram center "${center.name}" is not part of the node set` });
231
231
  else {
232
232
  edges = edges.filter((edge) => (edge.source === center || edge.target === center)
233
233
  && edge.source !== edge.target);
@@ -250,7 +250,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
250
250
  nodes = nodes.filter((node) => connected.has(node));
251
251
  }
252
252
  if (nodes.length === 0)
253
- errors.push("diagram yields no nodes");
253
+ errors.push({ reason: "diagram yields no nodes" });
254
254
  if (errors.length > 0)
255
255
  return { errors };
256
256
  /* the "collapse" handling (enabled by default) silently omits a
@@ -277,9 +277,21 @@ export const specDiagrams = (specification, config) => {
277
277
  /* validate all configured diagrams of a specification, reporting
278
278
  every invalid diagram situation as a file/line-precise diagnostic */
279
279
  export const validateDiagrams = (ctx, specification, config) => {
280
- for (const [object, result] of specDiagrams(specification, config)) {
281
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
282
- for (const error of result.errors)
283
- ctx.diagnose(meta.file, meta.line, `invalid diagram on ${object.kind} "${object.name}": ${error}`);
284
- }
280
+ const seen = new Set();
281
+ for (const [object, result] of specDiagrams(specification, config))
282
+ for (const error of result.errors) {
283
+ /* attribute an edge object situation to the offending edge
284
+ object itself and report it just once, as a wildcard
285
+ "edges" pattern lets every diagram walk the very same
286
+ edge objects again */
287
+ const meta = ctx.objectMeta.get(error.object ?? object) ?? { file: "", line: 1 };
288
+ const message = error.object !== undefined ?
289
+ `invalid diagram edge on ${error.object.kind} "${error.object.name}": ${error.reason}` :
290
+ `invalid diagram on ${object.kind} "${object.name}": ${error.reason}`;
291
+ const key = `${meta.file}${meta.line}${message}`;
292
+ if (seen.has(key))
293
+ continue;
294
+ seen.add(key);
295
+ ctx.diagnose(meta.file, meta.line, message);
296
+ }
285
297
  };
@@ -342,7 +342,8 @@ const inlineValue = (kind, { key, value, embedding }) => {
342
342
  .map((content) => `<div class="embedding">${content}</div>`).join("");
343
343
  if (member === undefined || text === "")
344
344
  return safe(`${inline(text)}${embeddings}`);
345
- const items = member === "tags" ? splitItems(text) : [text];
345
+ const items = member === "tags" ?
346
+ splitItems(text).filter((item) => item !== "") : [text];
346
347
  return safe(items.map((item) => `<span class="value-member">${inline(item)}</span>`).join(" ") + embeddings);
347
348
  };
348
349
  /* expand the inline Markdown of the property values */
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import { specDiagrams } from "./specbook-diagram.js";
7
+ import { becauseRegex } from "./specbook-parse-common.js";
7
8
  /* format a timestamp in the frontmatter format */
8
9
  const formatTimestamp = (date) => {
9
10
  const pad = (n) => String(n).padStart(2, "0");
@@ -19,6 +20,10 @@ const renderKeyValuesMd = (properties, indent = "") => {
19
20
  `${indent}- ${property.key}:\n${indent} ${property.value}` :
20
21
  `${indent}- ${`${property.key}:`.padEnd(width)} ${property.value}`.trimEnd()).join("\n");
21
22
  };
23
+ /* whether a property can be a Concise Format segment: a value carrying
24
+ a ";" would split the segment, and a value carrying the rationale
25
+ marker would not be taken for a property at all on re-parsing */
26
+ const segmentableMd = (property) => !property.value.includes(";") && !becauseRegex.test(property.value);
22
27
  /* render a description statement with its optional rationale */
23
28
  const renderDescriptionMd = (description) => description.description + (description.rationale !== undefined ?
24
29
  `, BECAUSE ${description.rationale}` : "");
@@ -27,20 +32,22 @@ const renderDescriptionMd = (description) => description.description + (descript
27
32
  const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
28
33
  (object.paren !== undefined ? ` (${object.paren})` : "") +
29
34
  (object.anchor !== undefined ? ` {{${object.anchor}}}` : "");
30
- /* whether any descendant of an object carries a multi-line description
31
- (a fenced code block, an ordered list, a blockquote, or multiple
32
- paragraphs), which no Concise Format item can carry */
33
- const multiLineBelowMd = (object) => object.childs.some((child) => (child.description !== undefined
34
- && renderDescriptionMd(child.description).includes("\n")) || multiLineBelowMd(child));
35
+ /* whether any descendant of an object carries a description which no
36
+ Concise Format item can carry -- a multi-line one (a fenced code
37
+ block, an ordered list, a blockquote, or multiple paragraphs) or one
38
+ carrying a ";", as only a ";"-free description stays a single segment
39
+ on re-parsing instead of splitting off a spurious property */
40
+ const complexBelowMd = (object) => object.childs.some((child) => (child.description !== undefined
41
+ && /[\n;]/.test(renderDescriptionMd(child.description))) || complexBelowMd(child));
35
42
  /* render an object in the Concise Format as a list item of the
36
43
  given nesting depth, with its childs as nested list items */
37
44
  const renderConciseMd = (object, depth = 0) => {
38
45
  const indent = " ".repeat(depth * 4);
39
46
  const segments = [`${object.kind}: ${object.name}${nameSuffixMd(object)}`];
40
- /* a value carrying ";" cannot be a segment, so its property has to
41
- become a nested key/value item below the item instead */
42
- const wrapped = object.properties.filter((property) => property.value.includes(";"));
43
- const inline = object.properties.filter((property) => !property.value.includes(";"));
47
+ /* a property which cannot be a segment has to become a nested
48
+ key/value item below the item instead */
49
+ const wrapped = object.properties.filter((property) => !segmentableMd(property));
50
+ const inline = object.properties.filter((property) => segmentableMd(property));
44
51
  segments.push(...inline.map((property) => `${property.key}: ${property.value}`));
45
52
  if (object.description !== undefined)
46
53
  segments.push(renderDescriptionMd(object.description));
@@ -57,10 +64,10 @@ const renderConciseMd = (object, depth = 0) => {
57
64
  };
58
65
  /* render an object in the Complex Format (levels 1-3), with childs from
59
66
  level 4 upwards in the Concise Format -- unless a descendant carries
60
- a multi-line description, so all childs (as siblings have to share
61
- the format) stay in the Complex Format down to heading level 6 --,
62
- and the optionally derived Gradia diagram spec embedded as a
63
- "gradia" fenced code block below the heading */
67
+ a description no concise item can carry, so all childs (as siblings
68
+ have to share the format) stay in the Complex Format down to heading
69
+ level 6 --, and the optionally derived Gradia diagram spec embedded
70
+ as a "gradia" fenced code block below the heading */
64
71
  const renderObjectMd = (object, level, diagrams) => {
65
72
  const heading = `${"#".repeat(level)}${" ".repeat(Math.max(1, 4 - level))}` +
66
73
  `${object.kind}: ${object.name}${nameSuffixMd(object)}`;
@@ -74,7 +81,7 @@ const renderObjectMd = (object, level, diagrams) => {
74
81
  parts.push(renderDescriptionMd(object.description));
75
82
  /* the childs share one format, as a concise item following
76
83
  a heading would attach to the heading's object instead */
77
- if (level >= 3 && !multiLineBelowMd(object))
84
+ if (level >= 3 && !complexBelowMd(object))
78
85
  parts.push(object.childs.map((child) => renderConciseMd(child)).join("\n"));
79
86
  else
80
87
  parts.push(...object.childs.map((child) => renderObjectMd(child, level + 1, diagrams)));
@@ -37,9 +37,9 @@ task:
37
37
  - **SpecBook SCHEMA Model**:
38
38
  This is an object model, described in *YAML*, outside of
39
39
  **SpecBook**, which defines a particular **SpecBook** *Schema*.
40
- It is user-supplied and non-generic but reusable and applied onto
41
- the **SpecBook SPEC Model** to validate that it is domain-wise
42
- well-structured.
40
+ It is user-supplied (or the bundled standard one) and non-generic
41
+ but reusable and applied onto the **SpecBook SPEC Model** to
42
+ validate that it is domain-wise well-structured.
43
43
 
44
44
  - **SpecBook SPEC Model**:
45
45
  This is an object model, described in *Markdown*, outside
@@ -160,7 +160,8 @@ type SchemaGradiaConfig = Partial<{
160
160
  BECAUSE artifacts and their content follow one recursive structure
161
161
 
162
162
  - `SchemaObject.kind: string`:
163
- kind name of the object (e.g. `SPEC`, `REQUIREMENT`),
163
+ kind name of the object (e.g. `SPEC`, `REQUIREMENT`), unique among
164
+ its siblings, on level 1 together with its `id`,
164
165
  BECAUSE it discriminates objects and anchors `[[xxx]]` references
165
166
 
166
167
  - `SchemaObject.name?: string`:
@@ -530,7 +531,9 @@ SpecBook SCHEMA Model
530
531
  ### Artifacts
531
532
 
532
533
  A schema consists of a YAML file, which is exactly a serialization of
533
- the object model defined by the **SpecBook SCHEMA Meta Model**.
534
+ the object model defined by the **SpecBook SCHEMA Meta Model**. If no
535
+ particular schema is given, the standard schema configuration bundled
536
+ with **SpecBook** applies.
534
537
 
535
538
  SpecBook SPEC Model
536
539
  -------------------
@@ -557,6 +560,12 @@ follow each other on level 1, below the single frontmatter block of that
557
560
  file. Both ATX headings (`# <text/>`, `## <text/>`, ...) and Setext
558
561
  headings (`<text/>` underlined with `===` or `---`) are supported.
559
562
 
563
+ As the timestamps are a property of the *file* and not of the individual
564
+ artifact, the normalized Markdown export -- which merges the entire
565
+ corpus into a single document -- carries the earliest `Created:` and the
566
+ latest `Modified:` timestamp of all artifacts in its single frontmatter
567
+ block, so the per-artifact timestamps do not survive this round-trip.
568
+
560
569
  ### Object Model
561
570
 
562
571
  An artifact carries a tree of *objects*. Every object has a mandatory
@@ -644,12 +653,13 @@ the item would look like a key/value pair) -- backquote it in this case.
644
653
 
645
654
  When **SpecBook** itself generates specification Markdown, it emits
646
655
  the Complex Format on levels 1-3 and the Concise Format from level 4
647
- upwards -- unless a descendant of an object carries a multi-line
648
- description (a fenced code block, an ordered list, a blockquote, or
649
- multiple paragraphs), which no single list item can carry: all child
650
- objects of such an object then stay in the Complex Format (as sibling
651
- objects have to share the format), with headings reaching down to level
652
- 6. When **SpecBook** edits existing files, it mirrors the format each
656
+ upwards -- unless a descendant of an object carries a description which
657
+ no single list item can carry: a multi-line one (a fenced code block, an
658
+ ordered list, a blockquote, or multiple paragraphs) or one carrying a
659
+ `;` (which a re-parse would split into a spurious property plus a
660
+ truncated description). All child objects of such an object then stay in
661
+ the Complex Format (as sibling objects have to share the format), with
662
+ headings reaching down to level 6. When **SpecBook** edits existing files, it mirrors the format each
653
663
  existing object already uses.
654
664
 
655
665
  ### Names, Anchors, and Ids
@@ -679,9 +689,10 @@ In the Complex Format, each property is an unordered list item of the
679
689
  form `<key/>: <value/>`, whose value may continue on the following
680
690
  indented lines of the item (joined with spaces). In the Concise Format,
681
691
  each property is a `<key/>: <value/>` segment. A value carrying a `;`
682
- cannot be a segment and has to start on a continuation line of a
683
- key/value item, as a `;` on the first line of a list item marks a
684
- Concise Format object. The key is matched
692
+ or a ` BECAUSE ` cannot be a segment and has to become a key/value item
693
+ nested below the item instead -- a `;` value additionally starting on a
694
+ continuation line of that item, as a `;` on the first line of a list
695
+ item marks a Concise Format object. The key is matched
685
696
  against the schema exactly as written and may occur only once per
686
697
  object, and the value has to satisfy the configured constraint as a
687
698
  whole.
@@ -695,7 +706,9 @@ fenced code blocks. A fenced code block
695
706
  of language `gradia` is skipped, as it is the derived diagram which
696
707
  **SpecBook** itself emits into exported Markdown and which must not
697
708
  become authored content on a re-parse. In the Concise Format, the
698
- description is formed by the non-property segments of the item.
709
+ description is formed by the non-property segments of the item, so a
710
+ description carrying a `;` cannot be a segment either and forces its
711
+ object into the Complex Format.
699
712
 
700
713
  A description is split into its *statement* and *rationale* at the first
701
714
  `, BECAUSE ` (or `, **BECAUSE** `) occurrence: