reladraw 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -23
- package/SYNTAX.md +137 -54
- package/dist/ast.d.ts +73 -5
- package/dist/ast.js +99 -6
- package/dist/constants.d.ts +8 -5
- package/dist/constants.js +14 -12
- package/dist/grammar.d.ts +7 -7
- package/dist/grammar.js +9 -9
- package/dist/icons.d.ts +3 -3
- package/dist/icons.js +2 -2
- package/dist/lexer.d.ts +1 -1
- package/dist/lexer.js +1 -1
- package/dist/parser.js +47 -6
- package/dist/render.js +97 -81
- package/dist/resolve.js +144 -26
- package/package.json +1 -1
package/dist/resolve.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describePlacement } from './ast.js';
|
|
1
|
+
import { ALL_ATTR_KEYS, ATTR_KEYS, COLOR_KEYS, COLOR_PARTS, describePlacement, } from './ast.js';
|
|
2
2
|
import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, LABEL_CLEARANCE, PAD, SEPARATION_GAP, fontSizeFor, labelExtent, labelStyleFor, } from './constants.js';
|
|
3
3
|
import { fix, reachability, tightest } from './constrain.js';
|
|
4
4
|
import { SourceError } from './errors.js';
|
|
@@ -21,9 +21,10 @@ export function resolve(doc, options = {}) {
|
|
|
21
21
|
const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
|
|
22
22
|
const margin = options.margin ?? DEFAULT_MARGIN;
|
|
23
23
|
const styles = collectStyles(doc.statements);
|
|
24
|
+
checkStyleKeys(doc.statements);
|
|
24
25
|
const { nodes, byName, roots } = buildTree(doc.statements, styles);
|
|
25
26
|
applyDecks(doc.statements, byName);
|
|
26
|
-
// Links are resolved to nodes before anything is sized, because a
|
|
27
|
+
// Links are resolved to nodes before anything is sized, because a labeled
|
|
27
28
|
// link claims room in the gap it crosses and so has to be in hand while the
|
|
28
29
|
// gaps are being worked out. Nothing here reads geometry.
|
|
29
30
|
const links = buildLinks(doc.statements, byName, styles);
|
|
@@ -97,6 +98,9 @@ function buildTree(statements, styles) {
|
|
|
97
98
|
placements: stmt.placements,
|
|
98
99
|
line: stmt.line,
|
|
99
100
|
};
|
|
101
|
+
const kind = kindOf(node);
|
|
102
|
+
checkAttrs(kind, node.name, stmt.attrs, stmt.line);
|
|
103
|
+
checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
|
|
100
104
|
const cut = stmt.name.lastIndexOf('.');
|
|
101
105
|
if (cut === -1) {
|
|
102
106
|
roots.push(node);
|
|
@@ -115,6 +119,122 @@ function buildTree(statements, styles) {
|
|
|
115
119
|
}
|
|
116
120
|
return { nodes, byName, roots };
|
|
117
121
|
}
|
|
122
|
+
/** How each kind reads in an error, and what it is actually made of. */
|
|
123
|
+
const KIND_WORD = { box: 'box', note: 'note', glyph: 'glyph body', link: 'link' };
|
|
124
|
+
const KIND_PARTS = {
|
|
125
|
+
box: 'a fill, a border and text',
|
|
126
|
+
note: 'bare text and nothing else',
|
|
127
|
+
glyph: 'a picture and the text under it',
|
|
128
|
+
link: 'a line and its label',
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* An attribute is refused on a kind that has no use for it — the same rule as
|
|
132
|
+
* an unknown `diagram` key, and for the same reason: a key that silently does
|
|
133
|
+
* nothing looks like the tool being broken. A color names a part and so is a
|
|
134
|
+
* special case of this, which is why the two checks are one.
|
|
135
|
+
*
|
|
136
|
+
* What is checked is what the author wrote *on this statement*, not what a
|
|
137
|
+
* style contributed. A style is a bundle meant to be shared across kinds — the
|
|
138
|
+
* benchmark's `synced` carries a fill and a border for the green boxes and a
|
|
139
|
+
* line for the four links joining them — so a key it carries that this kind has
|
|
140
|
+
* no part for is simply unused, and is not a mistake anybody made here. That is
|
|
141
|
+
* forced rather than chosen: checking the merged appearance would refuse the
|
|
142
|
+
* benchmark's own central idiom four times over. `checkStyleUse` is what keeps
|
|
143
|
+
* the permissiveness honest.
|
|
144
|
+
*/
|
|
145
|
+
function checkAttrs(kind, name, attrs, line) {
|
|
146
|
+
const allowed = ATTR_KEYS[kind];
|
|
147
|
+
// In the author's own order, so the error names the first offending word as
|
|
148
|
+
// it is read rather than the first in some list of ours.
|
|
149
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
150
|
+
if (allowed.includes(key))
|
|
151
|
+
continue;
|
|
152
|
+
if (!ALL_ATTR_KEYS.includes(key)) {
|
|
153
|
+
// Nothing anywhere in the language answers to this word, so the only
|
|
154
|
+
// remedy is the vocabulary itself.
|
|
155
|
+
throw new SourceError(`"${name}" has ${key}: ${value}, which is not an attribute. A ${KIND_WORD[kind]} takes ${allowed.join(', ')}`, line);
|
|
156
|
+
}
|
|
157
|
+
// A real word in the wrong place, and the two sorts of word want different
|
|
158
|
+
// explanations. A color names a *part*, so saying what the kind is made of
|
|
159
|
+
// is the whole reason it has no such color, and the remedy is the narrower
|
|
160
|
+
// list of parts it does have: `line:` on a box is a different mistake from
|
|
161
|
+
// `border:` on a note, and one hint cannot serve both.
|
|
162
|
+
if (COLOR_KEYS.includes(key)) {
|
|
163
|
+
const parts = COLOR_PARTS[kind].map((part) => `\`${part}:\``).join(', ');
|
|
164
|
+
throw new SourceError(`"${name}" is a ${KIND_WORD[kind]} and has ${key}: ${value}. A ${KIND_WORD[kind]} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
|
|
165
|
+
}
|
|
166
|
+
// Anything else names no part, so what the kind is made of explains
|
|
167
|
+
// nothing. What does explain it is where the word *does* belong, which is
|
|
168
|
+
// also the more useful thing to be told: the author has usually written a
|
|
169
|
+
// real statement about the wrong half of the diagram.
|
|
170
|
+
throw new SourceError(`"${name}" is a ${KIND_WORD[kind]} and has ${key}: ${value}. \`${key}:\` belongs to ` +
|
|
171
|
+
`${listKinds(belongTo(key))} — a ${KIND_WORD[kind]} takes ${allowed.join(', ')}`, line);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A style may carry keys this kind has no use for, but it may not carry *only*
|
|
176
|
+
* those. Partial overlap is the normal case and the reason styles exist; zero
|
|
177
|
+
* overlap is a style name written on the wrong sort of thing, and nothing else.
|
|
178
|
+
*
|
|
179
|
+
* This is the check that lets `checkAttrs` ignore style-contributed keys
|
|
180
|
+
* without the silence coming back. It cannot catch a style that names every key
|
|
181
|
+
* in the language, since such a style contributes to everything by
|
|
182
|
+
* construction — that hole is left open, because nobody writes one by accident.
|
|
183
|
+
*/
|
|
184
|
+
function checkStyleUse(kind, name, attrs, styles, line) {
|
|
185
|
+
const named = attrs['style'];
|
|
186
|
+
if (named === undefined)
|
|
187
|
+
return;
|
|
188
|
+
const base = styles.get(named);
|
|
189
|
+
if (base === undefined)
|
|
190
|
+
return; // `appearanceOf` reports the missing style.
|
|
191
|
+
// A style naming another style is the one way to carry nothing at all: the
|
|
192
|
+
// parser already refuses one with no attributes, and `appearanceOf` does not
|
|
193
|
+
// recurse, so the name would sit there doing nothing.
|
|
194
|
+
const carried = Object.keys(base).filter((key) => key !== 'style');
|
|
195
|
+
if (carried.length === 0) {
|
|
196
|
+
throw new SourceError(`style "${named}" carries nothing but a style name`, line);
|
|
197
|
+
}
|
|
198
|
+
if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
|
|
199
|
+
return;
|
|
200
|
+
throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
|
|
201
|
+
`a ${KIND_WORD[kind]} is ${KIND_PARTS[kind]}`, line);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A style is a bundle spanning kinds, so its keys cannot be checked against any
|
|
205
|
+
* one of them — but a word that is an attribute of *nothing* is a misspelling
|
|
206
|
+
* wherever it sits, and a style was the last place in the language where one
|
|
207
|
+
* could hide.
|
|
208
|
+
*/
|
|
209
|
+
function checkStyleKeys(statements) {
|
|
210
|
+
for (const stmt of statements) {
|
|
211
|
+
if (stmt.kind !== 'style')
|
|
212
|
+
continue;
|
|
213
|
+
for (const [key, value] of Object.entries(stmt.attrs)) {
|
|
214
|
+
if (ALL_ATTR_KEYS.includes(key))
|
|
215
|
+
continue;
|
|
216
|
+
throw new SourceError(`style "${stmt.name}" has ${key}: ${value}, which is not an attribute. ` +
|
|
217
|
+
`The attributes are ${ALL_ATTR_KEYS.join(', ')}`, stmt.line);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Which kinds understand an attribute, in the order the table declares them. */
|
|
222
|
+
function belongTo(key) {
|
|
223
|
+
return Object.keys(ATTR_KEYS).filter((kind) => ATTR_KEYS[kind].includes(key));
|
|
224
|
+
}
|
|
225
|
+
/** "a link", "a box or a glyph body", "a box, a note or a glyph body". */
|
|
226
|
+
function listKinds(kinds) {
|
|
227
|
+
const words = kinds.map((kind) => `a ${KIND_WORD[kind]}`);
|
|
228
|
+
if (words.length <= 1)
|
|
229
|
+
return words[0] ?? 'nothing';
|
|
230
|
+
return `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`;
|
|
231
|
+
}
|
|
232
|
+
/** Which of the four kinds a node is, which its `shape:` may decide. */
|
|
233
|
+
function kindOf(node) {
|
|
234
|
+
if (node.kind === 'note')
|
|
235
|
+
return 'note';
|
|
236
|
+
return shapeFor(node.appearance, node.line).body !== undefined ? 'glyph' : 'box';
|
|
237
|
+
}
|
|
118
238
|
function appearanceOf(attrs, styles, line) {
|
|
119
239
|
const named = attrs['style'];
|
|
120
240
|
if (named === undefined)
|
|
@@ -155,6 +275,10 @@ function buildLinks(statements, byName, styles) {
|
|
|
155
275
|
}),
|
|
156
276
|
...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
|
|
157
277
|
};
|
|
278
|
+
const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
|
|
279
|
+
const what = `${stmt.from} -> ${stmt.to}`;
|
|
280
|
+
checkAttrs('link', what, stmt.attrs, stmt.line);
|
|
281
|
+
checkStyleUse('link', what, stmt.attrs, styles, stmt.line);
|
|
158
282
|
links.push({
|
|
159
283
|
from,
|
|
160
284
|
to,
|
|
@@ -162,7 +286,7 @@ function buildLinks(statements, byName, styles) {
|
|
|
162
286
|
...(stmt.label !== undefined ? { label: stmt.label } : {}),
|
|
163
287
|
...(between ? { between } : {}),
|
|
164
288
|
attrs: stmt.attrs,
|
|
165
|
-
appearance
|
|
289
|
+
appearance,
|
|
166
290
|
line: stmt.line,
|
|
167
291
|
});
|
|
168
292
|
}
|
|
@@ -188,14 +312,6 @@ function sizeNode(node, links, measurer, fontSize, local) {
|
|
|
188
312
|
const labelHeight = hasLabel ? node.lines.length * lineHeight : 0;
|
|
189
313
|
if (node.kind === 'note') {
|
|
190
314
|
// A note is bare text, so it gets no padding and takes no children.
|
|
191
|
-
// Both are refused rather than ignored, for the reason an unknown diagram
|
|
192
|
-
// key is: a note is bare text with no box to decorate or replace, so either
|
|
193
|
-
// word would silently do nothing and look like the tool being broken.
|
|
194
|
-
for (const key of ['icon', 'shape']) {
|
|
195
|
-
if (node.appearance[key] !== undefined) {
|
|
196
|
-
throw new SourceError(`"${node.name}" is a note and has ${key}: ${node.appearance[key]}. A note is bare text, with no box to ${key === 'icon' ? 'decorate' : 'replace'}`, node.line);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
315
|
node.width = labelWidth;
|
|
200
316
|
node.height = labelHeight;
|
|
201
317
|
return;
|
|
@@ -221,13 +337,15 @@ function sizeNode(node, links, measurer, fontSize, local) {
|
|
|
221
337
|
const iconSide = icon === undefined ? 0 : glyphSide;
|
|
222
338
|
const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
|
|
223
339
|
if (node.children.length === 0) {
|
|
224
|
-
// A band only exists because contents have to sit clear of it
|
|
225
|
-
// none, so
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
340
|
+
// A band only exists because contents have to sit clear of it, and a leaf
|
|
341
|
+
// has none, so `at` has no end to name. `align` is a different question and
|
|
342
|
+
// is allowed: a label of several lines has lines of unequal length in any
|
|
343
|
+
// box, and ranging them left rather than centering them is a real thing to
|
|
344
|
+
// want. It was refused here too until 2026-09-09, purely because the two
|
|
345
|
+
// words arrive in the same brackets.
|
|
346
|
+
if (node.label['at'] !== undefined) {
|
|
347
|
+
throw new SourceError(`"${node.name}" holds nothing and its label carries at: ${node.label['at']}. ` +
|
|
348
|
+
`A label sits at one end of a box so its contents can have the other; with no contents there is no band for it to sit at either end of`, node.line);
|
|
231
349
|
}
|
|
232
350
|
node.width = labelWidth + iconRoom + PAD * 2;
|
|
233
351
|
node.height = Math.max(labelHeight, iconSide) + PAD * 2;
|
|
@@ -419,11 +537,11 @@ function liftTo(node, indexOf, local) {
|
|
|
419
537
|
height: node.height,
|
|
420
538
|
};
|
|
421
539
|
}
|
|
422
|
-
/** The
|
|
540
|
+
/** The labeled links whose two ends are different members of this group. */
|
|
423
541
|
function corridorsIn(links, locate, measurer, fontSize) {
|
|
424
542
|
const corridors = [];
|
|
425
543
|
for (const link of links) {
|
|
426
|
-
// An
|
|
544
|
+
// An unlabeled link asks for nothing: every gap holds an arrowhead. And a
|
|
427
545
|
// link told to pass between two named things carries its label in *that*
|
|
428
546
|
// corridor rather than in the gap between its own ends, so widening this one
|
|
429
547
|
// would make room where the label never goes.
|
|
@@ -543,11 +661,11 @@ function positionGroup(members, locate, extra, corridors = []) {
|
|
|
543
661
|
}
|
|
544
662
|
}
|
|
545
663
|
}
|
|
546
|
-
// An axis nobody spoke to falls back to the
|
|
664
|
+
// An axis nobody spoke to falls back to the center line of whatever the
|
|
547
665
|
// node was placed against, which is why "right of docker" alone is a whole
|
|
548
666
|
// position. Two different targets would decide which row the node shares,
|
|
549
667
|
// so that is refused rather than guessed — but two targets named by one
|
|
550
|
-
// placement are a single region, and
|
|
668
|
+
// placement are a single region, and centering on it is unambiguous.
|
|
551
669
|
for (const axis of AXES) {
|
|
552
670
|
if (spokenFor[axis])
|
|
553
671
|
continue;
|
|
@@ -563,7 +681,7 @@ function positionGroup(members, locate, extra, corridors = []) {
|
|
|
563
681
|
`"${describePlacement(first.placement)}" and "${describePlacement(other.placement)}" ` +
|
|
564
682
|
`would put it in different places`, other.placement.line);
|
|
565
683
|
}
|
|
566
|
-
alignOn(axis, '
|
|
684
|
+
alignOn(axis, 'center', first.targets, first.placement);
|
|
567
685
|
}
|
|
568
686
|
}
|
|
569
687
|
const solved = { x: [], y: [] };
|
|
@@ -604,7 +722,7 @@ function spanOf(targets, axis, base) {
|
|
|
604
722
|
}
|
|
605
723
|
/** Where a node of this size sits so that the named edge of it meets the span's. */
|
|
606
724
|
function alignedAt(edge, span, own) {
|
|
607
|
-
if (edge === '
|
|
725
|
+
if (edge === 'center')
|
|
608
726
|
return span.start + (span.size - own) / 2;
|
|
609
727
|
if (edge === 'top' || edge === 'left')
|
|
610
728
|
return span.start;
|
|
@@ -958,12 +1076,12 @@ function widestLine(lines, measurer, fontSize) {
|
|
|
958
1076
|
* and never becomes a coordinate in disguise.
|
|
959
1077
|
*/
|
|
960
1078
|
function linesFor(text, attrs, line) {
|
|
961
|
-
const stated = attrs['
|
|
1079
|
+
const stated = attrs['wrap'];
|
|
962
1080
|
if (stated === undefined)
|
|
963
1081
|
return splitLines(text);
|
|
964
1082
|
const columns = Number(stated);
|
|
965
1083
|
if (!Number.isInteger(columns) || columns < 1) {
|
|
966
|
-
throw new SourceError(`
|
|
1084
|
+
throw new SourceError(`wrap must be a whole number of characters, not "${stated}"`, line);
|
|
967
1085
|
}
|
|
968
1086
|
return splitLines(text).flatMap((part) => wrap(part, columns));
|
|
969
1087
|
}
|