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/NOTICE +37 -0
- package/README.md +1 -1
- package/SYNTAX.md +98 -9
- package/dist/ast.d.ts +33 -11
- package/dist/ast.js +25 -3
- package/dist/cli.js +18 -2
- 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/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/model.d.ts +4 -3
- package/dist/parser.js +158 -32
- package/dist/render.d.ts +1 -22
- package/dist/render.js +20 -34
- package/dist/resolve.js +105 -39
- package/dist/themes.d.ts +46 -0
- package/dist/themes.js +190 -0
- package/package.json +1 -1
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
|
|
165
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
const container = node.
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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);
|