reladraw 0.2.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/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { COLOR_KEYS, DIAGRAM_KEYS, EDGE_AXIS, EDGES, PASSAGE_AXES, LABEL_KEYS, PLACEMENT_KEYS, isDirection, listTargets, } from './ast.js';
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';
2
2
  import { SourceError } from './errors.js';
3
3
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
4
  /** Parse a whole source file. One statement per line; blanks and comments drop out. */
@@ -14,122 +14,343 @@ export function parse(source) {
14
14
  return { statements };
15
15
  }
16
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];
17
+ const keyword = tokens[0];
21
18
  if (!keyword || keyword.quoted) {
22
19
  throw new SourceError('a statement must begin with a keyword', line);
23
20
  }
24
21
  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);
22
+ case 'node':
23
+ return parseNode(tokens, line);
24
+ case 'edge':
25
+ return parseEdge(tokens, line);
33
26
  case 'style':
34
- return parseStyle(head, attrs, line);
27
+ return parseStyle(tokens, line);
35
28
  case 'diagram':
36
- return parseDiagram(head, attrs, line);
29
+ return parseDiagram(tokens, line);
37
30
  default:
38
- throw new SourceError(`unknown statement "${keyword.text}"`, line);
31
+ throw new SourceError(substitution(keyword.text, tokens), line);
39
32
  }
40
33
  }
41
34
  /**
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)`.
35
+ * The statement keywords that are not words in this language, and the word to
36
+ * write instead. `box` and `link` were the keywords until 0.3.0; `rect` and
37
+ * `arrow` never were, and are here because they are what somebody arriving from
38
+ * another format types first.
39
+ *
40
+ * Refused by name with the substitution quoted, the same treatment `stroke:`
41
+ * and `width:` get. A synonym was the other candidate and is refused for the
42
+ * reasons in the design record: an alias is a variant every reader has to
43
+ * learn, and the statement keyword would become the one place a misspelling
44
+ * silently succeeds.
45
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;
46
+ const SUBSTITUTIONS = {
47
+ box: 'node',
48
+ rect: 'node',
49
+ link: 'edge',
50
+ arrow: 'edge',
51
+ };
52
+ /**
53
+ * What to say about a word that opens no statement. A word this language once
54
+ * used, or one another format uses, gets the replacement quoted back in the
55
+ * author's own name for the thing; anything else has no remedy but its
56
+ * spelling.
57
+ */
58
+ function substitution(word, head) {
59
+ // A note is not a kind of statement any more, and the reason is worth the
60
+ // longer message: a keyword names a picture, and "note" names a use. Free
61
+ // text is a brace caption, a title over a diagram or an aside, so the
62
+ // picture it names is a node with no body — which is what to write.
63
+ if (word === 'note') {
64
+ return `reladraw has no \`note\` statement — a note is a node with no body, so try ` +
65
+ `\`node ${rewrite(head)} shape: none\``;
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
+ }
75
+ const replacement = SUBSTITUTIONS[word];
76
+ if (replacement === undefined)
77
+ return `unknown statement "${word}"`;
78
+ const plural = replacement === 'node' ? 'nodes' : 'edges';
79
+ // Quote the fix in the line the author actually wrote. `node parser` and
80
+ // `edge a -> b` both say more than a placeholder does, and the whole head is
81
+ // what makes the second of those readable.
82
+ const rest = rewrite(head);
83
+ const example = rest === '' ? '' : ` \u2014 try \`${replacement} ${rest}\``;
84
+ return `reladraw calls these ${plural}, so there is no \`${word}\` statement${example}`;
85
+ }
86
+ /** Everything after the keyword, written back the way the author would type it. */
87
+ function rewrite(head) {
88
+ return head
89
+ .slice(1)
90
+ .map((token) => (token.quoted ? quoteOf(token.text) : token.text))
91
+ .join(' ');
59
92
  }
60
- /** The value as the author would have to write it back into a label. */
93
+ /** The value as the author would have to write it back into a text. */
61
94
  function quoteOf(text) {
62
95
  return `"${text.replace(/"/g, '\\"')}"`;
63
96
  }
64
- function parseAttrs(tokens, line) {
97
+ /**
98
+ * Everything after a statement's positional head: its attributes and, on a
99
+ * node, its placements, in whatever order they were written.
100
+ *
101
+ * The ordering rule that used to stand here — placements first, attributes
102
+ * after — existed because a bare `gap:` written between two placements could
103
+ * not be told from the node-wide default. Gaps went into brackets on their own
104
+ * placement, so that ambiguity is gone and with it the reason for the rule. A
105
+ * `key:` token can never open a placement and a placement never opens with one,
106
+ * so the two interleave with nothing to resolve.
107
+ */
108
+ function parseTail(tokens, start, line, subject, other) {
65
109
  const attrs = {};
66
- let i = 0;
110
+ const placements = [];
111
+ let i = start;
67
112
  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);
113
+ const token = tokens[i];
114
+ if (isAttrKey(token)) {
115
+ i = readAttr(tokens, i, attrs, line, subject);
116
+ continue;
77
117
  }
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);
118
+ const taken = other?.(tokens, i);
119
+ if (taken !== undefined) {
120
+ i = taken;
121
+ continue;
84
122
  }
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);
123
+ const read = readPlacement(tokens, i, line, subject);
124
+ placements.push(read.placement);
125
+ i = read.next;
126
+ }
127
+ return { attrs, placements };
128
+ }
129
+ /**
130
+ * The attribute keys whose value is a bracket rather than a word, and what may
131
+ * be written inside it. `text:` is a style's way of saying what a node says in
132
+ * the brackets after its own string; `contents:` names a part whose two
133
+ * properties are independent and sit one level below the node.
134
+ */
135
+ const BRACKET_KEYS = {
136
+ text: TEXT_KEYS,
137
+ contents: CONTENTS_KEYS,
138
+ };
139
+ /** How each bracketed key's error quotes itself back, and what it is about. */
140
+ const BRACKET_ABOUT = {
141
+ text: { kind: 'a text', example: 'color: muted' },
142
+ contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
143
+ };
144
+ /**
145
+ * The top-level keys that moved into the text's bracket in 0.3.0, and the
146
+ * substitution each one gets. They are properties of a node's *text* and never
147
+ * of the node, and leaving them at the top level is what let `size:` sit beside
148
+ * `fill:` as though the two were the same sort of statement.
149
+ */
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
+ }
164
+ /** Read one `key: value` pair, and refuse the words that used to be keys. */
165
+ function readAttr(tokens, at, attrs, line, subject) {
166
+ const keyToken = tokens[at];
167
+ const key = keyToken.text.slice(0, -1);
168
+ const bracketKeys = BRACKET_KEYS[key];
169
+ if (bracketKeys !== undefined && follows(tokens, at + 1, '(')) {
170
+ // `text: (color: muted)` — the whole bracket belongs to one part, and it is
171
+ // stored under dotted keys so that a style merges into a node exactly the
172
+ // way every other attribute does.
173
+ const read = readBracket(tokens, at + 1, bracketKeys, {
174
+ subject,
175
+ what: `\`${key}:\``,
176
+ kind: BRACKET_ABOUT[key].kind,
177
+ example: BRACKET_ABOUT[key].example,
178
+ line,
179
+ });
180
+ if (Object.keys(read.values).length === 0) {
181
+ throw new SourceError(`${subject}: \`${key}:\` opens empty brackets`, line);
92
182
  }
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);
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 });
100
187
  }
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);
188
+ return read.next;
189
+ }
190
+ if (key === 'url') {
191
+ // `url: https://example.com` loses everything from the `//` onwards, because
192
+ // `//` opens a comment — so the value is either missing entirely or is the
193
+ // bare scheme, which reads as another attribute key. Neither report says
194
+ // what is wrong, and the remedy is punctuation rather than a missing word.
195
+ const value = tokens[at + 1];
196
+ if (!value || !value.quoted) {
197
+ throw new SourceError(`${subject}: a url is written in quotes — \`url: "https://example.com"\`. Without them ` +
198
+ 'everything from the `//` onwards is read as a comment', line);
199
+ }
200
+ setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
201
+ return at + 2;
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);
111
214
  }
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);
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);
116
219
  }
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);
220
+ names.push(name);
221
+ next += 1;
222
+ if (follows(tokens, next, 'and')) {
223
+ next += 1;
224
+ continue;
119
225
  }
120
- throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
226
+ if (listed)
227
+ continue;
228
+ break;
121
229
  }
122
- attrs[key] = valueToken.text;
123
- i += 2;
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
+ }
251
+ if (bracketKeys !== undefined && key !== 'text') {
252
+ // `contents: match` names the part and then says one of its two properties
253
+ // without saying which. The brackets are what make the level shift visible,
254
+ // so there is no unbracketed spelling to fall back to.
255
+ const given = tokens[at + 1];
256
+ throw new SourceError(`${subject}: \`${key}:\` takes its properties in brackets — write \`${key}: (${BRACKET_ABOUT[key].example})\`` +
257
+ (given && !isAttrKey(given) ? `, not \`${key}: ${given.text}\`` : ''), line);
258
+ }
259
+ const valueToken = tokens[at + 1];
260
+ if (!valueToken || isAttrKey(valueToken) || valueToken.text === ')') {
261
+ throw new SourceError(`attribute "${key}" has no value`, line);
262
+ }
263
+ if (key === 'stroke') {
264
+ // Removed 2026-09-09. It meant a different part on every kind — the
265
+ // border of a box, the text of a note or a glyph body, the line of a
266
+ // edge — so it could never be wrong, and a node's text had no word at all.
267
+ // Refused by name rather than ignored: an older file must be told what
268
+ // to write, not silently drawn without its colors.
269
+ throw new SourceError('`stroke:` has been replaced by the part it colors — `border:` on a node, `text: (color: …)` on the text of anything, `line:` on an edge. A style shared between nodes and edges writes both, as in `border: #d2904e line: #d2904e`', line);
270
+ }
271
+ if (key === 'width') {
272
+ // Renamed 2026-09-09, and moved into the text's bracket in 0.3.0.
273
+ throw new SourceError('`width:` is now `wrap:` and belongs to the text — it folds the text every n characters and says nothing about how wide anything is, so write it as `"…" (wrap: 30)`', line);
274
+ }
275
+ if (key === 'subtext') {
276
+ // Removed in 0.3.0. It colored "every line after the first", which is a
277
+ // positional slice: the rule lived in a style elsewhere in the file and was
278
+ // applied by counting, so a reader of the text could not see it. Markup
279
+ // says what is quiet where it is quiet, and reaches a word in the middle of
280
+ // a line, which the slice never could.
281
+ throw new SourceError('`subtext:` has been replaced by markup in the text — write `style dim text: (color: muted)` ' +
282
+ 'and mark the quiet words as `"Dropbox / [dim]synced[/dim]"`', line);
283
+ }
284
+ if (key === 'text') {
285
+ // `text:` is the text's bracket now, so a bare word after it is either the
286
+ // old color key or an attempt to set the words themselves. The quotes tell
287
+ // the two apart, and they want different remedies.
288
+ throw new SourceError(valueToken.quoted
289
+ ? `\`text:\` is how a style says something about text, not how anything sets it — write the words in quotes after the name, as in \`node name ${quoteOf(valueToken.text)}\``
290
+ : `\`text:\` takes the text's properties in brackets — write \`text: (color: ${valueToken.text})\` in a style, and \`(color: ${valueToken.text})\` in the brackets after a node's or an edge's own text`, line);
291
+ }
292
+ if (key === 'align' && valueToken.text === 'widths') {
293
+ // Removed in 0.3.0. It was a size operation wearing an alignment's name,
294
+ // and its value set had one member — a flag in a property's clothes. Its
295
+ // job is `contents: (widths: match)`, and with it gone `align` means one
296
+ // thing everywhere.
297
+ throw new SourceError('`align: widths` is now `contents: (widths: match)` — it is a size, not an alignment, and ' +
298
+ 'the same brackets take `align: center` for where the contents sit when the title is wider', line);
299
+ }
300
+ if (MOVED_INTO_BRACKET.includes(key)) {
301
+ throw new SourceError(`\`${key}:\` belongs to the text rather than to the node — write it in the brackets after ` +
302
+ `the text, as in \`"…" (${key}: ${valueToken.text})\`, or as \`text: (${key}: ${valueToken.text})\` in a style`, line);
303
+ }
304
+ if (valueToken.quoted && COLOR_KEYS.includes(key)) {
305
+ // A quoted value is the author saying "this is text", and every one of
306
+ // these keys takes a color. Without this the string is passed through as
307
+ // a color, turns out not to be one, and nothing is drawn and nothing is
308
+ // said.
309
+ if (valueToken.text.startsWith('#')) {
310
+ // A hex color that was merely quoted. The author wrote a color and the
311
+ // remedy is punctuation, so say that rather than that it is not one.
312
+ throw new SourceError(`a color is written without quotes — "${key}: ${valueToken.text}"`, line);
313
+ }
314
+ throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
315
+ }
316
+ setOnce(attrs, key, valueToken.text, subject, line);
317
+ return at + 2;
318
+ }
319
+ /** Attributes only, for the statements that take no placements. */
320
+ function attrsOnly(tokens, start, line, subject) {
321
+ const attrs = {};
322
+ let i = start;
323
+ while (i < tokens.length) {
324
+ const token = tokens[i];
325
+ if (!isAttrKey(token)) {
326
+ throw new SourceError(`${subject}: expected an attribute like "key: value", found "${token.text}"`, line);
327
+ }
328
+ i = readAttr(tokens, i, attrs, line, subject);
124
329
  }
125
330
  return attrs;
126
331
  }
332
+ /** Everything a text takes, less the one word that needs a box to sit in. */
333
+ const EDGE_TEXT_KEYS = TEXT_KEYS.filter((key) => key !== 'at');
127
334
  /**
128
- * `box <name> ["<text>"] [(<label modifiers>)] [<placement> ...]`
335
+ * `text:` is how a *style* says something about the text of whatever wears it,
336
+ * because a style has no string of its own. A node and an edge do, so they say
337
+ * it in the brackets after that string, and there is one spelling per place.
338
+ */
339
+ function refuseTextKey(attrs, subject, where, line) {
340
+ for (const key of Object.keys(attrs)) {
341
+ if (!key.startsWith('text.'))
342
+ continue;
343
+ const inner = key.slice('text.'.length);
344
+ throw new SourceError(`${subject}: \`text: (…)\` is how a style says it, having no text of its own. This has one, ` +
345
+ `so write \`(${inner}: ${attrs[key]})\` in the brackets ${where}`, line);
346
+ }
347
+ }
348
+ /**
349
+ * `node <name> ["<text>"] [(<text modifiers>)] [<placement> ...]`
129
350
  *
130
- * The text is optional and the name stands in for it, because a bare `box a`
351
+ * The text is optional and the name stands in for it, because a bare `node a`
131
352
  * 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
353
+ * anybody types are `node a` and `node b right of a`, and they mean the two
133
354
  * boxes to say "a" and "b". `""` is how a box says it is deliberately blank —
134
355
  * an invisible container, a glyph body, a node that is nothing but its icon —
135
356
  * and every such box already writes it, so nothing that predates this changed
@@ -140,130 +361,134 @@ function parseAttrs(tokens, line) {
140
361
  * `server.docker` reading "docker" says everything the whole path would.
141
362
  *
142
363
  * 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.
364
+ * the price, and it is honest: a file that states no text is saying the name
365
+ * is the text.
145
366
  */
146
- function parseBox(head, attrs, line) {
147
- const name = requireName(head[1], 'box', line);
367
+ function parseNode(head, line) {
368
+ const name = requireName(head[1], 'node', line);
148
369
  const written = head[2];
149
370
  const textToken = written?.quoted ? written : undefined;
150
- // A bare word here is a label somebody forgot to quote far more often than
371
+ // A bare word here is a text somebody forgot to quote far more often than
151
372
  // it is anything else, and `"Parser" is not a direction` would send them
152
373
  // looking in the wrong place.
153
374
  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);
375
+ throw new SourceError(`node "${name}": a text is quoted — write "${written.text}" rather than ${written.text}`, line);
155
376
  }
156
377
  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, {
378
+ const subject = `node "${name}"`;
379
+ const bracket = readBracket(head, textToken ? 3 : 2, TEXT_KEYS, {
159
380
  subject,
160
- what: 'the label',
161
- kind: 'a label',
381
+ what: 'the text',
382
+ kind: 'a text',
162
383
  example: 'at: bottom',
163
384
  line,
164
385
  });
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 };
386
+ const tail = parseTail(head, bracket.next, line, subject);
387
+ refuseTextKey(tail.attrs, subject, 'after the name', line);
388
+ return {
389
+ kind: 'node',
390
+ name,
391
+ text,
392
+ statedText: textToken !== undefined,
393
+ textAttrs: bracket.values,
394
+ placements: tail.placements,
395
+ attrs: tail.attrs,
396
+ line,
397
+ };
182
398
  }
183
399
  /**
184
- * `link <from> -> <to> ["<label>"] [between <a> and <b>]`, with `<->` for a
400
+ * `edge <from> -> <to> ["<text>"] [between <a> and <b>]`, with `<->` for a
185
401
  * two-headed arrow and `<-` for one pointing the other way.
186
402
  *
187
403
  * `a <- b` is exactly `b -> a` and carries no meaning of its own downstream.
188
404
  * 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.
405
+ * about, and plenty of edges have the target as their subject.
190
406
  */
191
407
  const ARROWS = ['->', '<->', '<-'];
192
- function parseLink(head, attrs, line) {
193
- const left = requireName(head[1], 'link', line);
408
+ function parseEdge(head, line) {
409
+ const left = requireName(head[1], 'edge', line);
194
410
  const arrow = head[2];
195
411
  if (!arrow || arrow.quoted || !ARROWS.includes(arrow.text)) {
196
- throw new SourceError('a link needs "->", "<-" or "<->" between its endpoints', line);
412
+ throw new SourceError('an edge needs "->", "<-" or "<->" between its endpoints', line);
197
413
  }
198
414
  const rightToken = head[3];
199
415
  if (!rightToken || rightToken.quoted) {
200
- throw new SourceError('a link needs a node on the right of the arrow', line);
416
+ throw new SourceError('an edge needs a node on the right of the arrow', line);
201
417
  }
202
418
  const back = arrow.text === '<-';
203
419
  let at = 4;
204
- const labelToken = head[at]?.quoted ? head[at] : undefined;
205
- if (labelToken)
420
+ const textToken = head[at]?.quoted ? head[at] : undefined;
421
+ if (textToken)
206
422
  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".
423
+ const subject = `edge ${left} ${arrow.text} ${rightToken.text}`;
424
+ // An edge's text takes the same bracket a node's does, less `at:`: a node's
425
+ // text sits somewhere in a box and an edge's rides at the middle of its line,
426
+ // so there is no position to name until a diagram asks for one.
427
+ const bracket = readBracket(head, at, EDGE_TEXT_KEYS, {
428
+ subject,
429
+ what: 'the text',
430
+ kind: "an edge's text",
431
+ example: 'color: muted',
432
+ line,
433
+ });
434
+ at = bracket.next;
210
435
  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);
436
+ // An edge is not placed, so its tail holds attributes and the one clause that
437
+ // is neither: `between`, which says which gap the line travels down.
438
+ const tail = parseTail(head, at, line, subject, (tokens, index) => {
439
+ const word = tokens[index];
440
+ if (word.quoted || word.text !== 'between')
441
+ return undefined;
442
+ if (between)
443
+ throw new SourceError(`${subject}: "between" is written twice`, line);
444
+ // A gap has two sides, so `between` takes exactly two targets rather than
445
+ // the open list a placement takes. `right of a and b` means "clear of
446
+ // both", and there is no matching reading of "pass between three things".
447
+ const read = readTargets(tokens, index + 1, subject, 'between', line);
217
448
  if (read.targets.length !== 2) {
218
449
  throw new SourceError(`"between" takes two nodes, one for each side of the gap — found ${read.targets.length}`, line);
219
450
  }
220
- at = read.next;
451
+ let next = read.next;
221
452
  // Two targets sitting diagonally have two gaps between them, and this is
222
453
  // the only way to say which. It is optional because most pairs have one.
223
- const trailing = head[at];
454
+ const trailing = tokens[next];
224
455
  const axis = trailing && !trailing.quoted ? PASSAGE_AXES[trailing.text] : undefined;
225
456
  if (axis !== undefined)
226
- at += 1;
457
+ next += 1;
227
458
  between = { targets: read.targets, ...(axis !== undefined ? { axis } : {}) };
459
+ return next;
460
+ });
461
+ if (tail.placements.length > 0) {
462
+ throw new SourceError(`${subject}: "${describePlacement(tail.placements[0])}" places a node, and an edge is not ` +
463
+ 'placed — it joins two things that are', line);
228
464
  }
229
- if (at < head.length) {
230
- throw new SourceError(`unexpected "${head[at].text}" after the link`, line);
231
- }
465
+ refuseTextKey(tail.attrs, subject, 'after the arrow', line);
232
466
  return {
233
- kind: 'link',
467
+ kind: 'edge',
468
+ textAttrs: bracket.values,
234
469
  from: back ? rightToken.text : left,
235
470
  to: back ? left : rightToken.text,
236
471
  both: arrow.text === '<->',
237
- ...(labelToken ? { label: labelToken.text } : {}),
472
+ ...(textToken ? { text: textToken.text } : {}),
238
473
  ...(between ? { between } : {}),
239
- attrs,
474
+ attrs: tail.attrs,
240
475
  line,
241
476
  };
242
477
  }
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
478
  /** `style <name> <attributes>` */
259
- function parseStyle(head, attrs, line) {
479
+ function parseStyle(head, line) {
260
480
  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
- }
481
+ const attrs = attrsOnly(head, 2, line, `style "${name}"`);
264
482
  if (Object.keys(attrs).length === 0) {
265
483
  throw new SourceError(`style "${name}" sets nothing`, line);
266
484
  }
485
+ if (attrs['url'] !== undefined) {
486
+ // A destination is content, not appearance. A style is a bundle worn by
487
+ // many things, so a `url:` in one would point every node wearing it at the
488
+ // same place — which is never what anybody means, and would be silent.
489
+ throw new SourceError(`style "${name}" has a url. A destination is part of what a node says rather than how it ` +
490
+ 'looks, so it is written on the node or the edge itself', line);
491
+ }
267
492
  return { kind: 'style', name, attrs, line };
268
493
  }
269
494
  /**
@@ -271,10 +496,8 @@ function parseStyle(head, attrs, line) {
271
496
  * keys are refused rather than ignored: a misspelt diagram-wide setting that
272
497
  * silently does nothing is the kind of thing an author stares at for a while.
273
498
  */
274
- function parseDiagram(head, attrs, line) {
275
- if (head.length > 1) {
276
- throw new SourceError(`unexpected "${head[1].text}" after diagram`, line);
277
- }
499
+ function parseDiagram(head, line) {
500
+ const attrs = attrsOnly(head, 1, line, 'diagram');
278
501
  if (Object.keys(attrs).length === 0) {
279
502
  throw new SourceError('diagram sets nothing', line);
280
503
  }
@@ -292,71 +515,159 @@ function requireName(token, keyword, line) {
292
515
  return token.text;
293
516
  }
294
517
  /**
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.
518
+ * Read one placement. A direction and a target (`right of docker`, `below
519
+ * deploy`), an alignment (`level with docker`), or an overlay (`on hub at
520
+ * top-right`).
298
521
  *
299
522
  * `of` is optional after every direction. "left of X" and "below X" are both
300
523
  * 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.
524
+ * helps and never demanded. Shorthands added later extend this without
525
+ * disturbing what it already reads.
303
526
  */
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);
527
+ function readPlacement(tokens, at, line, subject) {
528
+ const word = tokens[at];
529
+ if (word.quoted) {
530
+ throw new SourceError(`${subject}: unexpected text "${word.text}"`, line);
531
+ }
532
+ if (word.text === 'on')
533
+ return readOn(tokens, at, line, subject);
534
+ if (word.text === 'inside' || word.text === 'outside') {
535
+ return readTucked(tokens, at, line, subject, word.text);
536
+ }
537
+ // `top level with media` names a side rather than the center line. `left`
538
+ // and `right` are sides as well as directions, so it is the word after them
539
+ // that says which was meant — "left of drive" against "left level with drive".
540
+ const side = isSideWord(word.text) && follows(tokens, at + 1, 'level') ? word.text : undefined;
541
+ const head = side ? tokens[at + 1] : word;
542
+ if (head.text === 'level' && !head.quoted) {
543
+ const from = side ? at + 1 : at;
544
+ const written = side ? `${side} level with` : 'level with';
545
+ if (!follows(tokens, from + 1, 'with')) {
546
+ throw new SourceError(`${subject}: an alignment reads "${written} <node>"`, line);
311
547
  }
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({
548
+ const read = readTargets(tokens, from + 2, subject, written, line);
549
+ const modifiers = readModifiers(tokens, read.next, subject, `${written} ${listTargets(read.targets)}`, line);
550
+ // An alignment shares a line outright, so there is no distance in it for
551
+ // a gap to set. Refusing rather than dropping it, for the reason unknown
552
+ // modifier names are refused: a word that quietly does nothing reads as a
553
+ // fault in the tool.
554
+ if (modifiers.gap !== undefined) {
555
+ throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" shares a line rather than leaving a space, so it takes no gap`, line);
556
+ }
557
+ return {
558
+ placement: {
333
559
  kind: 'align',
334
- axis: EDGE_AXIS[edge ?? 'center'],
335
- edge: edge ?? 'center',
560
+ axis: SIDE_AXIS[side ?? 'center'],
561
+ side: side ?? 'center',
336
562
  targets: read.targets,
337
563
  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);
564
+ },
565
+ next: modifiers.next,
566
+ };
567
+ }
568
+ if (!isDirection(word.text)) {
569
+ // A position word where a direction belongs is the one confusable pair, and
570
+ // it is worth naming rather than only refusing: the two vocabularies reach
571
+ // the same corner with different words and only one of them takes `of`.
572
+ if (isPosition(word.text)) {
573
+ throw new SourceError(`${subject}: "${word.text}" is a position on a box rather than a direction from one — ` +
574
+ `write \`inside <node> ${word.text}\` to put this in that corner, \`on <node> ` +
575
+ `${word.text}\` to straddle it, or a direction like ${DIRECTIONS.join(', ')} to put ` +
576
+ 'it outside', line);
344
577
  }
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({
578
+ throw new SourceError(`${subject}: "${word.text}" is not a direction`, line);
579
+ }
580
+ let next = at + 1;
581
+ const of = follows(tokens, next, 'of');
582
+ if (of)
583
+ next += 1;
584
+ const read = readTargets(tokens, next, subject, word.text, line);
585
+ const modifiers = readModifiers(tokens, read.next, subject, `${word.text}${of ? ' of' : ''} ${listTargets(read.targets)}`, line);
586
+ return {
587
+ placement: {
351
588
  kind: 'offset',
352
589
  direction: word.text,
353
590
  targets: read.targets,
354
591
  ...(modifiers.gap !== undefined ? { gap: modifiers.gap } : {}),
355
592
  line,
356
- });
357
- i = modifiers.next;
593
+ },
594
+ next: modifiers.next,
595
+ };
596
+ }
597
+ /**
598
+ * `on hub top-right` — the node's center at the part's center, straddling it.
599
+ *
600
+ * One target, and the `and` list the other placements take is refused by name.
601
+ * A direction against several targets means "clear of the box that bounds them
602
+ * all", which is a floor and decomposes into one constraint per target; this
603
+ * names an exact point of one box, and the box bounding two things is not a
604
+ * box anybody drew.
605
+ */
606
+ function readOn(tokens, at, line, subject) {
607
+ const read = readTargets(tokens, at + 1, subject, 'on', line, true);
608
+ const target = read.targets[0];
609
+ if (read.targets.length > 1) {
610
+ throw new SourceError(`${subject}: "on ${listTargets(read.targets)}" names ${read.targets.length} nodes, and a ` +
611
+ 'stamp sits on one box — name the one it is stamped on', line);
612
+ }
613
+ // `on X at <position>` shipped in 0.3.0 and never reached a release. Every
614
+ // picture it drew is still drawable, in words that had to exist anyway, so
615
+ // it is refused by name rather than left as a second spelling.
616
+ if (follows(tokens, read.next, 'at')) {
617
+ const wordToken = tokens[read.next + 1];
618
+ const word = wordToken && !wordToken.quoted ? wordToken.text : '<position>';
619
+ throw new SourceError(`${subject}: "on ${target.name} at ${word}" is no longer how a node is put on a box — ` +
620
+ `write \`inside ${target.name} ${word}\` to tuck it inside that corner, or ` +
621
+ `\`on ${target.name} ${word}\` to straddle it`, line);
622
+ }
623
+ const modifiers = readModifiers(tokens, read.next, subject, `on ${nameTarget(target)}`, line);
624
+ if (modifiers.gap !== undefined) {
625
+ throw new SourceError(`${subject}: "on ${nameTarget(target)}" puts this node's center on that point rather than ` +
626
+ 'leaving a space, so it takes no gap', line);
627
+ }
628
+ return {
629
+ placement: { kind: 'on', targets: read.targets, line },
630
+ next: modifiers.next,
631
+ };
632
+ }
633
+ /**
634
+ * `inside server right`, `outside board top-left` — a direction read off the
635
+ * part rather than written.
636
+ *
637
+ * Both are shorthands, and their expansion is *derived* rather than listed:
638
+ * inside is the direction from the named part toward the box's center, outside
639
+ * is away from it. One rule covers every part — `inside right` is `left of`,
640
+ * `inside top-right` is `below-left of` — so nobody writes a table and the
641
+ * long form can be printed back.
642
+ */
643
+ function readTucked(tokens, at, line, subject, written) {
644
+ const read = readTargets(tokens, at + 1, subject, written, line, true);
645
+ const target = read.targets[0];
646
+ if (read.targets.length > 1) {
647
+ throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" names ${read.targets.length} nodes, ` +
648
+ `and "${written}" reads its direction off one part of one box`, line);
358
649
  }
359
- return placements;
650
+ const inward = target.part === undefined ? undefined : INWARD[target.part];
651
+ if (inward === undefined) {
652
+ const named = target.part === undefined
653
+ ? `"${written} ${target.name}" names no part of "${target.name}"`
654
+ : `"${written} ${nameTarget(target)}" reads no direction from "${target.part}", ` +
655
+ 'which is not on the boundary';
656
+ throw new SourceError(`${subject}: ${named} — "${written}" takes a side or a point of the box: ` +
657
+ `${BOUNDARY_PARTS.join(', ')}`, line);
658
+ }
659
+ const modifiers = readModifiers(tokens, read.next, subject, `${written} ${nameTarget(target)}`, line);
660
+ return {
661
+ placement: {
662
+ kind: 'offset',
663
+ direction: written === 'inside' ? inward : OPPOSITE[inward],
664
+ written,
665
+ targets: read.targets,
666
+ ...(modifiers.gap !== undefined ? { gap: modifiers.gap } : {}),
667
+ line,
668
+ },
669
+ next: modifiers.next,
670
+ };
360
671
  }
361
672
  /**
362
673
  * The bracketed modifiers on one placement — `left of hub (gap: wide)`.
@@ -379,7 +690,7 @@ function readModifiers(tokens, start, subject, placement, line) {
379
690
  }
380
691
  /**
381
692
  * 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
693
+ * text's. Both exist for the same reason — a modifier belongs to the clause it
383
694
  * modifies, and the brackets say which clause that is rather than leaving it to
384
695
  * be inferred from what happens to precede it.
385
696
  *
@@ -412,7 +723,12 @@ function readBracket(tokens, start, keys, about) {
412
723
  // targets of a placement. `(at: bottom, align: center)` and the same without
413
724
  // the comma are the same statement.
414
725
  const value = valueToken.text;
415
- values[key] = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
726
+ const clean = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
727
+ const had = values[key];
728
+ if (had !== undefined) {
729
+ throw new SourceError(`${about.subject}: "${key}" is written twice in the brackets after ${about.what} (${had}, ${clean}) — keep one`, about.line);
730
+ }
731
+ values[key] = clean;
416
732
  i += 2;
417
733
  }
418
734
  return { values, next: i + 1 };
@@ -421,23 +737,36 @@ function follows(tokens, at, word) {
421
737
  const token = tokens[at];
422
738
  return token !== undefined && !token.quoted && token.text === word;
423
739
  }
424
- /** Could this token open a placement? `top` and `left` open the edge alignments. */
740
+ /**
741
+ * Could this token open a placement? `top` and `left` open the side alignments,
742
+ * `on` opens an overlay. Used only to tell a forgotten pair of quotes after a
743
+ * node's name from a placement, so a word that is nearly one counts.
744
+ */
425
745
  function startsPlacement(token) {
426
746
  if (token.quoted)
427
747
  return false;
428
748
  return (isDirection(token.text) ||
429
749
  token.text === 'level' ||
430
- EDGES.includes(token.text));
750
+ token.text === 'on' ||
751
+ token.text === 'inside' ||
752
+ token.text === 'outside' ||
753
+ SIDES.includes(token.text));
431
754
  }
432
- function isEdgeWord(word) {
433
- return word !== 'center' && EDGES.includes(word);
755
+ function isSideWord(word) {
756
+ return word !== 'center' && SIDES.includes(word);
434
757
  }
435
758
  /**
436
759
  * One target, or several joined by `and` — `right of borg and bare`, or
437
760
  * `level with borg, bare and media`. A trailing comma separates just as `and`
438
761
  * does, so both the way people write lists come out the same.
762
+ *
763
+ * Each name may be followed by a *part* of that node, spaced: `right of hub
764
+ * text`, `inside server right`. See `partAfter` for the two words that are
765
+ * parts everywhere else in the language too, and how they are told apart.
766
+ * `partFirst` is for the placements where a side word after the name can only
767
+ * be the part; see there.
439
768
  */
440
- function readTargets(tokens, start, subject, placement, line) {
769
+ function readTargets(tokens, start, subject, placement, line, partFirst = false) {
441
770
  const targets = [];
442
771
  let i = start;
443
772
  for (;;) {
@@ -446,8 +775,14 @@ function readTargets(tokens, start, subject, placement, line) {
446
775
  throw new SourceError(`${subject}: "${placement}" names no node`, line);
447
776
  }
448
777
  const listed = token.text.endsWith(',') && token.text.length > 1;
449
- targets.push(listed ? token.text.slice(0, -1) : token.text);
778
+ const name = listed ? token.text.slice(0, -1) : token.text;
450
779
  i += 1;
780
+ // A part can only follow a name the author did not already close with a
781
+ // comma — `a, b` is two targets and the comma says so.
782
+ const part = listed ? undefined : partAfter(tokens, i, partFirst);
783
+ if (part)
784
+ i += 1;
785
+ targets.push(part === undefined ? { name } : { name, part });
451
786
  if (follows(tokens, i, 'and')) {
452
787
  i += 1;
453
788
  continue;
@@ -457,3 +792,32 @@ function readTargets(tokens, start, subject, placement, line) {
457
792
  return { targets, next: i };
458
793
  }
459
794
  }
795
+ /**
796
+ * The part word after a target's name, if there is one.
797
+ *
798
+ * Two of the part words are also the openings of something else, and both are
799
+ * settled by the word that follows rather than by a reservation:
800
+ *
801
+ * - `right of hub right of mirror` — a side followed by `of` is the *direction*
802
+ * opening the next placement, which is how `left` and `right` have always
803
+ * been told apart.
804
+ * - `right of hub top level with mirror` — a side followed by `level` is the
805
+ * alignment opening the next placement, the same lookahead `readPlacement`
806
+ * makes for `top level with`.
807
+ *
808
+ * The second does not hold after `inside`, `outside` or `on`, so `partFirst`
809
+ * lifts it there: `inside server right level with server.db` is the right side
810
+ * and then an alignment. `inside` and `outside` need a part, so a partless
811
+ * reading is an error anyway; and `on` already fixes both axes, so an edge
812
+ * alignment after a partless `on hub` would contradict it.
813
+ */
814
+ function partAfter(tokens, at, partFirst = false) {
815
+ const token = tokens[at];
816
+ if (!token || token.quoted || !isPart(token.text))
817
+ return undefined;
818
+ if (follows(tokens, at + 1, 'of'))
819
+ return undefined;
820
+ if (!partFirst && follows(tokens, at + 1, 'level'))
821
+ return undefined;
822
+ return token.text;
823
+ }