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 +1 -1
- package/SYNTAX.md +34 -5
- package/dist/ast.d.ts +1 -9
- package/dist/ast.js +1 -1
- package/dist/constants.d.ts +4 -4
- package/dist/constants.js +15 -8
- package/dist/grammar.d.ts +1 -1
- package/dist/grammar.js +2 -2
- package/dist/model.d.ts +4 -3
- package/dist/parser.js +86 -31
- package/dist/render.js +7 -6
- package/dist/resolve.js +42 -33
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
605
|
+
node drive "Drive 1" deck: "Drive 2" "Drive 3"
|
|
606
606
|
```
|
|
607
607
|
|
|
608
|
-
|
|
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
|
|
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 |
|
|
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'],
|
package/dist/constants.d.ts
CHANGED
|
@@ -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.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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", "
|
|
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', '
|
|
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', '
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
165
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
const container = node.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 = [];
|