reladraw 0.3.0 → 0.4.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/README.md CHANGED
@@ -68,7 +68,7 @@ It is [plain Markdown](.claude/skills/reladraw/SKILL.md) with the syntax referen
68
68
 
69
69
  ## Status
70
70
 
71
- Version 0.3.0. Early, but it runs: a parser, resolver and SVG renderer in TypeScript with no runtime dependencies, and a command-line tool that takes a text file and writes a standalone SVG. The comparison at the top of this page is that pipeline run on [`examples/arch.reladraw`](examples/arch.reladraw). What is still visibly off there is typography, not placement.
71
+ Version 0.4.0. Early, but it runs: a parser, resolver and SVG renderer in TypeScript with no runtime dependencies, and a command-line tool that takes a text file and writes a standalone SVG. The comparison at the top of this page is that pipeline run on [`examples/arch.reladraw`](examples/arch.reladraw). What is still visibly off there is typography, not placement.
72
72
 
73
73
  ```
74
74
  npm install -g reladraw
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.3.0
1
+ # Syntax reference — 0.4.0
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -129,7 +129,7 @@ Bundling a text's properties into a style is how they come to mean something: `s
129
129
 
130
130
  `color` takes a color written as the viewer will receive it — `#8b8b8b`, or any CSS color — or the one reserved word `muted`, which means the theme's secondary text color and so survives a change of theme.
131
131
 
132
- `size: small | normal | large` sets how big the text is set. The sizes are named for the reason gaps are named: a number would be typography by coordinate, stale the moment the document is set at another size, and silent about *why* one piece of text is smaller than another. Every kind of text has a default and `size` overrides it, exactly as `fill:` overrides the theme's color; only `shape: none` defaults to anything but `normal`, and it defaults to `small`.
132
+ `size: small | normal | large` sets how big the text is set, and so does a plain number of pixels, `size: 22`. The names are the default and the preferred form: they follow the document's size if it is ever set differently, where `22` stays 22. A number says nothing about *why* one text is bigger than another, so bundle it into a style that does. `14px` is refused, since the number is already in pixels, and so is zero or less. Every kind of text has a default and `size` overrides it, exactly as `fill:` overrides the theme's color; only `shape: none` defaults to anything but `normal`, and it defaults to `small`.
133
133
 
134
134
  `wrap: <n>` folds the text at word boundaries every `n` characters, on top of whatever ` / ` already breaks. It is how you make a block of text narrow and tall so it can sit snugly beside something, rather than wide and short so it cannot. The number is a count of characters and not a distance: it says how much fits on a line and nothing about where anything sits.
135
135
 
@@ -602,10 +602,12 @@ Nothing bounds bare text the way a border bounds a node, so a sentence-length on
602
602
  ## Decks
603
603
 
604
604
  ```
605
- deck <name> "<text>" ["<text>" ...]
605
+ node drive "Drive 1" deck: "Drive 2" "Drive 3"
606
606
  ```
607
607
 
608
- Draws the named container with offset copies behind it, one per text, to say "there are several of these and they are the same." Only the front copy shows its contents.
608
+ `deck:` draws offset copies behind a box, one per text, back to front, to say "there are several of these and they are the same." Only the front copy shows its contents. It goes on a box only; a picture or a `shape: none` node has no outline to repeat, and refuses it. A style may carry it.
609
+
610
+ **Changed after 0.3.0.** This was a statement of its own, `deck drive "Drive 2" "Drive 3"`, which created nothing and only said more about a node declared elsewhere. The statement is now an error that quotes the attribute to write instead.
609
611
 
610
612
  ## Attributes
611
613
 
@@ -613,11 +615,12 @@ Every attribute, and what takes one. The kinds here are what a node's **body** i
613
615
 
614
616
  | attribute | `shape:` | `icon:` | `shape: none` | edge | says |
615
617
  |---|---|---|---|---|---|
616
- | `style` | ✓ | ✓ | ✓ | ✓ | the named bundle to take appearance from |
618
+ | `style` | ✓ | ✓ | ✓ | ✓ | the named bundles to take appearance from, one or a list |
617
619
  | `gap` | ✓ | ✓ | ✓ | | the default distance to whatever it is placed against |
618
620
  | `overlap` | ✓ | ✓ | ✓ | | `allow`, to opt out of non-overlap |
619
621
  | `contents` | ✓ | | | | how the children are sized and where the block of them sits, in brackets |
620
622
  | `badge` | ✓ | | | | the picture beside the text |
623
+ | `deck` | ✓ | | | | offset copies behind the box, one quoted text each |
621
624
  | `shape` | ✓ | | ✓ | | the outline the node is drawn with, `none` included |
622
625
  | `icon` | | ✓ | | | the picture the node is drawn as |
623
626
  | `from` `to` | | | | ✓ | which side the line leaves and arrives on |
@@ -637,6 +640,8 @@ The `diagram` statement has a vocabulary of its own — `background`, and so far
637
640
 
638
641
  Some of the gaps in the table are worth saying out loud, because none of them looks like a mistake while you are writing it. A picture and a bodiless node take no `fill:` or `border:` — there is no outline for either to reach. Neither takes `contents:` either, which says how a node's children sit, and neither may have any. `shape:` and `icon:` each appear only on the kind they make, and writing both is an error naming both. An edge takes no `gap:` or `overlap:` — those say where a node sits, and an edge is not placed, it joins two things that are.
639
642
 
643
+ **A key is written once per statement.** `fill: red fill: blue` is an error that quotes both values and asks you to keep one: two words on one line are equally explicit, so nothing says which was meant, and keeping either would drop the other in silence. It is almost always an edit that forgot to delete the old value. The same holds inside brackets — `(size: small, size: large)`, a placement's `(gap: wide, gap: tight)` — for `style:` written twice, and for a `style` statement that repeats one of its own keys. Two `text: (…)` brackets in one style are fine as long as they say different things. A node overriding its style is not a repeat; see "Styles".
644
+
640
645
  **Changed 2026-09-09.** Until then a node or edge attribute the tool did not recognize was parsed, stored and never read: `wibble: red` on a node drew nothing and said nothing. This was the last place in the language where a key could silently do nothing, and the rule everywhere else — an unknown `diagram` key, an unknown placement modifier, a color naming a part the kind has not got — has always been that a key which silently does nothing looks like the tool being broken rather than like a typo. A file that rendered with a stray word in it will now stop with an error naming it.
641
646
 
642
647
  ## Destinations
@@ -673,6 +678,23 @@ node server.mirror "shared folder / mirror" style: backup
673
678
 
674
679
  The appearance attributes are `fill`, `border`, `line`, `text`, `badge`, `icon` and `shape`. The first three each take a color written as the viewer will receive it — `#142814`, or any CSS color, or `none`; `text` takes the bracket described under "The text and its brackets".
675
680
 
681
+ **What a statement says itself beats what its style says**, wherever `style:` sits on the line. Here the server's border is red, not orange:
682
+
683
+ ```
684
+ style backup border: #d2904e
685
+ node server "Server" border: red style: backup
686
+ ```
687
+
688
+ Whoever wrote `border: red` on the node meant it for that node, so it is an override rather than a contradiction, and nothing is refused. The rest of the style still applies.
689
+
690
+ **A thing may take several styles**, listed the way a placement lists its targets — commas and `and` both separate:
691
+
692
+ ```
693
+ node pump "Pump 3" style: equipment, critical and alarm
694
+ ```
695
+
696
+ They apply in the order written, and a later style wins where two set the same key; the statement's own words still win over all of them. Each style in the list is checked on its own, so one that gives the thing nothing is refused even when its neighbors do not. A style named twice in one list is refused, and so is `style:` written twice on one statement — the list is the way to say several.
697
+
676
698
  ### A color names the part it colors
677
699
 
678
700
  A color attribute says which part of a thing it colors, and a part exists only on the kinds that have one:
@@ -810,6 +832,13 @@ That one was found by testing the lexer, not by rendering — and it could not h
810
832
 
811
833
  Pre-1.0, so the minor number is where a breaking change goes. Every removal below is refused by name with the replacement quoted, rather than dropped in silence — an older file stops with an error saying what to write instead.
812
834
 
835
+ **0.4.0**
836
+
837
+ - A node with any children takes the theme's container colors, however the children are placed. A node with a `badge:` or a single child in a corner now draws as a container too; in 0.3.0 only a node with a title band did. Its text still sits where it did. Say `fill:` and `border:` to keep one looking like a leaf.
838
+ - A text's `size` takes a plain number of pixels beside `small`, `normal` and `large`: `(size: 22)`.
839
+ - **Breaking:** `deck` is an attribute of the node, not a statement. `deck drive "Drive 2" "Drive 3"` is written `deck: "Drive 2" "Drive 3"` on `node drive`, and the old statement is an error that quotes that line back. It is refused on a picture and on a `shape: none` node, where the statement used to draw nothing.
840
+ - **Breaking:** a key written twice on one statement is an error that quotes both values — `fill: red fill: blue`, `style:` twice, or the same property twice inside one set of brackets. In 0.3.0 one of the two was dropped in silence. A node's own word overriding its style's is unaffected.
841
+ - `style:` takes a list, `style: equipment, critical and alarm`, applied in order with a later style winning where two set the same key.
813
842
  **0.3.0** — the vocabulary, reworked in one breaking version so there is one migration rather than five.
814
843
 
815
844
  - `box` is `node` and `link` is `edge`. The string on either is its *text*; "label" is not a word the language has.
package/dist/ast.d.ts CHANGED
@@ -272,14 +272,6 @@ export interface EdgeStmt {
272
272
  attrs: Attrs;
273
273
  line: number;
274
274
  }
275
- export interface DeckStmt {
276
- kind: 'deck';
277
- /** The container to draw with offset copies behind it. */
278
- name: string;
279
- /** One text per copy, back to front as written. */
280
- texts: string[];
281
- line: number;
282
- }
283
275
  /**
284
276
  * `diagram background: #111111` — settings that belong to the drawing as a
285
277
  * whole rather than to anything in it. It has no name because there is only
@@ -373,7 +365,7 @@ export interface StyleStmt {
373
365
  attrs: Attrs;
374
366
  line: number;
375
367
  }
376
- export type Stmt = NodeStmt | EdgeStmt | DeckStmt | StyleStmt | DiagramStmt;
368
+ export type Stmt = NodeStmt | EdgeStmt | StyleStmt | DiagramStmt;
377
369
  export interface Document {
378
370
  statements: Stmt[];
379
371
  }
package/dist/ast.js CHANGED
@@ -290,7 +290,7 @@ export const COLOR_PARTS = {
290
290
  * an edge is not placed — it joins two things that are.
291
291
  */
292
292
  export const ATTR_KEYS = {
293
- shape: ['style', 'gap', 'overlap', 'contents', 'badge', 'shape', 'fill', 'border', 'text', 'url'],
293
+ shape: ['style', 'gap', 'overlap', 'contents', 'badge', 'deck', 'shape', 'fill', 'border', 'text', 'url'],
294
294
  icon: ['style', 'gap', 'overlap', 'icon', 'text', 'url'],
295
295
  none: ['style', 'gap', 'overlap', 'shape', 'text', 'url'],
296
296
  edge: ['style', 'from', 'to', 'line', 'text', 'url'],
@@ -98,10 +98,10 @@ export declare const ICON_LINES = 2;
98
98
  export declare const ICON_GAP = 10;
99
99
  export declare const DEFAULT_FONT_SIZE = 14;
100
100
  /**
101
- * The named text sizes, each a multiple of the document's own size. Named
102
- * rather than numeric for the reason gaps are: a number here is typography by
103
- * coordinate. It goes stale the moment the document is set at another size, and
104
- * it says nothing about why one piece of text is smaller than another.
101
+ * The named text sizes, each a multiple of the document's own size. A plain
102
+ * number of pixels is accepted too, as it is for a gap: nothing else in the
103
+ * diagram moving can make it wrong. The names stay the default because they
104
+ * follow the document's size when that is retuned, and a number does not.
105
105
  *
106
106
  * `small` is sampled rather than chosen. In
107
107
  * `examples/reference/arch.png` the box and container texts run 25
package/dist/constants.js CHANGED
@@ -110,10 +110,10 @@ export const ICON_LINES = 2;
110
110
  export const ICON_GAP = 10;
111
111
  export const DEFAULT_FONT_SIZE = 14;
112
112
  /**
113
- * The named text sizes, each a multiple of the document's own size. Named
114
- * rather than numeric for the reason gaps are: a number here is typography by
115
- * coordinate. It goes stale the moment the document is set at another size, and
116
- * it says nothing about why one piece of text is smaller than another.
113
+ * The named text sizes, each a multiple of the document's own size. A plain
114
+ * number of pixels is accepted too, as it is for a gap: nothing else in the
115
+ * diagram moving can make it wrong. The names stay the default because they
116
+ * follow the document's size when that is retuned, and a number does not.
117
117
  *
118
118
  * `small` is sampled rather than chosen. In
119
119
  * `examples/reference/arch.png` the box and container texts run 25
@@ -143,10 +143,17 @@ const DEFAULT_TEXT_SIZE = { none: 'small' };
143
143
  export function fontSizeFor(kind, textAttrs, fontSize, line) {
144
144
  const named = textAttrs['size'] ?? DEFAULT_TEXT_SIZE[kind] ?? 'normal';
145
145
  const scale = TEXT_SIZES[named];
146
- if (scale === undefined) {
147
- throw new SourceError(`size takes ${Object.keys(TEXT_SIZES).join(', ')}, not "${named}"`, line);
148
- }
149
- return Math.round(fontSize * scale);
146
+ if (scale !== undefined)
147
+ return Math.round(fontSize * scale);
148
+ if (/^\d+(\.\d+)?$/.test(named) && Number(named) > 0)
149
+ return Number(named);
150
+ const unit = named.match(/^(\d+(?:\.\d+)?)px$/);
151
+ const hint = unit
152
+ ? `; write "size: ${unit[1]}", a size's number is already in pixels`
153
+ : named.startsWith('-') || /^0+(\.0+)?$/.test(named)
154
+ ? '; a text size has to be more than zero'
155
+ : '';
156
+ throw new SourceError(`size takes ${Object.keys(TEXT_SIZES).join(', ')} or a number of pixels, not "${named}"${hint}`, line);
150
157
  }
151
158
  export const DEFAULT_MARGIN = 40;
152
159
  /**
package/dist/grammar.d.ts CHANGED
@@ -61,7 +61,7 @@ export interface Span {
61
61
  * it. A word missing here is a word that draws in the plain color, which is a
62
62
  * dull page rather than a wrong one.
63
63
  */
64
- export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "deck", "style", "diagram"];
64
+ export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "diagram"];
65
65
  /**
66
66
  * Every word that says something about where a thing goes. Assembled from the
67
67
  * lists the parser itself reads, so a direction or a passage axis added there
package/dist/grammar.js CHANGED
@@ -30,9 +30,9 @@ import { DIRECTIONS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
30
30
  * it. A word missing here is a word that draws in the plain color, which is a
31
31
  * dull page rather than a wrong one.
32
32
  */
33
- export const STATEMENT_KEYWORDS = ['node', 'edge', 'deck', 'style', 'diagram'];
33
+ export const STATEMENT_KEYWORDS = ['node', 'edge', 'style', 'diagram'];
34
34
  /** Statements whose second word declares a name. `diagram` has none. */
35
- const DECLARES_NAME = ['node', 'deck', 'style'];
35
+ const DECLARES_NAME = ['node', 'style'];
36
36
  /**
37
37
  * Every word that says something about where a thing goes. Assembled from the
38
38
  * lists the parser itself reads, so a direction or a passage axis added there
package/dist/model.d.ts CHANGED
@@ -57,9 +57,10 @@ export interface LayoutNode {
57
57
  /**
58
58
  * Whether things stack beside this node's text — below it, or above it when
59
59
  * the text is at the bottom. That is what a title band *is*, so it is what
60
- * decides a container's look and its text's defaults, rather than whether
61
- * the node has children: a node whose only child sits beside its text or in
62
- * a corner has nothing stacked below the text, and draws as a leaf.
60
+ * decides its text's defaults, rather than whether the node has children: a
61
+ * node whose only child sits beside its text or in a corner has nothing
62
+ * stacked below the text, and its text sits as a leaf's does. Its *colors*
63
+ * follow having children, not this.
63
64
  */
64
65
  banded: boolean;
65
66
  /**
package/dist/parser.js CHANGED
@@ -23,8 +23,6 @@ function parseStatement(tokens, line) {
23
23
  return parseNode(tokens, line);
24
24
  case 'edge':
25
25
  return parseEdge(tokens, line);
26
- case 'deck':
27
- return parseDeck(tokens, line);
28
26
  case 'style':
29
27
  return parseStyle(tokens, line);
30
28
  case 'diagram':
@@ -66,6 +64,14 @@ function substitution(word, head) {
66
64
  return `reladraw has no \`note\` statement — a note is a node with no body, so try ` +
67
65
  `\`node ${rewrite(head)} shape: none\``;
68
66
  }
67
+ // A statement until 0.4.0. It created nothing, only said more about a node
68
+ // declared elsewhere, which is what an attribute on that node is for.
69
+ if (word === 'deck') {
70
+ const name = head[1] && !head[1].quoted ? head[1].text : '<name>';
71
+ const texts = head.slice(2).map((token) => (token.quoted ? quoteOf(token.text) : token.text));
72
+ return `reladraw has no \`deck\` statement — a deck is an attribute of the node, so write ` +
73
+ `\`deck: ${texts.length > 0 ? texts.join(' ') : '"…"'}\` on \`node ${name}\``;
74
+ }
69
75
  const replacement = SUBSTITUTIONS[word];
70
76
  if (replacement === undefined)
71
77
  return `unknown statement "${word}"`;
@@ -142,6 +148,19 @@ const BRACKET_ABOUT = {
142
148
  * `fill:` as though the two were the same sort of statement.
143
149
  */
144
150
  const MOVED_INTO_BRACKET = ['size', 'wrap', 'align'];
151
+ /**
152
+ * Store one attribute, refusing a key the line has already set. Two words on
153
+ * one line are equally explicit, so nothing says which was meant — and keeping
154
+ * either drops the other in silence. It is almost always an edit that forgot to
155
+ * delete the old value, so the error shows both and asks for one.
156
+ */
157
+ function setOnce(attrs, key, value, subject, line, shown = { key, value: (v) => v }) {
158
+ const had = attrs[key];
159
+ if (had !== undefined) {
160
+ throw new SourceError(`${subject}: "${shown.key}" is written twice (${shown.value(had)}, ${shown.value(value)}) — keep one`, line);
161
+ }
162
+ attrs[key] = value;
163
+ }
145
164
  /** Read one `key: value` pair, and refuse the words that used to be keys. */
146
165
  function readAttr(tokens, at, attrs, line, subject) {
147
166
  const keyToken = tokens[at];
@@ -161,8 +180,11 @@ function readAttr(tokens, at, attrs, line, subject) {
161
180
  if (Object.keys(read.values).length === 0) {
162
181
  throw new SourceError(`${subject}: \`${key}:\` opens empty brackets`, line);
163
182
  }
164
- for (const [inner, value] of Object.entries(read.values))
165
- attrs[`${key}.${inner}`] = value;
183
+ // Two brackets for one part are fine as long as they say different things;
184
+ // the same property in both is the same defect as `fill:` written twice.
185
+ for (const [inner, value] of Object.entries(read.values)) {
186
+ setOnce(attrs, `${key}.${inner}`, value, subject, line, { key: `${key}: (${inner}: …)`, value: (v) => v });
187
+ }
166
188
  return read.next;
167
189
  }
168
190
  if (key === 'url') {
@@ -175,9 +197,57 @@ function readAttr(tokens, at, attrs, line, subject) {
175
197
  throw new SourceError(`${subject}: a url is written in quotes — \`url: "https://example.com"\`. Without them ` +
176
198
  'everything from the `//` onwards is read as a comment', line);
177
199
  }
178
- attrs[key] = value.text;
200
+ setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
179
201
  return at + 2;
180
202
  }
203
+ if (key === 'style') {
204
+ // `style: base, critical and alarm` — several bundles, applied in the order
205
+ // written, a later one winning where two set the same key. The list reads
206
+ // the way a placement's targets do: commas and `and` both separate. Stored
207
+ // joined on a space, which no style name can hold.
208
+ const names = [];
209
+ let next = at + 1;
210
+ for (;;) {
211
+ const token = tokens[next];
212
+ if (!token || token.quoted || isAttrKey(token) || token.text === '(' || token.text === ')') {
213
+ throw new SourceError(names.length === 0 ? `attribute "style" has no value` : `${subject}: "style:" ends its list with a comma`, line);
214
+ }
215
+ const listed = token.text.endsWith(',') && token.text.length > 1;
216
+ const name = listed ? token.text.slice(0, -1) : token.text;
217
+ if (names.includes(name)) {
218
+ throw new SourceError(`${subject}: style "${name}" is named twice in one \`style:\` — keep one`, line);
219
+ }
220
+ names.push(name);
221
+ next += 1;
222
+ if (follows(tokens, next, 'and')) {
223
+ next += 1;
224
+ continue;
225
+ }
226
+ if (listed)
227
+ continue;
228
+ break;
229
+ }
230
+ setOnce(attrs, key, names.join(' '), subject, line, { key, value: (v) => v.split(' ').join(', ') });
231
+ return next;
232
+ }
233
+ if (key === 'deck') {
234
+ // One quoted text per copy behind the node, back to front, as many as are
235
+ // written. Stored joined on a line break, which no source line can hold.
236
+ const texts = [];
237
+ let next = at + 1;
238
+ while (tokens[next]?.quoted)
239
+ texts.push(tokens[next++].text);
240
+ if (texts.length === 0) {
241
+ const given = tokens[next];
242
+ throw new SourceError(`${subject}: \`deck:\` takes one quoted text per copy behind the node, as in \`deck: "Drive 2" "Drive 3"\`` +
243
+ (given && !isAttrKey(given) ? `, not \`deck: ${given.text}\`` : ''), line);
244
+ }
245
+ setOnce(attrs, key, texts.join('\n'), subject, line, {
246
+ key,
247
+ value: (v) => v.split('\n').map(quoteOf).join(' '),
248
+ });
249
+ return next;
250
+ }
181
251
  if (bracketKeys !== undefined && key !== 'text') {
182
252
  // `contents: match` names the part and then says one of its two properties
183
253
  // without saying which. The brackets are what make the level shift visible,
@@ -243,7 +313,7 @@ function readAttr(tokens, at, attrs, line, subject) {
243
313
  }
244
314
  throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
245
315
  }
246
- attrs[key] = valueToken.text;
316
+ setOnce(attrs, key, valueToken.text, subject, line);
247
317
  return at + 2;
248
318
  }
249
319
  /** Attributes only, for the statements that take no placements. */
@@ -405,27 +475,6 @@ function parseEdge(head, line) {
405
475
  line,
406
476
  };
407
477
  }
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
478
  /** `style <name> <attributes>` */
430
479
  function parseStyle(head, line) {
431
480
  const name = requireName(head[1], 'style', line);
@@ -497,7 +546,7 @@ function readPlacement(tokens, at, line, subject) {
497
546
  throw new SourceError(`${subject}: an alignment reads "${written} <node>"`, line);
498
547
  }
499
548
  const read = readTargets(tokens, from + 2, subject, written, line);
500
- const modifiers = readModifiers(tokens, read.next, subject, written, line);
549
+ const modifiers = readModifiers(tokens, read.next, subject, `${written} ${listTargets(read.targets)}`, line);
501
550
  // An alignment shares a line outright, so there is no distance in it for
502
551
  // a gap to set. Refusing rather than dropping it, for the reason unknown
503
552
  // modifier names are refused: a word that quietly does nothing reads as a
@@ -529,10 +578,11 @@ function readPlacement(tokens, at, line, subject) {
529
578
  throw new SourceError(`${subject}: "${word.text}" is not a direction`, line);
530
579
  }
531
580
  let next = at + 1;
532
- if (follows(tokens, next, 'of'))
581
+ const of = follows(tokens, next, 'of');
582
+ if (of)
533
583
  next += 1;
534
584
  const read = readTargets(tokens, next, subject, word.text, line);
535
- const modifiers = readModifiers(tokens, read.next, subject, word.text, line);
585
+ const modifiers = readModifiers(tokens, read.next, subject, `${word.text}${of ? ' of' : ''} ${listTargets(read.targets)}`, line);
536
586
  return {
537
587
  placement: {
538
588
  kind: 'offset',
@@ -673,7 +723,12 @@ function readBracket(tokens, start, keys, about) {
673
723
  // targets of a placement. `(at: bottom, align: center)` and the same without
674
724
  // the comma are the same statement.
675
725
  const value = valueToken.text;
676
- values[key] = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
726
+ const clean = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
727
+ const had = values[key];
728
+ if (had !== undefined) {
729
+ throw new SourceError(`${about.subject}: "${key}" is written twice in the brackets after ${about.what} (${had}, ${clean}) — keep one`, about.line);
730
+ }
731
+ values[key] = clean;
677
732
  i += 2;
678
733
  }
679
734
  return { values, next: i + 1 };
package/dist/render.js CHANGED
@@ -162,10 +162,10 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
162
162
  const parts = [];
163
163
  const kids = [];
164
164
  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;
165
+ // A node with children is colored as a backdrop however they are placed,
166
+ // including a lone badge beside its text. Every rule that tried to tell a
167
+ // badge from contents was a guess; this one is visible in the source.
168
+ const container = node.children.length > 0;
169
169
  const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
170
170
  const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
171
171
  // A box is the one kind with two inkable parts, which is why its text needs
@@ -188,8 +188,9 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
188
188
  // A leaf's text defaults to the middle of its box, a container's to the top
189
189
  // left of the band; both then read `at` for where it really goes. Where the
190
190
  // 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');
191
+ // its lines against each other is read here. This follows the band, not the
192
+ // colors: a badged leaf is a backdrop but its text still centers.
193
+ const textStyle = textStyleFor(node.textAttrs, node.line, node.banded ? 'start' : 'middle', node.banded ? 'top-left' : 'center');
193
194
  parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
194
195
  color: text,
195
196
  align: textStyle.align,
package/dist/resolve.js CHANGED
@@ -24,7 +24,6 @@ export function resolve(doc, options = {}) {
24
24
  const styles = collectStyles(doc.statements);
25
25
  checkStyleKeys(doc.statements);
26
26
  const { nodes, byName, roots } = buildTree(doc.statements, styles);
27
- applyDecks(doc.statements, byName);
28
27
  // Edges are resolved to nodes before anything is sized, because a labeled
29
28
  // edge claims room in the gap it crosses and so has to be in hand while the
30
29
  // gaps are being worked out. Nothing here reads geometry.
@@ -129,6 +128,11 @@ function buildTree(statements, styles) {
129
128
  };
130
129
  checkAttrs(kind, node.name, stmt.attrs, stmt.line);
131
130
  checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
131
+ // `deck:` holds one text per copy, joined on a line break the source cannot
132
+ // contain. Like a badge, only a box has an outline to repeat.
133
+ const deck = appearance['deck'];
134
+ if (deck !== undefined && body.kind === 'shape')
135
+ node.deckTexts = deck.split('\n');
132
136
  const cut = stmt.name.lastIndexOf('.');
133
137
  if (cut === -1) {
134
138
  roots.push(node);
@@ -254,7 +258,9 @@ function checkAttrs(kind, name, attrs, line) {
254
258
  continue;
255
259
  // Quoted back the way it was written. A bracketed value arrives one dotted
256
260
  // 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})`;
261
+ // A deck's texts are stored joined on a line break; quote them back as written.
262
+ const shown = key === 'deck' ? value.split('\n').map((text) => `"${text}"`).join(' ') : value;
263
+ const wrote = written === key ? `${key}: ${shown}` : `${key}: (${written.slice(key.length + 1)}: ${shown})`;
258
264
  if (!ALL_ATTR_KEYS.includes(key)) {
259
265
  // Nothing anywhere in the language answers to this word, so the only
260
266
  // remedy is the vocabulary itself.
@@ -288,23 +294,25 @@ function checkAttrs(kind, name, attrs, line) {
288
294
  * construction — that hole is left open, because nobody writes one by accident.
289
295
  */
290
296
  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);
297
+ // Each style in a list is checked on its own: one that gives this thing
298
+ // nothing is a name written on the wrong sort of thing, whatever its
299
+ // neighbours in the list carry.
300
+ for (const named of styleNames(attrs)) {
301
+ const base = styles.get(named);
302
+ if (base === undefined)
303
+ continue; // `appearanceOf` reports the missing style.
304
+ // A style naming another style is the one way to carry nothing at all: the
305
+ // parser already refuses one with no attributes, and `appearanceOf` does not
306
+ // recurse, so the name would sit there doing nothing.
307
+ const carried = [...new Set(Object.keys(base).map(topKey))].filter((key) => key !== 'style');
308
+ if (carried.length === 0) {
309
+ throw new SourceError(`style "${named}" carries nothing but a style name`, line);
310
+ }
311
+ if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
312
+ continue;
313
+ throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
314
+ `${article(KIND_WORD[kind])} is ${KIND_PARTS[kind]}`, line);
303
315
  }
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
316
  }
309
317
  /**
310
318
  * A style is a bundle spanning kinds, so its keys cannot be checked against any
@@ -345,24 +353,25 @@ function listKinds(kinds) {
345
353
  return words[0] ?? 'nothing';
346
354
  return `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`;
347
355
  }
348
- function appearanceOf(attrs, styles, line) {
356
+ /** The styles a `style:` names, in the order written. The parser joins them on a space. */
357
+ function styleNames(attrs) {
349
358
  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 };
359
+ return named === undefined ? [] : named.split(' ');
356
360
  }
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;
361
+ /**
362
+ * The styles in the order written, a later one winning where two set the same
363
+ * key, and then the statement's own words over all of them — a word written on
364
+ * the line was meant for this thing, wherever `style:` sits.
365
+ */
366
+ function appearanceOf(attrs, styles, line) {
367
+ const merged = {};
368
+ for (const named of styleNames(attrs)) {
369
+ const base = styles.get(named);
370
+ if (!base)
371
+ throw new SourceError(`no style named "${named}"`, line);
372
+ Object.assign(merged, base);
365
373
  }
374
+ return { ...merged, ...attrs };
366
375
  }
367
376
  function buildEdges(statements, byName, styles) {
368
377
  const edges = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {