reladraw 0.3.0 → 0.5.0

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/dist/parser.js CHANGED
@@ -1,6 +1,7 @@
1
- import { COLOR_KEYS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
1
+ import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
+ import { THEME_NAMES, THEMES } from './themes.js';
4
5
  /** Parse a whole source file. One statement per line; blanks and comments drop out. */
5
6
  export function parse(source) {
6
7
  const statements = [];
@@ -23,12 +24,12 @@ function parseStatement(tokens, line) {
23
24
  return parseNode(tokens, line);
24
25
  case 'edge':
25
26
  return parseEdge(tokens, line);
26
- case 'deck':
27
- return parseDeck(tokens, line);
28
27
  case 'style':
29
28
  return parseStyle(tokens, line);
30
29
  case 'diagram':
31
30
  return parseDiagram(tokens, line);
31
+ case 'default':
32
+ return parseDefault(tokens, line);
32
33
  default:
33
34
  throw new SourceError(substitution(keyword.text, tokens), line);
34
35
  }
@@ -66,6 +67,14 @@ function substitution(word, head) {
66
67
  return `reladraw has no \`note\` statement — a note is a node with no body, so try ` +
67
68
  `\`node ${rewrite(head)} shape: none\``;
68
69
  }
70
+ // A statement until 0.4.0. It created nothing, only said more about a node
71
+ // declared elsewhere, which is what an attribute on that node is for.
72
+ if (word === 'deck') {
73
+ const name = head[1] && !head[1].quoted ? head[1].text : '<name>';
74
+ const texts = head.slice(2).map((token) => (token.quoted ? quoteOf(token.text) : token.text));
75
+ return `reladraw has no \`deck\` statement — a deck is an attribute of the node, so write ` +
76
+ `\`deck: ${texts.length > 0 ? texts.join(' ') : '"…"'}\` on \`node ${name}\``;
77
+ }
69
78
  const replacement = SUBSTITUTIONS[word];
70
79
  if (replacement === undefined)
71
80
  return `unknown statement "${word}"`;
@@ -142,6 +151,19 @@ const BRACKET_ABOUT = {
142
151
  * `fill:` as though the two were the same sort of statement.
143
152
  */
144
153
  const MOVED_INTO_BRACKET = ['size', 'wrap', 'align'];
154
+ /**
155
+ * Store one attribute, refusing a key the line has already set. Two words on
156
+ * one line are equally explicit, so nothing says which was meant — and keeping
157
+ * either drops the other in silence. It is almost always an edit that forgot to
158
+ * delete the old value, so the error shows both and asks for one.
159
+ */
160
+ function setOnce(attrs, key, value, subject, line, shown = { key, value: (v) => v }) {
161
+ const had = attrs[key];
162
+ if (had !== undefined) {
163
+ throw new SourceError(`${subject}: "${shown.key}" is written twice (${shown.value(had)}, ${shown.value(value)}) — keep one`, line);
164
+ }
165
+ attrs[key] = value;
166
+ }
145
167
  /** Read one `key: value` pair, and refuse the words that used to be keys. */
146
168
  function readAttr(tokens, at, attrs, line, subject) {
147
169
  const keyToken = tokens[at];
@@ -161,8 +183,11 @@ function readAttr(tokens, at, attrs, line, subject) {
161
183
  if (Object.keys(read.values).length === 0) {
162
184
  throw new SourceError(`${subject}: \`${key}:\` opens empty brackets`, line);
163
185
  }
164
- for (const [inner, value] of Object.entries(read.values))
165
- attrs[`${key}.${inner}`] = value;
186
+ // Two brackets for one part are fine as long as they say different things;
187
+ // the same property in both is the same defect as `fill:` written twice.
188
+ for (const [inner, value] of Object.entries(read.values)) {
189
+ setOnce(attrs, `${key}.${inner}`, value, subject, line, { key: `${key}: (${inner}: …)`, value: (v) => v });
190
+ }
166
191
  return read.next;
167
192
  }
168
193
  if (key === 'url') {
@@ -175,9 +200,57 @@ function readAttr(tokens, at, attrs, line, subject) {
175
200
  throw new SourceError(`${subject}: a url is written in quotes — \`url: "https://example.com"\`. Without them ` +
176
201
  'everything from the `//` onwards is read as a comment', line);
177
202
  }
178
- attrs[key] = value.text;
203
+ setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
179
204
  return at + 2;
180
205
  }
206
+ if (key === 'style') {
207
+ // `style: base, critical and alarm` — several bundles, applied in the order
208
+ // written, a later one winning where two set the same key. The list reads
209
+ // the way a placement's targets do: commas and `and` both separate. Stored
210
+ // joined on a space, which no style name can hold.
211
+ const names = [];
212
+ let next = at + 1;
213
+ for (;;) {
214
+ const token = tokens[next];
215
+ if (!token || token.quoted || isAttrKey(token) || token.text === '(' || token.text === ')') {
216
+ throw new SourceError(names.length === 0 ? `attribute "style" has no value` : `${subject}: "style:" ends its list with a comma`, line);
217
+ }
218
+ const listed = token.text.endsWith(',') && token.text.length > 1;
219
+ const name = listed ? token.text.slice(0, -1) : token.text;
220
+ if (names.includes(name)) {
221
+ throw new SourceError(`${subject}: style "${name}" is named twice in one \`style:\` — keep one`, line);
222
+ }
223
+ names.push(name);
224
+ next += 1;
225
+ if (follows(tokens, next, 'and')) {
226
+ next += 1;
227
+ continue;
228
+ }
229
+ if (listed)
230
+ continue;
231
+ break;
232
+ }
233
+ setOnce(attrs, key, names.join(' '), subject, line, { key, value: (v) => v.split(' ').join(', ') });
234
+ return next;
235
+ }
236
+ if (key === 'deck') {
237
+ // One quoted text per copy behind the node, back to front, as many as are
238
+ // written. Stored joined on a line break, which no source line can hold.
239
+ const texts = [];
240
+ let next = at + 1;
241
+ while (tokens[next]?.quoted)
242
+ texts.push(tokens[next++].text);
243
+ if (texts.length === 0) {
244
+ const given = tokens[next];
245
+ throw new SourceError(`${subject}: \`deck:\` takes one quoted text per copy behind the node, as in \`deck: "Drive 2" "Drive 3"\`` +
246
+ (given && !isAttrKey(given) ? `, not \`deck: ${given.text}\`` : ''), line);
247
+ }
248
+ setOnce(attrs, key, texts.join('\n'), subject, line, {
249
+ key,
250
+ value: (v) => v.split('\n').map(quoteOf).join(' '),
251
+ });
252
+ return next;
253
+ }
181
254
  if (bracketKeys !== undefined && key !== 'text') {
182
255
  // `contents: match` names the part and then says one of its two properties
183
256
  // without saying which. The brackets are what make the level shift visible,
@@ -243,7 +316,7 @@ function readAttr(tokens, at, attrs, line, subject) {
243
316
  }
244
317
  throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
245
318
  }
246
- attrs[key] = valueToken.text;
319
+ setOnce(attrs, key, valueToken.text, subject, line);
247
320
  return at + 2;
248
321
  }
249
322
  /** Attributes only, for the statements that take no placements. */
@@ -405,27 +478,6 @@ function parseEdge(head, line) {
405
478
  line,
406
479
  };
407
480
  }
408
- /** `deck <name> "<text>" ["<text>" ...]` */
409
- function parseDeck(head, line) {
410
- const name = requireName(head[1], 'deck', line);
411
- const texts = [];
412
- for (const token of head.slice(2)) {
413
- if (!token.quoted) {
414
- // Until 0.3.0 the head/attributes split cut these off and threw them
415
- // away, so `deck d "one" fill: red` drew an uncolored deck in silence.
416
- if (isAttrKey(token)) {
417
- throw new SourceError(`deck "${name}" has ${token.text} — a deck says how many copies a node has and what ` +
418
- `each one reads, so write attributes on \`node ${name}\` itself`, line);
419
- }
420
- throw new SourceError(`deck "${name}" takes quoted texts only`, line);
421
- }
422
- texts.push(token.text);
423
- }
424
- if (texts.length === 0) {
425
- throw new SourceError(`deck "${name}" needs at least one text`, line);
426
- }
427
- return { kind: 'deck', name, texts, line };
428
- }
429
481
  /** `style <name> <attributes>` */
430
482
  function parseStyle(head, line) {
431
483
  const name = requireName(head[1], 'style', line);
@@ -453,12 +505,80 @@ function parseDiagram(head, line) {
453
505
  throw new SourceError('diagram sets nothing', line);
454
506
  }
455
507
  for (const key of Object.keys(attrs)) {
508
+ if (key.startsWith('text.')) {
509
+ // The theme has one text color, shared by everything, and that is all
510
+ // the diagram's text sets. A size or a wrap is about one kind of text.
511
+ if (key !== 'text.color') {
512
+ const inner = key.slice('text.'.length);
513
+ throw new SourceError(`diagram text: sets only a color — \`${inner}\` is about one kind of text, so write ` +
514
+ `\`text: (${inner}: ${attrs[key]})\` on a \`default node\` or \`default edge\``, line);
515
+ }
516
+ continue;
517
+ }
456
518
  if (!DIAGRAM_KEYS.includes(key)) {
457
519
  throw new SourceError(`diagram has no "${key}" — it takes ${DIAGRAM_KEYS.join(', ')}`, line);
458
520
  }
459
521
  }
522
+ if (attrs['text'] !== undefined) {
523
+ throw new SourceError('diagram text: takes a bracket — `text: (color: #e0e0e0)`', line);
524
+ }
525
+ const theme = attrs['theme'];
526
+ if (theme !== undefined && THEMES[theme] === undefined) {
527
+ throw new SourceError(`there is no theme called "${theme}" — the themes are ${THEME_NAMES.join(', ')}`, line);
528
+ }
460
529
  return { kind: 'diagram', attrs, line };
461
530
  }
531
+ /**
532
+ * `default <node | leaf | container | edge> <attributes>` — a style every thing
533
+ * of that kind wears without naming it. It is strict about kind where a style
534
+ * is permissive, because it names the kind it is for: a word that kind has no
535
+ * use for can only be a mistake.
536
+ */
537
+ function parseDefault(head, line) {
538
+ const target = head[1];
539
+ if (!target || target.quoted || !DEFAULT_TARGETS.includes(target.text)) {
540
+ throw new SourceError(`default needs the kind it is for — ${DEFAULT_TARGETS.join(', ')}` +
541
+ (target && !isAttrKey(target) ? `, not "${target.text}"` : ''), line);
542
+ }
543
+ const kind = target.text;
544
+ const subject = `default ${kind}`;
545
+ // `default leaf node` reads naturally and says nothing `default leaf` does not.
546
+ const extra = head[2];
547
+ if (extra && !extra.quoted && !isAttrKey(extra) && extra.text === 'node' && kind !== 'node') {
548
+ throw new SourceError(`write \`default ${kind}\` — a ${kind} is already a node`, line);
549
+ }
550
+ const attrs = attrsOnly(head, 2, line, subject);
551
+ if (Object.keys(attrs).length === 0) {
552
+ throw new SourceError(`${subject} sets nothing`, line);
553
+ }
554
+ const allowed = DEFAULT_KEYS[kind];
555
+ for (const key of new Set(Object.keys(attrs).map((k) => k.split('.')[0]))) {
556
+ if (allowed.includes(key))
557
+ continue;
558
+ throw new SourceError(`${subject} has ${key}:, ${defaultRefusal(kind, key)}`, line);
559
+ }
560
+ return { kind: 'default', target: kind, attrs, line };
561
+ }
562
+ /** Why a default cannot carry a word, and what to write instead. */
563
+ function defaultRefusal(kind, key) {
564
+ const takes = `it takes ${DEFAULT_KEYS[kind].map((k) => `${k}:`).join(', ')}`;
565
+ if (key === 'url')
566
+ return `and a destination belongs to one thing rather than to every one of a kind`;
567
+ if (key === 'badge') {
568
+ return 'and a badge is a child, so every leaf given one would become a container. ' +
569
+ 'Put the badge in a style, or write `default container badge:`';
570
+ }
571
+ if (key === 'icon') {
572
+ return 'and a picture cannot hold children, so an icon is a leaf\'s word — write `default leaf icon:`';
573
+ }
574
+ if (kind === 'edge' && (key === 'fill' || key === 'border')) {
575
+ return `which an edge does not have — an edge is colored by line: and text: (color: …)`;
576
+ }
577
+ if (kind !== 'edge' && key === 'line') {
578
+ return `which is an edge's — a node is colored by fill:, border: and text: (color: …)`;
579
+ }
580
+ return `which a default does not set — ${takes}`;
581
+ }
462
582
  function requireName(token, keyword, line) {
463
583
  if (!token || token.quoted) {
464
584
  throw new SourceError(`${keyword} needs a name`, line);
@@ -497,7 +617,7 @@ function readPlacement(tokens, at, line, subject) {
497
617
  throw new SourceError(`${subject}: an alignment reads "${written} <node>"`, line);
498
618
  }
499
619
  const read = readTargets(tokens, from + 2, subject, written, line);
500
- const modifiers = readModifiers(tokens, read.next, subject, written, line);
620
+ const modifiers = readModifiers(tokens, read.next, subject, `${written} ${listTargets(read.targets)}`, line);
501
621
  // An alignment shares a line outright, so there is no distance in it for
502
622
  // a gap to set. Refusing rather than dropping it, for the reason unknown
503
623
  // modifier names are refused: a word that quietly does nothing reads as a
@@ -529,10 +649,11 @@ function readPlacement(tokens, at, line, subject) {
529
649
  throw new SourceError(`${subject}: "${word.text}" is not a direction`, line);
530
650
  }
531
651
  let next = at + 1;
532
- if (follows(tokens, next, 'of'))
652
+ const of = follows(tokens, next, 'of');
653
+ if (of)
533
654
  next += 1;
534
655
  const read = readTargets(tokens, next, subject, word.text, line);
535
- const modifiers = readModifiers(tokens, read.next, subject, word.text, line);
656
+ const modifiers = readModifiers(tokens, read.next, subject, `${word.text}${of ? ' of' : ''} ${listTargets(read.targets)}`, line);
536
657
  return {
537
658
  placement: {
538
659
  kind: 'offset',
@@ -673,7 +794,12 @@ function readBracket(tokens, start, keys, about) {
673
794
  // targets of a placement. `(at: bottom, align: center)` and the same without
674
795
  // the comma are the same statement.
675
796
  const value = valueToken.text;
676
- values[key] = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
797
+ const clean = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
798
+ const had = values[key];
799
+ if (had !== undefined) {
800
+ throw new SourceError(`${about.subject}: "${key}" is written twice in the brackets after ${about.what} (${had}, ${clean}) — keep one`, about.line);
801
+ }
802
+ values[key] = clean;
677
803
  i += 2;
678
804
  }
679
805
  return { values, next: i + 1 };
package/dist/render.d.ts CHANGED
@@ -1,31 +1,10 @@
1
1
  import { type Measurer } from './measure.js';
2
2
  import type { Layout } from './model.js';
3
+ import { type Theme } from './themes.js';
3
4
  export interface RenderOptions {
4
5
  measurer?: Measurer;
5
6
  fontSize?: number;
6
7
  theme?: Theme;
7
8
  }
8
- export interface Theme {
9
- background: string;
10
- boxFill: string;
11
- boxStroke: string;
12
- containerFill: string;
13
- /** A container is a region rather than a thing, so its outline is quieter. */
14
- containerStroke: string;
15
- text: string;
16
- mutedText: string;
17
- edge: string;
18
- /** An icon's drawn line. */
19
- iconInk: string;
20
- /** The body an icon's lines enclose. */
21
- iconShade: string;
22
- }
23
- /**
24
- * Sampled out of `examples/reference/arch.png` rather than invented,
25
- * so the benchmark render and the drawing it is measured against differ by
26
- * geometry and typography alone. A container is a shade off the page and barely
27
- * outlined; a leaf is the navy that carries the diagram's weight.
28
- */
29
- export declare const DARK_THEME: Theme;
30
9
  /** Turn solved geometry into a standalone SVG document. */
31
10
  export declare function render(layout: Layout, options?: RenderOptions): string;
package/dist/render.js CHANGED
@@ -3,40 +3,25 @@ import { describeAxis } from './ast.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { ICON_STROKE } from './icons.js';
5
5
  import { monospaceMeasurer } from './measure.js';
6
+ import { DARK_THEME, THEMES } from './themes.js';
6
7
  import { plain } from './text.js';
7
- /**
8
- * Sampled out of `examples/reference/arch.png` rather than invented,
9
- * so the benchmark render and the drawing it is measured against differ by
10
- * geometry and typography alone. A container is a shade off the page and barely
11
- * outlined; a leaf is the navy that carries the diagram's weight.
12
- */
13
- export const DARK_THEME = {
14
- background: '#111111',
15
- boxFill: '#191728',
16
- boxStroke: '#4f5367',
17
- containerFill: '#191920',
18
- containerStroke: '#25242f',
19
- text: '#d9d9d9',
20
- mutedText: '#8b8b8b',
21
- edge: '#5c5c7c',
22
- // Both sampled off the reference's machine glyphs. Note that the reference
23
- // gives each icon its own hue — the drive is gray, the laptop periwinkle, the
24
- // workstation violet — which is a drawing tool's per-shape default and not a
25
- // system. One pair for the whole set is the deliberate difference: an icon
26
- // should read as part of the diagram's palette, not as clip art dropped in.
27
- iconInk: '#8d8d8e',
28
- iconShade: '#3e3d58',
29
- };
30
8
  const CORNER = 8;
31
9
  /** Turn solved geometry into a standalone SVG document. */
32
10
  export function render(layout, options = {}) {
33
11
  const measurer = options.measurer ?? monospaceMeasurer();
34
12
  const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
35
- // `diagram background:` is the author overruling the theme for this one
36
- // drawing, so it is folded in here and everything downstream sees one theme.
37
- const base = options.theme ?? DARK_THEME;
38
- const stated = layout.diagram['background'];
39
- const theme = stated === undefined ? base : { ...base, background: stated };
13
+ // A theme passed in — the command line's `--theme` — beats the one the file
14
+ // names, so one source renders in either. `diagram background:` and `text:`
15
+ // are the author overruling a color of whichever theme that is, and a color
16
+ // written by hand wins over any theme, so they are folded in afterwards and
17
+ // everything downstream sees one theme.
18
+ const named = layout.diagram['theme'];
19
+ const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
20
+ const theme = {
21
+ ...base,
22
+ ...(layout.diagram['background'] !== undefined && { background: layout.diagram['background'] }),
23
+ ...(layout.diagram['text.color'] !== undefined && { text: layout.diagram['text.color'] }),
24
+ };
40
25
  const body = [];
41
26
  for (const root of layout.roots) {
42
27
  body.push(drawNode(root, theme, measurer, fontSize, layout.markup));
@@ -162,10 +147,10 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
162
147
  const parts = [];
163
148
  const kids = [];
164
149
  const face = faceOf(node);
165
- // A container *looks* like one because things stack beside its text, not
166
- // because it has children: a node whose only child sits beside its text is
167
- // drawn as the leaf it reads as.
168
- const container = node.banded;
150
+ // A node with children is colored as a backdrop however they are placed,
151
+ // including a lone badge beside its text. Every rule that tried to tell a
152
+ // badge from contents was a guess; this one is visible in the source.
153
+ const container = node.children.length > 0;
169
154
  const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
170
155
  const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
171
156
  // A box is the one kind with two inkable parts, which is why its text needs
@@ -188,8 +173,9 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
188
173
  // A leaf's text defaults to the middle of its box, a container's to the top
189
174
  // left of the band; both then read `at` for where it really goes. Where the
190
175
  // text sits is the resolver's answer, in `textBox`; only the alignment of
191
- // its lines against each other is read here.
192
- const textStyle = textStyleFor(node.textAttrs, node.line, container ? 'start' : 'middle', container ? 'top-left' : 'center');
176
+ // its lines against each other is read here. This follows the band, not the
177
+ // colors: a badged leaf is a backdrop but its text still centers.
178
+ const textStyle = textStyleFor(node.textAttrs, node.line, node.banded ? 'start' : 'middle', node.banded ? 'top-left' : 'center');
193
179
  parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
194
180
  color: text,
195
181
  align: textStyle.align,
package/dist/resolve.js CHANGED
@@ -23,12 +23,12 @@ export function resolve(doc, options = {}) {
23
23
  const margin = options.margin ?? DEFAULT_MARGIN;
24
24
  const styles = collectStyles(doc.statements);
25
25
  checkStyleKeys(doc.statements);
26
- const { nodes, byName, roots } = buildTree(doc.statements, styles);
27
- applyDecks(doc.statements, byName);
26
+ const defaults = collectDefaults(doc.statements, styles);
27
+ const { nodes, byName, roots } = buildTree(doc.statements, styles, defaults);
28
28
  // Edges are resolved to nodes before anything is sized, because a labeled
29
29
  // edge claims room in the gap it crosses and so has to be in hand while the
30
30
  // gaps are being worked out. Nothing here reads geometry.
31
- const edges = buildEdges(doc.statements, byName, styles);
31
+ const edges = buildEdges(doc.statements, byName, styles, defaults);
32
32
  const local = new Map();
33
33
  for (const root of roots)
34
34
  sizeNode(root, edges, measurer, fontSize, local);
@@ -59,6 +59,40 @@ function collectStyles(statements) {
59
59
  }
60
60
  return styles;
61
61
  }
62
+ /**
63
+ * Each kind's default, with any style it names already folded in beneath its
64
+ * own words, so a default arrives at a node as one flat bundle. The same kind
65
+ * written twice is refused, as a second `diagram` is: nothing says which was
66
+ * meant.
67
+ */
68
+ function collectDefaults(statements, styles) {
69
+ const defaults = new Map();
70
+ const seen = new Map();
71
+ for (const stmt of statements) {
72
+ if (stmt.kind !== 'default')
73
+ continue;
74
+ const earlier = seen.get(stmt.target);
75
+ if (earlier !== undefined) {
76
+ throw new SourceError(`default ${stmt.target} is written twice, here and on line ${earlier} — keep one`, stmt.line);
77
+ }
78
+ seen.set(stmt.target, stmt.line);
79
+ const { style: _named, ...flat } = appearanceOf(stmt.attrs, styles, stmt.line);
80
+ defaults.set(stmt.target, flat);
81
+ }
82
+ return defaults;
83
+ }
84
+ /**
85
+ * One bundle laid over a weaker one, key by key — except the body, which is
86
+ * one thing said by either of two words. A default's `shape: cylinder` under
87
+ * a style's `icon: disk` is not a node with two bodies; the style said what
88
+ * this node is drawn as, and that settles it.
89
+ */
90
+ function over(base, top) {
91
+ if (top['shape'] === undefined && top['icon'] === undefined)
92
+ return { ...base, ...top };
93
+ const { shape: _shape, icon: _icon, ...rest } = base;
94
+ return { ...rest, ...top };
95
+ }
62
96
  /** A file holds one diagram, so a second `diagram` statement is a mistake. */
63
97
  function collectDiagram(statements) {
64
98
  let found;
@@ -71,12 +105,27 @@ function collectDiagram(statements) {
71
105
  }
72
106
  return found ?? {};
73
107
  }
74
- function buildTree(statements, styles) {
108
+ function buildTree(statements, styles, defaults) {
75
109
  const nodes = [];
76
110
  const byName = new Map();
77
111
  const roots = [];
78
112
  /** Each badge child's name, and the node whose `badge:` it was written out from. */
79
113
  const badges = new Map();
114
+ // Which nodes will hold children has to be known before any node is built,
115
+ // because it decides which default a node wears, and its children are
116
+ // declared after it. A child is a dotted name, or a badge the node's own
117
+ // line or styles give it; a default cannot give a leaf a badge, so what
118
+ // the defaults say cannot change the answer.
119
+ const parents = new Set();
120
+ for (const stmt of statements) {
121
+ if (stmt.kind !== 'node')
122
+ continue;
123
+ const cut = stmt.name.lastIndexOf('.');
124
+ if (cut !== -1)
125
+ parents.add(stmt.name.slice(0, cut));
126
+ if (appearanceOf(stmt.attrs, styles, stmt.line)['badge'] !== undefined)
127
+ parents.add(stmt.name);
128
+ }
80
129
  for (const stmt of statements) {
81
130
  if (stmt.kind !== 'node')
82
131
  continue;
@@ -88,7 +137,9 @@ function buildTree(statements, styles) {
88
137
  }
89
138
  throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
90
139
  }
91
- const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
140
+ // Weakest first: every node's default, then the leaf's or the container's,
141
+ // then the node's own styles and words over both.
142
+ const appearance = over(over(defaults.get('node') ?? {}, defaults.get(parents.has(stmt.name) ? 'container' : 'leaf') ?? {}), appearanceOf(stmt.attrs, styles, stmt.line));
92
143
  const body = bodyFor(stmt.attrs, appearance, stmt.line);
93
144
  const kind = KIND_OF_BODY[body.kind];
94
145
  // A node with no text of its own is labelled with its name, because the
@@ -129,6 +180,11 @@ function buildTree(statements, styles) {
129
180
  };
130
181
  checkAttrs(kind, node.name, stmt.attrs, stmt.line);
131
182
  checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
183
+ // `deck:` holds one text per copy, joined on a line break the source cannot
184
+ // contain. Like a badge, only a box has an outline to repeat.
185
+ const deck = appearance['deck'];
186
+ if (deck !== undefined && body.kind === 'shape')
187
+ node.deckTexts = deck.split('\n');
132
188
  const cut = stmt.name.lastIndexOf('.');
133
189
  if (cut === -1) {
134
190
  roots.push(node);
@@ -254,7 +310,9 @@ function checkAttrs(kind, name, attrs, line) {
254
310
  continue;
255
311
  // Quoted back the way it was written. A bracketed value arrives one dotted
256
312
  // key at a time, and `contents: match` is not a line anybody could look for.
257
- const wrote = written === key ? `${key}: ${value}` : `${key}: (${written.slice(key.length + 1)}: ${value})`;
313
+ // A deck's texts are stored joined on a line break; quote them back as written.
314
+ const shown = key === 'deck' ? value.split('\n').map((text) => `"${text}"`).join(' ') : value;
315
+ const wrote = written === key ? `${key}: ${shown}` : `${key}: (${written.slice(key.length + 1)}: ${shown})`;
258
316
  if (!ALL_ATTR_KEYS.includes(key)) {
259
317
  // Nothing anywhere in the language answers to this word, so the only
260
318
  // remedy is the vocabulary itself.
@@ -269,6 +327,11 @@ function checkAttrs(kind, name, attrs, line) {
269
327
  const parts = COLOR_PARTS[kind].map((part) => `\`${part}\``).join(', ');
270
328
  throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. ${capital(article(KIND_WORD[kind]))} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
271
329
  }
330
+ // A word only the whole drawing takes, such as a theme.
331
+ if (belongTo(key).length === 0) {
332
+ throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. \`${key}:\` is said about the ` +
333
+ `whole drawing — write \`diagram ${key}: ${shown}\``, line);
334
+ }
272
335
  // Anything else names no part, so what the kind is made of explains
273
336
  // nothing. What does explain it is where the word *does* belong, which is
274
337
  // also the more useful thing to be told: the author has usually written a
@@ -288,23 +351,25 @@ function checkAttrs(kind, name, attrs, line) {
288
351
  * construction — that hole is left open, because nobody writes one by accident.
289
352
  */
290
353
  function checkStyleUse(kind, name, attrs, styles, line) {
291
- const named = attrs['style'];
292
- if (named === undefined)
293
- return;
294
- const base = styles.get(named);
295
- if (base === undefined)
296
- return; // `appearanceOf` reports the missing style.
297
- // A style naming another style is the one way to carry nothing at all: the
298
- // parser already refuses one with no attributes, and `appearanceOf` does not
299
- // recurse, so the name would sit there doing nothing.
300
- const carried = [...new Set(Object.keys(base).map(topKey))].filter((key) => key !== 'style');
301
- if (carried.length === 0) {
302
- throw new SourceError(`style "${named}" carries nothing but a style name`, line);
354
+ // Each style in a list is checked on its own: one that gives this thing
355
+ // nothing is a name written on the wrong sort of thing, whatever its
356
+ // neighbours in the list carry.
357
+ for (const named of styleNames(attrs)) {
358
+ const base = styles.get(named);
359
+ if (base === undefined)
360
+ continue; // `appearanceOf` reports the missing style.
361
+ // A style naming another style is the one way to carry nothing at all: the
362
+ // parser already refuses one with no attributes, and `appearanceOf` does not
363
+ // recurse, so the name would sit there doing nothing.
364
+ const carried = [...new Set(Object.keys(base).map(topKey))].filter((key) => key !== 'style');
365
+ if (carried.length === 0) {
366
+ throw new SourceError(`style "${named}" carries nothing but a style name`, line);
367
+ }
368
+ if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
369
+ continue;
370
+ throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
371
+ `${article(KIND_WORD[kind])} is ${KIND_PARTS[kind]}`, line);
303
372
  }
304
- if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
305
- return;
306
- throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
307
- `${article(KIND_WORD[kind])} is ${KIND_PARTS[kind]}`, line);
308
373
  }
309
374
  /**
310
375
  * A style is a bundle spanning kinds, so its keys cannot be checked against any
@@ -345,26 +410,27 @@ function listKinds(kinds) {
345
410
  return words[0] ?? 'nothing';
346
411
  return `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`;
347
412
  }
348
- function appearanceOf(attrs, styles, line) {
413
+ /** The styles a `style:` names, in the order written. The parser joins them on a space. */
414
+ function styleNames(attrs) {
349
415
  const named = attrs['style'];
350
- if (named === undefined)
351
- return { ...attrs };
352
- const base = styles.get(named);
353
- if (!base)
354
- throw new SourceError(`no style named "${named}"`, line);
355
- return { ...base, ...attrs };
416
+ return named === undefined ? [] : named.split(' ');
356
417
  }
357
- function applyDecks(statements, byName) {
358
- for (const stmt of statements) {
359
- if (stmt.kind !== 'deck')
360
- continue;
361
- const node = byName.get(stmt.name);
362
- if (!node)
363
- throw new SourceError(`deck names "${stmt.name}", which does not exist`, stmt.line);
364
- node.deckTexts = stmt.texts;
418
+ /**
419
+ * The styles in the order written, a later one winning where two set the same
420
+ * key, and then the statement's own words over all of them — a word written on
421
+ * the line was meant for this thing, wherever `style:` sits.
422
+ */
423
+ function appearanceOf(attrs, styles, line) {
424
+ const merged = {};
425
+ for (const named of styleNames(attrs)) {
426
+ const base = styles.get(named);
427
+ if (!base)
428
+ throw new SourceError(`no style named "${named}"`, line);
429
+ Object.assign(merged, base);
365
430
  }
431
+ return { ...merged, ...attrs };
366
432
  }
367
- function buildEdges(statements, byName, styles) {
433
+ function buildEdges(statements, byName, styles, defaults) {
368
434
  const edges = [];
369
435
  for (const stmt of statements) {
370
436
  if (stmt.kind !== 'edge')
@@ -391,7 +457,7 @@ function buildEdges(statements, byName, styles) {
391
457
  }),
392
458
  ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
393
459
  };
394
- const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
460
+ const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
395
461
  const what = `${stmt.from} -> ${stmt.to}`;
396
462
  checkAttrs('edge', what, stmt.attrs, stmt.line);
397
463
  checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);