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/README.md +11 -7
- package/SYNTAX.md +810 -680
- package/dist/ast.d.ts +342 -18
- package/dist/ast.js +138 -27
- package/dist/checks.d.ts +58 -0
- package/dist/checks.js +891 -0
- package/dist/cli.js +88 -12
- package/dist/constants.d.ts +15 -55
- package/dist/constants.js +30 -78
- package/dist/grammar.d.ts +2 -2
- package/dist/grammar.js +70 -10
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -1
- package/dist/lines.d.ts +96 -0
- package/dist/lines.js +1968 -0
- package/dist/linetexts.d.ts +44 -0
- package/dist/linetexts.js +412 -0
- package/dist/model.d.ts +187 -36
- package/dist/model.js +23 -1
- package/dist/parser.js +1777 -107
- package/dist/place.d.ts +501 -0
- package/dist/place.js +2226 -0
- package/dist/render.d.ts +3 -1
- package/dist/render.js +691 -2493
- package/dist/resolve.d.ts +1 -1
- package/dist/resolve.js +1195 -1789
- package/dist/size.d.ts +64 -0
- package/dist/size.js +222 -0
- package/dist/themes.d.ts +17 -0
- package/dist/themes.js +28 -3
- package/package.json +2 -2
- package/dist/constrain.d.ts +0 -56
- package/dist/constrain.js +0 -95
package/dist/parser.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES,
|
|
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
|
|
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
|
|
205
|
-
`\`deck: ${texts.length > 0 ? texts.join(' ') : '"…"'}
|
|
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 === '
|
|
383
|
-
// One quoted text per
|
|
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}: \`
|
|
392
|
-
(given && !isAttrKey(given) ? `, not \`
|
|
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 `
|
|
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 `
|
|
456
|
-
'
|
|
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
|
-
/**
|
|
493
|
-
|
|
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
|
|
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: '
|
|
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
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
// is
|
|
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
|
-
|
|
692
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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.
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
1557
|
+
texts,
|
|
729
1558
|
from: left,
|
|
730
1559
|
to: rightToken.text,
|
|
731
1560
|
arrow: arrow.text,
|
|
732
1561
|
marks: arrow.marks,
|
|
733
|
-
...(
|
|
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
|
-
|
|
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[
|
|
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,
|
|
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 === '
|
|
843
|
-
return
|
|
844
|
-
|
|
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, '
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|