reladraw 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/parser.js ADDED
@@ -0,0 +1,418 @@
1
+ import { 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
+ function parseAttrs(tokens, line) {
61
+ const attrs = {};
62
+ let i = 0;
63
+ while (i < tokens.length) {
64
+ const keyToken = tokens[i];
65
+ if (!isAttrKey(keyToken)) {
66
+ // Attributes end the positional part of a statement, so a placement
67
+ // written after one is a real mistake with an obvious remedy. Saying what
68
+ // the parser expected describes its own state; say what to move instead.
69
+ if (startsPlacement(keyToken)) {
70
+ throw new SourceError(`"${keyToken.text}" starts a placement, and placements come before the attributes — move it in front of the first "key: value"`, line);
71
+ }
72
+ throw new SourceError(`expected an attribute like "key: value", found "${keyToken.text}"`, line);
73
+ }
74
+ const key = keyToken.text.slice(0, -1);
75
+ const valueToken = tokens[i + 1];
76
+ if (!valueToken)
77
+ throw new SourceError(`attribute "${key}" has no value`, line);
78
+ if (isAttrKey(valueToken)) {
79
+ throw new SourceError(`attribute "${key}" has no value`, line);
80
+ }
81
+ attrs[key] = valueToken.text;
82
+ i += 2;
83
+ }
84
+ return attrs;
85
+ }
86
+ /**
87
+ * `box <name> ["<text>"] [(<label modifiers>)] [<placement> ...]`
88
+ *
89
+ * The text is optional and the name stands in for it, because a bare `box a`
90
+ * asking for an empty rectangle is a default nobody wants: the first lines
91
+ * anybody types are `box a` and `box b right of a`, and they mean the two
92
+ * boxes to say "a" and "b". `""` is how a box says it is deliberately blank —
93
+ * an invisible container, a glyph body, a node that is nothing but its icon —
94
+ * and every such box already writes it, so nothing that predates this changed
95
+ * meaning. The syntax being added was a parse error before, which is what
96
+ * makes it purely additive.
97
+ *
98
+ * A dotted name shows its last segment only. Containment is already drawn, so
99
+ * `server.docker` reading "docker" says everything the whole path would.
100
+ *
101
+ * A name now has two jobs, so renaming a node can change the picture. That is
102
+ * the price, and it is honest: a file that states no label is saying the name
103
+ * is the label.
104
+ */
105
+ function parseBox(head, attrs, line) {
106
+ const name = requireName(head[1], 'box', line);
107
+ const written = head[2];
108
+ const textToken = written?.quoted ? written : undefined;
109
+ // A bare word here is a label somebody forgot to quote far more often than
110
+ // it is anything else, and `"Parser" is not a direction` would send them
111
+ // looking in the wrong place.
112
+ if (written && !textToken && !isAttrKey(written) && !startsPlacement(written) && written.text !== '(') {
113
+ throw new SourceError(`box "${name}": a label is quoted — write "${written.text}" rather than ${written.text}`, line);
114
+ }
115
+ const text = textToken ? textToken.text : name.slice(name.lastIndexOf('.') + 1);
116
+ const subject = `box "${name}"`;
117
+ const label = readBracket(head, textToken ? 3 : 2, LABEL_KEYS, {
118
+ subject,
119
+ what: 'the label',
120
+ kind: 'a label',
121
+ example: 'at: bottom',
122
+ line,
123
+ });
124
+ const placements = parsePlacements(head.slice(label.next), line, subject);
125
+ return { kind: 'box', name, text, label: label.values, placements, attrs, line };
126
+ }
127
+ /** `note <name> "<text>" [<placement> ...]` */
128
+ function parseNote(head, attrs, line) {
129
+ const name = requireName(head[1], 'note', line);
130
+ const textToken = head[2];
131
+ if (!textToken || !textToken.quoted) {
132
+ throw new SourceError(`note "${name}" needs quoted text`, line);
133
+ }
134
+ // A note is bare text with no box, so it has no band for a label to sit in
135
+ // and nowhere for `at:` to put one. Refused by name rather than ignored.
136
+ if (follows(head, 3, '(')) {
137
+ 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);
138
+ }
139
+ const placements = parsePlacements(head.slice(3), line, `note "${name}"`);
140
+ return { kind: 'note', name, text: textToken.text, placements, attrs, line };
141
+ }
142
+ /**
143
+ * `link <from> -> <to> ["<label>"] [between <a> and <b>]`, with `<->` for a
144
+ * two-headed arrow and `<-` for one pointing the other way.
145
+ *
146
+ * `a <- b` is exactly `b -> a` and carries no meaning of its own downstream.
147
+ * What it buys is the ordering: the name written first is the one the line is
148
+ * about, and plenty of links have the target as their subject.
149
+ */
150
+ const ARROWS = ['->', '<->', '<-'];
151
+ function parseLink(head, attrs, line) {
152
+ const left = requireName(head[1], 'link', line);
153
+ const arrow = head[2];
154
+ if (!arrow || arrow.quoted || !ARROWS.includes(arrow.text)) {
155
+ throw new SourceError('a link needs "->", "<-" or "<->" between its endpoints', line);
156
+ }
157
+ const rightToken = head[3];
158
+ if (!rightToken || rightToken.quoted) {
159
+ throw new SourceError('a link needs a node on the right of the arrow', line);
160
+ }
161
+ const back = arrow.text === '<-';
162
+ let at = 4;
163
+ const labelToken = head[at]?.quoted ? head[at] : undefined;
164
+ if (labelToken)
165
+ at += 1;
166
+ // A gap has two sides, so `between` takes exactly two targets rather than the
167
+ // open list a placement takes. `right of a and b` means "clear of both", and
168
+ // there is no matching reading of "pass between three things".
169
+ let between;
170
+ if (at < head.length) {
171
+ const word = head[at];
172
+ if (word.quoted || word.text !== 'between') {
173
+ throw new SourceError(`unexpected "${word.text}" after the link`, line);
174
+ }
175
+ const read = readTargets(head, at + 1, 'link', 'between', line);
176
+ if (read.targets.length !== 2) {
177
+ throw new SourceError(`"between" takes two nodes, one for each side of the gap — found ${read.targets.length}`, line);
178
+ }
179
+ at = read.next;
180
+ // Two targets sitting diagonally have two gaps between them, and this is
181
+ // the only way to say which. It is optional because most pairs have one.
182
+ const trailing = head[at];
183
+ const axis = trailing && !trailing.quoted ? PASSAGE_AXES[trailing.text] : undefined;
184
+ if (axis !== undefined)
185
+ at += 1;
186
+ between = { targets: read.targets, ...(axis !== undefined ? { axis } : {}) };
187
+ }
188
+ if (at < head.length) {
189
+ throw new SourceError(`unexpected "${head[at].text}" after the link`, line);
190
+ }
191
+ return {
192
+ kind: 'link',
193
+ from: back ? rightToken.text : left,
194
+ to: back ? left : rightToken.text,
195
+ both: arrow.text === '<->',
196
+ ...(labelToken ? { label: labelToken.text } : {}),
197
+ ...(between ? { between } : {}),
198
+ attrs,
199
+ line,
200
+ };
201
+ }
202
+ /** `deck <name> "<label>" ["<label>" ...]` */
203
+ function parseDeck(head, line) {
204
+ const name = requireName(head[1], 'deck', line);
205
+ const labels = [];
206
+ for (const token of head.slice(2)) {
207
+ if (!token.quoted) {
208
+ throw new SourceError(`deck "${name}" takes quoted labels only`, line);
209
+ }
210
+ labels.push(token.text);
211
+ }
212
+ if (labels.length === 0) {
213
+ throw new SourceError(`deck "${name}" needs at least one label`, line);
214
+ }
215
+ return { kind: 'deck', name, labels, line };
216
+ }
217
+ /** `style <name> <attributes>` */
218
+ function parseStyle(head, attrs, line) {
219
+ const name = requireName(head[1], 'style', line);
220
+ if (head.length > 2) {
221
+ throw new SourceError(`unexpected "${head[2].text}" after style name`, line);
222
+ }
223
+ if (Object.keys(attrs).length === 0) {
224
+ throw new SourceError(`style "${name}" sets nothing`, line);
225
+ }
226
+ return { kind: 'style', name, attrs, line };
227
+ }
228
+ /**
229
+ * `diagram <attributes>` — no name, because a file holds one diagram. Unknown
230
+ * keys are refused rather than ignored: a misspelt diagram-wide setting that
231
+ * silently does nothing is the kind of thing an author stares at for a while.
232
+ */
233
+ function parseDiagram(head, attrs, line) {
234
+ if (head.length > 1) {
235
+ throw new SourceError(`unexpected "${head[1].text}" after diagram`, line);
236
+ }
237
+ if (Object.keys(attrs).length === 0) {
238
+ throw new SourceError('diagram sets nothing', line);
239
+ }
240
+ for (const key of Object.keys(attrs)) {
241
+ if (!DIAGRAM_KEYS.includes(key)) {
242
+ throw new SourceError(`diagram has no "${key}" — it takes ${DIAGRAM_KEYS.join(', ')}`, line);
243
+ }
244
+ }
245
+ return { kind: 'diagram', attrs, line };
246
+ }
247
+ function requireName(token, keyword, line) {
248
+ if (!token || token.quoted) {
249
+ throw new SourceError(`${keyword} needs a name`, line);
250
+ }
251
+ return token.text;
252
+ }
253
+ /**
254
+ * Read however many placements the author wrote. Each is a direction and a target
255
+ * (`right of docker`, `below deploy`) or an alignment (`level with docker`).
256
+ * None at all is fine — that node is the anchor.
257
+ *
258
+ * `of` is optional after every direction. "left of X" and "below X" are both
259
+ * good English and "below of X" is not, so the word is accepted wherever it
260
+ * helps and never demanded. Shorthands added later — chaining targets with
261
+ * `and`, say — extend this loop without disturbing what it already reads.
262
+ */
263
+ function parsePlacements(tokens, line, subject) {
264
+ const placements = [];
265
+ let i = 0;
266
+ while (i < tokens.length) {
267
+ const word = tokens[i];
268
+ if (word.quoted) {
269
+ throw new SourceError(`${subject}: unexpected text "${word.text}"`, line);
270
+ }
271
+ // `top level with media` names an edge rather than the centre line. `left`
272
+ // and `right` are edges as well as directions, so it is the word after them
273
+ // that says which was meant — "left of bup_hd" against "left level with bup_hd".
274
+ const edge = isEdgeWord(word.text) && follows(tokens, i + 1, 'level') ? word.text : undefined;
275
+ const head = edge ? tokens[i + 1] : word;
276
+ if (head.text === 'level' && !head.quoted) {
277
+ const at = edge ? i + 1 : i;
278
+ const written = edge ? `${edge} level with` : 'level with';
279
+ if (!follows(tokens, at + 1, 'with')) {
280
+ throw new SourceError(`${subject}: an alignment reads "${written} <node>"`, line);
281
+ }
282
+ const read = readTargets(tokens, at + 2, subject, written, line);
283
+ const modifiers = readModifiers(tokens, read.next, subject, written, line);
284
+ // An alignment shares a line outright, so there is no distance in it for
285
+ // a gap to set. Refusing rather than dropping it, for the reason unknown
286
+ // modifier names are refused: a word that quietly does nothing reads as a
287
+ // fault in the tool.
288
+ if (modifiers.gap !== undefined) {
289
+ throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" shares a line rather than leaving a space, so it takes no gap`, line);
290
+ }
291
+ placements.push({
292
+ kind: 'align',
293
+ axis: EDGE_AXIS[edge ?? 'centre'],
294
+ edge: edge ?? 'centre',
295
+ targets: read.targets,
296
+ line,
297
+ });
298
+ i = modifiers.next;
299
+ continue;
300
+ }
301
+ if (!isDirection(word.text)) {
302
+ throw new SourceError(`${subject}: "${word.text}" is not a direction`, line);
303
+ }
304
+ let next = i + 1;
305
+ if (follows(tokens, next, 'of'))
306
+ next += 1;
307
+ const read = readTargets(tokens, next, subject, word.text, line);
308
+ const modifiers = readModifiers(tokens, read.next, subject, word.text, line);
309
+ placements.push({
310
+ kind: 'offset',
311
+ direction: word.text,
312
+ targets: read.targets,
313
+ ...(modifiers.gap !== undefined ? { gap: modifiers.gap } : {}),
314
+ line,
315
+ });
316
+ i = modifiers.next;
317
+ }
318
+ return placements;
319
+ }
320
+ /**
321
+ * The bracketed modifiers on one placement — `left of hub (gap: wide)`.
322
+ *
323
+ * A gap describes the relationship rather than the box at either end of it, so
324
+ * a node wedged between two things can be tight against one and wide of the
325
+ * other. The brackets are what make the scope visible: a bare `gap:` sitting
326
+ * between two placements cannot be told from the node-wide default, and would
327
+ * attach silently to whichever clause happened to precede it.
328
+ */
329
+ function readModifiers(tokens, start, subject, placement, line) {
330
+ const read = readBracket(tokens, start, PLACEMENT_KEYS, {
331
+ subject,
332
+ what: `"${placement}"`,
333
+ kind: 'a placement',
334
+ example: 'gap: wide',
335
+ line,
336
+ });
337
+ return { ...read.values, next: read.next };
338
+ }
339
+ /**
340
+ * A bracketed `key: value` list, shared by a placement's modifiers and a
341
+ * label's. Both exist for the same reason — a modifier belongs to the clause it
342
+ * modifies, and the brackets say which clause that is rather than leaving it to
343
+ * be inferred from what happens to precede it.
344
+ *
345
+ * The keys are whitelisted and an unknown one is refused by name, the same rule
346
+ * `DIAGRAM_KEYS` follows: a modifier that silently does nothing is worse than an
347
+ * error, because the picture moves and nothing says why.
348
+ */
349
+ function readBracket(tokens, start, keys, about) {
350
+ if (!follows(tokens, start, '('))
351
+ return { values: {}, next: start };
352
+ const values = {};
353
+ let i = start + 1;
354
+ while (!follows(tokens, i, ')')) {
355
+ const keyToken = tokens[i];
356
+ if (!keyToken) {
357
+ throw new SourceError(`${about.subject}: ${about.what} opens a "(" and never closes it`, about.line);
358
+ }
359
+ if (!isAttrKey(keyToken)) {
360
+ throw new SourceError(`${about.subject}: ${about.what} takes modifiers like "${about.example}" in its brackets, found "${keyToken.text}"`, about.line);
361
+ }
362
+ const key = keyToken.text.slice(0, -1);
363
+ if (!keys.includes(key)) {
364
+ throw new SourceError(`${about.kind} has no "${key}" — it takes ${keys.join(', ')}`, about.line);
365
+ }
366
+ const valueToken = tokens[i + 1];
367
+ if (!valueToken || valueToken.quoted || isAttrKey(valueToken) || valueToken.text === ')') {
368
+ throw new SourceError(`${about.subject}: "${key}" has no value`, about.line);
369
+ }
370
+ // A comma between modifiers is punctuation, exactly as it is between the
371
+ // targets of a placement. `(at: bottom, align: centre)` and the same without
372
+ // the comma are the same statement.
373
+ const value = valueToken.text;
374
+ values[key] = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
375
+ i += 2;
376
+ }
377
+ return { values, next: i + 1 };
378
+ }
379
+ function follows(tokens, at, word) {
380
+ const token = tokens[at];
381
+ return token !== undefined && !token.quoted && token.text === word;
382
+ }
383
+ /** Could this token open a placement? `top` and `left` open the edge alignments. */
384
+ function startsPlacement(token) {
385
+ if (token.quoted)
386
+ return false;
387
+ return (isDirection(token.text) ||
388
+ token.text === 'level' ||
389
+ EDGES.includes(token.text));
390
+ }
391
+ function isEdgeWord(word) {
392
+ return word !== 'centre' && EDGES.includes(word);
393
+ }
394
+ /**
395
+ * One target, or several joined by `and` — `right of borg and bare`, or
396
+ * `level with borg, bare and media`. A trailing comma separates just as `and`
397
+ * does, so both the way people write lists come out the same.
398
+ */
399
+ function readTargets(tokens, start, subject, placement, line) {
400
+ const targets = [];
401
+ let i = start;
402
+ for (;;) {
403
+ const token = tokens[i];
404
+ if (!token || token.quoted || token.text === '(' || token.text === ')') {
405
+ throw new SourceError(`${subject}: "${placement}" names no node`, line);
406
+ }
407
+ const listed = token.text.endsWith(',') && token.text.length > 1;
408
+ targets.push(listed ? token.text.slice(0, -1) : token.text);
409
+ i += 1;
410
+ if (follows(tokens, i, 'and')) {
411
+ i += 1;
412
+ continue;
413
+ }
414
+ if (listed)
415
+ continue;
416
+ return { targets, next: i };
417
+ }
418
+ }
@@ -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;