reladraw 0.0.1 → 0.2.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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Text measurement, kept behind one interface on purpose.
3
+ *
4
+ * The parser and resolver are pure — text in, geometry out — and the only part
5
+ * of the system that needs to know about fonts is this. In Node there is no
6
+ * rendering engine to ask, so the default implementation assumes a monospace
7
+ * face, where every glyph has the same advance width and the answer is
8
+ * arithmetic. In a browser the same interface can be backed by the DOM, which
9
+ * measures exactly.
10
+ */
11
+ /**
12
+ * A slash with whitespace on both sides marks a line break, so a label is
13
+ * really a short stack of lines. Each line is trimmed; empty ones are dropped.
14
+ *
15
+ * The whitespace is what makes the marker safe. Splitting on a bare `/` meant
16
+ * no label could contain one, so `TCP/IP` came out as two lines, and so did
17
+ * `16/9`, `I/O` and every path or URL. Requiring the spaces keeps the marker
18
+ * legible where it is meant — `"Computer 1 / Ubuntu"` — while a
19
+ * slash inside a word stays an ordinary character.
20
+ *
21
+ * That leaves the label that wants a spaced slash and no break — `Before / After`
22
+ * — which writes it `\/`. The lexer preserves that escape rather than resolving
23
+ * it, so the backslash is still here to suppress the split, and is dropped once
24
+ * the splitting is done.
25
+ */
26
+ export function splitLines(text) {
27
+ const lines = text
28
+ .split(/\s+\/\s+/)
29
+ .map((part) => part.trim().replace(/\\\//g, '/'))
30
+ .filter((part) => part.length > 0);
31
+ return lines.length > 0 ? lines : [''];
32
+ }
33
+ /**
34
+ * Every monospace face used here is assumed to advance 0.6 em per character.
35
+ * DejaVu Sans Mono and Menlo both sit at 0.602; Consolas is narrower at 0.55,
36
+ * so a diagram viewed with Consolas substituted in comes out uniformly tight
37
+ * rather than raggedly wrong.
38
+ */
39
+ const DEFAULT_ADVANCE_RATIO = 0.6;
40
+ const LINE_HEIGHT_RATIO = 1.35;
41
+ /**
42
+ * Named families only, in order of likelihood, ending in the generic.
43
+ * Deliberately no `ui-monospace`: it is a CSS keyword many SVG renderers do
44
+ * not know, and it means "whatever this interface uses", which is not
45
+ * guaranteed to be monospace at all. Every width here is computed on the
46
+ * assumption that it is.
47
+ */
48
+ const DEFAULT_STACK = '"DejaVu Sans Mono", "Menlo", "Consolas", "Liberation Mono", "Courier New", monospace';
49
+ export function monospaceMeasurer(fontFamily = DEFAULT_STACK, advanceRatio = DEFAULT_ADVANCE_RATIO) {
50
+ return {
51
+ fontFamily,
52
+ lineHeight(fontSize) {
53
+ return Math.round(fontSize * LINE_HEIGHT_RATIO);
54
+ },
55
+ measure(text, fontSize) {
56
+ const lines = splitLines(text);
57
+ const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
58
+ const lineHeight = Math.round(fontSize * LINE_HEIGHT_RATIO);
59
+ return {
60
+ width: Math.ceil(longest * fontSize * advanceRatio),
61
+ height: lines.length * lineHeight,
62
+ lines,
63
+ };
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,78 @@
1
+ import type { Attrs, Axis, Placement } from './ast.js';
2
+ /** A `between` clause with its targets resolved. Mirrors `Passage` in `ast.ts`. */
3
+ export interface LayoutPassage {
4
+ nodes: [LayoutNode, LayoutNode];
5
+ /** Which gap, where the pair has two. Absent when the pair leaves no doubt. */
6
+ axis?: Axis;
7
+ }
8
+ /** A node with its geometry solved. Coordinates are absolute, origin top-left. */
9
+ export interface LayoutNode {
10
+ name: string;
11
+ kind: 'box' | 'note';
12
+ /** The label as written, before line splitting. */
13
+ text: string;
14
+ /** The label split into the lines that will be drawn. */
15
+ lines: string[];
16
+ parent?: LayoutNode;
17
+ children: LayoutNode[];
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ /**
23
+ * Distance from the node's outer box to its drawn face. Zero for everything
24
+ * except a deck, where the offset copies sit in that margin.
25
+ */
26
+ inset: number;
27
+ /** One label per copy behind this node, back to front. Empty for most nodes. */
28
+ deckLabels: string[];
29
+ /**
30
+ * Vertical space this node's own label and icon occupy, at whichever end of
31
+ * the box `label.at` puts them. Zero for leaves.
32
+ */
33
+ headerHeight: number;
34
+ /** The label's bracketed modifiers, as written. Usually empty. */
35
+ label: Attrs;
36
+ attrs: Attrs;
37
+ /** Style attributes merged in from a named style, then overridden by the node's own. */
38
+ appearance: Attrs;
39
+ /** What the author said about where it goes, kept so diagnostics can quote the source back. */
40
+ placements: Placement[];
41
+ line: number;
42
+ }
43
+ export interface LayoutLink {
44
+ from: LayoutNode;
45
+ to: LayoutNode;
46
+ both: boolean;
47
+ label?: string;
48
+ /**
49
+ * The gap a `between` clause named, with its two nodes resolved. Nothing in
50
+ * the resolver uses this — a corridor is measured off the solved layout
51
+ * rather than solved for, so links stay out of the constraint system entirely.
52
+ */
53
+ between?: LayoutPassage;
54
+ attrs: Attrs;
55
+ appearance: Attrs;
56
+ line: number;
57
+ }
58
+ export interface Layout {
59
+ /** Every node, containers and children alike, in declaration order. */
60
+ nodes: LayoutNode[];
61
+ /** Top-level nodes only, in declaration order. */
62
+ roots: LayoutNode[];
63
+ links: LayoutLink[];
64
+ /**
65
+ * What the `diagram` statement said, as written. Nothing here affects
66
+ * geometry; it rides along so the renderer sees the whole compiled document
67
+ * and not only the shapes.
68
+ */
69
+ diagram: Attrs;
70
+ width: number;
71
+ height: number;
72
+ /**
73
+ * The clear band left around the drawing. Kept so the renderer can hold the
74
+ * same band open around a link that leaves the boxes' bounds — a curve out of
75
+ * a `top` side does exactly that, and the canvas has to grow to hold it.
76
+ */
77
+ margin: number;
78
+ }
package/dist/model.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { Document } from './ast.js';
2
+ /** Parse a whole source file. One statement per line; blanks and comments drop out. */
3
+ export declare function parse(source: string): Document;
package/dist/parser.js ADDED
@@ -0,0 +1,459 @@
1
+ import { COLOR_KEYS, DIAGRAM_KEYS, EDGE_AXIS, EDGES, PASSAGE_AXES, LABEL_KEYS, PLACEMENT_KEYS, isDirection, listTargets, } from './ast.js';
2
+ import { SourceError } from './errors.js';
3
+ import { isAttrKey, tokenizeLine } from './lexer.js';
4
+ /** Parse a whole source file. One statement per line; blanks and comments drop out. */
5
+ export function parse(source) {
6
+ const statements = [];
7
+ source.split(/\r?\n/).forEach((line, index) => {
8
+ const lineNumber = index + 1;
9
+ const tokens = tokenizeLine(line, lineNumber);
10
+ if (tokens.length === 0)
11
+ return;
12
+ statements.push(parseStatement(tokens, lineNumber));
13
+ });
14
+ return { statements };
15
+ }
16
+ function parseStatement(tokens, line) {
17
+ const split = attributesBegin(tokens);
18
+ const head = split === -1 ? tokens : tokens.slice(0, split);
19
+ const attrs = split === -1 ? {} : parseAttrs(tokens.slice(split), line);
20
+ const keyword = head[0];
21
+ if (!keyword || keyword.quoted) {
22
+ throw new SourceError('a statement must begin with a keyword', line);
23
+ }
24
+ switch (keyword.text) {
25
+ case 'box':
26
+ return parseBox(head, attrs, line);
27
+ case 'note':
28
+ return parseNote(head, attrs, line);
29
+ case 'link':
30
+ return parseLink(head, attrs, line);
31
+ case 'deck':
32
+ return parseDeck(head, line);
33
+ case 'style':
34
+ return parseStyle(head, attrs, line);
35
+ case 'diagram':
36
+ return parseDiagram(head, attrs, line);
37
+ default:
38
+ throw new SourceError(`unknown statement "${keyword.text}"`, line);
39
+ }
40
+ }
41
+ /**
42
+ * Where the node's own attributes start: the first `key:` outside any brackets.
43
+ * A placement's modifiers are `key: value` too, so a plain search for the first
44
+ * attribute key would cut the head in the middle of `left of hub (gap: wide)`.
45
+ */
46
+ function attributesBegin(tokens) {
47
+ let depth = 0;
48
+ for (const [at, token] of tokens.entries()) {
49
+ if (token.quoted)
50
+ continue;
51
+ if (token.text === '(')
52
+ depth += 1;
53
+ else if (token.text === ')')
54
+ depth = Math.max(0, depth - 1);
55
+ else if (depth === 0 && isAttrKey(token))
56
+ return at;
57
+ }
58
+ return -1;
59
+ }
60
+ /** The value as the author would have to write it back into a label. */
61
+ function quoteOf(text) {
62
+ return `"${text.replace(/"/g, '\\"')}"`;
63
+ }
64
+ function parseAttrs(tokens, line) {
65
+ const attrs = {};
66
+ let i = 0;
67
+ while (i < tokens.length) {
68
+ const keyToken = tokens[i];
69
+ if (!isAttrKey(keyToken)) {
70
+ // Attributes end the positional part of a statement, so a placement
71
+ // written after one is a real mistake with an obvious remedy. Saying what
72
+ // the parser expected describes its own state; say what to move instead.
73
+ if (startsPlacement(keyToken)) {
74
+ throw new SourceError(`"${keyToken.text}" starts a placement, and placements come before the attributes — move it in front of the first "key: value"`, line);
75
+ }
76
+ throw new SourceError(`expected an attribute like "key: value", found "${keyToken.text}"`, line);
77
+ }
78
+ const key = keyToken.text.slice(0, -1);
79
+ const valueToken = tokens[i + 1];
80
+ if (!valueToken)
81
+ throw new SourceError(`attribute "${key}" has no value`, line);
82
+ if (isAttrKey(valueToken)) {
83
+ throw new SourceError(`attribute "${key}" has no value`, line);
84
+ }
85
+ if (key === 'stroke') {
86
+ // Removed 2026-09-09. It meant a different part on every kind — the
87
+ // border of a box, the text of a note or a glyph body, the line of a
88
+ // link — so it could never be wrong, and a box's text had no word at all.
89
+ // Refused by name rather than ignored: an older file must be told what
90
+ // to write, not silently drawn without its colors.
91
+ throw new SourceError('`stroke:` has been replaced by the part it colors — `border:` on a box, `text:` on a note or a glyph body, `line:` on a link. A style shared between boxes and links writes both, as in `border: #d2904e line: #d2904e`', line);
92
+ }
93
+ if (key === 'width') {
94
+ // Renamed 2026-09-09. It folds a label every n *characters* and never
95
+ // said how wide anything is, so `width: 200` meaning units was accepted,
96
+ // wrapped at 200 characters, and did nothing visible — the silent drop
97
+ // this vocabulary is otherwise free of. Refused by name for the reason
98
+ // `stroke:` is: an older file must be told, not quietly drawn unwrapped.
99
+ throw new SourceError('`width:` is now `wrap:`, because it folds the text every n characters and says nothing about how wide anything is', line);
100
+ }
101
+ if (valueToken.quoted && COLOR_KEYS.includes(key)) {
102
+ // A quoted value is the author saying "this is text", and every one of
103
+ // these keys takes a color. Without this the string is passed through as
104
+ // a color, turns out not to be one, and nothing is drawn and nothing is
105
+ // said. Name the likely intent rather than only the rule: the qualifier
106
+ // under a name is a second label line, not a `subtext` value.
107
+ if (valueToken.text.startsWith('#')) {
108
+ // A hex color that was merely quoted. The author wrote a color and the
109
+ // remedy is punctuation, so say that rather than that it is not one.
110
+ throw new SourceError(`a color is written without quotes — "${key}: ${valueToken.text}"`, line);
111
+ }
112
+ // `text:` is the color of a label, not the label itself, and that is a
113
+ // mistake worth naming rather than only refusing — the word invites it.
114
+ if (key === 'text') {
115
+ throw new SourceError(`\`text:\` is the color of a label, not the label — write the words in quotes after the name, as in \`box name ${quoteOf(valueToken.text)}\``, line);
116
+ }
117
+ if (key === 'subtext') {
118
+ throw new SourceError(`\`subtext:\` is the color of a label's later lines, not the words — write them into the label, as in \`"Name / ${valueToken.text}"\`, and \`subtext: muted\` to make them quieter`, line);
119
+ }
120
+ throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
121
+ }
122
+ attrs[key] = valueToken.text;
123
+ i += 2;
124
+ }
125
+ return attrs;
126
+ }
127
+ /**
128
+ * `box <name> ["<text>"] [(<label modifiers>)] [<placement> ...]`
129
+ *
130
+ * The text is optional and the name stands in for it, because a bare `box a`
131
+ * asking for an empty rectangle is a default nobody wants: the first lines
132
+ * anybody types are `box a` and `box b right of a`, and they mean the two
133
+ * boxes to say "a" and "b". `""` is how a box says it is deliberately blank —
134
+ * an invisible container, a glyph body, a node that is nothing but its icon —
135
+ * and every such box already writes it, so nothing that predates this changed
136
+ * meaning. The syntax being added was a parse error before, which is what
137
+ * makes it purely additive.
138
+ *
139
+ * A dotted name shows its last segment only. Containment is already drawn, so
140
+ * `server.docker` reading "docker" says everything the whole path would.
141
+ *
142
+ * A name now has two jobs, so renaming a node can change the picture. That is
143
+ * the price, and it is honest: a file that states no label is saying the name
144
+ * is the label.
145
+ */
146
+ function parseBox(head, attrs, line) {
147
+ const name = requireName(head[1], 'box', line);
148
+ const written = head[2];
149
+ const textToken = written?.quoted ? written : undefined;
150
+ // A bare word here is a label somebody forgot to quote far more often than
151
+ // it is anything else, and `"Parser" is not a direction` would send them
152
+ // looking in the wrong place.
153
+ if (written && !textToken && !isAttrKey(written) && !startsPlacement(written) && written.text !== '(') {
154
+ throw new SourceError(`box "${name}": a label is quoted — write "${written.text}" rather than ${written.text}`, line);
155
+ }
156
+ const text = textToken ? textToken.text : name.slice(name.lastIndexOf('.') + 1);
157
+ const subject = `box "${name}"`;
158
+ const label = readBracket(head, textToken ? 3 : 2, LABEL_KEYS, {
159
+ subject,
160
+ what: 'the label',
161
+ kind: 'a label',
162
+ example: 'at: bottom',
163
+ line,
164
+ });
165
+ const placements = parsePlacements(head.slice(label.next), line, subject);
166
+ return { kind: 'box', name, text, label: label.values, placements, attrs, line };
167
+ }
168
+ /** `note <name> "<text>" [<placement> ...]` */
169
+ function parseNote(head, attrs, line) {
170
+ const name = requireName(head[1], 'note', line);
171
+ const textToken = head[2];
172
+ if (!textToken || !textToken.quoted) {
173
+ throw new SourceError(`note "${name}" needs quoted text`, line);
174
+ }
175
+ // A note is bare text with no box, so it has no band for a label to sit in
176
+ // and nowhere for `at:` to put one. Refused by name rather than ignored.
177
+ if (follows(head, 3, '(')) {
178
+ throw new SourceError(`note "${name}" carries label modifiers. A note is bare text, so there is no box for its label to sit anywhere in`, line);
179
+ }
180
+ const placements = parsePlacements(head.slice(3), line, `note "${name}"`);
181
+ return { kind: 'note', name, text: textToken.text, placements, attrs, line };
182
+ }
183
+ /**
184
+ * `link <from> -> <to> ["<label>"] [between <a> and <b>]`, with `<->` for a
185
+ * two-headed arrow and `<-` for one pointing the other way.
186
+ *
187
+ * `a <- b` is exactly `b -> a` and carries no meaning of its own downstream.
188
+ * What it buys is the ordering: the name written first is the one the line is
189
+ * about, and plenty of links have the target as their subject.
190
+ */
191
+ const ARROWS = ['->', '<->', '<-'];
192
+ function parseLink(head, attrs, line) {
193
+ const left = requireName(head[1], 'link', line);
194
+ const arrow = head[2];
195
+ if (!arrow || arrow.quoted || !ARROWS.includes(arrow.text)) {
196
+ throw new SourceError('a link needs "->", "<-" or "<->" between its endpoints', line);
197
+ }
198
+ const rightToken = head[3];
199
+ if (!rightToken || rightToken.quoted) {
200
+ throw new SourceError('a link needs a node on the right of the arrow', line);
201
+ }
202
+ const back = arrow.text === '<-';
203
+ let at = 4;
204
+ const labelToken = head[at]?.quoted ? head[at] : undefined;
205
+ if (labelToken)
206
+ at += 1;
207
+ // A gap has two sides, so `between` takes exactly two targets rather than the
208
+ // open list a placement takes. `right of a and b` means "clear of both", and
209
+ // there is no matching reading of "pass between three things".
210
+ let between;
211
+ if (at < head.length) {
212
+ const word = head[at];
213
+ if (word.quoted || word.text !== 'between') {
214
+ throw new SourceError(`unexpected "${word.text}" after the link`, line);
215
+ }
216
+ const read = readTargets(head, at + 1, 'link', 'between', line);
217
+ if (read.targets.length !== 2) {
218
+ throw new SourceError(`"between" takes two nodes, one for each side of the gap — found ${read.targets.length}`, line);
219
+ }
220
+ at = read.next;
221
+ // Two targets sitting diagonally have two gaps between them, and this is
222
+ // the only way to say which. It is optional because most pairs have one.
223
+ const trailing = head[at];
224
+ const axis = trailing && !trailing.quoted ? PASSAGE_AXES[trailing.text] : undefined;
225
+ if (axis !== undefined)
226
+ at += 1;
227
+ between = { targets: read.targets, ...(axis !== undefined ? { axis } : {}) };
228
+ }
229
+ if (at < head.length) {
230
+ throw new SourceError(`unexpected "${head[at].text}" after the link`, line);
231
+ }
232
+ return {
233
+ kind: 'link',
234
+ from: back ? rightToken.text : left,
235
+ to: back ? left : rightToken.text,
236
+ both: arrow.text === '<->',
237
+ ...(labelToken ? { label: labelToken.text } : {}),
238
+ ...(between ? { between } : {}),
239
+ attrs,
240
+ line,
241
+ };
242
+ }
243
+ /** `deck <name> "<label>" ["<label>" ...]` */
244
+ function parseDeck(head, line) {
245
+ const name = requireName(head[1], 'deck', line);
246
+ const labels = [];
247
+ for (const token of head.slice(2)) {
248
+ if (!token.quoted) {
249
+ throw new SourceError(`deck "${name}" takes quoted labels only`, line);
250
+ }
251
+ labels.push(token.text);
252
+ }
253
+ if (labels.length === 0) {
254
+ throw new SourceError(`deck "${name}" needs at least one label`, line);
255
+ }
256
+ return { kind: 'deck', name, labels, line };
257
+ }
258
+ /** `style <name> <attributes>` */
259
+ function parseStyle(head, attrs, line) {
260
+ const name = requireName(head[1], 'style', line);
261
+ if (head.length > 2) {
262
+ throw new SourceError(`unexpected "${head[2].text}" after style name`, line);
263
+ }
264
+ if (Object.keys(attrs).length === 0) {
265
+ throw new SourceError(`style "${name}" sets nothing`, line);
266
+ }
267
+ return { kind: 'style', name, attrs, line };
268
+ }
269
+ /**
270
+ * `diagram <attributes>` — no name, because a file holds one diagram. Unknown
271
+ * keys are refused rather than ignored: a misspelt diagram-wide setting that
272
+ * silently does nothing is the kind of thing an author stares at for a while.
273
+ */
274
+ function parseDiagram(head, attrs, line) {
275
+ if (head.length > 1) {
276
+ throw new SourceError(`unexpected "${head[1].text}" after diagram`, line);
277
+ }
278
+ if (Object.keys(attrs).length === 0) {
279
+ throw new SourceError('diagram sets nothing', line);
280
+ }
281
+ for (const key of Object.keys(attrs)) {
282
+ if (!DIAGRAM_KEYS.includes(key)) {
283
+ throw new SourceError(`diagram has no "${key}" — it takes ${DIAGRAM_KEYS.join(', ')}`, line);
284
+ }
285
+ }
286
+ return { kind: 'diagram', attrs, line };
287
+ }
288
+ function requireName(token, keyword, line) {
289
+ if (!token || token.quoted) {
290
+ throw new SourceError(`${keyword} needs a name`, line);
291
+ }
292
+ return token.text;
293
+ }
294
+ /**
295
+ * Read however many placements the author wrote. Each is a direction and a target
296
+ * (`right of docker`, `below deploy`) or an alignment (`level with docker`).
297
+ * None at all is fine — that node is the anchor.
298
+ *
299
+ * `of` is optional after every direction. "left of X" and "below X" are both
300
+ * good English and "below of X" is not, so the word is accepted wherever it
301
+ * helps and never demanded. Shorthands added later — chaining targets with
302
+ * `and`, say — extend this loop without disturbing what it already reads.
303
+ */
304
+ function parsePlacements(tokens, line, subject) {
305
+ const placements = [];
306
+ let i = 0;
307
+ while (i < tokens.length) {
308
+ const word = tokens[i];
309
+ if (word.quoted) {
310
+ throw new SourceError(`${subject}: unexpected text "${word.text}"`, line);
311
+ }
312
+ // `top level with media` names an edge rather than the center line. `left`
313
+ // and `right` are edges as well as directions, so it is the word after them
314
+ // that says which was meant — "left of bup_hd" against "left level with bup_hd".
315
+ const edge = isEdgeWord(word.text) && follows(tokens, i + 1, 'level') ? word.text : undefined;
316
+ const head = edge ? tokens[i + 1] : word;
317
+ if (head.text === 'level' && !head.quoted) {
318
+ const at = edge ? i + 1 : i;
319
+ const written = edge ? `${edge} level with` : 'level with';
320
+ if (!follows(tokens, at + 1, 'with')) {
321
+ throw new SourceError(`${subject}: an alignment reads "${written} <node>"`, line);
322
+ }
323
+ const read = readTargets(tokens, at + 2, subject, written, line);
324
+ const modifiers = readModifiers(tokens, read.next, subject, written, line);
325
+ // An alignment shares a line outright, so there is no distance in it for
326
+ // a gap to set. Refusing rather than dropping it, for the reason unknown
327
+ // modifier names are refused: a word that quietly does nothing reads as a
328
+ // fault in the tool.
329
+ if (modifiers.gap !== undefined) {
330
+ throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" shares a line rather than leaving a space, so it takes no gap`, line);
331
+ }
332
+ placements.push({
333
+ kind: 'align',
334
+ axis: EDGE_AXIS[edge ?? 'center'],
335
+ edge: edge ?? 'center',
336
+ targets: read.targets,
337
+ line,
338
+ });
339
+ i = modifiers.next;
340
+ continue;
341
+ }
342
+ if (!isDirection(word.text)) {
343
+ throw new SourceError(`${subject}: "${word.text}" is not a direction`, line);
344
+ }
345
+ let next = i + 1;
346
+ if (follows(tokens, next, 'of'))
347
+ next += 1;
348
+ const read = readTargets(tokens, next, subject, word.text, line);
349
+ const modifiers = readModifiers(tokens, read.next, subject, word.text, line);
350
+ placements.push({
351
+ kind: 'offset',
352
+ direction: word.text,
353
+ targets: read.targets,
354
+ ...(modifiers.gap !== undefined ? { gap: modifiers.gap } : {}),
355
+ line,
356
+ });
357
+ i = modifiers.next;
358
+ }
359
+ return placements;
360
+ }
361
+ /**
362
+ * The bracketed modifiers on one placement — `left of hub (gap: wide)`.
363
+ *
364
+ * A gap describes the relationship rather than the box at either end of it, so
365
+ * a node wedged between two things can be tight against one and wide of the
366
+ * other. The brackets are what make the scope visible: a bare `gap:` sitting
367
+ * between two placements cannot be told from the node-wide default, and would
368
+ * attach silently to whichever clause happened to precede it.
369
+ */
370
+ function readModifiers(tokens, start, subject, placement, line) {
371
+ const read = readBracket(tokens, start, PLACEMENT_KEYS, {
372
+ subject,
373
+ what: `"${placement}"`,
374
+ kind: 'a placement',
375
+ example: 'gap: wide',
376
+ line,
377
+ });
378
+ return { ...read.values, next: read.next };
379
+ }
380
+ /**
381
+ * A bracketed `key: value` list, shared by a placement's modifiers and a
382
+ * label's. Both exist for the same reason — a modifier belongs to the clause it
383
+ * modifies, and the brackets say which clause that is rather than leaving it to
384
+ * be inferred from what happens to precede it.
385
+ *
386
+ * The keys are whitelisted and an unknown one is refused by name, the same rule
387
+ * `DIAGRAM_KEYS` follows: a modifier that silently does nothing is worse than an
388
+ * error, because the picture moves and nothing says why.
389
+ */
390
+ function readBracket(tokens, start, keys, about) {
391
+ if (!follows(tokens, start, '('))
392
+ return { values: {}, next: start };
393
+ const values = {};
394
+ let i = start + 1;
395
+ while (!follows(tokens, i, ')')) {
396
+ const keyToken = tokens[i];
397
+ if (!keyToken) {
398
+ throw new SourceError(`${about.subject}: ${about.what} opens a "(" and never closes it`, about.line);
399
+ }
400
+ if (!isAttrKey(keyToken)) {
401
+ throw new SourceError(`${about.subject}: ${about.what} takes modifiers like "${about.example}" in its brackets, found "${keyToken.text}"`, about.line);
402
+ }
403
+ const key = keyToken.text.slice(0, -1);
404
+ if (!keys.includes(key)) {
405
+ throw new SourceError(`${about.kind} has no "${key}" — it takes ${keys.join(', ')}`, about.line);
406
+ }
407
+ const valueToken = tokens[i + 1];
408
+ if (!valueToken || valueToken.quoted || isAttrKey(valueToken) || valueToken.text === ')') {
409
+ throw new SourceError(`${about.subject}: "${key}" has no value`, about.line);
410
+ }
411
+ // A comma between modifiers is punctuation, exactly as it is between the
412
+ // targets of a placement. `(at: bottom, align: center)` and the same without
413
+ // the comma are the same statement.
414
+ const value = valueToken.text;
415
+ values[key] = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
416
+ i += 2;
417
+ }
418
+ return { values, next: i + 1 };
419
+ }
420
+ function follows(tokens, at, word) {
421
+ const token = tokens[at];
422
+ return token !== undefined && !token.quoted && token.text === word;
423
+ }
424
+ /** Could this token open a placement? `top` and `left` open the edge alignments. */
425
+ function startsPlacement(token) {
426
+ if (token.quoted)
427
+ return false;
428
+ return (isDirection(token.text) ||
429
+ token.text === 'level' ||
430
+ EDGES.includes(token.text));
431
+ }
432
+ function isEdgeWord(word) {
433
+ return word !== 'center' && EDGES.includes(word);
434
+ }
435
+ /**
436
+ * One target, or several joined by `and` — `right of borg and bare`, or
437
+ * `level with borg, bare and media`. A trailing comma separates just as `and`
438
+ * does, so both the way people write lists come out the same.
439
+ */
440
+ function readTargets(tokens, start, subject, placement, line) {
441
+ const targets = [];
442
+ let i = start;
443
+ for (;;) {
444
+ const token = tokens[i];
445
+ if (!token || token.quoted || token.text === '(' || token.text === ')') {
446
+ throw new SourceError(`${subject}: "${placement}" names no node`, line);
447
+ }
448
+ const listed = token.text.endsWith(',') && token.text.length > 1;
449
+ targets.push(listed ? token.text.slice(0, -1) : token.text);
450
+ i += 1;
451
+ if (follows(tokens, i, 'and')) {
452
+ i += 1;
453
+ continue;
454
+ }
455
+ if (listed)
456
+ continue;
457
+ return { targets, next: i };
458
+ }
459
+ }
@@ -0,0 +1,31 @@
1
+ import { type Measurer } from './measure.js';
2
+ import type { Layout } from './model.js';
3
+ export interface RenderOptions {
4
+ measurer?: Measurer;
5
+ fontSize?: number;
6
+ theme?: Theme;
7
+ }
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
+ link: 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
+ /** Turn solved geometry into a standalone SVG document. */
31
+ export declare function render(layout: Layout, options?: RenderOptions): string;