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/ast.js CHANGED
@@ -16,13 +16,13 @@ export function isDirection(word) {
16
16
  return DIRECTIONS.includes(word);
17
17
  }
18
18
  /**
19
- * Which edge of the target a `level with` shares. `centre` is the plain form;
19
+ * Which edge of the target a `level with` shares. `center` is the plain form;
20
20
  * the rest are written in front of it, as in `top level with media`.
21
21
  */
22
- export const EDGES = ['centre', 'top', 'bottom', 'left', 'right'];
22
+ export const EDGES = ['center', 'top', 'bottom', 'left', 'right'];
23
23
  /** An edge belongs to one axis, so an alignment never has to say which. */
24
24
  export const EDGE_AXIS = {
25
- centre: 'y',
25
+ center: 'y',
26
26
  top: 'y',
27
27
  bottom: 'y',
28
28
  left: 'x',
@@ -30,7 +30,7 @@ export const EDGE_AXIS = {
30
30
  };
31
31
  /**
32
32
  * The modifiers a placement understands, in brackets after its targets. Refused
33
- * by name when unrecognised, for the reason `DIAGRAM_KEYS` are: a modifier that
33
+ * by name when unrecognized, for the reason `DIAGRAM_KEYS` are: a modifier that
34
34
  * silently does nothing looks like a bug in the tool rather than a typo.
35
35
  */
36
36
  export const PLACEMENT_KEYS = ['gap'];
@@ -38,7 +38,7 @@ export const PLACEMENT_KEYS = ['gap'];
38
38
  export function describePlacement(placement) {
39
39
  const targets = listTargets(placement.targets);
40
40
  if (placement.kind === 'align') {
41
- const edge = placement.edge === 'centre' ? '' : `${placement.edge} `;
41
+ const edge = placement.edge === 'center' ? '' : `${placement.edge} `;
42
42
  return `${edge}level with ${targets}`;
43
43
  }
44
44
  // "left of X" and "above X" are both good English; "above of X" is not.
@@ -65,7 +65,7 @@ export function describeAxis(axis) {
65
65
  return axis === 'y' ? 'vertically' : 'horizontally';
66
66
  }
67
67
  /**
68
- * What a label's brackets may say: `"Docker" (at: bottom, align: centre)`.
68
+ * What a label's brackets may say: `"Docker" (at: bottom, align: center)`.
69
69
  *
70
70
  * They are bracketed onto the label rather than written among the node's
71
71
  * attributes for the same reason a gap is bracketed onto its placement — they
@@ -76,3 +76,96 @@ export function describeAxis(axis) {
76
76
  export const LABEL_KEYS = ['at', 'align'];
77
77
  /** The attributes a `diagram` statement understands. */
78
78
  export const DIAGRAM_KEYS = ['background'];
79
+ /**
80
+ * The attributes whose value is a color rather than text. A color is written
81
+ * as the viewer will receive it and the renderer keeps no list of color words
82
+ * of its own, so there is nothing to check a value *against* — but quoting is
83
+ * the author saying "this is text", and an unquoted value cannot hold a space,
84
+ * so prose has to be quoted to get in at all. Refusing a quoted color is
85
+ * therefore the whole of what can be checked here, and it happens to be the
86
+ * mistake people actually make: `subtext: "medium-fine"` reads as the text
87
+ * that goes underneath, and was accepted and dropped in silence.
88
+ */
89
+ export const COLOR_KEYS = [
90
+ 'fill',
91
+ 'border',
92
+ 'text',
93
+ 'line',
94
+ 'subtext',
95
+ 'background',
96
+ ];
97
+ /**
98
+ * A color attribute names the *part* it colors, and a part exists only on the
99
+ * kinds that have one. A box has a border and text; a note and a glyph body are
100
+ * text and nothing else; a link is a line and its label.
101
+ *
102
+ * This table is what makes the words checkable. `border:` on a note is refused
103
+ * by name rather than ignored — the same rule as an unknown `diagram` key, and
104
+ * for the same reason: an attribute that silently does nothing looks like the
105
+ * tool being broken.
106
+ *
107
+ * A style spanning kinds writes one key per kind — `border: #d2904e line:
108
+ * #d2904e` — since a style contributes a part only to the kinds that have it.
109
+ * That is what replaced `stroke:`, which named no part and so could never be
110
+ * wrong, and which is why a box's text had no word of its own until now.
111
+ */
112
+ export const COLOR_PARTS = {
113
+ box: ['fill', 'border', 'text', 'subtext'],
114
+ note: ['text'],
115
+ glyph: ['text', 'subtext'],
116
+ link: ['line', 'text'],
117
+ };
118
+ /**
119
+ * Every attribute each kind understands. An attribute a kind has no use for is
120
+ * refused by name rather than dropped, the same rule as an unknown `diagram`
121
+ * key, a `PLACEMENT_KEYS` modifier or a color part — and for the same reason,
122
+ * which the color parts only closed one level down: a key that silently does
123
+ * nothing looks like the tool being broken rather than like a typo.
124
+ *
125
+ * The color entries repeat `COLOR_PARTS` and must agree with it. They are
126
+ * written out rather than spliced in because this table is the answer to "what
127
+ * may I write here", and a reader of it should not have to assemble the list
128
+ * from two places.
129
+ *
130
+ * Three of the exclusions are the whole of what this table decides, and each is
131
+ * a place the old silence hid something:
132
+ *
133
+ * - A glyph takes no `icon:`. It is drawn *as* a picture and has no box for a
134
+ * second one to sit in; `sizeNode` returns before it would ever be read.
135
+ * - A glyph and a note take no `align:`, which widens a node's children, and
136
+ * neither may have any.
137
+ * - A link takes no `gap:` or `overlap:`. Those are about where a box sits, and
138
+ * a link is not placed — it joins two things that are.
139
+ */
140
+ export const ATTR_KEYS = {
141
+ box: [
142
+ 'style',
143
+ 'size',
144
+ 'gap',
145
+ 'overlap',
146
+ 'align',
147
+ 'wrap',
148
+ 'icon',
149
+ 'shape',
150
+ 'fill',
151
+ 'border',
152
+ 'text',
153
+ 'subtext',
154
+ ],
155
+ note: ['style', 'size', 'gap', 'overlap', 'wrap', 'text'],
156
+ glyph: ['style', 'size', 'gap', 'overlap', 'wrap', 'shape', 'text', 'subtext'],
157
+ link: ['style', 'size', 'from', 'to', 'line', 'text'],
158
+ };
159
+ /**
160
+ * Every word that is an attribute *somewhere*, which is what separates a
161
+ * misspelling from a key written on the wrong kind of thing. The two deserve
162
+ * different errors: one has no remedy but the spelling, the other has a real
163
+ * meaning somewhere else in the file.
164
+ *
165
+ * `DIAGRAM_KEYS` is in here so that `background:` on a box is understood to be
166
+ * a real word in the wrong place — that mistake wants to be pointed at `fill:`,
167
+ * not told the word does not exist.
168
+ */
169
+ export const ALL_ATTR_KEYS = [
170
+ ...new Set([...Object.values(ATTR_KEYS).flat(), ...DIAGRAM_KEYS]),
171
+ ];
@@ -74,8 +74,8 @@ export declare const LABEL_CLEARANCE = 20;
74
74
  * renderer, which spaces the lanes of a channel by it, so the two cannot
75
75
  * disagree about how much room a label needs. The two ask different questions of
76
76
  * it and both are right: the resolver measures *along* the run, so a link
77
- * travelling horizontally needs the label's width; the renderer measures *across*
78
- * the channel, so a link travelling horizontally down one needs its height.
77
+ * traveling horizontally needs the label's width; the renderer measures *across*
78
+ * the channel, so a link traveling horizontally down one needs its height.
79
79
  */
80
80
  export declare function labelExtent(label: string, appearance: Attrs, axis: 'x' | 'y', measurer: Measurer, fontSize: number, line: number): number;
81
81
  /**
@@ -123,8 +123,11 @@ export declare const DEFAULT_MARGIN = 40;
123
123
  * rather than process" and a reader decodes it, while "lower down" means only
124
124
  * lower down.
125
125
  *
126
- * A leaf has no band — its label is centred in the box — so neither says
127
- * anything about one.
126
+ * A leaf has no band, so `at` says nothing about one and is refused there. But
127
+ * `align` is not about the band: a label of more than one line has lines of
128
+ * unequal length whatever kind of box it is in, and how those sit across each
129
+ * other is a real question anywhere. A leaf's default is centered rather than
130
+ * ranged left, which is why the fallback is a parameter.
128
131
  */
129
132
  export declare const LABEL_ENDS: readonly ["top", "bottom"];
130
133
  export type LabelEnd = (typeof LABEL_ENDS)[number];
@@ -139,4 +142,4 @@ export interface LabelStyle {
139
142
  * contents away from the band, and the renderer, which draws into it, so the two
140
143
  * cannot disagree about which end the band is at.
141
144
  */
142
- export declare function labelStyleFor(label: Attrs, line: number): LabelStyle;
145
+ export declare function labelStyleFor(label: Attrs, line: number, fallbackAlign?: 'start' | 'middle'): LabelStyle;
package/dist/constants.js CHANGED
@@ -79,8 +79,8 @@ export const LABEL_CLEARANCE = 20;
79
79
  * renderer, which spaces the lanes of a channel by it, so the two cannot
80
80
  * disagree about how much room a label needs. The two ask different questions of
81
81
  * it and both are right: the resolver measures *along* the run, so a link
82
- * travelling horizontally needs the label's width; the renderer measures *across*
83
- * the channel, so a link travelling horizontally down one needs its height.
82
+ * traveling horizontally needs the label's width; the renderer measures *across*
83
+ * the channel, so a link traveling horizontally down one needs its height.
84
84
  */
85
85
  export function labelExtent(label, appearance, axis, measurer, fontSize, line) {
86
86
  const size = fontSizeFor('link', appearance, fontSize, line);
@@ -121,7 +121,7 @@ export const TEXT_SIZES = {
121
121
  * the diagram rather than being part of it, and at the size of a box label an
122
122
  * aside reads as a statement — so `note` starts small and says so by being a
123
123
  * note. This is a default and not a ceiling: `size:` overrides it, the same way
124
- * `fill:` overrides the theme's colour.
124
+ * `fill:` overrides the theme's color.
125
125
  */
126
126
  const DEFAULT_TEXT_SIZE = { note: 'small' };
127
127
  /**
@@ -151,18 +151,20 @@ export const DEFAULT_MARGIN = 40;
151
151
  * rather than process" and a reader decodes it, while "lower down" means only
152
152
  * lower down.
153
153
  *
154
- * A leaf has no band — its label is centred in the box — so neither says
155
- * anything about one.
154
+ * A leaf has no band, so `at` says nothing about one and is refused there. But
155
+ * `align` is not about the band: a label of more than one line has lines of
156
+ * unequal length whatever kind of box it is in, and how those sit across each
157
+ * other is a real question anywhere. A leaf's default is centered rather than
158
+ * ranged left, which is why the fallback is a parameter.
156
159
  */
157
160
  export const LABEL_ENDS = ['top', 'bottom'];
158
161
  /**
159
- * Author's word to the SVG's. Both spellings of the middle one are taken: this
160
- * is a vocabulary an author types from memory, and being right about the
161
- * arrangement and wrong about a dialect is not a mistake worth an error.
162
+ * Author's word to the SVG's. One spelling of each, per the rule that refuses
163
+ * synonyms for `box` and `link`: an alias is a variant a reader has to learn,
164
+ * and every document and example has to pick one of them anyway.
162
165
  */
163
166
  const LABEL_ALIGNMENTS = {
164
167
  left: 'start',
165
- centre: 'middle',
166
168
  center: 'middle',
167
169
  right: 'end',
168
170
  };
@@ -171,17 +173,17 @@ const LABEL_ALIGNMENTS = {
171
173
  * contents away from the band, and the renderer, which draws into it, so the two
172
174
  * cannot disagree about which end the band is at.
173
175
  */
174
- export function labelStyleFor(label, line) {
176
+ export function labelStyleFor(label, line, fallbackAlign = 'start') {
175
177
  const at = label['at'];
176
178
  if (at !== undefined && !LABEL_ENDS.includes(at)) {
177
179
  throw new SourceError(`a label's at takes ${LABEL_ENDS.join(' or ')}, not "${at}"`, line);
178
180
  }
179
181
  const align = label['align'];
180
182
  if (align !== undefined && LABEL_ALIGNMENTS[align] === undefined) {
181
- throw new SourceError(`a label's align takes left, centre or right, not "${align}"`, line);
183
+ throw new SourceError(`a label's align takes left, center or right, not "${align}"`, line);
182
184
  }
183
185
  return {
184
186
  at: at ?? 'top',
185
- align: align === undefined ? 'start' : LABEL_ALIGNMENTS[align],
187
+ align: align === undefined ? fallbackAlign : LABEL_ALIGNMENTS[align],
186
188
  };
187
189
  }
package/dist/grammar.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The language's *lexical* vocabulary, and a scanner that classifies one line of
3
- * source into coloured spans.
3
+ * source into colored spans.
4
4
  *
5
5
  * This is deliberately separate from `parser.ts`, and it is not a second parser.
6
6
  * The parser answers "what does this file mean" and refuses anything it cannot
7
- * answer for; a highlighter has to colour a half-typed line without complaint,
7
+ * answer for; a highlighter has to color a half-typed line without complaint,
8
8
  * so it answers only "what kind of word is this" and never fails. Every rule
9
9
  * below is a regex applied to a single line, in priority order, with one bit of
10
10
  * carried state (whether anything has been seen on the line yet, and whether the
@@ -23,7 +23,7 @@
23
23
  * closes on one, and there are no blocks. That is the property those editor
24
24
  * formats need and the reason a `.reladraw` grammar is small in all of them.
25
25
  */
26
- /** What a span of source is, for colouring. */
26
+ /** What a span of source is, for coloring. */
27
27
  export type TokenKind =
28
28
  /** `// to the end of the line` */
29
29
  'comment'
@@ -42,7 +42,7 @@ export type TokenKind =
42
42
  /** The single word an attribute takes. */
43
43
  | 'value'
44
44
  /** `#14532d`, wherever it appears. */
45
- | 'colour'
45
+ | 'color'
46
46
  /** `(` and `)` around a placement's or a label's modifiers. */
47
47
  | 'bracket'
48
48
  /** Everything else: node names being referred to, and whitespace. */
@@ -56,14 +56,14 @@ export interface Span {
56
56
  /**
57
57
  * The words a statement may open with. `parseStatement` in `parser.ts` is the
58
58
  * authority — its switch is what actually accepts them — and this list mirrors
59
- * it. A word missing here is a word that draws in the plain colour, which is a
59
+ * it. A word missing here is a word that draws in the plain color, which is a
60
60
  * dull page rather than a wrong one.
61
61
  */
62
62
  export declare const STATEMENT_KEYWORDS: readonly ["box", "note", "link", "deck", "style", "diagram"];
63
63
  /**
64
64
  * Every word that says something about where a thing goes. Assembled from the
65
65
  * lists the parser itself reads, so a direction or a passage axis added there
66
- * colours here without anybody remembering to come back.
66
+ * colors here without anybody remembering to come back.
67
67
  */
68
68
  export declare const RELATION_WORDS: string[];
69
69
  /**
@@ -80,7 +80,7 @@ export declare const PATTERNS: {
80
80
  readonly keyword: `(?:${string})\\b`;
81
81
  readonly arrow: "<->|->|<-";
82
82
  readonly attribute: "[A-Za-z][A-Za-z0-9_-]*:";
83
- readonly colour: "#[0-9A-Fa-f]{3,8}\\b";
83
+ readonly color: "#[0-9A-Fa-f]{3,8}\\b";
84
84
  readonly relation: `(?:${string})\\b`;
85
85
  readonly bracket: "[()]";
86
86
  readonly word: "(?:[^\\s()\"\\/]|\\/(?!\\/))(?:[^\\s\"\\/]|\\/(?!\\/))*";
package/dist/grammar.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The language's *lexical* vocabulary, and a scanner that classifies one line of
3
- * source into coloured spans.
3
+ * source into colored spans.
4
4
  *
5
5
  * This is deliberately separate from `parser.ts`, and it is not a second parser.
6
6
  * The parser answers "what does this file mean" and refuses anything it cannot
7
- * answer for; a highlighter has to colour a half-typed line without complaint,
7
+ * answer for; a highlighter has to color a half-typed line without complaint,
8
8
  * so it answers only "what kind of word is this" and never fails. Every rule
9
9
  * below is a regex applied to a single line, in priority order, with one bit of
10
10
  * carried state (whether anything has been seen on the line yet, and whether the
@@ -27,7 +27,7 @@ import { DIRECTIONS, EDGES, PASSAGE_AXES } from './ast.js';
27
27
  /**
28
28
  * The words a statement may open with. `parseStatement` in `parser.ts` is the
29
29
  * authority — its switch is what actually accepts them — and this list mirrors
30
- * it. A word missing here is a word that draws in the plain colour, which is a
30
+ * it. A word missing here is a word that draws in the plain color, which is a
31
31
  * dull page rather than a wrong one.
32
32
  */
33
33
  export const STATEMENT_KEYWORDS = ['box', 'note', 'link', 'deck', 'style', 'diagram'];
@@ -36,7 +36,7 @@ const DECLARES_NAME = ['box', 'note', 'deck', 'style'];
36
36
  /**
37
37
  * Every word that says something about where a thing goes. Assembled from the
38
38
  * lists the parser itself reads, so a direction or a passage axis added there
39
- * colours here without anybody remembering to come back.
39
+ * colors here without anybody remembering to come back.
40
40
  */
41
41
  export const RELATION_WORDS = [
42
42
  ...DIRECTIONS,
@@ -72,7 +72,7 @@ export const PATTERNS = {
72
72
  // `<->` first, or `<-` would match its opening half and leave a stray `>`.
73
73
  arrow: '<->|->|<-',
74
74
  attribute: '[A-Za-z][A-Za-z0-9_-]*:',
75
- colour: '#[0-9A-Fa-f]{3,8}\\b',
75
+ color: '#[0-9A-Fa-f]{3,8}\\b',
76
76
  relation: `(?:${alternation(RELATION_WORDS)})\\b`,
77
77
  bracket: '[()]',
78
78
  // Matches what `tokenizeLine` treats as one bare token, and the awkwardness is
@@ -102,7 +102,7 @@ const RULES = [
102
102
  // Before `relation`, because the trailing colon is what tells `left: …` from
103
103
  // the `left` of a placement, and after `arrow` so `->` is never a word.
104
104
  { kind: 'attribute', re: sticky(PATTERNS.attribute) },
105
- { kind: 'colour', re: sticky(PATTERNS.colour) },
105
+ { kind: 'color', re: sticky(PATTERNS.color) },
106
106
  { kind: 'relation', re: sticky(PATTERNS.relation) },
107
107
  ];
108
108
  const SPACE = sticky('[ \\t]+');
@@ -159,9 +159,9 @@ export function highlightLine(line) {
159
159
  if (space !== null) {
160
160
  push('plain', at + space.length);
161
161
  const name = match(WORD, line, at);
162
- // `style backup stroke: …` declares a name; `box fill: red` is a
162
+ // `style backup border: …` declares a name; `box fill: red` is a
163
163
  // half-typed line whose second word is already an attribute, and
164
- // colouring that as a name would be a lie about what it is.
164
+ // coloring that as a name would be a lie about what it is.
165
165
  if (name !== null && !name.endsWith(':'))
166
166
  push('name', at + name.length);
167
167
  }
@@ -194,7 +194,7 @@ export function highlightLine(line) {
194
194
  // An attribute's value is whatever single token follows it, whatever it
195
195
  // would otherwise have been called: `to: right` is a value, not a
196
196
  // direction, and `style: wide` is a style name, not a gap.
197
- const kind = expectingValue && rule.kind !== 'string' && rule.kind !== 'colour' && rule.kind !== 'bracket'
197
+ const kind = expectingValue && rule.kind !== 'string' && rule.kind !== 'color' && rule.kind !== 'bracket'
198
198
  ? 'value'
199
199
  : rule.kind;
200
200
  expectingValue = rule.kind === 'attribute';
package/dist/icons.d.ts CHANGED
@@ -19,9 +19,9 @@
19
19
  * when a diagram asks for a distinction it cannot otherwise make.
20
20
  */
21
21
  /**
22
- * Icons carry three tones rather than colours. `ink` is the drawn line, `shade`
22
+ * Icons carry three tones rather than colors. `ink` is the drawn line, `shade`
23
23
  * the body it encloses, and `void` is the page showing through — the same
24
- * knockout a link label already uses. Naming tones instead of colours is what
24
+ * knockout a link label already uses. Naming tones instead of colors is what
25
25
  * lets one glyph sit correctly on a dark theme and a light one.
26
26
  */
27
27
  export type IconTone = 'ink' | 'shade' | 'void';
@@ -56,7 +56,7 @@ export declare const ICON_NAMES: string[];
56
56
  *
57
57
  * Named for what a node *is*, never for the geometry, which is the same rule the
58
58
  * icon names follow: `document` and not `folded-corner`. A shape carrying a
59
- * conventional meaning is a second channel alongside colour, and a stronger one
59
+ * conventional meaning is a second channel alongside color, and a stronger one
60
60
  * — a fill is whatever the author assigned and has to be learnt from the
61
61
  * diagram, while a folded corner has meant "an artifact, not a process" in
62
62
  * flowcharts for decades and reads with no legend at all.
package/dist/icons.js CHANGED
@@ -26,7 +26,7 @@ export const ICON_STROKE = 1.1;
26
26
  function circle(cx, cy, r) {
27
27
  return `M${cx - r} ${cy} a${r} ${r} 0 1 0 ${r * 2} 0 a${r} ${r} 0 1 0 ${-r * 2} 0 Z`;
28
28
  }
29
- /** The three visible faces of an isometric cube, top face centred on `cx, cy`. */
29
+ /** The three visible faces of an isometric cube, top face centered on `cx, cy`. */
30
30
  function cube(cx, cy, s) {
31
31
  const half = s / 2;
32
32
  return [
@@ -123,7 +123,7 @@ export const ICON_NAMES = Object.keys(ICONS);
123
123
  *
124
124
  * Named for what a node *is*, never for the geometry, which is the same rule the
125
125
  * icon names follow: `document` and not `folded-corner`. A shape carrying a
126
- * conventional meaning is a second channel alongside colour, and a stronger one
126
+ * conventional meaning is a second channel alongside color, and a stronger one
127
127
  * — a fill is whatever the author assigned and has to be learnt from the
128
128
  * diagram, while a folded corner has meant "an artifact, not a process" in
129
129
  * flowcharts for decades and reads with no legend at all.
package/dist/lexer.d.ts CHANGED
@@ -9,7 +9,7 @@ export interface Token {
9
9
  * runs to the end of the line, so a comment may trail a statement.
10
10
  *
11
11
  * A lone `/` is an ordinary character, which keeps a path or a ratio writable
12
- * unquoted. `#` is ordinary too: it opens a hex colour, which is why comments
12
+ * unquoted. `#` is ordinary too: it opens a hex color, which is why comments
13
13
  * are spelled `//` rather than the `#` an earlier version used.
14
14
  *
15
15
  * Parentheses group the modifiers on a placement — `left of hub (gap: wide)` —
package/dist/lexer.js CHANGED
@@ -5,7 +5,7 @@ import { SourceError } from './errors.js';
5
5
  * runs to the end of the line, so a comment may trail a statement.
6
6
  *
7
7
  * A lone `/` is an ordinary character, which keeps a path or a ratio writable
8
- * unquoted. `#` is ordinary too: it opens a hex colour, which is why comments
8
+ * unquoted. `#` is ordinary too: it opens a hex color, which is why comments
9
9
  * are spelled `//` rather than the `#` an earlier version used.
10
10
  *
11
11
  * Parentheses group the modifiers on a placement — `left of hub (gap: wide)` —
package/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DIAGRAM_KEYS, EDGE_AXIS, EDGES, PASSAGE_AXES, LABEL_KEYS, PLACEMENT_KEYS, isDirection, listTargets, } from './ast.js';
1
+ import { COLOR_KEYS, DIAGRAM_KEYS, EDGE_AXIS, EDGES, PASSAGE_AXES, LABEL_KEYS, PLACEMENT_KEYS, isDirection, listTargets, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
4
  /** Parse a whole source file. One statement per line; blanks and comments drop out. */
@@ -57,6 +57,10 @@ function attributesBegin(tokens) {
57
57
  }
58
58
  return -1;
59
59
  }
60
+ /** The value as the author would have to write it back into a label. */
61
+ function quoteOf(text) {
62
+ return `"${text.replace(/"/g, '\\"')}"`;
63
+ }
60
64
  function parseAttrs(tokens, line) {
61
65
  const attrs = {};
62
66
  let i = 0;
@@ -78,6 +82,43 @@ function parseAttrs(tokens, line) {
78
82
  if (isAttrKey(valueToken)) {
79
83
  throw new SourceError(`attribute "${key}" has no value`, line);
80
84
  }
85
+ if (key === 'stroke') {
86
+ // Removed 2026-09-09. It meant a different part on every kind — the
87
+ // border of a box, the text of a note or a glyph body, the line of a
88
+ // link — so it could never be wrong, and a box's text had no word at all.
89
+ // Refused by name rather than ignored: an older file must be told what
90
+ // to write, not silently drawn without its colors.
91
+ throw new SourceError('`stroke:` has been replaced by the part it colors — `border:` on a box, `text:` on a note or a glyph body, `line:` on a link. A style shared between boxes and links writes both, as in `border: #d2904e line: #d2904e`', line);
92
+ }
93
+ if (key === 'width') {
94
+ // Renamed 2026-09-09. It folds a label every n *characters* and never
95
+ // said how wide anything is, so `width: 200` meaning units was accepted,
96
+ // wrapped at 200 characters, and did nothing visible — the silent drop
97
+ // this vocabulary is otherwise free of. Refused by name for the reason
98
+ // `stroke:` is: an older file must be told, not quietly drawn unwrapped.
99
+ throw new SourceError('`width:` is now `wrap:`, because it folds the text every n characters and says nothing about how wide anything is', line);
100
+ }
101
+ if (valueToken.quoted && COLOR_KEYS.includes(key)) {
102
+ // A quoted value is the author saying "this is text", and every one of
103
+ // these keys takes a color. Without this the string is passed through as
104
+ // a color, turns out not to be one, and nothing is drawn and nothing is
105
+ // said. Name the likely intent rather than only the rule: the qualifier
106
+ // under a name is a second label line, not a `subtext` value.
107
+ if (valueToken.text.startsWith('#')) {
108
+ // A hex color that was merely quoted. The author wrote a color and the
109
+ // remedy is punctuation, so say that rather than that it is not one.
110
+ throw new SourceError(`a color is written without quotes — "${key}: ${valueToken.text}"`, line);
111
+ }
112
+ // `text:` is the color of a label, not the label itself, and that is a
113
+ // mistake worth naming rather than only refusing — the word invites it.
114
+ if (key === 'text') {
115
+ throw new SourceError(`\`text:\` is the color of a label, not the label — write the words in quotes after the name, as in \`box name ${quoteOf(valueToken.text)}\``, line);
116
+ }
117
+ if (key === 'subtext') {
118
+ throw new SourceError(`\`subtext:\` is the color of a label's later lines, not the words — write them into the label, as in \`"Name / ${valueToken.text}"\`, and \`subtext: muted\` to make them quieter`, line);
119
+ }
120
+ throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
121
+ }
81
122
  attrs[key] = valueToken.text;
82
123
  i += 2;
83
124
  }
@@ -268,7 +309,7 @@ function parsePlacements(tokens, line, subject) {
268
309
  if (word.quoted) {
269
310
  throw new SourceError(`${subject}: unexpected text "${word.text}"`, line);
270
311
  }
271
- // `top level with media` names an edge rather than the centre line. `left`
312
+ // `top level with media` names an edge rather than the center line. `left`
272
313
  // and `right` are edges as well as directions, so it is the word after them
273
314
  // that says which was meant — "left of bup_hd" against "left level with bup_hd".
274
315
  const edge = isEdgeWord(word.text) && follows(tokens, i + 1, 'level') ? word.text : undefined;
@@ -290,8 +331,8 @@ function parsePlacements(tokens, line, subject) {
290
331
  }
291
332
  placements.push({
292
333
  kind: 'align',
293
- axis: EDGE_AXIS[edge ?? 'centre'],
294
- edge: edge ?? 'centre',
334
+ axis: EDGE_AXIS[edge ?? 'center'],
335
+ edge: edge ?? 'center',
295
336
  targets: read.targets,
296
337
  line,
297
338
  });
@@ -368,7 +409,7 @@ function readBracket(tokens, start, keys, about) {
368
409
  throw new SourceError(`${about.subject}: "${key}" has no value`, about.line);
369
410
  }
370
411
  // A comma between modifiers is punctuation, exactly as it is between the
371
- // targets of a placement. `(at: bottom, align: centre)` and the same without
412
+ // targets of a placement. `(at: bottom, align: center)` and the same without
372
413
  // the comma are the same statement.
373
414
  const value = valueToken.text;
374
415
  values[key] = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
@@ -389,7 +430,7 @@ function startsPlacement(token) {
389
430
  EDGES.includes(token.text));
390
431
  }
391
432
  function isEdgeWord(word) {
392
- return word !== 'centre' && EDGES.includes(word);
433
+ return word !== 'center' && EDGES.includes(word);
393
434
  }
394
435
  /**
395
436
  * One target, or several joined by `and` — `right of borg and bare`, or