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/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 labelled
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: appearanceOf(stmt.attrs, styles, stmt.line),
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. A leaf has
225
- // none, so its label is centred in the box and there is nothing for `at` or
226
- // `align` to move it relative to. Refused rather than silently dropped.
227
- const stated = Object.keys(node.label);
228
- if (stated.length > 0) {
229
- throw new SourceError(`"${node.name}" holds nothing and its label carries ${stated.join(' and ')}. ` +
230
- `A label sits at one end of a box so its contents can have the other; with no contents it is centred, and there is nothing to say`, node.line);
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 labelled links whose two ends are different members of this group. */
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 unlabelled link asks for nothing: every gap holds an arrowhead. And a
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 centre line of whatever 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 centring on it is unambiguous.
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, 'centre', first.targets, first.placement);
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 === 'centre')
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['width'];
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(`width must be a whole number of characters, not "${stated}"`, line);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A diagram language where placement is stated, not computed.",
5
5
  "type": "module",
6
6
  "bin": {