@rse/specbook 0.9.2 → 0.9.3

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
@@ -33,18 +33,46 @@ service with tools `specbook_<cmd>()`.
33
33
  Specification Format
34
34
  --------------------
35
35
 
36
- A specification consists of Markdown *artifact* files, each carrying a
37
- frontmatter with `Created:` and `Modified:` timestamps and a tree of
38
- *objects*. Every object has a mandatory *kind*, a mandatory *name*, an
39
- optional unique anchor *id*, optional *properties* (key/value pairs),
40
- an optional *description* statement with an optional rationale, and
41
- optional *child* objects.
36
+ ### Artifact Files
37
+
38
+ A specification consists of Markdown *artifact* files, which are
39
+ scanned recursively from a base directory. Every file has to start with
40
+ a frontmatter block carrying the `Created:` and `Modified:` timestamps
41
+ (format `yyyy-LL-dd HH:mm`):
42
+
43
+ ```
44
+ ---
45
+ Created: 2026-06-18 10:18
46
+ Modified: 2026-06-18 10:18
47
+ ---
48
+ ```
49
+
50
+ Every level 1 heading opens a new *artifact*, so usually each file
51
+ carries exactly one. Both ATX headings (`# <text/>`, `## <text/>`, ...)
52
+ and Setext headings (`<text/>` underlined with `===` or `---`) are
53
+ supported.
54
+
55
+ ### Object Model
56
+
57
+ An artifact carries a tree of *objects*. Every object has a mandatory
58
+ *kind* (e.g. `ENTITY`), a mandatory *name*, a unique anchor *id*
59
+ (explicitly given or derived from the name), an optional *primary*
60
+ marker, optional *properties* (key/value pairs), an optional
61
+ *description* statement with an optional rationale, and optional *child*
62
+ objects. Which kinds, properties, and nestings are allowed is defined by
63
+ the schema configuration (see below); object kinds and property keys are
64
+ case-sensitive and have to be written exactly as configured. Backquotes
65
+ in names and property values are preserved as code markup for rendering,
66
+ but are ignored when matching names, references, and constraints.
42
67
 
43
68
  Three concrete syntaxes exist:
44
69
 
45
70
  ### Complex Format
46
71
 
47
- Usually used on object hierarchy levels 1-3 (`#`, `##`, `###`).
72
+ Usually used on object hierarchy levels 1-3 (`#`, `##`, `###`). A
73
+ heading opens the object, an optional unordered list carries its
74
+ properties, and the remaining content up to the next heading is its
75
+ description:
48
76
 
49
77
  ```
50
78
  # <kind/>: <name/> (<id/>)
@@ -55,16 +83,38 @@ Usually used on object hierarchy levels 1-3 (`#`, `##`, `###`).
55
83
  <statement/>, BECAUSE <rationale/>.
56
84
  ```
57
85
 
86
+ The heading level directly reflects the nesting level: a `##` object
87
+ becomes a child of the preceding `#` object, a `###` object a child of
88
+ the preceding `##` object, etc. Skipping a level (a heading without a
89
+ parent object on the level above) is an error.
90
+
58
91
  ### Concise Format
59
92
 
60
- Usually used on level 4 and deeper, with child objects nested as
61
- indented list items below their parent item:
93
+ Usually used on level 4 and deeper. A single unordered list item carries
94
+ the entire object as `;`-separated segments -- the `<kind/>: <name/>`
95
+ head, the `<key/>: <value/>` properties, and the trailing description --
96
+ and may wrap over multiple (indented) lines:
62
97
 
63
98
  ```
64
99
  - <kind/>: <name/>; <key/>: <value/>; [...];
65
100
  <statement/>, BECAUSE <rationale/>.
66
101
  ```
67
102
 
103
+ A list item is recognized as a Concise Format object (instead of a
104
+ property of the enclosing object) by carrying at least one `;` on its
105
+ first line. Every segment of the form `<key/>: <value/>` becomes a
106
+ property (unless it contains a ` BECAUSE `), all remaining segments are
107
+ joined into the description. Child objects nest as indented list items
108
+ below their parent item, where key/value items become properties and
109
+ concise items become child objects of that parent:
110
+
111
+ ```
112
+ - <kind/>: <name/>; <key/>: <value/>;
113
+ <statement/>, BECAUSE <rationale/>.
114
+ - <kind/>: <name/>; <key/>: <value/>;
115
+ <statement/>, BECAUSE <rationale/>.
116
+ ```
117
+
68
118
  ### Grouped Format
69
119
 
70
120
  A heading carrying just a *kind* and no `<kind/>: <name/>` pair opens a
@@ -81,13 +131,126 @@ with the (optionally backquoted) name directly:
81
131
 
82
132
  The heading kind is singularized by stripping one trailing `S`, so both
83
133
  `### STATES` and `### STATE` group objects of kind `STATE`. The grouped
84
- objects become childs of the object the heading is nested under.
134
+ objects become childs of the object the heading is nested under. An
135
+ unquoted name must not contain a `:` (else the item would look like a
136
+ key/value pair) -- backquote it in this case.
85
137
 
86
138
  When **SpecBook** itself generates specification Markdown, it emits
87
139
  the Complex Format on levels 1-3 and the Concise Format from level 4
88
140
  upwards. When **SpecBook** edits existing files, it mirrors the format
89
141
  each existing object already uses.
90
142
 
143
+ ### Names, Anchors, and Ids
144
+
145
+ In all formats, the `<name/>` may carry trailing decorations, in any
146
+ order:
147
+
148
+ - `{{<id/>}}`: the explicit Wiki-style anchor, setting the object id
149
+ (e.g. `## ENTITY: Attendee Browser {{attendee}}`).
150
+
151
+ - `(*)`: the *primary* marker, flagging the object as primary (e.g.
152
+ the primary attributes of an entity).
153
+
154
+ - `(<token/>)`: a parenthesized token with three possible roles: on
155
+ level 1 it becomes the artifact id (e.g. `# SPEC: Data Model (DM)`);
156
+ on deeper levels it is either accepted as the value of a still
157
+ missing configured property whose constraint it matches (e.g.
158
+ `### SCENARIO: Successful Token Login (Main)` satisfying an
159
+ `enum(Main,Alternative,Exceptional)` property), or else acts as the
160
+ implicit anchor id.
161
+
162
+ - `<a id="<id/>"></a>`: an HTML anchor at the very end of a heading,
163
+ as an alternative to `{{<id/>}}` (emitted by **SpecBook** itself
164
+ when exporting normalized Markdown).
165
+
166
+ When no explicit id is given, the id is derived by slugifying the name
167
+ (lowercased, with non-alphanumeric character runs dashed). An explicit
168
+ `{{<id/>}}` anchor always takes precedence over a `(<token/>)` id. When
169
+ the schema configures a fixed `id` for an object, it has to be
170
+ explicitly written in the input, via either `{{<id/>}}` or `(<id/>)`.
171
+
172
+ ### Properties
173
+
174
+ In the Complex Format, each property is an unordered list item of the
175
+ form `<key/>: <value/>`, whose value may continue on the following
176
+ indented lines of the item (joined with spaces). In the Concise Format,
177
+ each property is a `<key/>: <value/>` segment. A property key may carry
178
+ a trailing parenthesized annotation (e.g. `WHEN (Context)`), which is
179
+ kept for rendering but ignored when matching the key against the schema.
180
+
181
+ A multi-token convenience exists for pattern-constrained properties:
182
+ when a property value fails its (regex or enum) constraint as a whole,
183
+ but consists of multiple whitespace-separated tokens of which one
184
+ matches the property's own constraint, that token is kept as the value
185
+ and the remaining tokens are distributed (case-insensitively) across the
186
+ other still unset pattern-constrained properties of the object. Tokens
187
+ assignable to no property are reported as errors.
188
+
189
+ ### Descriptions and Rationales
190
+
191
+ The description of a Complex Format object is all block content below
192
+ its heading (up to the next heading) except the property list:
193
+ paragraphs, blockquotes, ordered lists (e.g. scenario steps), and
194
+ fenced code blocks. A fenced code block
195
+ of language `gradia` is skipped, as it is the derived diagram which
196
+ **SpecBook** itself emits into exported Markdown and which must not
197
+ become authored content on a re-parse. In the Concise Format, the
198
+ description is formed by the non-property segments of the item.
199
+
200
+ A description is split into its *statement* and *rationale* at the first
201
+ `, BECAUSE ` (or `, **BECAUSE** `) occurrence:
202
+
203
+ ```
204
+ The event is created and configured but not visible to attendees,
205
+ BECAUSE an event needs a private setup phase.
206
+ ```
207
+
208
+ ### Wiki-Style References
209
+
210
+ A `[[<reference/>]]` anywhere in a name, property value, description, or
211
+ rationale is a Wiki-style reference to another object, which is checked
212
+ for unique resolvability and rendered as a hyperlink in the HTML/PDF
213
+ exports. A reference is a `.`-separated path of segments, where each
214
+ segment matches a single object as:
215
+
216
+ - `<id/>`: the object id or explicit anchor (e.g. `[[attendee]]`)
217
+ - `<name/>`: the object name (e.g. `[[Attendee Browser]]`)
218
+ - `<kind/>:<name-or-id/>`: kind-qualified (e.g. `[[ENTITY:Event]]`)
219
+ - `<kind/>:*` or `*`: wildcards (for match sets, see below)
220
+
221
+ Each part can be double-quoted to allow spaces, dots, and colons inside
222
+ it (e.g. `[["Vue.js"]]`, whose dot would otherwise be taken as a path
223
+ separator). A multi-segment path matches object chains connected by
224
+ direct parent-to-child steps, where the leading segments up to the root
225
+ may be omitted (e.g. `[[FV.relay]]` matches the object `relay` directly
226
+ below the artifact with id `FV`).
227
+
228
+ A reference has to resolve to exactly one object: a single-segment
229
+ reference tries the variants (1) id/anchor, (2) name, and (3)
230
+ `<kind/>:<name-or-id/>` in order, where the first variant yielding any
231
+ matches decides; several matches are an ambiguity and zero matches an
232
+ unresolvable reference, both reported by the linter. Wildcard references
233
+ resolve into match *sets* and are used in the schema configuration to
234
+ constrain reference-valued properties (e.g. `[[PERSONA:*]]` or
235
+ `[[DM.ENTITY:*]]`).
236
+
237
+ ### Image Embeddings
238
+
239
+ A Markdown image `![<alt/>](<file/>)` inside a description or property
240
+ value embeds a local image file, resolved relative to the artifact file:
241
+ SVG files are inlined as-is, PNG/JPEG files are embedded as base64
242
+ `data:` URLs. URLs and other file types are left untouched.
243
+
244
+ ### Normalization
245
+
246
+ The semantic phase validates the parsed objects against the schema
247
+ configuration (unknown kinds and properties, missing required properties
248
+ and child kinds, and violated value constraints are reported with
249
+ file/line-precise diagnostics) and then normalizes the specification:
250
+ artifacts, child objects, and properties are stably reordered along the
251
+ schema definition, so the exports always present them in their
252
+ configured canonical order.
253
+
91
254
  Schema Configuration
92
255
  --------------------
93
256
 
@@ -110,8 +273,11 @@ object itself plus all objects below it), `center` names the object a
110
273
  `hub` is projected onto (default: `self`), `onlyConnected` drops the
111
274
  edge-less nodes of a `graph`, `collapse` (default: `true`) omits a
112
275
  degenerated diagram consisting of a single node only, `qualified` labels
113
- every node with its object kind, and `config` passes arbitrary Gradia
114
- rendering options (e.g. `grid-columns-max: 5`) through to the diagram.
276
+ every node with its object kind, `properties` lists the property names
277
+ whose values are attached to the nodes as key/value annotations (with
278
+ every `[[xxx]]` reference stripped to its target object name), and
279
+ `config` passes arbitrary Gradia rendering options (e.g.
280
+ `grid-columns-max: 5`) through to the diagram.
115
281
 
116
282
  The edges are derived from the `[[xxx]]` references of the node objects
117
283
  -- from their property values only, or, with `links: all`, from their
@@ -133,6 +299,22 @@ demonstrate:
133
299
  A `grid` is edge-less by definition and hence accepts neither `edges`
134
300
  nor `hierarchy`.
135
301
 
302
+ An object kind can furthermore carry a `format` field, which controls
303
+ how the HTML/PDF export renders the child objects of every object of
304
+ that kind. Its `type` selects the rendering: `complex` (nested
305
+ sections), `concise` (compact per-kind tables), or the default `auto`,
306
+ which collapses only the deepest level into tables. Below a `concise`
307
+ object, unconfigured childs implicitly stay `concise` and render as
308
+ sub-tables inside the description cells, while an explicitly configured
309
+ `type` is always honored, even a `complex` rendering pressed into a
310
+ cell. `maxTableColumns` (default: `4`) bounds the columns of the compact
311
+ tables below the object (wider groups chunk their properties into
312
+ embedded per-object tables), and `withUnusedProps` (default: `false`)
313
+ unconditionally renders the defined but unused properties of that kind,
314
+ as empty property table lines or table columns. Objects collapsed into
315
+ tables carry no headings and hence leave the table of contents and the
316
+ PDF outline.
317
+
136
318
  Usage
137
319
  -----
138
320
 
@@ -142,7 +324,6 @@ Usage
142
324
  $ specbook init [-v] [-c <yaml-file>] [-b <basedir>]
143
325
  $ specbook lint [-v] [-c <yaml-file>] [-b <basedir>]
144
326
  $ specbook export [-v] [-c <yaml-file>] [-b <basedir>] [-o [<format>:]<output-file>] [...]
145
- [--max-table-columns <count>]
146
327
  $ specbook describe [-v] [-c <yaml-file>] [-o <markdown-file>]
147
328
  $ specbook import [-v] [-c <yaml-file>] [-b <basedir>] <input-files...>
148
329
  $ specbook edit [-v] [-c <yaml-file>] [-b <basedir>] <query>
@@ -25,7 +25,6 @@ export declare class SpecBook {
25
25
  config?: string;
26
26
  basedir?: string;
27
27
  formats?: ExportFormat[];
28
- maxTableColumns?: number;
29
28
  }): Promise<Buffer[]>;
30
29
  describe(options: {
31
30
  config?: string;
@@ -55,7 +55,7 @@ export class SpecBook {
55
55
  result.diagnostics.map(renderDiagnostic).join("\n"));
56
56
  const buffers = [];
57
57
  for (const format of options.formats ?? ["json"])
58
- buffers.push(await exportSpecification(result.specification, format, this.verbose, options.maxTableColumns, result.config));
58
+ buffers.push(await exportSpecification(result.specification, format, this.verbose, result.config));
59
59
  return buffers;
60
60
  }
61
61
  /* describe the configured specification format as Markdown */
@@ -95,7 +95,6 @@ common(program.command("export"))
95
95
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
96
96
  .option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
97
97
  "from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), [])
98
- .option("--max-table-columns <count>", "maximum name/property/description columns for the compact table rendering", (value) => parseInt(value, 10), parseInt(envDefault("max-table-columns", "4") ?? "4", 10))
99
98
  .action(async (opts) => {
100
99
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
101
100
  const outputs = (opts.output.length > 0 ? opts.output : [envDefault("output") ?? "-"])
@@ -103,7 +102,7 @@ common(program.command("export"))
103
102
  /* parse the input once and export each distinct format once */
104
103
  const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
105
104
  const buffers = await specbook.export({ config: opts.config, basedir: opts.basedir,
106
- formats: distinct, maxTableColumns: opts.maxTableColumns });
105
+ formats: distinct });
107
106
  for (const { format, output } of outputs)
108
107
  await writeOutput(output, buffers[distinct.indexOf(format)], verboseOf(opts));
109
108
  });
@@ -6,4 +6,4 @@ export declare const parseOutputSpec: (spec: string) => {
6
6
  format: ExportFormat;
7
7
  output: string;
8
8
  };
9
- export declare const exportSpecification: (specification: Specification, format: ExportFormat, verbose: (msg: string) => void, maxTableColumns?: number, config?: SchemaSpecification) => Promise<Buffer>;
9
+ export declare const exportSpecification: (specification: Specification, format: ExportFormat, verbose: (msg: string) => void, config?: SchemaSpecification) => Promise<Buffer>;
@@ -38,7 +38,7 @@ export const parseOutputSpec = (spec) => {
38
38
  "(use an explicit \"<format>:<filename>\" specification)");
39
39
  };
40
40
  /* export a specification into the requested format */
41
- export const exportSpecification = async (specification, format, verbose, maxTableColumns = 4, config) => {
41
+ export const exportSpecification = async (specification, format, verbose, config) => {
42
42
  if (!formats.includes(format))
43
43
  throw new Error(`unknown export format "${format}"`);
44
44
  verbose(`exporting specification as "${format}"`);
@@ -62,7 +62,7 @@ export const exportSpecification = async (specification, format, verbose, maxTab
62
62
  const css = themeStylesheet(colors) + await subsetStylesheet(charset) + paperStylesheet(paper);
63
63
  if (format === "html") {
64
64
  /* compress the rendered HTML (whitespace, comments, and inline CSS/JS) */
65
- const html = await renderHtml(specification, maxTableColumns, config, undefined, css);
65
+ const html = await renderHtml(specification, config, undefined, css);
66
66
  const minified = await minify(Buffer.from(html, "utf8"), {
67
67
  collapseWhitespaces: "smart",
68
68
  removeComments: true,
@@ -74,5 +74,5 @@ export const exportSpecification = async (specification, format, verbose, maxTab
74
74
  else
75
75
  /* the PDF export (like print in general) always uses the light
76
76
  theme, so its decoration colors are the light mapping, too */
77
- return htmlToPdf((tocPages) => renderHtml(specification, maxTableColumns, config, tocPages, css), { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, maxTableColumns), verbose, css, themeMapping(colors, "light"), paper);
77
+ return htmlToPdf((tocPages) => renderHtml(specification, config, tocPages, css), { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, config), verbose, css, themeMapping(colors, "light"), paper);
78
78
  };
@@ -210,6 +210,20 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
210
210
  attrs.push(`type: ${atom(node.kind)}`);
211
211
  if (type === "hub" ? node === center : node.primary === true)
212
212
  attrs.push("primary: true");
213
+ /* attach the values of the configured properties as Gradia
214
+ key/value attributes (a node lacking a property is fine, as
215
+ the node set can mix objects of different kinds), with every
216
+ "[[...]]" reference stripped to its target object name */
217
+ for (const key of diagram.properties ?? []) {
218
+ const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
219
+ if (value !== undefined) {
220
+ const text = value.replace(referenceRegex, (_, reference) => {
221
+ const target = resolveUnique(index, reference.trim()).target;
222
+ return target !== undefined ? target.name : reference.trim();
223
+ });
224
+ attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
225
+ }
226
+ }
213
227
  lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
214
228
  }
215
229
  if (edges.length > 0) {
@@ -192,6 +192,20 @@ table.objects div.description { margin: 0; }
192
192
  table.objects div.description > :first-child { margin-top: 0; }
193
193
  table.objects div.description > :last-child { margin-bottom: 0; }
194
194
 
195
+ /* the embedded per-object chunk tables of the over-wide compact tables */
196
+ table.objects td.chunks { padding: 0; }
197
+ table.chunks { border-collapse: collapse; table-layout: fixed;
198
+ width: 100%; }
199
+ table.objects table.chunks tr td { background-color: transparent; }
200
+ table.objects table.chunks td:first-child { font-weight: normal; }
201
+
202
+ /* the nested renderings inside the description cells of the compact
203
+ tables ("concise" sub-tables and pressed-in "complex" objects),
204
+ reduced in size to fit into the cell */
205
+ table.objects td table.objects { font-size: 85%; margin: 0.5rem 0; }
206
+ table.objects td section { font-size: 85%; margin: 0.5rem 0; }
207
+ table.objects td section h6 { margin: 0.5rem 0 0.25rem 0; }
208
+
195
209
  p.description,
196
210
  div.description { margin: 1.0rem 0 0.5rem 0; }
197
211
  div.description p { margin: 0.5rem 0; }
@@ -5,5 +5,5 @@ export type OutlineEntry = {
5
5
  anchor: string;
6
6
  childs: OutlineEntry[];
7
7
  };
8
- export declare const htmlOutline: (specification: Specification, maxColumns: number) => OutlineEntry[];
9
- export declare const renderHtml: (specification: Specification, maxColumns: number, config?: SchemaSpecification, tocPages?: Map<string, number>, css?: string) => Promise<string>;
8
+ export declare const htmlOutline: (specification: Specification, config?: SchemaSpecification) => OutlineEntry[];
9
+ export declare const renderHtml: (specification: Specification, config?: SchemaSpecification, tocPages?: Map<string, number>, css?: string) => Promise<string>;
@@ -144,6 +144,32 @@ const templates = {
144
144
  {% endfor %}
145
145
  </tbody>
146
146
  </table>
147
+ `,
148
+ /* <TableChunked/> */
149
+ "TableChunked": textframe `
150
+ <table class="objects">
151
+ <thead>
152
+ <tr>
153
+ <th class="object-kind" style="width: {{ Table.width }}%">{{ Table.head }}</th>
154
+ <th class="description">Properties</th>
155
+ </tr>
156
+ </thead>
157
+ <tbody>
158
+ {% for row in Table.rows %}
159
+ <tr id="{{ row.id }}">
160
+ <td>{{ row.name }}{% if row.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
161
+ <td class="chunks">
162
+ <table class="chunks">
163
+ {% for chunk in row.chunks %}
164
+ <tr>{% for cell in chunk %}<th{% if not cell.desc %} class="property-name"{% endif %}{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.key }}</th>{% endfor %}</tr>
165
+ <tr>{% for cell in chunk %}<td{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.value }}</td>{% endfor %}</tr>
166
+ {% endfor %}
167
+ </table>
168
+ </td>
169
+ </tr>
170
+ {% endfor %}
171
+ </tbody>
172
+ </table>
147
173
  `
148
174
  };
149
175
  /* ==== Rendering ==== */
@@ -185,11 +211,12 @@ nunjucksAddons(env);
185
211
  const safe = (html) => new nunjucks.runtime.SafeString(html);
186
212
  const render = (name, context) => env.renderString(templates[name], context);
187
213
  /* the active per-document reference expander, fully-qualified
188
- anchor paths, enum/tags property value kinds, and pre-rendered
189
- diagram SVGs (all set during HTML rendering) */
214
+ anchor paths, enum/tags property value kinds, object schema nodes,
215
+ and pre-rendered diagram SVGs (all set during HTML rendering) */
190
216
  let linker = null;
191
217
  let anchors = null;
192
218
  let members = null;
219
+ let schemas = null;
193
220
  let diagrams = null;
194
221
  /* determine the fully-qualified anchor path of an object */
195
222
  const anchorOf = (object) => anchors?.get(object) ?? object.id;
@@ -252,6 +279,32 @@ const collectMembers = (schemas, result) => {
252
279
  }
253
280
  return result;
254
281
  };
282
+ /* map the specification objects onto their schema configuration
283
+ nodes (the schema resolution mirrors the semantic validation) */
284
+ const collectSchemas = (specification, config) => {
285
+ const result = new Map();
286
+ const walk = (object, schema) => {
287
+ result.set(object, schema);
288
+ for (const child of object.childs) {
289
+ const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
290
+ if (childSchema !== undefined)
291
+ walk(child, childSchema);
292
+ }
293
+ };
294
+ for (const artifact of specification.artifacts) {
295
+ for (const object of artifact.objects) {
296
+ const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
297
+ config.find((s) => (s.name ?? "").toUpperCase() ===
298
+ object.name.replace(/`/g, "").toUpperCase());
299
+ if (schema !== undefined)
300
+ walk(object, schema);
301
+ }
302
+ }
303
+ return result;
304
+ };
305
+ /* strip a trailing parenthesized annotation from a
306
+ property key (e.g. "WHEN (Context)") */
307
+ const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
255
308
  /* render a property value, badging the individual members of an
256
309
  "enum(...)" (a single member) or "tags(...)" (a member set) value */
257
310
  const inlineValue = (kind, key, value) => {
@@ -264,26 +317,53 @@ const inlineValue = (kind, key, value) => {
264
317
  /* expand the inline Markdown of the property values */
265
318
  const inlineProperties = (kind, properties) => properties.map((property) => ({ key: property.key,
266
319
  value: inlineValue(kind, property.key, property.value) }));
267
- /* determine the column shape of a potential compact table */
320
+ /* resolve the format configuration of an object */
321
+ const formatOf = (object) => schemas?.get(object)?.format;
322
+ /* determine the maximum table columns configured on an object */
323
+ const maxColumnsOf = (object) => formatOf(object)?.maxTableColumns ?? 4;
324
+ /* determine the effective properties of an object, with
325
+ "withUnusedProps" injecting the defined but still unused schema
326
+ properties (in schema order) as empty key/value entries */
327
+ const effectiveProperties = (object) => {
328
+ const schema = schemas?.get(object);
329
+ if (schema?.format?.withUnusedProps !== true)
330
+ return object.properties;
331
+ const merged = (schema.props ?? []).flatMap((prop) => {
332
+ const present = object.properties.filter((property) => plainKey(property.key) === prop.name);
333
+ return present.length > 0 ? present : [{ key: prop.name, value: "" }];
334
+ });
335
+ return [...merged, ...object.properties.filter((property) => !merged.includes(property))];
336
+ };
337
+ /* determine the column shape of a potential compact table, with
338
+ "withUnusedProps" injecting the defined but still unused schema
339
+ properties (in schema order) as additional columns */
268
340
  const tableShape = (childs) => {
269
- const keys = new Array();
341
+ let keys = new Array();
270
342
  for (const child of childs)
271
343
  for (const property of child.properties)
272
344
  if (!keys.includes(property.key))
273
345
  keys.push(property.key);
274
- return { keys, desc: childs.some((child) => child.description !== undefined) };
346
+ const schema = schemas?.get(childs[0]);
347
+ if (schema?.format?.withUnusedProps === true) {
348
+ const merged = (schema.props ?? []).flatMap((prop) => {
349
+ const present = keys.filter((key) => plainKey(key) === prop.name);
350
+ return present.length > 0 ? present : [prop.name];
351
+ });
352
+ keys = [...merged, ...keys.filter((key) => !merged.includes(key))];
353
+ }
354
+ return { keys, desc: childs.some((child) => child.description !== undefined || child.childs.length > 0) };
275
355
  };
276
- /* check whether the childs of an object form the deepest level and every
277
- per-kind group is compact enough (at most maxColumns name/property/
278
- description columns) for a tabular rendering */
279
- const tabularChilds = (object, maxColumns) => {
280
- if (object.childs.length === 0
281
- || !object.childs.every((child) => child.childs.length === 0))
356
+ /* decide whether the childs of an object collapse into the concise
357
+ (tabular) rendering: an explicit "format" type wins, "auto" collapses
358
+ the deepest level only, and inside an already concise rendering
359
+ context "auto" childs implicitly stay concise, too */
360
+ const conciseChilds = (object, schemaMap, concise) => {
361
+ if (object.childs.length === 0)
282
362
  return false;
283
- return groupChilds(object.childs).every((group) => {
284
- const { keys, desc } = tableShape(group);
285
- return 1 + keys.length + (desc ? 1 : 0) <= maxColumns;
286
- });
363
+ const type = schemaMap?.get(object)?.format?.type ?? "auto";
364
+ if (type !== "auto")
365
+ return type === "concise";
366
+ return concise || object.childs.every((child) => child.childs.length === 0);
287
367
  };
288
368
  /* group the childs of an object by their kind, preserving order */
289
369
  const groupChilds = (childs) => {
@@ -297,49 +377,96 @@ const groupChilds = (childs) => {
297
377
  }
298
378
  return [...groups.values()];
299
379
  };
300
- /* render a single-kind group of leaf childs into one compact table:
301
- the name first, then the property columns, then the description */
302
- const renderTable = (childs) => {
380
+ /* render the description cell of a table row: the description of the
381
+ object followed by its recursively rendered childs (implicitly or
382
+ explicitly concise childs as nested sub-tables, explicitly complex
383
+ ones as regular nested object renderings pressed into the cell) */
384
+ const renderCell = (child) => {
385
+ let html = child.description !== undefined ? renderDescription(child.description) : "";
386
+ if (child.childs.length > 0)
387
+ html += conciseChilds(child, schemas, true) ?
388
+ groupChilds(child.childs).map((group) => renderTable(group, maxColumnsOf(child))).join("") :
389
+ child.childs.map((sub) => renderObject(sub, 6, true)).join("");
390
+ return html;
391
+ };
392
+ /* render a single-kind group of childs into one compact table:
393
+ the name first, then the property columns, then the description;
394
+ a group wider than maxColumns instead chunks the property and
395
+ description cells of every object into an embedded per-object table */
396
+ const renderTable = (childs, maxColumns) => {
303
397
  const { keys, desc } = tableShape(childs);
304
- return render("Table", { Table: {
398
+ if (1 + keys.length + (desc ? 1 : 0) <= maxColumns)
399
+ return render("Table", { Table: {
400
+ head: childs[0].kind !== "" ? childs[0].kind : "Name",
401
+ keys,
402
+ desc,
403
+ /* under the fixed table layout the description column claims
404
+ twice the share of a regular column, compressing the others */
405
+ width: Math.round(200 / (keys.length + 3)),
406
+ rows: childs.map((child) => ({
407
+ id: anchorOf(child),
408
+ paren: child.paren,
409
+ primary: child.primary,
410
+ name: inline(child.name),
411
+ values: keys.map((key) => {
412
+ const value = child.properties.find((property) => property.key === key)?.value;
413
+ return value !== undefined ? inlineValue(child.kind, key, value) : "";
414
+ }),
415
+ description: safe(renderCell(child))
416
+ }))
417
+ } });
418
+ /* the embedded rows hold at most maxColumns - 1 cells (of the
419
+ group-wide union of property keys, plus the trailing description),
420
+ with the last cell spanning the leftover columns of the final row */
421
+ const size = Math.max(1, maxColumns - 1);
422
+ return render("TableChunked", { Table: {
305
423
  head: childs[0].kind !== "" ? childs[0].kind : "Name",
306
- keys,
307
- desc,
308
- /* under the fixed table layout the description column claims
309
- twice the share of a regular column, compressing the others */
310
- width: Math.round(200 / (keys.length + 3)),
311
- rows: childs.map((child) => ({
312
- id: anchorOf(child),
313
- paren: child.paren,
314
- primary: child.primary,
315
- name: inline(child.name),
316
- values: keys.map((key) => {
424
+ width: Math.round(100 / maxColumns),
425
+ rows: childs.map((child) => {
426
+ const cells = keys.map((key) => {
317
427
  const value = child.properties.find((property) => property.key === key)?.value;
318
- return value !== undefined ? inlineValue(child.kind, key, value) : "";
319
- }),
320
- description: child.description !== undefined ?
321
- safe(renderDescription(child.description)) : ""
322
- }))
428
+ return { key, desc: false, span: 1,
429
+ value: value !== undefined ? inlineValue(child.kind, key, value) : safe("") };
430
+ });
431
+ if (desc)
432
+ cells.push({ key: "Description", desc: true, span: 1,
433
+ value: safe(renderCell(child)) });
434
+ const chunks = new Array();
435
+ for (let i = 0; i < cells.length; i += size)
436
+ chunks.push(cells.slice(i, i + size));
437
+ const last = chunks[chunks.length - 1];
438
+ last[last.length - 1].span = size - last.length + 1;
439
+ return {
440
+ id: anchorOf(child),
441
+ paren: child.paren,
442
+ primary: child.primary,
443
+ name: inline(child.name),
444
+ chunks
445
+ };
446
+ })
323
447
  } });
324
448
  };
325
449
  /* recursively render an object into HTML */
326
- const renderObject = (object, level, maxColumns) => render("Object", { Object: {
327
- level: Math.min(level, 6),
328
- kind: object.kind,
329
- id: anchorOf(object),
330
- paren: object.paren,
331
- primary: object.primary,
332
- name: inline(object.name),
333
- diagram: diagrams?.has(object) === true ?
334
- safe(`<div class="diagram">${diagrams.get(object) ?? ""}</div>`) : "",
335
- properties: object.properties.length > 0 ?
336
- safe(render("Properties", { Properties: inlineProperties(object.kind, object.properties) })) : "",
337
- description: object.description !== undefined ?
338
- safe(renderDescription(object.description)) : "",
339
- childs: tabularChilds(object, maxColumns) ?
340
- safe(groupChilds(object.childs).map(renderTable).join("")) :
341
- safe(object.childs.map((child) => renderObject(child, level + 1, maxColumns)).join(""))
342
- } });
450
+ const renderObject = (object, level, concise) => {
451
+ const properties = effectiveProperties(object);
452
+ return render("Object", { Object: {
453
+ level: Math.min(level, 6),
454
+ kind: object.kind,
455
+ id: anchorOf(object),
456
+ paren: object.paren,
457
+ primary: object.primary,
458
+ name: inline(object.name),
459
+ diagram: diagrams?.has(object) === true ?
460
+ safe(`<div class="diagram">${diagrams.get(object) ?? ""}</div>`) : "",
461
+ properties: properties.length > 0 ?
462
+ safe(render("Properties", { Properties: inlineProperties(object.kind, properties) })) : "",
463
+ description: object.description !== undefined ?
464
+ safe(renderDescription(object.description)) : "",
465
+ childs: conciseChilds(object, schemas, concise) ?
466
+ safe(groupChilds(object.childs).map((group) => renderTable(group, maxColumnsOf(object))).join("")) :
467
+ safe(object.childs.map((child) => renderObject(child, level + 1, concise)).join(""))
468
+ } });
469
+ };
343
470
  /* render the title object into a title page */
344
471
  const renderTitlePage = (object, created, modified) => {
345
472
  const prop = (name) => object.properties.find((property) => property.key === name)?.value;
@@ -365,17 +492,18 @@ const renderTitlePage = (object, created, modified) => {
365
492
  } });
366
493
  };
367
494
  /* render an artifact into HTML */
368
- const renderArtifact = (artifact, maxColumns) => render("Artifact", { Artifact: {
369
- objects: safe(artifact.objects.map((object) => renderObject(object, 1, maxColumns)).join(""))
495
+ const renderArtifact = (artifact) => render("Artifact", { Artifact: {
496
+ objects: safe(artifact.objects.map((object) => renderObject(object, 1, false)).join(""))
370
497
  } });
371
498
  /* derive the hierarchy of the rendered object headings, skipping the
372
- title page object and the leaf childs collapsing into compact tables */
373
- export const htmlOutline = (specification, maxColumns) => {
499
+ title page object and the childs collapsing into compact tables */
500
+ export const htmlOutline = (specification, config) => {
374
501
  const paths = anchorPaths(buildLinkIndex(specification));
502
+ const schemaMap = config !== undefined ? collectSchemas(specification, config) : null;
375
503
  const entry = (object) => ({
376
504
  title: (object.kind !== "" ? `${object.kind}: ` : "") + object.name,
377
505
  anchor: paths.get(object) ?? object.id,
378
- childs: tabularChilds(object, maxColumns) ? [] : object.childs.map(entry)
506
+ childs: conciseChilds(object, schemaMap, false) ? [] : object.childs.map(entry)
379
507
  });
380
508
  return specification.artifacts
381
509
  .filter((artifact) => !artifact.objects.some(isTitleObject))
@@ -386,7 +514,7 @@ export const htmlOutline = (specification, maxColumns) => {
386
514
  with the build-time pre-assembled stylesheet embedded inline, the
387
515
  artifact timestamps aggregated into min(Created)/max(Modified), and
388
516
  optional per-anchor page numbers attached to the ToC entries */
389
- export const renderHtml = async (specification, maxColumns, config, tocPages, css) => {
517
+ export const renderHtml = async (specification, config, tocPages, css) => {
390
518
  /* the document language selects the smart typography quote style */
391
519
  const lang = documentLang(specification);
392
520
  quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
@@ -418,6 +546,7 @@ export const renderHtml = async (specification, maxColumns, config, tocPages, cs
418
546
  const index = buildLinkIndex(specification);
419
547
  anchors = anchorPaths(index);
420
548
  members = config !== undefined ? collectMembers(config, new Map()) : null;
549
+ schemas = config !== undefined ? collectSchemas(specification, config) : null;
421
550
  linker = (text) => expandReferences(text, (reference) => {
422
551
  const target = resolveUnique(index, reference).target;
423
552
  if (target === undefined)
@@ -447,6 +576,6 @@ export const renderHtml = async (specification, maxColumns, config, tocPages, cs
447
576
  safe(renderTitlePage(title, created.toISOString().slice(0, 10), modified.toISOString().slice(0, 10))) : "",
448
577
  search: title !== undefined ? safe(searchScript()) : "",
449
578
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
450
- artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact, maxColumns)).join(""))
579
+ artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact)).join(""))
451
580
  } });
452
581
  };
@@ -70,14 +70,12 @@ export const serveMcp = async (verbose) => {
70
70
  config: z.string().optional().describe("YAML schema configuration file"),
71
71
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
72
72
  format: z.enum(formats).optional().describe("output format (default: json)"),
73
- output: z.string().optional().describe("output file path"),
74
- maxTableColumns: z.number().optional().describe("maximum name/property/description " +
75
- "columns for the compact table rendering (default: 4)")
73
+ output: z.string().optional().describe("output file path")
76
74
  }
77
75
  }, async (args) => {
78
76
  try {
79
77
  const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
80
- formats: [args.format ?? "json"], maxTableColumns: args.maxTableColumns });
78
+ formats: [args.format ?? "json"] });
81
79
  if (args.output !== undefined) {
82
80
  await fs.promises.writeFile(args.output, data);
83
81
  return { content: [{ type: "text", text: `exported specification into "${args.output}" (${data.length} bytes)` }] };
@@ -9,6 +9,7 @@ export type SchemaObject = {
9
9
  desc?: string;
10
10
  optional?: boolean;
11
11
  diagram?: SchemaDiagram;
12
+ format?: SchemaFormat;
12
13
  props?: SchemaProperty[];
13
14
  childs?: SchemaObject[];
14
15
  };
@@ -24,8 +25,14 @@ export type SchemaDiagram = {
24
25
  onlyConnected?: boolean;
25
26
  collapse?: boolean;
26
27
  qualified?: boolean;
28
+ properties?: string[];
27
29
  config?: Partial<GradiaConfig>;
28
30
  };
31
+ export type SchemaFormat = {
32
+ type?: "auto" | "complex" | "concise";
33
+ maxTableColumns?: number;
34
+ withUnusedProps?: boolean;
35
+ };
29
36
  export type SchemaProperty = {
30
37
  name: string;
31
38
  desc?: string;
@@ -31,8 +31,14 @@ const SchemaDiagram = v.object({
31
31
  onlyConnected: v.optional(v.boolean()),
32
32
  collapse: v.optional(v.boolean()),
33
33
  qualified: v.optional(v.boolean()),
34
+ properties: v.optional(v.array(v.string())),
34
35
  config: v.optional(SchemaDiagramConfig)
35
36
  });
37
+ const SchemaFormat = v.object({
38
+ type: v.optional(v.picklist(["auto", "complex", "concise"])),
39
+ maxTableColumns: v.optional(v.number()),
40
+ withUnusedProps: v.optional(v.boolean())
41
+ });
36
42
  const SchemaObject = v.object({
37
43
  kind: v.string(),
38
44
  name: v.optional(v.string()),
@@ -41,6 +47,7 @@ const SchemaObject = v.object({
41
47
  desc: v.optional(v.string()),
42
48
  optional: v.optional(v.boolean()),
43
49
  diagram: v.optional(SchemaDiagram),
50
+ format: v.optional(SchemaFormat),
44
51
  props: v.optional(v.array(SchemaProperty)),
45
52
  childs: v.optional(v.array(v.lazy(() => SchemaObject)))
46
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -23,8 +23,8 @@
23
23
  "json5": "2.2.3",
24
24
  "pdf-lib": "1.17.1",
25
25
  "subset-font": "2.5.0",
26
- "@rse/mrcs": "0.9.1",
27
- "@rse/gradia": "0.9.4",
26
+ "@rse/mrcs": "0.9.2",
27
+ "@rse/gradia": "0.9.8",
28
28
  "@toon-format/toon": "4.1.1",
29
29
  "valibot": "1.4.2",
30
30
  "nunjucks": "3.2.4",
@@ -33,7 +33,7 @@
33
33
  "@swc/html": "1.16.1",
34
34
  "tokenizr": "2.2.1",
35
35
  "playwright": "1.62.1",
36
- "ai": "7.0.68",
36
+ "ai": "7.0.69",
37
37
  "@ai-sdk/anthropic": "4.0.39",
38
38
  "@ai-sdk/openai": "4.0.43",
39
39
  "@openrouter/ai-sdk-provider": "3.0.0",