reladraw 0.16.0 → 0.17.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,7 +1,10 @@
1
- import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
1
+ import { BUNDLE_KEYS, COLOR_KEYS, POINT_KEYS, REGION_KEYS, WAYS, describePoint, fractionAxis, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, TEXT_KEYS, MARK_TEXT_KEYS, POINT_TEXT_SIDES, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, EDGE_ORDER_KEYS, LAYOUT_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
+ import { CHECK_DEFAULTS, CHECK_KINDS, setCheck } from './checks.js';
2
3
  import { SourceError } from './errors.js';
3
4
  import { STATEMENT_KEYWORDS } from './grammar.js';
4
5
  import { isAttrKey, nameMark, OpenString, tokenizeLine } from './lexer.js';
6
+ import { checkOpacity } from './place.js';
7
+ import { readSize, SIZE_KEYS, writeSize } from './size.js';
5
8
  import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
6
9
  /**
7
10
  * Parse a whole source file. A statement starts on an unindented line and runs
@@ -159,6 +162,14 @@ function parseStatement(tokens, line) {
159
162
  return parseDiagram(tokens, line);
160
163
  case 'default':
161
164
  return parseDefault(tokens, line);
165
+ case 'point':
166
+ return parsePoint(tokens, line);
167
+ case 'region':
168
+ return parseRegion(tokens, line);
169
+ case 'bundle':
170
+ return parseBundle(tokens, line);
171
+ case 'guide':
172
+ return parseGuide(tokens, line);
162
173
  default:
163
174
  throw new SourceError(substitution(keyword.text, tokens), line);
164
175
  }
@@ -196,13 +207,12 @@ function substitution(word, head) {
196
207
  return `reladraw has no \`note\` statement — a note is a node with no body, so try ` +
197
208
  `\`node ${rewrite(head)} shape: none\``;
198
209
  }
199
- // A statement until 0.4.0. It created nothing, only said more about a node
200
- // declared elsewhere, which is what an attribute on that node is for.
210
+ // A statement until 0.4.0, then the attribute `deck:` until the redesign.
201
211
  if (word === 'deck') {
202
212
  const name = head[1] && !head[1].quoted ? head[1].text : '<name>';
203
213
  const texts = head.slice(2).map((token) => (token.quoted ? quoteOf(token.text) : token.text));
204
- return `reladraw has no \`deck\` statement — a deck is an attribute of the node, so write ` +
205
- `\`deck: ${texts.length > 0 ? texts.join(' ') : '"…"'}\` on \`node ${name}\``;
214
+ return `reladraw has no \`deck\` statement — a deck is a container and its cards are its children, the front one first: ` +
215
+ `\`node ${name} "" layout: deck cards: "…" ${texts.length > 0 ? texts.join(' ') : '"…"'}\``;
206
216
  }
207
217
  const replacement = SUBSTITUTIONS[word];
208
218
  if (replacement === undefined)
@@ -268,12 +278,14 @@ const BRACKET_KEYS = {
268
278
  text: TEXT_KEYS,
269
279
  contents: CONTENTS_KEYS,
270
280
  line: LINE_KEYS,
281
+ checks: CHECK_KINDS,
271
282
  };
272
283
  /** How each bracketed key's error quotes itself back, and what it is about. */
273
284
  const BRACKET_ABOUT = {
274
285
  text: { kind: 'a text', example: 'color: theme-muted' },
275
286
  contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
276
287
  line: { kind: 'a line', example: 'path: square' },
288
+ checks: { kind: 'a `checks:` bracket', example: 'edge-crossings: on' },
277
289
  };
278
290
  /**
279
291
  * Which of the line's properties a word belongs to, so that `line: square` —
@@ -310,6 +322,285 @@ function setOnce(attrs, key, value, subject, line, shown = { key, value: (v) =>
310
322
  function readAttr(tokens, at, attrs, line, subject) {
311
323
  const keyToken = tokens[at];
312
324
  const key = keyToken.text.slice(0, -1);
325
+ if (key === 'gap') {
326
+ throw new SourceError(`${subject}: a node has no gap:, only a placement does — write it in the placement's bracket, ` +
327
+ `such as \`right of a (gap: ${tokens[at + 1]?.text ?? 'wide'})\``, line);
328
+ }
329
+ if (key === 'contents') {
330
+ throw new SourceError(`${subject}: contents: is gone — a container's children are arranged with layout:, and its settings ` +
331
+ 'go in its bracket: `layout: column (widths: match, align: center)`', line);
332
+ }
333
+ if (key === 'deck') {
334
+ // The node's own text was the front card's, and the copies' texts went back to front.
335
+ const name = subject.replace(/^node "|"$/g, '');
336
+ const own = tokens.slice(0, at).find((token) => token.quoted);
337
+ const cards = [quoteOf(own?.text ?? name)];
338
+ for (let next = at + 1; tokens[next]?.quoted; next++)
339
+ cards.push(quoteOf(tokens[next].text));
340
+ throw new SourceError(`${subject}: deck: is gone — a deck is a container and its cards are its children, the front one first: ` +
341
+ `\`node ${name} "" layout: deck cards: ${cards.join(' ')}\`; a card that holds things is written as a child, such as node ${name}.1`, line);
342
+ }
343
+ if (key === 'heading') {
344
+ // `heading: d1.head (inside top, align: left)` — the child, then where it
345
+ // sits, in the words a title uses.
346
+ const value = tokens[at + 1];
347
+ if (!value || value.quoted || isAttrKey(value) || value.text === '(') {
348
+ throw new SourceError(`${subject}: heading: names one of its own children, such as heading: ${subject.replace(/^node "|"$/g, '')}.head`, line);
349
+ }
350
+ setOnce(attrs, 'heading', value.text, subject, line);
351
+ const taken = takeTitlePlace(tokens, at + 2, subject, line);
352
+ const read = readBracket(taken.tokens, at + 2, ['align'], {
353
+ subject,
354
+ what: '`heading:`',
355
+ kind: 'a heading',
356
+ example: 'align: left',
357
+ line,
358
+ });
359
+ if (taken.place !== undefined)
360
+ setOnce(attrs, 'heading.place', taken.place, subject, line);
361
+ if (read.values['align'] !== undefined)
362
+ setOnce(attrs, 'heading.align', read.values['align'], subject, line);
363
+ // Taking the place words out shortened the tokens; count from the original.
364
+ const removed = tokens.length - taken.tokens.length;
365
+ return read.next + removed;
366
+ }
367
+ if (key === 'depth') {
368
+ // `depth: behind b`, `depth: in front of a, b and c`, or both, separated
369
+ // by a comma: `depth: behind a, in front of a.api`. Stored as clauses
370
+ // joined on `;`, which no name can hold.
371
+ const clauses = [];
372
+ let next = at + 1;
373
+ for (;;) {
374
+ let way;
375
+ if (follows(tokens, next, 'behind')) {
376
+ way = 'behind';
377
+ next += 1;
378
+ }
379
+ else if (follows(tokens, next, 'in') && follows(tokens, next + 1, 'front') && follows(tokens, next + 2, 'of')) {
380
+ way = 'in front of';
381
+ next += 3;
382
+ }
383
+ else {
384
+ const given = tokens[next];
385
+ throw new SourceError(`${subject}: depth: says what this is drawn behind or in front of — \`depth: behind b\` or \`depth: in front of b\`` +
386
+ (given && !isAttrKey(given) ? `, not "${given.text}"` : ''), line);
387
+ }
388
+ const names = [];
389
+ let more = false;
390
+ for (;;) {
391
+ const token = tokens[next];
392
+ if (!token || token.quoted || isAttrKey(token) || token.text === '(' || token.text === ')') {
393
+ throw new SourceError(`${subject}: "depth: ${way}" names nothing${names.length > 0 ? ' after its comma' : ''}`, line);
394
+ }
395
+ const listed = token.text.endsWith(',') && token.text.length > 1;
396
+ names.push(listed ? token.text.slice(0, -1) : token.text);
397
+ next += 1;
398
+ if (follows(tokens, next, 'and')) {
399
+ next += 1;
400
+ continue;
401
+ }
402
+ if (!listed)
403
+ break;
404
+ // A comma before `behind` or `in front of` starts the next clause.
405
+ if (follows(tokens, next, 'behind') || (follows(tokens, next, 'in') && follows(tokens, next + 1, 'front'))) {
406
+ more = true;
407
+ break;
408
+ }
409
+ }
410
+ clauses.push(`${way} ${names.join(', ')}`);
411
+ if (!more)
412
+ break;
413
+ }
414
+ setOnce(attrs, 'depth', clauses.join('; '), subject, line, { key, value: (v) => v.split('; ').join(', ') });
415
+ return next;
416
+ }
417
+ if (key === 'via') {
418
+ // Stored as JSON, which `parseEdge` takes back out: a `via:` list belongs
419
+ // to one line, never to a style.
420
+ if (!subject.startsWith('edge ')) {
421
+ throw new SourceError(`${subject}: via: says where one line goes, so it is written on the edge itself`, line);
422
+ }
423
+ const read = readVia(tokens, at + 1, subject, line);
424
+ setOnce(attrs, 'via', JSON.stringify(read.entries), subject, line, { key, value: () => 'a list' });
425
+ return read.next;
426
+ }
427
+ if (key === 'from' || key === 'to') {
428
+ // `to: top`, `to: left 3/4 down`, `to: bottom 1/4 across from right`,
429
+ // `to: top, right of z`, `from: bottom-center`, `from: center`. Stored as
430
+ // written, one space between words; `lines.ts` reads it (design.org 6.2).
431
+ const first = tokens[at + 1];
432
+ if (!first || first.quoted || isAttrKey(first) || first.text === '(') {
433
+ throw new SourceError(`${subject}: ${key}: names a side or a point, such as ${key}: top`, line);
434
+ }
435
+ const words = [first.text];
436
+ let even;
437
+ let next = at + 2;
438
+ const side = first.text.replace(/,$/, '');
439
+ const second = tokens[at + 2];
440
+ const plain = (token) => token !== undefined && !token.quoted && !isAttrKey(token) && token.text !== '(';
441
+ if (WAYS.includes(side) && second?.text === 'from' && plain(tokens[at + 3])) {
442
+ // `to: down from z3`, `from: left from bin`: lined up with another thing (6.2).
443
+ words.push('from', tokens[at + 3].text);
444
+ next = at + 4;
445
+ }
446
+ else if (side === 'at' && plain(second)) {
447
+ // `to: at 1/3`: how far along the line this end is on.
448
+ words.push(second.text);
449
+ next = at + 3;
450
+ }
451
+ else if (!isSideWord(side) && second?.text === 'at' && plain(tokens[at + 3])) {
452
+ // `to: climb at 1/3`: how far along a leg of that line.
453
+ words.push('at', tokens[at + 3].text);
454
+ next = at + 4;
455
+ }
456
+ else if (!isSideWord(side) && !isPosition(side) && (second?.text === 'start' || second?.text === 'end')) {
457
+ // `from: req end`: where another line ends.
458
+ words.push(second.text);
459
+ next = at + 3;
460
+ }
461
+ else if (isSideWord(side)) {
462
+ let listed = first.text.endsWith(',');
463
+ let fractionWritten;
464
+ if (!listed) {
465
+ const fraction = readFraction(tokens, next);
466
+ if (fraction) {
467
+ words.push(fraction.fraction.written);
468
+ next = fraction.next;
469
+ listed = fraction.fraction.written.endsWith(',');
470
+ fractionWritten = fraction.fraction.written.replace(/,$/, '');
471
+ }
472
+ }
473
+ // An end's place along its side is said once: by a fraction, by its
474
+ // place among the others, or by what it is even with.
475
+ const twice = (one, other) => {
476
+ throw new SourceError(`${subject}: "${key}: ${side}, …" says where along the ${side} side twice — "${one}" and "${other}" each place the end; keep one`, line);
477
+ };
478
+ if (listed && follows(tokens, next, 'even')) {
479
+ // `to: left, even with cat1`: on that side, level with cat1's center,
480
+ // or at its place across on a top or bottom side. The targets are
481
+ // stored beside the words, and `parseEdge` takes them out.
482
+ const written = `${key}: ${side}, even with`;
483
+ if (!follows(tokens, next + 1, 'with')) {
484
+ throw new SourceError(`${subject}: "${key}: ${side}, even" reads "${written} <thing>"`, line);
485
+ }
486
+ const targets = [];
487
+ let i = next + 2;
488
+ for (;;) {
489
+ const token = tokens[i];
490
+ if (!token || token.quoted || isAttrKey(token) || token.text === '(' || token.text === ')') {
491
+ throw new SourceError(`${subject}: "${written}" names nothing — write \`${written} <thing>\``, line);
492
+ }
493
+ const read = readOneTarget(tokens, i, subject, line);
494
+ targets.push(read.target);
495
+ i = read.next;
496
+ if (follows(tokens, i, 'and')) {
497
+ i += 1;
498
+ continue;
499
+ }
500
+ if (!read.listed)
501
+ break;
502
+ const after = tokens[i]?.text;
503
+ if (after === 'above' || after === 'below' || ((after === 'left' || after === 'right') && follows(tokens, i + 1, 'of'))) {
504
+ twice(`even with ${listTargets(targets)}`, `${after}${after === 'left' || after === 'right' ? ' of' : ''} ${tokens[i + (after === 'left' || after === 'right' ? 2 : 1)]?.text.replace(/,$/, '') ?? '…'}`);
505
+ }
506
+ }
507
+ if (fractionWritten !== undefined)
508
+ twice(fractionWritten, `even with ${listTargets(targets)}`);
509
+ checkEvenTargets(targets, side === 'left' || side === 'right' ? 'y' : 'x', written, subject, line);
510
+ words.push('even with', listTargets(targets));
511
+ even = targets;
512
+ next = i;
513
+ }
514
+ else if (listed) {
515
+ // `top, right of z`: this end's place among the others on the side.
516
+ const way = tokens[next]?.text;
517
+ const two = way === 'left' || way === 'right';
518
+ const name = tokens[next + (two ? 2 : 1)];
519
+ if (way !== undefined && name && !name.quoted) {
520
+ words.push(two ? `${way} ${tokens[next + 1]?.text ?? ''}` : way, name.text);
521
+ next += two ? 3 : 2;
522
+ if (name.text.endsWith(',') && follows(tokens, next, 'even')) {
523
+ twice(`${words[words.length - 2]} ${name.text.slice(0, -1)}`, `even with ${tokens[next + 2]?.text ?? '…'}`);
524
+ }
525
+ }
526
+ }
527
+ }
528
+ setOnce(attrs, key, words.join(' '), subject, line);
529
+ if (even)
530
+ attrs[`${key}.even`] = JSON.stringify(even);
531
+ return next;
532
+ }
533
+ if (EDGE_ORDER_KEYS.includes(key)) {
534
+ // `right-edge-order: ae, ad, ac`: names separated by commas, as a bundle's order: lists them.
535
+ if (!subject.startsWith('node ')) {
536
+ throw new SourceError(`${subject}: ${key}: names the lines on one box's side, so it is written on the node itself`, line);
537
+ }
538
+ const names = [];
539
+ let next = at + 1;
540
+ for (;;) {
541
+ const item = tokens[next];
542
+ if (!item || item.quoted || isAttrKey(item) || item.text === '(' || item.text === ',') {
543
+ throw new SourceError(`${subject}: ${key}: lists the named lines on that side in order, such as ${key}: ae, ad, ac${names.length > 0 ? ' — the comma at the end promises another' : ''}`, line);
544
+ }
545
+ const listed = item.text.endsWith(',');
546
+ names.push(listed ? item.text.slice(0, -1) : item.text);
547
+ next += 1;
548
+ if (!listed && follows(tokens, next, ',')) {
549
+ next += 1;
550
+ continue;
551
+ }
552
+ if (!listed)
553
+ break;
554
+ }
555
+ setOnce(attrs, key, names.join(', '), subject, line);
556
+ return next;
557
+ }
558
+ if (key === 'join-with') {
559
+ // `join-with: hub text` or `join-with: hub.icon` — what the node moves with.
560
+ const value = tokens[at + 1];
561
+ const own = subject.replace(/^node "|"$/g, '');
562
+ const parent = own.includes('.') ? own.slice(0, own.lastIndexOf('.')) : 'hub';
563
+ if (!value || value.quoted || isAttrKey(value) || value.text === '(') {
564
+ throw new SourceError(`${subject}: join-with: names what it moves with — its container's text, such as join-with: ${parent} text, or a sibling`, line);
565
+ }
566
+ const part = tokens[at + 2];
567
+ if (part && !part.quoted && part.text === 'text') {
568
+ setOnce(attrs, 'join-with', `${value.text} text`, subject, line);
569
+ return at + 3;
570
+ }
571
+ setOnce(attrs, 'join-with', value.text, subject, line);
572
+ return at + 2;
573
+ }
574
+ if (key === 'layout') {
575
+ // `layout: column (gap: 0, widths: match)` — a value, then its settings.
576
+ // `centers: 5 apart` reads its last word as part of the value.
577
+ const value = tokens[at + 1];
578
+ if (!value || value.quoted || isAttrKey(value) || value.text === '(') {
579
+ throw new SourceError(`${subject}: layout: names an arrangement — column, row, venn or deck`, line);
580
+ }
581
+ setOnce(attrs, 'layout', value.text, subject, line);
582
+ let next = at + 2;
583
+ if (follows(tokens, next, '(')) {
584
+ const inner = tokens.slice(next);
585
+ const apart = inner.findIndex((token, index) => index > 1 && token.text.replace(/,$/, '') === 'apart' && inner[index - 2]?.text === 'centers:');
586
+ if (apart !== -1) {
587
+ if (inner[apart].text.endsWith(','))
588
+ inner[apart - 1] = { ...inner[apart - 1], text: `${inner[apart - 1].text},` };
589
+ inner.splice(apart, 1);
590
+ }
591
+ const read = readBracket(inner, 0, LAYOUT_KEYS, {
592
+ subject,
593
+ what: '`layout:`',
594
+ kind: 'a layout',
595
+ example: 'gap: tight',
596
+ line,
597
+ });
598
+ for (const [inside, set] of Object.entries(read.values))
599
+ setOnce(attrs, `layout.${inside}`, set, subject, line);
600
+ next += read.next + (apart !== -1 && apart < read.next ? 1 : 0);
601
+ }
602
+ return next;
603
+ }
313
604
  const bracketKeys = BRACKET_KEYS[key];
314
605
  if (bracketKeys !== undefined && follows(tokens, at + 1, '(')) {
315
606
  // `text: (color: theme-muted)` — the whole bracket belongs to one part, and it is
@@ -321,6 +612,7 @@ function readAttr(tokens, at, attrs, line, subject) {
321
612
  kind: BRACKET_ABOUT[key].kind,
322
613
  example: BRACKET_ABOUT[key].example,
323
614
  line,
615
+ ...(key === 'text' ? { listed: TEXT_KEYS.filter((word) => word !== 'at') } : {}),
324
616
  });
325
617
  if (Object.keys(read.values).length === 0) {
326
618
  throw new SourceError(`${subject}: \`${key}:\` opens empty brackets`, line);
@@ -379,8 +671,8 @@ function readAttr(tokens, at, attrs, line, subject) {
379
671
  setOnce(attrs, key, names.join(' '), subject, line, { key, value: (v) => v.split(' ').join(', ') });
380
672
  return next;
381
673
  }
382
- if (key === 'deck') {
383
- // One quoted text per copy behind the node, back to front, as many as are
674
+ if (key === 'cards') {
675
+ // One quoted text per card of a deck, front first, as many as are
384
676
  // written. Stored joined on a line break, which no source line can hold.
385
677
  const texts = [];
386
678
  let next = at + 1;
@@ -388,8 +680,8 @@ function readAttr(tokens, at, attrs, line, subject) {
388
680
  texts.push(tokens[next++].text);
389
681
  if (texts.length === 0) {
390
682
  const given = tokens[next];
391
- throw new SourceError(`${subject}: \`deck:\` takes one quoted text per copy behind the node, as in \`deck: "Drive 2" "Drive 3"\`` +
392
- (given && !isAttrKey(given) ? `, not \`deck: ${given.text}\`` : ''), line);
683
+ throw new SourceError(`${subject}: \`cards:\` takes one quoted text per card, front first, as in \`cards: "Drive 1" "Drive 2" "Drive 3"\`` +
684
+ (given && !isAttrKey(given) ? `, not \`cards: ${given.text}\`` : ''), line);
393
685
  }
394
686
  setOnce(attrs, key, texts.join('\n'), subject, line, {
395
687
  key,
@@ -405,6 +697,13 @@ function readAttr(tokens, at, attrs, line, subject) {
405
697
  throw new SourceError(`${subject}: \`${key}:\` takes its properties in brackets — write \`${key}: (${BRACKET_ABOUT[key].example})\`` +
406
698
  (given && !isAttrKey(given) ? `, not \`${key}: ${given.text}\`` : ''), line);
407
699
  }
700
+ if (SIZE_KEYS[key] !== undefined) {
701
+ // `height: 0.5 group height`: read and checked here, stored spaced out for
702
+ // the placing pass to work out. It ends where a placement or a key starts.
703
+ const read = readSize(tokens, at + 1, key, subject, line, startsPlacement);
704
+ setOnce(attrs, key, writeSize(read.size), subject, line);
705
+ return read.next;
706
+ }
408
707
  const valueToken = tokens[at + 1];
409
708
  if (!valueToken || isAttrKey(valueToken) || valueToken.text === ')') {
410
709
  throw new SourceError(`attribute "${key}" has no value`, line);
@@ -417,10 +716,6 @@ function readAttr(tokens, at, attrs, line, subject) {
417
716
  // to write, not silently drawn without its colors.
418
717
  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);
419
718
  }
420
- if (key === 'width') {
421
- // Renamed 2026-09-09, and moved into the text's bracket in 0.3.0.
422
- 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);
423
- }
424
719
  if (key === 'subtext') {
425
720
  // Removed in 0.3.0. It colored "every line after the first", which is a
426
721
  // positional slice: the rule lived in a style elsewhere in the file and was
@@ -450,10 +745,10 @@ function readAttr(tokens, at, attrs, line, subject) {
450
745
  if (key === 'align' && valueToken.text === 'widths') {
451
746
  // Removed in 0.3.0. It was a size operation wearing an alignment's name,
452
747
  // and its value set had one member — a flag in a property's clothes. Its
453
- // job is `contents: (widths: match)`, and with it gone `align` means one
748
+ // job is a layout's `(widths: match)`, and with it gone `align` means one
454
749
  // thing everywhere.
455
- throw new SourceError('`align: widths` is now `contents: (widths: match)` — it is a size, not an alignment, and ' +
456
- 'the same brackets take `align: center` for where the contents sit when the title is wider', line);
750
+ throw new SourceError('`align: widths` is now `layout: column (widths: match)` — it is a size, not an alignment, and ' +
751
+ 'gather-children: center says where the children sit when the title is wider', line);
457
752
  }
458
753
  if (MOVED_INTO_BRACKET.includes(key)) {
459
754
  throw new SourceError(`\`${key}:\` belongs to the text rather than to the node — write it in the brackets after ` +
@@ -489,8 +784,240 @@ function attrsOnly(tokens, start, line, subject) {
489
784
  }
490
785
  return attrs;
491
786
  }
492
- /** Everything a text takes, less the one word that needs a box to sit in. */
493
- const EDGE_TEXT_KEYS = TEXT_KEYS.filter((key) => key !== 'at');
787
+ /**
788
+ * How a text on a line looks, which is a node text's keys less the two that
789
+ * place it in a box, and the patch behind it, in a box's words: its `fill:`
790
+ * and the `padding:` it leaves round the text (design.org 7.3).
791
+ */
792
+ const EDGE_TEXT_LOOK = [...TEXT_KEYS.filter((key) => key !== 'at' && key !== 'align'), 'fill', 'padding'];
793
+ /** Where a text on a line sits (design.org 7.3). */
794
+ const EDGE_TEXT_PLACE = ['at', 'on', 'gap', 'edge-gap', 'reads'];
795
+ const EDGE_TEXT_KEYS = [...EDGE_TEXT_LOOK, ...EDGE_TEXT_PLACE];
796
+ /**
797
+ * The bracket after one text on a line: `(at: start, above edge, gap: tight)`,
798
+ * `(on: rise, straight right from c)`, `(above s1, left of edge)`. Keys say how
799
+ * it looks and where along the line it sits; the relations say where it sits
800
+ * by a box, or beside its line. Everything is checked here that the words
801
+ * alone can settle; what needs the drawn line is checked when it is drawn.
802
+ */
803
+ function readEdgeTextBracket(tokens, start, subject, text, line) {
804
+ const attrs = {};
805
+ const place = {};
806
+ if (!follows(tokens, start, '('))
807
+ return { attrs, place, next: start };
808
+ const about = `${subject}: the text "${text}"`;
809
+ const plain = (token) => token && !token.quoted ? token.text.replace(/(.),$/, '$1') : undefined;
810
+ const seen = new Set();
811
+ // What each key in the bracket was given, so a repeat can name both values.
812
+ const given = new Map();
813
+ let atWritten = '';
814
+ const once = (what, written) => {
815
+ if (seen.has(what))
816
+ throw new SourceError(`${about} says ${written === what ? what : `${what} (${written})`} twice — keep one`, line);
817
+ seen.add(what);
818
+ };
819
+ let i = start + 1;
820
+ while (!follows(tokens, i, ')')) {
821
+ const token = tokens[i];
822
+ if (!token)
823
+ throw new SourceError(`${about} opens a "(" and never closes it`, line);
824
+ if (token.quoted)
825
+ throw new SourceError(`${about} has a quoted "${token.text}" in its brackets — a second text goes after the ")"`, line);
826
+ if (isAttrKey(token)) {
827
+ const key = token.text.slice(0, -1);
828
+ if (key === 'align') {
829
+ throw new SourceError(`${about}: a text on a line has no align: — it is placed along the line with at: or on:, ` +
830
+ 'by a box with a relation such as `above s1`, and beside the line with `right of edge` and the rest', line);
831
+ }
832
+ if (!EDGE_TEXT_KEYS.includes(key)) {
833
+ throw new SourceError(`a text on a line has no "${key}" — it takes ${EDGE_TEXT_KEYS.join(', ')}, and relations such as \`above s1\` or \`right of edge\``, line);
834
+ }
835
+ const value = plain(tokens[i + 1]);
836
+ if (value === undefined || isAttrKey(tokens[i + 1]) || value === ')') {
837
+ throw new SourceError(`${about}: "${key}" has no value`, line);
838
+ }
839
+ if (key === 'gap' && place.gap !== undefined && !given.has('gap')) {
840
+ throw new SourceError(`${about} says its distance from the box twice (${place.gap} in front, gap: ${value}) — keep one`, line);
841
+ }
842
+ const before = given.get(key);
843
+ if (before !== undefined)
844
+ throw new SourceError(`${about} says ${key} twice (${key}: ${before}, ${key}: ${value}) — keep one`, line);
845
+ given.set(key, value);
846
+ once(key, key);
847
+ i += 2;
848
+ if (key === 'color' || key === 'fill')
849
+ checkColorWord(value, subject, line);
850
+ if (key === 'at') {
851
+ const fraction = /^(\d+)\/(\d+)$/.exec(value);
852
+ const at = value === 'start' || value === 'end'
853
+ ? value
854
+ : fraction
855
+ ? Number(fraction[1]) / Number(fraction[2])
856
+ : /^\d*\.?\d+$/.test(value)
857
+ ? Number(value)
858
+ : NaN;
859
+ if (typeof at === 'number' && !(at >= 0 && at <= 1)) {
860
+ throw new SourceError(`${about}: "at: ${value}" — at: takes a fraction along the line, such as 1/4 or 0.25, or start or end`, line);
861
+ }
862
+ place.at = at;
863
+ atWritten = value;
864
+ }
865
+ else if (key === 'on' && value === 'longest') {
866
+ if (plain(tokens[i]) !== 'level') {
867
+ throw new SourceError(`${about}: "on: longest" — the longest piece a text can sit on is the longest level one: \`on: longest level\``, line);
868
+ }
869
+ place.longestLevel = true;
870
+ i += 1;
871
+ }
872
+ else if (key === 'on') {
873
+ place.on = value;
874
+ }
875
+ else if (key === 'gap') {
876
+ place.gap = value;
877
+ }
878
+ else if (key === 'edge-gap') {
879
+ place.edgeGap = value;
880
+ }
881
+ else if (key === 'reads') {
882
+ if (value !== 'up' && value !== 'down' && value !== 'along') {
883
+ throw new SourceError(`${about}: "reads: ${value}" — a text reads up, down or along`, line);
884
+ }
885
+ place.reads = value;
886
+ }
887
+ else {
888
+ attrs[key] = value;
889
+ }
890
+ continue;
891
+ }
892
+ const word = plain(token);
893
+ const from = i;
894
+ if (word === 'even' || ((isSideWord(word) || isAxisWord(word)) && plain(tokens[i + 1]) === 'even')) {
895
+ // `left even with x right`: one axis from a box, the other from the line.
896
+ // A comma ends it, as it ends every item in the bracket, so read only up to one.
897
+ let end = i;
898
+ while (end < tokens.length && plain(tokens[end]) !== ')' && !(tokens[end].text.endsWith(',') && !tokens[end].quoted))
899
+ end += 1;
900
+ const comma = end < tokens.length && plain(tokens[end]) !== ')';
901
+ const item = tokens.slice(i, end + (comma ? 1 : 0)).map((t, k, all) => (comma && k === all.length - 1 ? { ...t, text: t.text.slice(0, -1) } : t));
902
+ const read = readPlacement(item, 0, line, subject);
903
+ const placement = read.placement;
904
+ const written = item.slice(0, read.next).map((t) => t.text).join(' ');
905
+ if (read.next < item.length) {
906
+ const after = item[read.next].text;
907
+ if (AXIS_ADVERBS.has(after))
908
+ throw new SourceError(`${about}: ${axisAdverb(after, listTargets(read.placement.targets))}`, line);
909
+ throw new SourceError(`${about}: "${written}" is followed by "${after}" — put a comma between them`, line);
910
+ }
911
+ once(placement.axis === 'x' ? 'where it sits across' : 'how high it sits', written);
912
+ (place.even ??= []).push({ placement, written });
913
+ i += item.length;
914
+ continue;
915
+ }
916
+ if (word === 'on' && plain(tokens[i + 1]) === 'the' && plain(tokens[i + 2]) === 'line') {
917
+ // `above s1, on the line`: the text stays on its line, where it passes above s1.
918
+ once('on the line', 'on the line');
919
+ place.onLine = true;
920
+ i += 3;
921
+ continue;
922
+ }
923
+ // `2 above s1`: the distance from the box, written in front as a box writes it.
924
+ const distance = /^\d*\.?\d+$/.test(word) && /^(above|below|left|right)$/.test(plain(tokens[i + 1]) ?? '') ? word : undefined;
925
+ if (distance !== undefined) {
926
+ if (place.gap !== undefined)
927
+ throw new SourceError(`${about} says its distance from the box twice (gap: ${place.gap}, ${distance} in front) — keep one`, line);
928
+ place.gap = distance;
929
+ seen.add('gap');
930
+ i += 1;
931
+ }
932
+ const said = distance === undefined ? word : plain(tokens[i]);
933
+ let relation;
934
+ if (said === 'straight') {
935
+ const way = plain(tokens[i + 1]);
936
+ if (!WAYS.includes(way ?? '') || plain(tokens[i + 2]) !== 'from') {
937
+ throw new SourceError(`${about}: "straight" is followed by a way and the box it starts from, such as \`straight right from c\``, line);
938
+ }
939
+ relation = 'straight';
940
+ i += 3;
941
+ }
942
+ else if (said === 'above' || said === 'below') {
943
+ relation = said;
944
+ i += 1;
945
+ }
946
+ else if ((said === 'left' || said === 'right') && plain(tokens[i + 1]) === 'of') {
947
+ relation = `${said} of`;
948
+ i += 2;
949
+ }
950
+ else {
951
+ throw new SourceError(`${about}: "${word}" in its brackets is not something a text on a line takes — it takes ` +
952
+ `${EDGE_TEXT_KEYS.join(':, ')}:, relations such as \`2 above s1\`, \`straight right from c\` or \`right of edge\`, \`x even with s1\`, and \`on the line\``, line);
953
+ }
954
+ const besideIt = relation !== 'straight' && plain(tokens[i]) === 'edge';
955
+ if (besideIt && distance !== undefined) {
956
+ throw new SourceError(`${about}: "${distance} ${relation} edge" — the distance off the line is written \`edge-gap: ${distance}\``, line);
957
+ }
958
+ if (besideIt) {
959
+ once('a side of the line', `${relation} edge`);
960
+ place.beside = relation;
961
+ i += 1;
962
+ }
963
+ else {
964
+ if (!tokens[i] || plain(tokens[i]) === ')' || isAttrKey(tokens[i])) {
965
+ throw new SourceError(`${about}: "${tokens.slice(from, i).map((t) => t.text).join(' ')}" names nothing — name a box, or \`edge\` for the line itself`, line);
966
+ }
967
+ const read = readOneTarget(tokens, i, subject, line);
968
+ const written = tokens.slice(from, read.next).map((t) => t.text).join(' ').replace(/,$/, '');
969
+ if (place.straight || place.by) {
970
+ throw new SourceError(`${about} is placed by a box twice (${place.straight?.written ?? place.by.written}, ${written}) — keep one`, line);
971
+ }
972
+ if (relation === 'straight') {
973
+ place.straight = { way: plain(tokens[from + 1]), target: read.target, written };
974
+ }
975
+ else {
976
+ place.by = { side: relation, target: read.target, written };
977
+ }
978
+ i = read.next;
979
+ }
980
+ if (follows(tokens, i, '(')) {
981
+ const said = tokens.slice(from, i).map((t) => t.text).join(' ');
982
+ throw new SourceError(besideIt
983
+ ? `${about}: "${said} (…)" — the distance off the line is written in the text's own bracket, \`edge-gap: tight\``
984
+ : relation === 'straight'
985
+ ? `${about}: "${said} (…)" — a text placed straight from a box sits where the way across meets the line, so it takes no distance`
986
+ : `${about}: "${said} (…)" — the distance from the box is written in the text's own bracket, \`gap: tight\``, line);
987
+ }
988
+ }
989
+ if (place.at !== undefined && (place.straight || place.by)) {
990
+ throw new SourceError(`${about}: "at: ${atWritten}" and "${(place.straight ?? place.by).written}" both say where along the line it sits — keep one`, line);
991
+ }
992
+ if (place.onLine && !place.by) {
993
+ throw new SourceError(`${about}: "on the line" keeps a text placed by a box on its line, as in \`above s1, on the line\`, and nothing here places it by a box` +
994
+ (place.straight ? ` — "${place.straight.written}" is on the line already` : ''), line);
995
+ }
996
+ if (place.by && !place.onLine && place.beside) {
997
+ throw new SourceError(`${about}: "${place.beside} edge" moves a text off its line, and "${place.by.written}" puts it by the box, off the line already — ` +
998
+ `keep one, or write \`${place.by.written}, on the line, ${place.beside} edge\` to start from the line`, line);
999
+ }
1000
+ if (place.gap !== undefined && place.at !== 'start' && place.at !== 'end' && !place.by) {
1001
+ throw new SourceError(`${about}: gap: is the distance from a box, which only \`at: start\`, \`at: end\` and a box relation such as \`above s1\` have` +
1002
+ (place.beside ? ' — the distance off the line is edge-gap:' : ''), line);
1003
+ }
1004
+ for (const { placement, written } of place.even ?? []) {
1005
+ // A relation already says where the text sits on its own axis.
1006
+ const axisOf = (side) => (side === 'above' || side === 'below' ? 'y' : 'x');
1007
+ const other = [
1008
+ ...(place.by ? [{ axis: axisOf(place.by.side), said: place.by.written }] : []),
1009
+ ...(place.beside ? [{ axis: axisOf(place.beside), said: `${place.beside} edge` }] : []),
1010
+ ...(place.straight ? [{ axis: (place.straight.way === 'up' || place.straight.way === 'down' ? 'x' : 'y'), said: place.straight.written }] : []),
1011
+ ].find((each) => each.axis === placement.axis);
1012
+ if (other) {
1013
+ throw new SourceError(`${about}: "${written}" and "${other.said}" both say where it sits ${placement.axis === 'x' ? 'across' : 'up and down'} — keep one`, line);
1014
+ }
1015
+ }
1016
+ if (place.edgeGap !== undefined && !place.beside) {
1017
+ throw new SourceError(`${about}: edge-gap: is the distance off the line for a text beside it, and it says no side — add \`right of edge\` or another`, line);
1018
+ }
1019
+ return { attrs, place, next: i + 1 };
1020
+ }
494
1021
  /**
495
1022
  * `text:` is how a *style* says something about the text of whatever wears it,
496
1023
  * because a style has no string of its own. A node and an edge do, so they say
@@ -505,6 +1032,88 @@ function refuseTextKey(attrs, subject, where, line) {
505
1032
  `so write \`(${inner}: ${attrs[key]})\` in the brackets ${where}`, line);
506
1033
  }
507
1034
  }
1035
+ /**
1036
+ * `(inside top, align: left)`, `(on top)`, `(outside bottom)`, `(center)`:
1037
+ * where a title or a heading sits, written first in a bracket. They are
1038
+ * placements, so they take no colon. Returns the tokens with those words taken
1039
+ * out, leaving the bracket's keyed settings for `readBracket`.
1040
+ */
1041
+ function takeTitlePlace(tokens, open, subject, line) {
1042
+ if (!follows(tokens, open, '('))
1043
+ return { tokens, placements: [] };
1044
+ const first = tokens[open + 1];
1045
+ const word = first && !first.quoted ? first.text.replace(/,$/, '') : undefined;
1046
+ if (word === 'center') {
1047
+ return { ...takeTitlePlacements([...tokens.slice(0, open + 1), ...tokens.slice(open + 2)], open, subject, line), place: 'center' };
1048
+ }
1049
+ if (word === 'inside' || word === 'on' || word === 'outside') {
1050
+ const side = tokens[open + 2]?.text.replace(/,$/, '');
1051
+ if (!side || !['top', 'bottom', 'left', 'right'].includes(side)) {
1052
+ throw new SourceError(`${subject}: a title sits ${word} a side — top, bottom, left or right`, line);
1053
+ }
1054
+ return {
1055
+ ...takeTitlePlacements([...tokens.slice(0, open + 1), ...tokens.slice(open + 3)], open, subject, line),
1056
+ place: `${word} ${side}`,
1057
+ };
1058
+ }
1059
+ return takeTitlePlacements(tokens, open, subject, line);
1060
+ }
1061
+ /**
1062
+ * The placements in a title's bracket, against the box's own children:
1063
+ * `(right even with ranged.d, top even with ranged.a)` (design.org 7.2). Each
1064
+ * comma-separated item that opens a placement is read as one and taken out;
1065
+ * the rest stay for the bracket's keys.
1066
+ */
1067
+ function takeTitlePlacements(tokens, open, subject, line) {
1068
+ let close = open + 1;
1069
+ let depth = 0;
1070
+ for (; close < tokens.length; close += 1) {
1071
+ const text = tokens[close].quoted ? '' : tokens[close].text;
1072
+ if (text === '(')
1073
+ depth += 1;
1074
+ if (text === ')') {
1075
+ if (depth === 0)
1076
+ break;
1077
+ depth -= 1;
1078
+ }
1079
+ }
1080
+ const inner = tokens.slice(open + 1, close);
1081
+ const items = [];
1082
+ let item = [];
1083
+ for (const token of inner) {
1084
+ const comma = !token.quoted && token.text.endsWith(',') && token.text.length > 1;
1085
+ item.push(comma ? { ...token, text: token.text.slice(0, -1) } : token);
1086
+ if (comma || (!token.quoted && token.text === ',')) {
1087
+ if (!token.quoted && token.text === ',')
1088
+ item.pop();
1089
+ items.push(item);
1090
+ item = [];
1091
+ }
1092
+ }
1093
+ if (item.length > 0)
1094
+ items.push(item);
1095
+ const placements = [];
1096
+ const kept = [];
1097
+ for (const each of items) {
1098
+ const head = each[0];
1099
+ if (head && !isAttrKey(head) && startsPlacement(head, each[1])) {
1100
+ for (let at = 0; at < each.length;) {
1101
+ const read = readPlacement(each, at, line, subject);
1102
+ placements.push(read.placement);
1103
+ at = read.next;
1104
+ }
1105
+ }
1106
+ else {
1107
+ kept.push(each);
1108
+ }
1109
+ }
1110
+ if (placements.length === 0)
1111
+ return { tokens, placements };
1112
+ const rejoined = kept.flatMap((each, index) => index < kept.length - 1 && each.length > 0
1113
+ ? [...each.slice(0, -1), { ...each[each.length - 1], text: `${each[each.length - 1].text},` }]
1114
+ : each);
1115
+ return { tokens: [...tokens.slice(0, open + 1), ...rejoined, ...tokens.slice(close)], placements };
1116
+ }
508
1117
  /**
509
1118
  * `node <name> ["<text>"] [(<text modifiers>)] [<placement> ...]`
510
1119
  *
@@ -531,18 +1140,25 @@ function parseNode(head, line) {
531
1140
  // A bare word here is a text somebody forgot to quote far more often than
532
1141
  // it is anything else, and `"Parser" is not a direction` would send them
533
1142
  // looking in the wrong place.
534
- if (written && !textToken && !isAttrKey(written) && !startsPlacement(written) && written.text !== '(') {
1143
+ if (written && !textToken && !isAttrKey(written) && !startsPlacement(written, head[3]) && written.text !== '(') {
535
1144
  throw new SourceError(`node "${name}": a text is quoted — write "${written.text}" rather than ${written.text}`, line);
536
1145
  }
537
1146
  const text = textToken ? textToken.text : name.slice(name.lastIndexOf('.') + 1);
538
1147
  const subject = `node "${name}"`;
539
- const bracket = readBracket(head, textToken ? 3 : 2, TEXT_KEYS, {
1148
+ const open = textToken ? 3 : 2;
1149
+ const taken = takeTitlePlace(head, open, subject, line);
1150
+ head = taken.tokens;
1151
+ const place = taken.place;
1152
+ const titlePlacements = taken.placements;
1153
+ const bracket = readBracket(head, open, NODE_TEXT_KEYS, {
540
1154
  subject,
541
1155
  what: 'the text',
542
1156
  kind: 'a text',
543
- example: 'at: bottom',
1157
+ example: 'size: small',
544
1158
  line,
1159
+ listed: NODE_TEXT_LISTED,
545
1160
  });
1161
+ refuseNodeReads(bracket.values['reads'], subject, line);
546
1162
  const tail = parseTail(head, bracket.next, line, subject);
547
1163
  refuseTextKey(tail.attrs, subject, 'after the name', line);
548
1164
  return {
@@ -550,12 +1166,93 @@ function parseNode(head, line) {
550
1166
  name,
551
1167
  text,
552
1168
  statedText: textToken !== undefined,
553
- textAttrs: bracket.values,
1169
+ textAttrs: place === undefined ? bracket.values : { ...bracket.values, place },
1170
+ ...(titlePlacements.length > 0 ? { titlePlacements } : {}),
554
1171
  placements: tail.placements,
555
1172
  attrs: tail.attrs,
556
1173
  line,
557
1174
  };
558
1175
  }
1176
+ /**
1177
+ * A node's text takes the look keys and `reads:` (design.org 7.2). `at` is
1178
+ * read only to be refused with the side form, so it isn't listed as one it takes.
1179
+ */
1180
+ const NODE_TEXT_KEYS = [...TEXT_KEYS, 'reads'];
1181
+ const NODE_TEXT_LISTED = NODE_TEXT_KEYS.filter((key) => key !== 'at');
1182
+ /** A node's text turns a quarter turn; `along` follows a line, and a box has none. */
1183
+ function refuseNodeReads(reads, subject, line, whose = "a node's text") {
1184
+ if (reads === undefined || reads === 'up' || reads === 'down')
1185
+ return;
1186
+ throw new SourceError(reads === 'along'
1187
+ ? `${subject}: reads: along follows a line's angle, and ${whose} has no line — write reads: up or reads: down`
1188
+ : `${subject}: reads: ${reads} is not one of up, down`, line);
1189
+ }
1190
+ /**
1191
+ * `point p "merge" (above-right, gap: 1)` or `region r "trust" (inside top)`:
1192
+ * the text after a point's or a region's name, and its bracket (design.org
1193
+ * 7.4). A point's text says which way from the dot, with no target, since it
1194
+ * has nothing else to sit beside; a region's takes a container title's side
1195
+ * words. Returns where the rest of the statement starts.
1196
+ */
1197
+ function readMarkText(head, kind, subject, line) {
1198
+ const written = head[2];
1199
+ if (!written?.quoted)
1200
+ return { tokens: head, next: 2 };
1201
+ let tokens = head;
1202
+ let place;
1203
+ if (kind === 'point') {
1204
+ if (follows(tokens, 3, '(')) {
1205
+ const first = tokens[4];
1206
+ const word = first && !first.quoted ? first.text.replace(/,$/, '') : undefined;
1207
+ if (word !== undefined && POINT_TEXT_SIDES.includes(word)) {
1208
+ const after = tokens[5]?.text;
1209
+ if ((word === 'left' || word === 'right') && after === 'of') {
1210
+ throw new SourceError(`${subject}: a point's text has nothing else to sit beside, so it's (${word}), not (${word} of …)`, line);
1211
+ }
1212
+ if (!first.text.endsWith(',') && after !== undefined && after !== ')' && after !== ',' && !isAttrKey(tokens[5])) {
1213
+ throw new SourceError(`${subject}: a point's text has nothing else to sit beside, so it's (${word}) alone — found "${after}" after it`, line);
1214
+ }
1215
+ place = word;
1216
+ tokens = [...tokens.slice(0, 4), ...tokens.slice(5)];
1217
+ }
1218
+ else if (word !== undefined && !isAttrKey(first)) {
1219
+ throw new SourceError(`${subject}: a point's text sits ${POINT_TEXT_SIDES.slice(0, 4).join(', ')} or at a corner such as above-right, not "${word}"`, line);
1220
+ }
1221
+ }
1222
+ }
1223
+ else {
1224
+ const taken = takeTitlePlace(tokens, 3, subject, line);
1225
+ if (taken.placements.length > 0) {
1226
+ throw new SourceError(`${subject}: a region's text is placed against a side of it, such as (outside top) — a region holds nothing to place it against`, line);
1227
+ }
1228
+ if (taken.place === 'center') {
1229
+ throw new SourceError(`${subject}: a region's text goes against a side, such as (inside top) — a region holds nothing, so its middle is whatever it covers`, line);
1230
+ }
1231
+ tokens = taken.tokens;
1232
+ place = taken.place;
1233
+ }
1234
+ const bracket = readBracket(tokens, 3, MARK_TEXT_KEYS, {
1235
+ subject,
1236
+ what: 'the text',
1237
+ kind: `a ${kind}'s text`,
1238
+ example: kind === 'point' ? 'gap: 1' : 'align: left',
1239
+ line,
1240
+ });
1241
+ const values = bracket.values;
1242
+ refuseNodeReads(values['reads'], subject, line, `a ${kind}'s text`);
1243
+ const across = place === undefined || /top|bottom/.test(place);
1244
+ const words = kind === 'point' || across ? ['left', 'center', 'right'] : ['top', 'center', 'bottom'];
1245
+ if (values['align'] !== undefined && !words.includes(values['align'])) {
1246
+ throw new SourceError(kind === 'point'
1247
+ ? `${subject}: align: lines up a point's text's own lines, ${words.join(', ')} — not ${values['align']}`
1248
+ : `${subject}: a text on the ${place?.split(' ')[1] ?? 'top'} side takes align: ${words.join(', ')} — not ${values['align']}`, line);
1249
+ }
1250
+ return {
1251
+ text: { text: written.text, textAttrs: place === undefined ? values : { ...values, place } },
1252
+ tokens,
1253
+ next: bracket.next,
1254
+ };
1255
+ }
559
1256
  const NEEDS_ARROW = 'an edge needs a line between its endpoints: "->", "<-", "<->" or "--"';
560
1257
  /**
561
1258
  * The arrow between an edge's two names: a mark, a shaft, a mark. The shaft is
@@ -639,6 +1336,139 @@ function readMark(written, end, arrow, line) {
639
1336
  * every edge has — so `from:` names the first name's side on every edge.
640
1337
  */
641
1338
  function parseEdge(head, line) {
1339
+ // `edge each child of a -> b`: one line from each direct child of a (design.org 6.6).
1340
+ const each = eachChildAt(head);
1341
+ if (each.length > 0) {
1342
+ const kept = head.filter((_, index) => !each.some((at) => index >= at && index < at + 3));
1343
+ const stmt = parseEdge(kept, line);
1344
+ // Before the arrow, the first end; after it, the second.
1345
+ const sides = each.map((at) => (head.slice(1, at).some(looksLikeArrow) ? 'to' : 'from'));
1346
+ const subject = `edge ${sides.includes('from') ? 'each child of ' : ''}${stmt.from} ${stmt.arrow} ${sides.includes('to') ? 'each child of ' : ''}${stmt.to}`;
1347
+ if (stmt.several !== undefined) {
1348
+ throw new SourceError(`${subject}: "each child of" and a list of ends can't share a statement — write a statement for each`, line);
1349
+ }
1350
+ if (sides.length > 1) {
1351
+ throw new SourceError(`${subject}: "each child of" on both ends would pair the children of two containers, and nothing on the page says which goes with which — write the lines out`, line);
1352
+ }
1353
+ if (stmt.name !== undefined) {
1354
+ throw new SourceError(`${subject}: a name belongs to one line, and "each child of" draws one line per child — leave the name off`, line);
1355
+ }
1356
+ return { ...stmt, each: sides[0] };
1357
+ }
1358
+ // `edge a -> b, c, d`: one line to each name in the list, drawn as one group,
1359
+ // so the stretch they share leaving `a` is said rather than happened on (6.6).
1360
+ const lists = endLists(head, line);
1361
+ if (lists !== undefined) {
1362
+ const stmt = parseEdge(lists.head, line);
1363
+ const written = (names) => names.join(', ');
1364
+ const subject = `edge ${written(lists.from)} ${stmt.arrow} ${written(lists.to)}`;
1365
+ if (lists.from.length > 1 && lists.to.length > 1) {
1366
+ throw new SourceError(`${subject}: a list on both ends would pair the names on one side with those on the other, and nothing says which goes with which — list one end and write a statement for each name on the other`, line);
1367
+ }
1368
+ const side = lists.from.length > 1 ? 'from' : 'to';
1369
+ const names = lists[side];
1370
+ const twice = names.find((name, index) => names.indexOf(name) !== index);
1371
+ if (twice !== undefined) {
1372
+ throw new SourceError(`${subject}: ${twice} is listed twice — copies of one line are separate statements`, line);
1373
+ }
1374
+ if (stmt.name !== undefined) {
1375
+ throw new SourceError(`${subject}: a name belongs to one line, and a list of ends draws one line to each — leave the name off`, line);
1376
+ }
1377
+ return { ...stmt, several: { side, names } };
1378
+ }
1379
+ // `edge req client -> api`: a word before the ends names the line, told
1380
+ // from the ends by position, as in `node api "API"` (design.org 6.1).
1381
+ if (head[2] && head[3] && !head[2].quoted && !looksLikeArrow(head[2]) && looksLikeArrow(head[3])) {
1382
+ const name = requireName(head[1], 'edge', line);
1383
+ const stmt = parseEdge([head[0], ...head.slice(2)], line);
1384
+ if (stmt.several !== undefined) {
1385
+ throw new SourceError(`edge ${name}: a name belongs to one line, and a list of ends draws one line to each — leave the name off`, line);
1386
+ }
1387
+ return { ...stmt, name };
1388
+ }
1389
+ const stmt = parseEdgeEnds(head, line);
1390
+ // `via:` and the targets of `to: left, even with cat1` were stored as JSON
1391
+ // among the attributes; they belong to the line, so they come out here.
1392
+ const { via, 'from.even': fromEven, 'to.even': toEven, ...attrs } = stmt.attrs;
1393
+ if (via === undefined && fromEven === undefined && toEven === undefined)
1394
+ return stmt;
1395
+ const evenWith = {
1396
+ ...(fromEven !== undefined ? { from: JSON.parse(fromEven) } : {}),
1397
+ ...(toEven !== undefined ? { to: JSON.parse(toEven) } : {}),
1398
+ };
1399
+ return {
1400
+ ...stmt,
1401
+ attrs,
1402
+ ...(via !== undefined ? { via: JSON.parse(via) } : {}),
1403
+ ...(fromEven !== undefined || toEven !== undefined ? { evenWith } : {}),
1404
+ };
1405
+ }
1406
+ /**
1407
+ * The ends of `edge a -> b, c, d` as written, and the head with only the first
1408
+ * name of each list left in it, or nothing when neither end is a list. Names
1409
+ * are separated by commas or `and`, as a placement's targets are.
1410
+ */
1411
+ function endLists(head, line) {
1412
+ const left = readEndList(head, 1, line);
1413
+ if (left.names.length === 0 || !head[left.next] || !looksLikeArrow(head[left.next]))
1414
+ return undefined;
1415
+ const shortened = [head[0], { ...head[1], text: left.names[0] }, ...head.slice(left.next)];
1416
+ const arrow = readArrow(shortened, line);
1417
+ const right = readEndList(shortened, arrow.next, line);
1418
+ if (left.names.length < 2 && right.names.length < 2)
1419
+ return undefined;
1420
+ return {
1421
+ head: [...shortened.slice(0, arrow.next), { ...shortened[arrow.next], text: right.names[0] }, ...shortened.slice(right.next)],
1422
+ from: left.names,
1423
+ to: right.names,
1424
+ };
1425
+ }
1426
+ /** Names separated by commas or `and`, starting at `at`, up to the first that nothing follows. */
1427
+ function readEndList(head, at, line) {
1428
+ const names = [];
1429
+ for (;;) {
1430
+ const token = head[at];
1431
+ if (!token || token.quoted || isAttrKey(token) || token.text === '(' || token.text === ',' || token.text === 'and') {
1432
+ if (names.length === 0)
1433
+ return { names: [], next: at };
1434
+ throw new SourceError(`edge: the list of ends after ${names.at(-1)} promises another name, and none follows`, line);
1435
+ }
1436
+ const comma = token.text.endsWith(',') && token.text.length > 1;
1437
+ names.push(comma ? token.text.slice(0, -1) : token.text);
1438
+ at += 1;
1439
+ if (comma)
1440
+ continue;
1441
+ const next = head[at];
1442
+ if (next && !next.quoted && (next.text === ',' || next.text === 'and')) {
1443
+ at += 1;
1444
+ continue;
1445
+ }
1446
+ return { names, next: at };
1447
+ }
1448
+ }
1449
+ /** Where `each child of` starts in an edge's head, before the attributes, once per time it is written. */
1450
+ function eachChildAt(head) {
1451
+ const found = [];
1452
+ for (let index = 1; index + 2 < head.length; index += 1) {
1453
+ const token = head[index];
1454
+ if (token.quoted || isAttrKey(token))
1455
+ break;
1456
+ if (token.text === 'each' && head[index + 1]?.text === 'child' && head[index + 2]?.text === 'of' && !head[index + 1].quoted && !head[index + 2].quoted) {
1457
+ found.push(index);
1458
+ index += 2;
1459
+ }
1460
+ }
1461
+ return found;
1462
+ }
1463
+ /** Whether a token is an arrow, or begins one: a bracketed mark, or a run of dashes with marks at its ends. */
1464
+ function looksLikeArrow(token) {
1465
+ if (token.quoted)
1466
+ return false;
1467
+ if (token.text === '(' || /^\[[^\]]*\]-*$/.test(token.text))
1468
+ return true;
1469
+ return /^[<|*o]*\|?-{1,}[>|*o]*$/.test(token.text) || /^-+$/.test(token.text);
1470
+ }
1471
+ function parseEdgeEnds(head, line) {
642
1472
  const left = requireName(head[1], 'edge', line);
643
1473
  const arrow = readArrow(head, line);
644
1474
  const rightToken = head[arrow.next];
@@ -660,83 +1490,278 @@ function parseEdge(head, line) {
660
1490
  throw new SourceError(`edge ${left} ${arrow.text} ${rightToken.text} ${stray.text}: a mark touches the dashes or is ` +
661
1491
  `in brackets — write "${arrow.text}${rightToken.text}" or "${arrow.text} [${rightToken.text}]"`, line);
662
1492
  }
663
- const textToken = head[at]?.quoted ? head[at] : undefined;
664
- if (textToken)
665
- at += 1;
666
1493
  const subject = `edge ${left} ${arrow.text} ${rightToken.text}`;
667
- // An edge's text takes the same bracket a node's does, less `at:`: a node's
668
- // text sits somewhere in a box and an edge's rides at the middle of its line,
669
- // so there is no position to name until a diagram asks for one.
670
- const bracket = readBracket(head, at, EDGE_TEXT_KEYS, {
671
- subject,
672
- what: 'the text',
673
- kind: "an edge's text",
674
- example: 'color: theme-muted',
675
- line,
676
- });
677
- at = bracket.next;
678
- let between;
679
- // An edge is not placed, so its tail holds attributes and the one clause that
680
- // is neither: `between`, which says which gap the line travels down.
1494
+ // Several texts are written one after another, each with its own bracket,
1495
+ // so one text is written exactly as it always was (design.org 7.3).
1496
+ const texts = [];
1497
+ while (head[at]?.quoted) {
1498
+ const text = head[at].text;
1499
+ const bracketed = follows(head, at + 1, '(');
1500
+ const read = readEdgeTextBracket(head, at + 1, subject, text, line);
1501
+ texts.push({ text, textAttrs: read.attrs, place: read.place });
1502
+ at = read.next;
1503
+ if (bracketed && follows(head, at, '(')) {
1504
+ throw new SourceError(`${subject}: the text "${text}" has two brackets — everything about one text goes in one`, line);
1505
+ }
1506
+ }
1507
+ // An edge is not placed, so its tail holds only attributes. The clauses that
1508
+ // once said what a line keeps clear of, `between c and d` and `below b`, are
1509
+ // gone: a line says the places it passes instead (design.org 6.4).
1510
+ let straight;
681
1511
  const tail = parseTail(head, at, line, subject, (tokens, index) => {
682
1512
  const word = tokens[index];
1513
+ if (!word.quoted && word.text === 'straight') {
1514
+ // `straight down`: leave that way and stop at what the line meets (6.2).
1515
+ const way = tokens[index + 1]?.text;
1516
+ if (!WAYS.includes(way ?? '')) {
1517
+ throw new SourceError(`${subject}: "straight" is followed by the way the line goes — up, down, left or right`, line);
1518
+ }
1519
+ if (straight !== undefined)
1520
+ throw new SourceError(`${subject}: "straight" is written twice — keep one`, line);
1521
+ straight = way;
1522
+ return index + 2;
1523
+ }
683
1524
  if (word.quoted || word.text !== 'between')
684
1525
  return undefined;
685
- if (between)
686
- throw new SourceError(`${subject}: "between" is written twice`, line);
687
- // A gap has two sides, so `between` takes exactly two targets rather than
688
- // the open list a placement takes. `right of a and b` means "clear of
689
- // both", and there is no matching reading of "pass between three things".
690
1526
  const read = readTargets(tokens, index + 1, subject, 'between', line);
691
- if (read.targets.length !== 2) {
692
- throw new SourceError(`"between" takes two nodes, one for each side of the gap — found ${read.targets.length}`, line);
693
- }
694
- let next = read.next;
695
- // Two targets sitting diagonally have two gaps between them, and this is
696
- // the only way to say which. It is optional because most pairs have one.
697
- const trailing = tokens[next];
698
- const axis = trailing && !trailing.quoted ? PASSAGE_AXES[trailing.text] : undefined;
699
- if (axis !== undefined)
700
- next += 1;
701
- between = { targets: read.targets, ...(axis !== undefined ? { axis } : {}) };
702
- return next;
1527
+ const named = read.targets.map(nameTarget);
1528
+ throw new SourceError(`${subject}: "between ${named.join(' and ')}" on an edge is gone — a line says the places it passes, ` +
1529
+ `so write \`via: between ${named[0] ?? 'c'} and ${named[1] ?? 'd'}\``, line);
703
1530
  });
704
- // An edge is not placed, so a direction on its line says which side of that
705
- // node the line passes. The other placements say where a thing *is*, and
706
- // have no reading for a line.
707
- const passes = [];
708
- for (const placement of tail.placements) {
1531
+ if (tail.placements.length > 0) {
1532
+ const placement = tail.placements[0];
709
1533
  const written = describePlacement(placement);
710
1534
  if (placement.kind !== 'offset' || placement.written !== undefined) {
711
1535
  throw new SourceError(`${subject}: "${written}" places a node, and an edge is not placed — it joins two things ` +
712
- 'that are. On an edge, above, below, left of and right of say which side of a node the ' +
713
- 'line passes', line);
714
- }
715
- if (placement.gap !== undefined) {
716
- throw new SourceError(`${subject}: "${written}" gives a gap, and a gap is kept between nodes — the line passes ` +
717
- 'as close as it reads clearly. Drop the brackets', line);
1536
+ 'that are. A line says the places it passes with via:, such as `via: b top`', line);
718
1537
  }
719
- passes.push(placement);
720
- }
721
- if (passes.length > 0 && between) {
722
- throw new SourceError(`${subject}: "between" and "${describePlacement(passes[0])}" on one edge — an edge passing ` +
723
- 'between two things already has a side of each, so say one or the other', line);
1538
+ // `below b` said what to keep clear of, and left the engine to find the
1539
+ // line; the place it passes is written instead.
1540
+ const part = {
1541
+ above: 'top',
1542
+ below: 'bottom',
1543
+ left: 'left',
1544
+ right: 'right',
1545
+ 'above-left': 'top-left',
1546
+ 'above-right': 'top-right',
1547
+ 'below-left': 'bottom-left',
1548
+ 'below-right': 'bottom-right',
1549
+ }[placement.direction];
1550
+ const stops = placement.targets.map((target) => `${target.name} ${part}`).join(', ');
1551
+ throw new SourceError(`${subject}: "${written}" on an edge is gone — it said what the line keeps clear of, and a line now ` +
1552
+ `says the places it passes: \`via: ${stops}\``, line);
724
1553
  }
725
1554
  refuseTextKey(tail.attrs, subject, 'after the arrow', line);
726
1555
  return {
727
1556
  kind: 'edge',
728
- textAttrs: bracket.values,
1557
+ texts,
729
1558
  from: left,
730
1559
  to: rightToken.text,
731
1560
  arrow: arrow.text,
732
1561
  marks: arrow.marks,
733
- ...(textToken ? { text: textToken.text } : {}),
734
- ...(between ? { between } : {}),
735
- ...(passes.length > 0 ? { passes } : {}),
1562
+ ...(straight !== undefined ? { straight } : {}),
736
1563
  attrs: tail.attrs,
737
1564
  line,
738
1565
  };
739
1566
  }
1567
+ /**
1568
+ * `guide point funnel …`, `guide edge a -> b`, `guide region r …`, `guide
1569
+ * node frame ""`: the statement as it would be, reference only (design.org
1570
+ * 8.1). The word applies to its own statement and nothing inside it.
1571
+ */
1572
+ function parseGuide(head, line) {
1573
+ const kind = head[1];
1574
+ const kinds = ['node', 'edge', 'point', 'region'];
1575
+ if (!kind || kind.quoted || !kinds.includes(kind.text)) {
1576
+ throw new SourceError(`guide goes before the statement it makes reference only — guide node, guide edge, guide point or guide region` +
1577
+ (kind && !kind.quoted ? `, not "guide ${kind.text}"` : ''), line);
1578
+ }
1579
+ const stmt = parseStatement(head.slice(1), line);
1580
+ if (stmt.kind !== 'node' && stmt.kind !== 'edge' && stmt.kind !== 'point' && stmt.kind !== 'region') {
1581
+ throw new SourceError('guide goes before a node, an edge, a point or a region', line);
1582
+ }
1583
+ return { ...stmt, guide: true };
1584
+ }
1585
+ /**
1586
+ * `bundle trunk via: through slot horizontal layout: parallel (gap: tight)
1587
+ * order: y, z, x`: a line with no ends, which others travel together
1588
+ * (design.org 6.6). Or `bundle calls a -> b layout: fan`: the direct way
1589
+ * between two boxes, which copies of one line share.
1590
+ */
1591
+ function parseBundle(head, line) {
1592
+ const name = requireName(head[1], 'bundle', line);
1593
+ const subject = `bundle "${name}"`;
1594
+ const attrs = {};
1595
+ let via;
1596
+ let order;
1597
+ let between;
1598
+ let i = 2;
1599
+ const shaft = head[3];
1600
+ if (head[2] && !head[2].quoted && !isAttrKey(head[2]) && shaft && !shaft.quoted && /^[^-]*-+[^-]*$/.test(shaft.text)) {
1601
+ if (shaft.text !== '--' && shaft.text !== '->') {
1602
+ throw new SourceError(`${subject}: "${shaft.text}" — a bundle's lines carry their own marks, so it is written a -- b or a -> b`, line);
1603
+ }
1604
+ const other = head[4];
1605
+ if (!other || other.quoted || isAttrKey(other)) {
1606
+ throw new SourceError(`${subject}: a bundle between two boxes names both, such as \`bundle ${name} a -> b\``, line);
1607
+ }
1608
+ between = [head[2].text, other.text];
1609
+ i = 5;
1610
+ }
1611
+ while (i < head.length) {
1612
+ const token = head[i];
1613
+ if (!isAttrKey(token)) {
1614
+ throw new SourceError(`${subject}: expected an attribute like "via: b top", found "${token.text}"`, line);
1615
+ }
1616
+ const key = token.text.slice(0, -1);
1617
+ if (key === 'via') {
1618
+ if (via !== undefined)
1619
+ throw new SourceError(`${subject}: "via" is written twice — keep one`, line);
1620
+ const read = readVia(head, i + 1, subject, line);
1621
+ via = read.entries;
1622
+ i = read.next;
1623
+ continue;
1624
+ }
1625
+ if (key === 'order') {
1626
+ // `order: y, z, x`: names separated by commas, as `depth:` lists them.
1627
+ if (order !== undefined)
1628
+ throw new SourceError(`${subject}: "order" is written twice — keep one`, line);
1629
+ order = [];
1630
+ let next = i + 1;
1631
+ for (;;) {
1632
+ const item = head[next];
1633
+ if (!item || item.quoted || isAttrKey(item) || item.text === '(' || item.text === ',') {
1634
+ throw new SourceError(`${subject}: order: lists the lines through the bundle, such as order: y, z, x${order.length > 0 ? ' — the comma at the end promises another' : ''}`, line);
1635
+ }
1636
+ const listed = item.text.endsWith(',');
1637
+ order.push(listed ? item.text.slice(0, -1) : item.text);
1638
+ next += 1;
1639
+ if (!listed && follows(head, next, ',')) {
1640
+ next += 1;
1641
+ continue;
1642
+ }
1643
+ if (!listed)
1644
+ break;
1645
+ }
1646
+ i = next;
1647
+ continue;
1648
+ }
1649
+ i = readAttr(head, i, attrs, line, subject);
1650
+ }
1651
+ refuseKeys(attrs, BUNDLE_KEYS, subject, 'a bundle', line);
1652
+ if (via === undefined && between === undefined) {
1653
+ throw new SourceError(`${subject} says the way its lines travel together: a path, such as \`bundle ${name} via: through slot horizontal\`, or two boxes, such as \`bundle ${name} a -> b\``, line);
1654
+ }
1655
+ if (via !== undefined && between !== undefined) {
1656
+ throw new SourceError(`${subject} runs directly between ${between[0]} and ${between[1]}, so it has no path for via: to name — keep one: the two boxes or the via:`, line);
1657
+ }
1658
+ const arrangement = attrs['layout'] ?? 'parallel';
1659
+ if (arrangement !== 'parallel' && arrangement !== 'collapsed' && arrangement !== 'fan') {
1660
+ throw new SourceError(`${subject}: layout: ${arrangement} is not a bundle's arrangement — parallel (the default), collapsed or fan`, line);
1661
+ }
1662
+ if (arrangement === 'fan' && between === undefined) {
1663
+ throw new SourceError(`${subject}: layout: fan spreads lines between two boxes, and this bundle has a path — write the boxes, such as \`bundle ${name} a -> b layout: fan\`, or say parallel`, line);
1664
+ }
1665
+ for (const which of ['from', 'to']) {
1666
+ if (attrs[which] !== undefined && between === undefined) {
1667
+ throw new SourceError(`${subject}: "${which}: ${attrs[which]}" — a bundle with a path ${which === 'from' ? 'starts' : 'ends'} where its path does; ${which}: belongs to a bundle between two boxes, such as \`bundle ${name} a -> b ${which}: ${attrs[which]}\``, line);
1668
+ }
1669
+ }
1670
+ const routedBy = ['from', 'to', 'line.path'].find((key) => attrs[key] !== undefined);
1671
+ if (arrangement === 'fan' && routedBy !== undefined) {
1672
+ const written = routedBy === 'line.path' ? `line: (path: ${attrs[routedBy]})` : `${routedBy}: ${attrs[routedBy]}`;
1673
+ throw new SourceError(`${subject}: layout: fan bows its lines along the direct way between the boxes, and "${written}" routes the bundle — keep one, or say parallel`, line);
1674
+ }
1675
+ const gap = attrs['layout.gap'];
1676
+ for (const key of Object.keys(attrs)) {
1677
+ if (key.startsWith('layout.') && key !== 'layout.gap') {
1678
+ throw new SourceError(`${subject}: a bundle's layout: takes only gap:, such as layout: parallel (gap: normal)`, line);
1679
+ }
1680
+ }
1681
+ if (arrangement === 'collapsed' && gap !== undefined) {
1682
+ throw new SourceError(`${subject}: a collapsed bundle runs every line on one path, so it has no gap — leave (gap: ${gap}) off, or say parallel`, line);
1683
+ }
1684
+ if (arrangement === 'collapsed' && order !== undefined) {
1685
+ throw new SourceError(`${subject}: a collapsed bundle runs every line on one path, so it has no order across — leave order: off, or say parallel`, line);
1686
+ }
1687
+ for (const key of ['layout', 'layout.gap'])
1688
+ delete attrs[key];
1689
+ return {
1690
+ kind: 'bundle',
1691
+ name,
1692
+ ...(via !== undefined ? { via } : {}),
1693
+ ...(between !== undefined ? { between } : {}),
1694
+ arrangement,
1695
+ ...(gap !== undefined ? { gap } : {}),
1696
+ ...(order ? { order } : {}),
1697
+ attrs,
1698
+ line,
1699
+ };
1700
+ }
1701
+ /** `point funnel between c2 and api at 2/3` */
1702
+ function parsePoint(head, line) {
1703
+ const name = requireName(head[1], 'point', line);
1704
+ const subject = `point "${name}"`;
1705
+ const marked = readMarkText(head, 'point', subject, line);
1706
+ head = marked.tokens;
1707
+ const start = marked.next;
1708
+ if (!head[start] || isAttrKey(head[start])) {
1709
+ throw new SourceError(`${subject} says where it is, such as \`point ${name} between a and b\``, line);
1710
+ }
1711
+ const read = readOneTarget(head, start, subject, line, true);
1712
+ if (read.listed)
1713
+ throw new SourceError(`${subject} is one point, so it takes no list`, line);
1714
+ refuseSpan(read.target, subject, line);
1715
+ const attrs = attrsOnly(head, read.next, line, subject);
1716
+ refuseKeys(attrs, POINT_KEYS, subject, 'a point', line);
1717
+ return { kind: 'point', name, point: read.target, ...(marked.text ? { text: marked.text } : {}), attrs, line };
1718
+ }
1719
+ /** `region r c bottom-left to d top-right`, or `region r between a and b (span: both)` */
1720
+ function parseRegion(head, line) {
1721
+ const name = requireName(head[1], 'region', line);
1722
+ const subject = `region "${name}"`;
1723
+ const marked = readMarkText(head, 'region', subject, line);
1724
+ head = marked.tokens;
1725
+ const start = marked.next;
1726
+ if (!head[start] || isAttrKey(head[start])) {
1727
+ throw new SourceError(`${subject} says what it covers: two corners, \`region ${name} a top-left to b bottom-right\`, or \`region ${name} between a and b\``, line);
1728
+ }
1729
+ const first = readOneTarget(head, start, subject, line, true);
1730
+ if (first.listed)
1731
+ throw new SourceError(`${subject}: a region runs from one point to another, written with "to", not a list`, line);
1732
+ let corners;
1733
+ let next = first.next;
1734
+ if (follows(head, next, 'to')) {
1735
+ if (!head[next + 1] || isAttrKey(head[next + 1]))
1736
+ throw new SourceError(`${subject}: "to" names no point`, line);
1737
+ const second = readOneTarget(head, next + 1, subject, line, true);
1738
+ for (const corner of [first.target, second.target])
1739
+ refuseSpan(corner, subject, line);
1740
+ corners = [first.target, second.target];
1741
+ next = second.next;
1742
+ }
1743
+ else {
1744
+ if (first.target.point?.kind !== 'between') {
1745
+ throw new SourceError(`${subject}: a region is the rectangle between two points — \`region ${name} ${nameTarget(first.target)} to …\`, ` +
1746
+ `or a region between two things, \`region ${name} between a and b\``, line);
1747
+ }
1748
+ corners = [first.target];
1749
+ }
1750
+ const attrs = attrsOnly(head, next, line, subject);
1751
+ refuseKeys(attrs, REGION_KEYS, subject, 'a region', line);
1752
+ if (attrs['opacity'] !== undefined)
1753
+ checkOpacity(attrs['opacity'], subject, line);
1754
+ return { kind: 'region', name, corners, ...(marked.text ? { text: marked.text } : {}), attrs, line };
1755
+ }
1756
+ /** Refuse what a point or a region has no use for, by name. */
1757
+ function refuseKeys(attrs, keys, subject, what, line) {
1758
+ for (const key of Object.keys(attrs)) {
1759
+ const top = key.split('.')[0];
1760
+ if (keys.includes(top))
1761
+ continue;
1762
+ throw new SourceError(`${subject}: ${what} has no ${top}: — it takes ${keys.map((k) => `${k}:`).join(', ')}`, line);
1763
+ }
1764
+ }
740
1765
  /** `style <name> <attributes>` */
741
1766
  function parseStyle(head, line) {
742
1767
  const name = requireName(head[1], 'style', line);
@@ -790,6 +1815,10 @@ function parseDiagram(head, line) {
790
1815
  }
791
1816
  continue;
792
1817
  }
1818
+ if (key.startsWith('checks.')) {
1819
+ setCheck({ ...CHECK_DEFAULTS }, key.slice('checks.'.length), attrs[key], (message) => new SourceError(message, line));
1820
+ continue;
1821
+ }
793
1822
  if (!DIAGRAM_KEYS.includes(key)) {
794
1823
  throw new SourceError(`diagram has no "${key}" — it takes ${DIAGRAM_KEYS.join(', ')}`, line);
795
1824
  }
@@ -797,6 +1826,13 @@ function parseDiagram(head, line) {
797
1826
  if (attrs['text'] !== undefined) {
798
1827
  throw new SourceError('diagram text: takes a bracket — `text: (color: #e0e0e0)`', line);
799
1828
  }
1829
+ if (attrs['checks'] !== undefined) {
1830
+ throw new SourceError('diagram checks: takes a bracket — `checks: (edge-crossings: on)`', line);
1831
+ }
1832
+ const guides = attrs['guides'];
1833
+ if (guides !== undefined && guides !== 'show' && guides !== 'hide') {
1834
+ throw new SourceError(`diagram guides: is show or hide, not "${guides}"`, line);
1835
+ }
800
1836
  const theme = attrs['theme'];
801
1837
  if (theme !== undefined && THEMES[theme] === undefined) {
802
1838
  throw new SourceError(`there is no theme called "${theme}" — the themes are ${THEME_NAMES.join(', ')}`, line);
@@ -810,19 +1846,31 @@ function parseDiagram(head, line) {
810
1846
  * use for can only be a mistake.
811
1847
  */
812
1848
  function parseDefault(head, line) {
813
- const target = head[1];
1849
+ let target = head[1];
1850
+ // `default guide node`: a shown guide's look, one line per kind.
1851
+ const guide = target !== undefined && !target.quoted && target.text === 'guide';
1852
+ if (guide) {
1853
+ const kind = head[2];
1854
+ const kinds = ['node', 'edge', 'point', 'region'];
1855
+ if (!kind || kind.quoted || !kinds.includes(kind.text)) {
1856
+ throw new SourceError('there is no bare `default guide`, since the kinds share almost no keys — write default guide node, ' +
1857
+ 'default guide edge, default guide point or default guide region', line);
1858
+ }
1859
+ target = { ...kind, text: `guide ${kind.text}` };
1860
+ }
814
1861
  if (!target || target.quoted || !DEFAULT_TARGETS.includes(target.text)) {
815
1862
  throw new SourceError(`default needs the kind it is for — ${DEFAULT_TARGETS.join(', ')}` +
816
1863
  (target && !isAttrKey(target) ? `, not "${target.text}"` : ''), line);
817
1864
  }
818
1865
  const kind = target.text;
819
1866
  const subject = `default ${kind}`;
1867
+ const start = guide ? 3 : 2;
820
1868
  // `default leaf node` reads naturally and says nothing `default leaf` does not.
821
- const extra = head[2];
822
- if (extra && !extra.quoted && !isAttrKey(extra) && extra.text === 'node' && kind !== 'node') {
1869
+ const extra = head[start];
1870
+ if (!guide && extra && !extra.quoted && !isAttrKey(extra) && extra.text === 'node' && kind !== 'node') {
823
1871
  throw new SourceError(`write \`default ${kind}\` — a ${kind} is already a node`, line);
824
1872
  }
825
- const attrs = attrsOnly(head, 2, line, subject);
1873
+ const attrs = attrsOnly(head, start, line, subject);
826
1874
  if (Object.keys(attrs).length === 0) {
827
1875
  throw new SourceError(`${subject} sets nothing`, line);
828
1876
  }
@@ -839,13 +1887,18 @@ function defaultRefusal(kind, key) {
839
1887
  const takes = `it takes ${DEFAULT_KEYS[kind].map((k) => `${k}:`).join(', ')}`;
840
1888
  if (key === 'url')
841
1889
  return `and a destination belongs to one thing rather than to every one of a kind`;
842
- if (key === 'badge') {
843
- return 'and a badge is a child, so every leaf given one would become a container. ' +
844
- 'Put the badge in a style, or write `default container badge:`';
1890
+ if (key === 'heading') {
1891
+ return "and a heading names one node's own child, so it is written on that node";
1892
+ }
1893
+ if (key === 'join-with') {
1894
+ return 'and a join names the one thing a node moves with, so it is written on that node';
845
1895
  }
846
1896
  if (key === 'icon') {
847
1897
  return 'and a picture cannot hold children, so an icon is a leaf\'s word — write `default leaf icon:`';
848
1898
  }
1899
+ if ((kind === 'edge' || kind === 'guide edge') && key === 'corners') {
1900
+ return "which is a node's — a line's corners go in its bracket: line: (corners: sharp)";
1901
+ }
849
1902
  if (kind === 'edge' && (key === 'fill' || key === 'border')) {
850
1903
  return `which an edge does not have — an edge is colored by line: and text: (color: …)`;
851
1904
  }
@@ -890,6 +1943,69 @@ function readPlacement(tokens, at, line, subject) {
890
1943
  if (word.quoted) {
891
1944
  throw new SourceError(`${subject}: unexpected text "${word.text}"`, line);
892
1945
  }
1946
+ // `center 12 right of a center`: a part of this box measured from a part of
1947
+ // another (design.org 5.2). Nothing sensible defaults between centers, so
1948
+ // the distance is required.
1949
+ if (!word.quoted && isPart(word.text) && !follows(tokens, at + 1, 'of') && !follows(tokens, at + 1, 'even')) {
1950
+ if (opensPartPlacement(tokens, at)) {
1951
+ const read = readPlacement(tokens, at + 1, line, subject);
1952
+ if (read.placement.kind !== 'offset')
1953
+ return read;
1954
+ return { placement: { ...read.placement, own: word.text }, next: read.next };
1955
+ }
1956
+ const direction = tokens[at + 1];
1957
+ if (direction && !direction.quoted && isDirection(direction.text)) {
1958
+ const of = /left|right/.test(direction.text) ? ' of' : '';
1959
+ const rest = tokens.slice(at + 2 + (follows(tokens, at + 2, 'of') ? 1 : 0), at + 4 + (follows(tokens, at + 2, 'of') ? 1 : 0));
1960
+ const target = rest.map((token) => token.text).join(' ') || '…';
1961
+ throw new SourceError(`${subject}: "${word.text} ${direction.text}${of} …" names a part, so say how far: ` +
1962
+ `\`${word.text} 12 ${direction.text}${of} ${target}\``, line);
1963
+ }
1964
+ }
1965
+ // `2 right of x` is `right of x (gap: 2)`: a distance written in front.
1966
+ const distance = /^-?\d*\.?\d+(px)?$/.exec(word.text);
1967
+ if (distance && tokens[at + 1] && isDirection(tokens[at + 1].text)) {
1968
+ if (word.text.startsWith('-')) {
1969
+ const flipped = OPPOSITE[tokens[at + 1].text];
1970
+ throw new SourceError(`${subject}: a distance is never negative — write \`${word.text.slice(1)} ${flipped}${/left|right/.test(flipped) ? ' of' : ''} …\`, ` +
1971
+ 'or inside or on for a box meant to overlap', line);
1972
+ }
1973
+ // `3 below and 2 right of a`: a different distance on each axis.
1974
+ const other = tokens[at + 3];
1975
+ if (follows(tokens, at + 2, 'and') && other && !other.quoted && /^\d*\.?\d+(px)?$/.test(other.text) && tokens[at + 4] && isDirection(tokens[at + 4].text)) {
1976
+ const first = tokens[at + 1].text;
1977
+ const second = tokens[at + 4].text;
1978
+ const vertical = (d) => d === 'above' || d === 'below';
1979
+ const horizontal = (d) => d === 'left' || d === 'right';
1980
+ if (!((vertical(first) && horizontal(second)) || (horizontal(first) && vertical(second)))) {
1981
+ throw new SourceError(`${subject}: "${word.text} ${first} and ${other.text} ${second}" gives two distances, one for each axis, ` +
1982
+ 'so one direction is above or below and the other left or right', line);
1983
+ }
1984
+ const read = readPlacement(tokens, at + 3, line, subject);
1985
+ if (read.placement.kind !== 'offset')
1986
+ return read;
1987
+ const y = vertical(first) ? first : second;
1988
+ const x = vertical(first) ? second : first;
1989
+ const gaps = { y: vertical(first) ? word.text : other.text, x: vertical(first) ? other.text : word.text };
1990
+ const { gap: _gap, ...rest } = read.placement;
1991
+ return {
1992
+ placement: { ...rest, direction: `${y}-${x}`, gaps },
1993
+ next: read.next,
1994
+ };
1995
+ }
1996
+ const read = readPlacement(tokens, at + 1, line, subject);
1997
+ if (read.placement.kind !== 'offset')
1998
+ return read;
1999
+ if (read.placement.gap !== undefined) {
2000
+ throw new SourceError(`${subject}: "${word.text} ${describePlacement(read.placement)}" says its distance twice, in front and in the bracket — keep one`, line);
2001
+ }
2002
+ return { placement: { ...read.placement, gap: word.text }, next: read.next };
2003
+ }
2004
+ // `level with` was renamed: in plain English it means "at the same height",
2005
+ // which made `left level with x` read wrong.
2006
+ if (word.text === 'level' || (isSideWord(word.text) && follows(tokens, at + 1, 'level'))) {
2007
+ throw new SourceError(`${subject}: "level with" is now "even with" — write \`${isSideWord(word.text) ? `${word.text} ` : ''}even with …\``, line);
2008
+ }
893
2009
  if (word.text === 'on')
894
2010
  return readOn(tokens, at, line, subject);
895
2011
  if (word.text === 'inside' || word.text === 'outside') {
@@ -898,15 +2014,21 @@ function readPlacement(tokens, at, line, subject) {
898
2014
  // `top level with media` names a side rather than the center line. `left`
899
2015
  // and `right` are sides as well as directions, so it is the word after them
900
2016
  // that says which was meant — "left of drive" against "left level with drive".
901
- const side = isSideWord(word.text) && follows(tokens, at + 1, 'level') ? word.text : undefined;
902
- const head = side ? tokens[at + 1] : word;
903
- if (head.text === 'level' && !head.quoted) {
904
- const from = side ? at + 1 : at;
905
- const written = side ? `${side} level with` : 'level with';
2017
+ const side = isSideWord(word.text) && follows(tokens, at + 1, 'even') ? word.text : undefined;
2018
+ // `x even with a` and `y even with a` name the axis outright, centers shared
2019
+ // on it; `y` says what the bare `even with a` says.
2020
+ const named = isAxisWord(word.text) && follows(tokens, at + 1, 'even') ? word.text : undefined;
2021
+ const front = side ?? named;
2022
+ const head = front ? tokens[at + 1] : word;
2023
+ if (head.text === 'even' && !head.quoted) {
2024
+ const from = front ? at + 1 : at;
2025
+ const written = front ? `${front} even with` : 'even with';
906
2026
  if (!follows(tokens, from + 1, 'with')) {
907
2027
  throw new SourceError(`${subject}: an alignment reads "${written} <node>"`, line);
908
2028
  }
909
2029
  const read = readTargets(tokens, from + 2, subject, written, line);
2030
+ const axis = named ?? SIDE_AXIS[side ?? 'center'];
2031
+ checkEvenTargets(read.targets, axis, written, subject, line);
910
2032
  const modifiers = readModifiers(tokens, read.next, subject, `${written} ${listTargets(read.targets)}`, line);
911
2033
  // An alignment shares a line outright, so there is no distance in it for
912
2034
  // a gap to set. Refusing rather than dropping it, for the reason unknown
@@ -918,7 +2040,7 @@ function readPlacement(tokens, at, line, subject) {
918
2040
  return {
919
2041
  placement: {
920
2042
  kind: 'align',
921
- axis: SIDE_AXIS[side ?? 'center'],
2043
+ axis,
922
2044
  side: side ?? 'center',
923
2045
  targets: read.targets,
924
2046
  line,
@@ -938,6 +2060,9 @@ function readPlacement(tokens, at, line, subject) {
938
2060
  }
939
2061
  // A semicolon is reserved rather than ordinary, and someone writing one
940
2062
  // between clauses wants to know that spaces already do that job.
2063
+ if (AXIS_ADVERBS.has(word.text)) {
2064
+ throw new SourceError(`${subject}: ${axisAdverb(word.text, tokens[at - 1]?.text ?? '…')}`, line);
2065
+ }
941
2066
  if (word.text.startsWith(';')) {
942
2067
  throw new SourceError(`${subject}: ";" separates nothing — clauses are separated by spaces alone`, line);
943
2068
  }
@@ -945,9 +2070,21 @@ function readPlacement(tokens, at, line, subject) {
945
2070
  }
946
2071
  let next = at + 1;
947
2072
  const of = follows(tokens, next, 'of');
2073
+ // Each direction has one spelling, as English says it: `below a`, `left of a`.
2074
+ const takesOf = /left|right/.test(word.text);
2075
+ if (of && !takesOf) {
2076
+ throw new SourceError(`${subject}: "${word.text} of" is written "${word.text}" — \`${word.text} ${tokens[next + 1]?.text ?? '…'}\``, line);
2077
+ }
2078
+ if (!of && takesOf) {
2079
+ throw new SourceError(`${subject}: "${word.text}" before a name is written "${word.text} of" — \`${word.text} of ${tokens[next]?.text ?? '…'}\``, line);
2080
+ }
948
2081
  if (of)
949
2082
  next += 1;
950
2083
  const read = readTargets(tokens, next, subject, word.text, line);
2084
+ for (const target of read.targets) {
2085
+ refuseSpan(target, subject, line);
2086
+ facedSide(target, word.text, subject, line);
2087
+ }
951
2088
  const modifiers = readModifiers(tokens, read.next, subject, `${word.text}${of ? ' of' : ''} ${listTargets(read.targets)}`, line);
952
2089
  return {
953
2090
  placement: {
@@ -960,6 +2097,62 @@ function readPlacement(tokens, at, line, subject) {
960
2097
  next: modifiers.next,
961
2098
  };
962
2099
  }
2100
+ /**
2101
+ * What `even with` may line up with, on one axis: shared by a placement
2102
+ * (`top even with x`) and a line's end (`to: left, even with x`), which name
2103
+ * one height (axis `y`) or one place across (`x`).
2104
+ */
2105
+ function checkEvenTargets(targets, axis, written, subject, line) {
2106
+ // `left even with 3/4 down x`: three quarters down names a height, which
2107
+ // has no place across to meet.
2108
+ for (const target of targets) {
2109
+ if (!target.fraction || fractionAxis(target.fraction) === axis)
2110
+ continue;
2111
+ throw new SourceError(`${subject}: "${nameTarget(target)}" names a ${axis === 'x' ? 'height' : 'place across'}, so "${written}" has nothing to meet there — ` +
2112
+ (axis === 'x' ? `write a fraction across, such as \`${written} 1/3 across ${target.name}\`` : `write a fraction down, such as \`${written} 3/4 down ${target.name}\``), line);
2113
+ }
2114
+ for (const target of targets)
2115
+ refuseSpan(target, subject, line);
2116
+ // `top even with server left`: the left side runs server's whole height, so
2117
+ // it has no one height to meet, and drawing it as `server`'s top would make
2118
+ // a second spelling of `top even with server`.
2119
+ for (const target of targets) {
2120
+ if (!target.part || !isSideWord(target.part) || SIDE_AXIS[target.part] === axis)
2121
+ continue;
2122
+ const [near, far] = axis === 'y' ? ['top', 'bottom'] : ['left', 'right'];
2123
+ throw new SourceError(`${subject}: "${target.name} ${target.part}" is the whole of ${target.name}'s ${target.part} side, ` +
2124
+ `so "${written}" has no one ${axis === 'y' ? 'height' : 'place across'} to meet there — ` +
2125
+ `name ${target.name} ${near}, center or ${far}, or a corner such as ${target.name} ${axis === 'y' ? `${near}-${target.part}` : `${target.part}-${near}`}`, line);
2126
+ }
2127
+ }
2128
+ /** `(span: both)` stretches a region; a point has nothing to stretch. */
2129
+ function refuseSpan(target, subject, line) {
2130
+ if (target.point?.kind !== 'between' || !target.point.span)
2131
+ return;
2132
+ throw new SourceError(`${subject}: "(span: both)" makes a region, and a placement is against a point or a thing — name the region, ` +
2133
+ `\`region r ${describePoint(target.point)}\`, and place against r`, line);
2134
+ }
2135
+ /**
2136
+ * `right of server 3/4 down`: the direction already says which side is meant,
2137
+ * so the fraction needs no side of its own (design.org 5.2).
2138
+ */
2139
+ function facedSide(target, direction, subject, line) {
2140
+ const fraction = target.fraction;
2141
+ if (!fraction || fraction.first || target.part !== undefined)
2142
+ return;
2143
+ const faced = { right: 'right', left: 'left', above: 'top', below: 'bottom' };
2144
+ const side = faced[direction];
2145
+ if (side === undefined) {
2146
+ throw new SourceError(`${subject}: "${direction} of ${nameTarget(target)}" faces a corner of ${target.name}, which has no side to go ` +
2147
+ `${fraction.along} — name the side: \`${direction} of ${target.name} ${fraction.along === 'across' ? 'bottom' : 'right'} ${fraction.written}\``, line);
2148
+ }
2149
+ const words = side === 'left' || side === 'right' ? '3/4 down or 1/4 up' : '1/3 across or 1/3 across from right';
2150
+ if ((fraction.along === 'across') !== (side === 'top' || side === 'bottom')) {
2151
+ throw new SourceError(`${subject}: "${direction}${/left|right/.test(direction) ? ' of' : ''} ${nameTarget(target)}" faces ${target.name}'s ${side} side, ` +
2152
+ `which ${fraction.along} doesn't run along — it takes ${words}`, line);
2153
+ }
2154
+ target.part = side;
2155
+ }
963
2156
  /**
964
2157
  * `on hub top-right` — the node's center at the part's center, straddling it.
965
2158
  *
@@ -972,6 +2165,7 @@ function readPlacement(tokens, at, line, subject) {
972
2165
  function readOn(tokens, at, line, subject) {
973
2166
  const read = readTargets(tokens, at + 1, subject, 'on', line, true);
974
2167
  const target = read.targets[0];
2168
+ refuseSpan(target, subject, line);
975
2169
  if (read.targets.length > 1) {
976
2170
  throw new SourceError(`${subject}: "on ${listTargets(read.targets)}" names ${read.targets.length} nodes, and a ` +
977
2171
  'stamp sits on one box — name the one it is stamped on', line);
@@ -1009,6 +2203,7 @@ function readOn(tokens, at, line, subject) {
1009
2203
  function readTucked(tokens, at, line, subject, written) {
1010
2204
  const read = readTargets(tokens, at + 1, subject, written, line, true);
1011
2205
  const target = read.targets[0];
2206
+ refuseSpan(target, subject, line);
1012
2207
  if (read.targets.length > 1) {
1013
2208
  throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" names ${read.targets.length} nodes, ` +
1014
2209
  `and "${written}" reads its direction off one part of one box`, line);
@@ -1079,7 +2274,7 @@ function readBracket(tokens, start, keys, about) {
1079
2274
  }
1080
2275
  const key = keyToken.text.slice(0, -1);
1081
2276
  if (!keys.includes(key)) {
1082
- throw new SourceError(`${about.kind} has no "${key}" — it takes ${keys.join(', ')}`, about.line);
2277
+ throw new SourceError(`${about.kind} has no "${key}" — it takes ${(about.listed ?? keys).join(', ')}`, about.line);
1083
2278
  }
1084
2279
  const valueToken = tokens[i + 1];
1085
2280
  if (!valueToken || valueToken.quoted || isAttrKey(valueToken) || valueToken.text === ')') {
@@ -1124,15 +2319,29 @@ function follows(tokens, at, word) {
1124
2319
  * `on` opens an overlay. Used only to tell a forgotten pair of quotes after a
1125
2320
  * node's name from a placement, so a word that is nearly one counts.
1126
2321
  */
1127
- function startsPlacement(token) {
2322
+ function startsPlacement(token, next) {
1128
2323
  if (token.quoted)
1129
2324
  return false;
1130
- return (isDirection(token.text) ||
2325
+ return (
2326
+ // `x even with a`: x and y open a placement only before `even`, so a box named x stays a name.
2327
+ (isAxisWord(token.text) && next !== undefined && !next.quoted && next.text === 'even') ||
2328
+ isDirection(token.text) ||
2329
+ /^-?\d*\.?\d+(px)?$/.test(token.text) ||
1131
2330
  token.text === 'level' ||
2331
+ token.text === 'even' ||
1132
2332
  token.text === 'on' ||
1133
2333
  token.text === 'inside' ||
1134
2334
  token.text === 'outside' ||
1135
- SIDES.includes(token.text));
2335
+ SIDES.includes(token.text) ||
2336
+ isPart(token.text));
2337
+ }
2338
+ /** Words someone reaches for to say which way `even with` lines up; the axis goes in front instead. */
2339
+ const AXIS_ADVERBS = new Set(['vertically', 'horizontally', 'across']);
2340
+ function axisAdverb(word, target) {
2341
+ return `"${word}" says nothing here — to line up on one axis, name it in front: \`x even with ${target}\` shares ${target}'s place across, \`y even with ${target}\` its height`;
2342
+ }
2343
+ function isAxisWord(word) {
2344
+ return word === 'x' || word === 'y';
1136
2345
  }
1137
2346
  function isSideWord(word) {
1138
2347
  return word !== 'center' && SIDES.includes(word);
@@ -1167,26 +2376,463 @@ function readTargets(tokens, start, subject, placement, line, partFirst = false)
1167
2376
  if (!token || token.quoted || token.text === '(' || token.text === ')' || (isAttrKey(token) && !token.joined)) {
1168
2377
  throw new SourceError(`${subject}: "${placement}" names no node`, line);
1169
2378
  }
1170
- refuseNameMark(token, `${subject}: "${token.joined ?? token.text}"`, line);
1171
- const listed = token.text.endsWith(',') && token.text.length > 1;
1172
- const name = listed ? token.text.slice(0, -1) : token.text;
1173
- i += 1;
1174
- // A part can only follow a name the author did not already close with a
1175
- // comma — `a, b` is two targets and the comma says so.
1176
- const part = listed ? undefined : partAfter(tokens, i, partFirst);
1177
- if (part)
1178
- i += 1;
1179
- targets.push(part === undefined ? { name } : { name, part });
2379
+ const read = readOneTarget(tokens, i, subject, line, partFirst);
2380
+ targets.push(read.target);
2381
+ i = read.next;
1180
2382
  if (follows(tokens, i, 'and')) {
1181
2383
  i += 1;
1182
2384
  continue;
1183
2385
  }
1184
- comma = listed ? name : undefined;
1185
- if (listed)
2386
+ comma = read.listed ? nameTarget(read.target) : undefined;
2387
+ if (read.listed)
1186
2388
  continue;
1187
2389
  return { targets, next: i };
1188
2390
  }
1189
2391
  }
2392
+ /**
2393
+ * One target: a name with its part and how far along, or a point written out
2394
+ * (design.org 4.2–4.4). `listed` says it ended on a comma, which promises
2395
+ * another.
2396
+ */
2397
+ function readOneTarget(tokens, at, subject, line, partFirst = false) {
2398
+ const token = tokens[at];
2399
+ const word = token.quoted ? '' : token.text;
2400
+ if (word === 'between')
2401
+ return readBetween(tokens, at, subject, line);
2402
+ // `3/4 down x`: a fraction of the way across one box, written first.
2403
+ const first = readFraction(tokens, at);
2404
+ if (first) {
2405
+ const nameToken = tokens[first.next];
2406
+ if (!nameToken || nameToken.quoted || isAttrKey(nameToken) || nameToken.text === '(' || nameToken.text === ')') {
2407
+ throw new SourceError(`${subject}: "${first.fraction.written}" names no box — write \`${first.fraction.written} <name>\``, line);
2408
+ }
2409
+ let listed = nameToken.text.endsWith(',') && nameToken.text.length > 1;
2410
+ const name = listed ? nameToken.text.slice(0, -1) : nameToken.text;
2411
+ let next = first.next + 1;
2412
+ const fraction = { ...first.fraction, first: true };
2413
+ // `1/3 across x from right`: which end it counts from comes after the name.
2414
+ if (!listed && fraction.along === 'across' && follows(tokens, next, 'from')) {
2415
+ const end = tokens[next + 1];
2416
+ const side = end?.text.replace(/,$/, '');
2417
+ if (side !== 'left' && side !== 'right') {
2418
+ throw new SourceError(`${subject}: "${fraction.written} ${name} from" counts from left or from right`, line);
2419
+ }
2420
+ fraction.from = side;
2421
+ fraction.written += ` from ${side}`;
2422
+ listed = end.text.endsWith(',');
2423
+ next += 2;
2424
+ }
2425
+ refuseFractionPart(tokens, next, fraction, name, subject, line);
2426
+ return { target: { name, fraction }, next, listed };
2427
+ }
2428
+ // `down from a and left from b`: where two straight lines meet.
2429
+ if (startsHalf(tokens, at)) {
2430
+ return readCrossing(tokens, at, subject, line);
2431
+ }
2432
+ // `2 below c bottom`: a point moved by a distance.
2433
+ if (/^-?\d*\.?\d+(px)?$/.test(word) && tokens[at + 1] && isDirection(tokens[at + 1].text)) {
2434
+ const direction = tokens[at + 1].text;
2435
+ if (word.startsWith('-')) {
2436
+ const flipped = OPPOSITE[direction];
2437
+ throw new SourceError(`${subject}: a distance is never negative — write \`${word.slice(1)} ${flipped}${/left|right/.test(flipped) ? ' of' : ''} …\``, line);
2438
+ }
2439
+ let next = at + 2;
2440
+ const takesOf = /left|right/.test(direction);
2441
+ const of = follows(tokens, next, 'of');
2442
+ if (of !== takesOf) {
2443
+ throw new SourceError(`${subject}: "${word} ${direction}${of ? ' of' : ''}" is written "${word} ${direction}${takesOf ? ' of' : ''}" — \`${word} ${direction}${takesOf ? ' of' : ''} ${tokens[next + (of ? 1 : 0)]?.text ?? '…'}\``, line);
2444
+ }
2445
+ if (of)
2446
+ next += 1;
2447
+ const inner = readOneTarget(tokens, next, subject, line, true);
2448
+ if (inner.target.point?.kind === 'moved') {
2449
+ throw new SourceError(`${subject}: a point is moved once — add the distances, as in \`${word} ${direction} …\``, line);
2450
+ }
2451
+ const point = { kind: 'moved', distance: word, direction, from: inner.target };
2452
+ return { target: { name: describePoint(point), point }, next: inner.next, listed: inner.listed };
2453
+ }
2454
+ refuseNameMark(token, `${subject}: "${token.joined ?? token.text}"`, line);
2455
+ let listed = token.text.endsWith(',') && token.text.length > 1;
2456
+ const name = listed ? token.text.slice(0, -1) : token.text;
2457
+ let i = at + 1;
2458
+ // A part can only follow a name the author did not already close with a
2459
+ // comma — `a, b` is two targets and the comma says so. The part may carry
2460
+ // the comma itself: `right of a text, b`.
2461
+ let part = listed ? undefined : partAfter(tokens, i, partFirst);
2462
+ if (part) {
2463
+ i += 1;
2464
+ }
2465
+ else if (!listed) {
2466
+ part = partBeforeComma(tokens[i]);
2467
+ if (part) {
2468
+ i += 1;
2469
+ listed = true;
2470
+ }
2471
+ }
2472
+ const target = part === undefined ? { name } : { name, part };
2473
+ if (listed)
2474
+ return { target, next: i, listed };
2475
+ // `server 3/4 down`, `server right 3/4 down`: how far along the side.
2476
+ const after = readFraction(tokens, i);
2477
+ if (after) {
2478
+ const fraction = after.fraction;
2479
+ i = after.next;
2480
+ listed = fraction.written.endsWith(',');
2481
+ if (listed)
2482
+ fraction.written = fraction.written.slice(0, -1);
2483
+ if (part !== undefined)
2484
+ checkFractionOnPart(fraction, name, part, subject, line);
2485
+ return { target: { ...target, fraction }, next: i, listed };
2486
+ }
2487
+ // `c bottom + 2` adds a length to a point, which doesn't say which way.
2488
+ if (follows(tokens, i, '+')) {
2489
+ const amount = tokens[i + 1]?.text ?? '2';
2490
+ throw new SourceError(`${subject}: "+" adds lengths, never points — "${nameTarget(target)} + ${amount}" doesn't say up or down. ` +
2491
+ `Move the point with a direction: \`${amount} below ${nameTarget(target)}\``, line);
2492
+ }
2493
+ return { target, next: i, listed };
2494
+ }
2495
+ /** What a leg's bracket may set: its own shape, in the words of the line's bracket. */
2496
+ const LEG_KEYS = ['path', 'turn', 'curve', 'corners', 'step-out'];
2497
+ /**
2498
+ * `via: b top, between c and d (gap: wide), leg climb, leg over b top` — the
2499
+ * places a line passes, in order, separated by commas; the list ends at the
2500
+ * next key (design.org 6.4).
2501
+ */
2502
+ function readVia(tokens, start, subject, line) {
2503
+ const entries = [];
2504
+ const ends = (token) => !token || isAttrKey(token);
2505
+ let i = start;
2506
+ for (;;) {
2507
+ const token = tokens[i];
2508
+ if (ends(token) || token.quoted) {
2509
+ throw new SourceError(entries.length === 0
2510
+ ? `${subject}: via: lists the places the line passes, such as \`via: b top, between c and d\``
2511
+ : `${subject}: the comma at the end of via: promises another stop — remove it, or name the stop`, line);
2512
+ }
2513
+ const entry = {};
2514
+ let listed = false;
2515
+ if (token.text === 'leg') {
2516
+ const nameToken = tokens[i + 1];
2517
+ if (!nameToken || nameToken.quoted || isAttrKey(nameToken) || nameToken.text === '(') {
2518
+ throw new SourceError(`${subject}: "leg" in via: is followed by the leg's name, such as \`leg climb\``, line);
2519
+ }
2520
+ listed = nameToken.text.endsWith(',');
2521
+ const name = listed ? nameToken.text.slice(0, -1) : nameToken.text;
2522
+ if (name === 'longest') {
2523
+ throw new SourceError(`${subject}: a leg can't be named "longest" — a text's \`on: longest level\` already means the line's longest level piece`, line);
2524
+ }
2525
+ i += 2;
2526
+ let attrs = {};
2527
+ if (!listed && follows(tokens, i, '(')) {
2528
+ const read = readBracket(tokens, i, LEG_KEYS, { subject, what: `leg ${name}`, kind: 'a leg', example: 'path: straight', line });
2529
+ attrs = read.values;
2530
+ i = read.next;
2531
+ }
2532
+ if (!listed && follows(tokens, i, ',')) {
2533
+ listed = true;
2534
+ i += 1;
2535
+ }
2536
+ entry.leg = { name, attrs };
2537
+ if (!listed && !ends(tokens[i])) {
2538
+ const read = readStop(tokens, i, subject, line);
2539
+ entry.stop = read.stop;
2540
+ i = read.next;
2541
+ listed = read.listed;
2542
+ }
2543
+ }
2544
+ else {
2545
+ const read = readStop(tokens, i, subject, line);
2546
+ entry.stop = read.stop;
2547
+ i = read.next;
2548
+ listed = read.listed;
2549
+ }
2550
+ entries.push(entry);
2551
+ if (!listed)
2552
+ break;
2553
+ }
2554
+ const after = tokens[i];
2555
+ if (after && !isAttrKey(after)) {
2556
+ throw new SourceError(`${subject}: the stops in via: are separated by commas — "${after.joined ?? after.text}" follows "${describeEntry(entries[entries.length - 1])}" with none`, line);
2557
+ }
2558
+ return { entries, next: i };
2559
+ }
2560
+ function describeEntry(entry) {
2561
+ return [entry.leg ? `leg ${entry.leg.name}` : '', entry.stop?.written ?? ''].filter((part) => part.length > 0).join(' ');
2562
+ }
2563
+ /** One stop in a `via:` list, and whether a comma after it promises another. */
2564
+ function readStop(tokens, start, subject, line) {
2565
+ const token = tokens[start];
2566
+ const word = token.text.replace(/,$/, '');
2567
+ const written = (from, to) => tokens.slice(from, to).map((t) => t.text).join(' ').replace(/,$/, '').replace(/ \( /g, ' (').replace(/ \)/g, ')');
2568
+ const finish = (stop, next, listed) => {
2569
+ if (!listed && follows(tokens, next, ','))
2570
+ return { stop, next: next + 1, listed: true };
2571
+ return { stop, next, listed };
2572
+ };
2573
+ const gapBracket = (at) => {
2574
+ if (!follows(tokens, at, '('))
2575
+ return { next: at, listed: false };
2576
+ const read = readBracket(tokens, at, ['gap'], { subject, what: 'the stop', kind: 'a stop', example: 'gap: wide', line });
2577
+ return { ...(read.values['gap'] !== undefined ? { gap: read.values['gap'] } : {}), next: read.next, listed: false };
2578
+ };
2579
+ // `through slot horizontal`: from one edge of a region to the other.
2580
+ if (word === 'through' && !token.text.endsWith(',')) {
2581
+ const region = tokens[start + 1];
2582
+ const axisToken = tokens[start + 2];
2583
+ const axis = axisToken?.text.replace(/,$/, '');
2584
+ if (!region || region.quoted || isAttrKey(region)) {
2585
+ throw new SourceError(`${subject}: "through" in via: names a region, such as \`via: through slot horizontal\``, line);
2586
+ }
2587
+ if (axis !== 'horizontal' && axis !== 'vertical') {
2588
+ throw new SourceError(`${subject}: "through ${region.text.replace(/,$/, '')}" says which way across it — \`through ${region.text.replace(/,$/, '')} horizontal\` or \`vertical\`; the axis is always written`, line);
2589
+ }
2590
+ return finish({ kind: 'through', region: region.text, axis, written: written(start, start + 3) }, start + 3, axisToken.text.endsWith(','));
2591
+ }
2592
+ // `above thru (gap: tight)`: the whole length of a named leg, beside it.
2593
+ const two = (word === 'left' || word === 'right') && follows(tokens, start + 1, 'of');
2594
+ if ((word === 'above' || word === 'below' || two) && !token.text.endsWith(',')) {
2595
+ const legToken = tokens[start + (two ? 2 : 1)];
2596
+ if (!legToken || legToken.quoted || isAttrKey(legToken)) {
2597
+ throw new SourceError(`${subject}: "${word}${two ? ' of' : ''}" in via: names a leg to run beside, such as \`via: above thru\``, line);
2598
+ }
2599
+ const listedName = legToken.text.endsWith(',');
2600
+ const leg = listedName ? legToken.text.slice(0, -1) : legToken.text;
2601
+ const next = start + (two ? 3 : 2);
2602
+ const bracket = listedName ? { next, listed: false } : gapBracket(next);
2603
+ return finish({
2604
+ kind: 'alongside',
2605
+ way: (two ? `${word} of` : word),
2606
+ leg,
2607
+ ...('gap' in bracket && bracket.gap !== undefined ? { gap: bracket.gap } : {}),
2608
+ written: written(start, bracket.next),
2609
+ }, bracket.next, listedName);
2610
+ }
2611
+ // `on b top`, `inside p top`: on the side itself, or just inside it.
2612
+ let mode = 'outside';
2613
+ let at = start;
2614
+ if (word === 'outside') {
2615
+ throw new SourceError(`${subject}: "outside" in via: says what via: already means — a part of a box is passed just outside it. Write \`via: ${tokens[start + 1]?.text ?? 'b'} ${tokens[start + 2]?.text ?? 'top'}\``, line);
2616
+ }
2617
+ if ((word === 'on' || word === 'inside') && !token.text.endsWith(',')) {
2618
+ mode = word;
2619
+ at += 1;
2620
+ }
2621
+ const read = readOneTarget(tokens, at, subject, line);
2622
+ let next = read.next;
2623
+ let listed = read.listed;
2624
+ const bare = read.target.part === undefined && read.target.fraction === undefined && read.target.point === undefined;
2625
+ // `req at 1/3`, `req start`, `req end`: a point on a named line.
2626
+ if (bare && !listed && mode === 'outside') {
2627
+ if (follows(tokens, next, 'at')) {
2628
+ const amount = tokens[next + 1];
2629
+ const value = amount ? fractionValue(amount.text.replace(/,$/, '')) : undefined;
2630
+ if (value === undefined || value > 1) {
2631
+ throw new SourceError(`${subject}: "${read.target.name} at" is followed by how far along, such as \`${read.target.name} at 1/3\``, line);
2632
+ }
2633
+ return finish({ kind: 'online', line: read.target.name, at: value, written: written(start, next + 2) }, next + 2, amount.text.endsWith(','));
2634
+ }
2635
+ const end = tokens[next]?.text.replace(/,$/, '');
2636
+ if (end === 'start' || end === 'end') {
2637
+ return finish({ kind: 'online', line: read.target.name, at: end, written: written(start, next + 1) }, next + 1, tokens[next].text.endsWith(','));
2638
+ }
2639
+ // `req b top`: a name, then one of that line's own stops, with no comma
2640
+ // between. Only a line can be followed by a stop, so anything else is the
2641
+ // missing comma `readVia` reports.
2642
+ const after = tokens[next];
2643
+ if (after && !after.quoted && !isAttrKey(after) && after.text !== '(' && after.text !== ',') {
2644
+ const inner = readOneTarget(tokens, next, subject, line);
2645
+ const along = written(next, inner.next);
2646
+ return finish({ kind: 'online', line: read.target.name, at: 1 / 2, along, written: written(start, inner.next) }, inner.next, inner.listed);
2647
+ }
2648
+ }
2649
+ let gap;
2650
+ let order;
2651
+ if (!listed && follows(tokens, next, '(')) {
2652
+ // A stop's bracket holds its gap, or, on a bundle, this line's place in its order.
2653
+ const read = readBracket(tokens, next, ['gap', 'after', 'before'], { subject, what: 'the stop', kind: 'a stop', example: 'gap: wide', line });
2654
+ gap = read.values['gap'];
2655
+ const after = read.values['after'];
2656
+ const before = read.values['before'];
2657
+ if (after !== undefined && before !== undefined) {
2658
+ throw new SourceError(`${subject}: "via: ${written(start, read.next)}" says after: and before:, which is this line's place said twice — keep one`, line);
2659
+ }
2660
+ if (after !== undefined || before !== undefined)
2661
+ order = { way: after !== undefined ? 'after' : 'before', name: (after ?? before) };
2662
+ next = read.next;
2663
+ }
2664
+ if (mode !== 'outside' && read.target.part === undefined) {
2665
+ throw new SourceError(`${subject}: "${mode} ${nameTarget(read.target)}" in via: names a part of a box to be ${mode === 'on' ? 'on' : 'just inside'}, such as \`via: ${mode} ${read.target.name} top\``, line);
2666
+ }
2667
+ return finish({ kind: 'place', target: read.target, mode, ...(gap !== undefined ? { gap } : {}), ...(order ? { order } : {}), written: written(start, next) }, next, listed);
2668
+ }
2669
+ /**
2670
+ * `3/4 down`, `1/4 up`, `1/3 across`, `1/3 across from right`, if one starts
2671
+ * here. `from` after `across` is taken only when the fraction follows a name;
2672
+ * written first, it comes after the name instead.
2673
+ */
2674
+ function readFraction(tokens, at) {
2675
+ const token = tokens[at];
2676
+ const along = tokens[at + 1];
2677
+ if (!token || token.quoted || !along || along.quoted)
2678
+ return undefined;
2679
+ const value = fractionValue(token.text);
2680
+ const word = along.text.replace(/,$/, '');
2681
+ if (value === undefined || (word !== 'down' && word !== 'up' && word !== 'across'))
2682
+ return undefined;
2683
+ if (value < 0 || value > 1)
2684
+ return undefined;
2685
+ const fraction = { value, along: word, written: `${token.text} ${along.text}` };
2686
+ let next = at + 2;
2687
+ if (word === 'across' && !along.text.endsWith(',') && follows(tokens, next, 'from')) {
2688
+ const side = tokens[next + 1]?.text;
2689
+ const clean = side?.replace(/,$/, '');
2690
+ if (clean === 'left' || clean === 'right') {
2691
+ fraction.from = clean;
2692
+ fraction.written = `${token.text} across from ${side}`;
2693
+ next += 2;
2694
+ }
2695
+ }
2696
+ return { fraction, next };
2697
+ }
2698
+ /** `3/4`, `0.75`, `.75`, `1`: a fraction of the way, or undefined. */
2699
+ function fractionValue(written) {
2700
+ const ratio = /^(\d+)\/(\d+)$/.exec(written);
2701
+ if (ratio)
2702
+ return Number(ratio[2]) === 0 ? undefined : Number(ratio[1]) / Number(ratio[2]);
2703
+ if (/^\d*\.?\d+$/.test(written))
2704
+ return Number(written);
2705
+ return undefined;
2706
+ }
2707
+ /** A fraction runs along one side: `down` and `up` a left or right one, `across` a top or bottom one. */
2708
+ function checkFractionOnPart(fraction, name, part, subject, line) {
2709
+ const along = fraction.along === 'across' ? ['top', 'bottom'] : ['left', 'right'];
2710
+ if (along.includes(part))
2711
+ return;
2712
+ const words = part === 'left' || part === 'right' ? 'down or up' : part === 'top' || part === 'bottom' ? 'across' : undefined;
2713
+ throw new SourceError(words
2714
+ ? `${subject}: "${name} ${part} ${fraction.written}" names nothing — ${fraction.along} doesn't run along a ${part} side; it takes ${words}`
2715
+ : `${subject}: "${name} ${part} ${fraction.written}" names nothing — ${part} is a point, not a side to go along. Name a side: ${name} left, right, top or bottom`, line);
2716
+ }
2717
+ /** `3/4 down x top`: a fraction written first names a line through the whole box, so a part after it says nothing. */
2718
+ function refuseFractionPart(tokens, at, fraction, name, subject, line) {
2719
+ const token = tokens[at];
2720
+ if (!token || token.quoted || !isPart(token.text.replace(/,$/, '')))
2721
+ return;
2722
+ if (follows(tokens, at + 1, 'of') || follows(tokens, at + 1, 'even') || opensPartPlacement(tokens, at))
2723
+ return;
2724
+ const part = token.text.replace(/,$/, '');
2725
+ throw new SourceError(`${subject}: "${fraction.written} ${name} ${part}" — a fraction written first is a line across the whole of ${name}. ` +
2726
+ `For a spot on one side, write the side first: \`${name} ${isSideWord(part) ? part : 'right'} ${fraction.written}\``, line);
2727
+ }
2728
+ /**
2729
+ * `between a and b`, `between a and b at 1/3`, and as a region
2730
+ * `between a and b (span: both)`. Always two things, joined by `and`.
2731
+ */
2732
+ function readBetween(tokens, at, subject, line) {
2733
+ const first = tokens[at + 1];
2734
+ if (!first || first.quoted || isAttrKey(first) || first.text === '(') {
2735
+ throw new SourceError(`${subject}: "between" names two things — \`between a and b\``, line);
2736
+ }
2737
+ if (first.text.endsWith(',')) {
2738
+ const second = tokens[at + 2]?.text ?? 'b';
2739
+ throw new SourceError(`${subject}: "between" joins its two things with "and" — write \`between ${first.text.slice(0, -1)} and ${second.replace(/,$/, '')}\``, line);
2740
+ }
2741
+ const a = readOneTarget(tokens, at + 1, subject, line, true);
2742
+ if (a.listed) {
2743
+ throw new SourceError(`${subject}: "between" joins its two things with "and" — write \`between ${nameTarget(a.target)} and …\``, line);
2744
+ }
2745
+ if (a.target.point)
2746
+ throw new SourceError(`${subject}: "between" joins two named things, not "${nameTarget(a.target)}"`, line);
2747
+ if (!follows(tokens, a.next, 'and') || !tokens[a.next + 1]) {
2748
+ throw new SourceError(`${subject}: "between ${nameTarget(a.target)}" names one thing — \`between ${nameTarget(a.target)} and …\``, line);
2749
+ }
2750
+ const b = readOneTarget(tokens, a.next + 1, subject, line, true);
2751
+ if (b.target.point)
2752
+ throw new SourceError(`${subject}: "between" joins two named things, not "${nameTarget(b.target)}"`, line);
2753
+ // An `and` after the pair starts the next target of the placement, as after any other.
2754
+ let next = b.next;
2755
+ let listed = b.listed;
2756
+ const point = { kind: 'between', a: a.target, b: b.target };
2757
+ if (!listed && follows(tokens, next, 'at')) {
2758
+ const written = tokens[next + 1]?.text.replace(/,$/, '') ?? '';
2759
+ const value = fractionValue(written);
2760
+ if (value === undefined || value < 0 || value > 1) {
2761
+ throw new SourceError(`${subject}: "between … at" takes a fraction of the way from the first, such as at 1/3 — not "${written}"`, line);
2762
+ }
2763
+ point.at = value;
2764
+ point.atWritten = written;
2765
+ listed = tokens[next + 1].text.endsWith(',');
2766
+ next += 2;
2767
+ }
2768
+ if (!listed && follows(tokens, next, '(') && tokens[next + 1]?.text === 'span:') {
2769
+ const value = tokens[next + 2]?.text;
2770
+ if (value !== 'both' || !follows(tokens, next + 3, ')')) {
2771
+ throw new SourceError(`${subject}: a region between two things takes (span: both) — not (span: ${value ?? ''})`, line);
2772
+ }
2773
+ point.span = 'both';
2774
+ next += 4;
2775
+ }
2776
+ return { target: { name: describePoint(point), point }, next, listed };
2777
+ }
2778
+ /** Whether a half of a point from two things starts here: `down from a`, or `even with a`. */
2779
+ function startsHalf(tokens, at) {
2780
+ const word = tokens[at];
2781
+ if (!word || word.quoted)
2782
+ return false;
2783
+ if (WAYS.includes(word.text))
2784
+ return follows(tokens, at + 1, 'from');
2785
+ return word.text === 'even' && follows(tokens, at + 1, 'with');
2786
+ }
2787
+ /** One half of a point from two things, as written. */
2788
+ function writtenHalf(half) {
2789
+ return `${half.way === 'even' ? 'even with' : `${half.way} from`} ${nameTarget(half.target)}`;
2790
+ }
2791
+ /**
2792
+ * `down from a and left from b`, each line from a thing, one of its parts or
2793
+ * a point; `even with a` is the level line through a, in either half.
2794
+ */
2795
+ function readCrossing(tokens, at, subject, line) {
2796
+ const first = readWay(tokens, at, subject, line);
2797
+ if (first.listed || !follows(tokens, first.next, 'and') || !startsHalf(tokens, first.next + 1)) {
2798
+ const other = first.way === 'up' || first.way === 'down' ? 'left from …' : 'down from …';
2799
+ throw new SourceError(`${subject}: "${writtenHalf(first)}" is one line, and a point is where two meet — ` +
2800
+ `add the other, as in \`${writtenHalf(first)} and ${other}\``, line);
2801
+ }
2802
+ const second = readWay(tokens, first.next + 1, subject, line);
2803
+ const vertical = (way) => way === 'up' || way === 'down';
2804
+ if (vertical(first.way) === vertical(second.way)) {
2805
+ throw new SourceError(`${subject}: "${writtenHalf(first)} and ${writtenHalf(second)}" are two ` +
2806
+ `${vertical(first.way) ? 'up-and-down' : 'level'} lines, which never meet at one point — make one of them ` +
2807
+ `${vertical(first.way) ? 'left or right, or even with something' : 'up or down'}`, line);
2808
+ }
2809
+ const point = {
2810
+ kind: 'from',
2811
+ lines: [
2812
+ { way: first.way, target: first.target },
2813
+ { way: second.way, target: second.target },
2814
+ ],
2815
+ };
2816
+ return { target: { name: describePoint(point), point }, next: second.next, listed: second.listed };
2817
+ }
2818
+ function readWay(tokens, at, subject, line) {
2819
+ if (!startsHalf(tokens, at)) {
2820
+ throw new SourceError(`${subject}: a point from two things reads \`down from a and left from b\``, line);
2821
+ }
2822
+ const way = tokens[at].text === 'even' ? 'even' : tokens[at].text;
2823
+ const words = way === 'even' ? 'even with' : `${way} from`;
2824
+ const target = tokens[at + 2];
2825
+ if (!target || target.quoted || isAttrKey(target)) {
2826
+ throw new SourceError(`${subject}: "${words}" names no thing`, line);
2827
+ }
2828
+ const read = readOneTarget(tokens, at + 2, subject, line, true);
2829
+ // Anything that names a place will do, but not another point from two
2830
+ // things, whose own "and" would make the pair unreadable.
2831
+ if (read.target.point?.kind === 'from') {
2832
+ throw new SourceError(`${subject}: "${words}" starts from a point from two things — name that point first (\`point p ${nameTarget(read.target)}\`) and write \`${words} p\``, line);
2833
+ }
2834
+ return { way, target: read.target, next: read.next, listed: read.listed };
2835
+ }
1190
2836
  /**
1191
2837
  * The part word after a target's name, if there is one.
1192
2838
  *
@@ -1198,7 +2844,7 @@ function readTargets(tokens, start, subject, placement, line, partFirst = false)
1198
2844
  * been told apart.
1199
2845
  * - `right of hub top level with mirror` — a side followed by `level` is the
1200
2846
  * alignment opening the next placement, the same lookahead `readPlacement`
1201
- * makes for `top level with`.
2847
+ * makes for `top even with`.
1202
2848
  *
1203
2849
  * The second does not hold after `inside`, `outside` or `on`, so `partFirst`
1204
2850
  * lifts it there: `inside server right level with server.db` is the right side
@@ -1206,13 +2852,37 @@ function readTargets(tokens, start, subject, placement, line, partFirst = false)
1206
2852
  * reading is an error anyway; and `on` already fixes both axes, so an edge
1207
2853
  * alignment after a partless `on hub` would contradict it.
1208
2854
  */
2855
+ /** A part word closed with a comma, `text,`, which ends this target and promises another. */
2856
+ function partBeforeComma(token) {
2857
+ if (!token || token.quoted || !token.text.endsWith(','))
2858
+ return undefined;
2859
+ const word = token.text.slice(0, -1);
2860
+ return isPart(word) ? word : undefined;
2861
+ }
1209
2862
  function partAfter(tokens, at, partFirst = false) {
1210
2863
  const token = tokens[at];
1211
2864
  if (!token || token.quoted || !isPart(token.text))
1212
2865
  return undefined;
1213
2866
  if (follows(tokens, at + 1, 'of'))
1214
2867
  return undefined;
1215
- if (!partFirst && follows(tokens, at + 1, 'level'))
2868
+ // `right of a top even with b` is this box's top lined up; only a side word
2869
+ // can open that, so `right of a text even with b` keeps `text` as a's.
2870
+ if (!partFirst && isSideWord(token.text) && follows(tokens, at + 1, 'even'))
2871
+ return undefined;
2872
+ // `right of a center 12 right of b center`: a part followed by a distance
2873
+ // and a direction opens the next placement, one measured from this box's part.
2874
+ if (opensPartPlacement(tokens, at))
1216
2875
  return undefined;
1217
2876
  return token.text;
1218
2877
  }
2878
+ /** Whether a part word here opens `center 12 right of …` rather than naming the part of a target before it. */
2879
+ function opensPartPlacement(tokens, at) {
2880
+ const distance = tokens[at + 1];
2881
+ const direction = tokens[at + 2];
2882
+ return (distance !== undefined &&
2883
+ !distance.quoted &&
2884
+ /^-?\d*\.?\d+(px)?$/.test(distance.text) &&
2885
+ direction !== undefined &&
2886
+ !direction.quoted &&
2887
+ isDirection(direction.text));
2888
+ }