reladraw 0.0.1 → 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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The language's *lexical* vocabulary, and a scanner that classifies one line of
3
+ * source into colored spans.
4
+ *
5
+ * This is deliberately separate from `parser.ts`, and it is not a second parser.
6
+ * The parser answers "what does this file mean" and refuses anything it cannot
7
+ * answer for; a highlighter has to color a half-typed line without complaint,
8
+ * so it answers only "what kind of word is this" and never fails. Every rule
9
+ * below is a regex applied to a single line, in priority order, with one bit of
10
+ * carried state (whether anything has been seen on the line yet, and whether the
11
+ * previous token was an attribute key).
12
+ *
13
+ * **That restriction is the point, and it is what makes this reusable.** An
14
+ * Emacs `font-lock-keywords` list, a Vim `syntax match` file and a TextMate
15
+ * grammar are all exactly this: an ordered list of single-line regexes with a
16
+ * face attached. So the vocabulary here — which is the half that goes stale, and
17
+ * which is imported from `ast.ts`, `constants.ts` and `icons.ts` rather than
18
+ * retyped — can be emitted into any of those without this file being ported.
19
+ * The scanner is the JavaScript consumer of the same spec, and the playground is
20
+ * its only caller.
21
+ *
22
+ * Nothing in the language spans lines: `//` runs to the end of one, a string
23
+ * closes on one, and there are no blocks. That is the property those editor
24
+ * formats need and the reason a `.reladraw` grammar is small in all of them.
25
+ */
26
+ import { DIRECTIONS, EDGES, PASSAGE_AXES } from './ast.js';
27
+ /**
28
+ * The words a statement may open with. `parseStatement` in `parser.ts` is the
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 color, which is a
31
+ * dull page rather than a wrong one.
32
+ */
33
+ export const STATEMENT_KEYWORDS = ['box', 'note', 'link', 'deck', 'style', 'diagram'];
34
+ /** Statements whose second word declares a name. `diagram` has none. */
35
+ const DECLARES_NAME = ['box', 'note', 'deck', 'style'];
36
+ /**
37
+ * Every word that says something about where a thing goes. Assembled from the
38
+ * lists the parser itself reads, so a direction or a passage axis added there
39
+ * colors here without anybody remembering to come back.
40
+ */
41
+ export const RELATION_WORDS = [
42
+ ...DIRECTIONS,
43
+ ...EDGES,
44
+ ...Object.keys(PASSAGE_AXES),
45
+ // The connecting words. `of` is optional after a direction, `and` joins
46
+ // targets, `between` opens a passage, `level with` is the alignment.
47
+ 'of',
48
+ 'and',
49
+ 'level',
50
+ 'with',
51
+ 'between',
52
+ ];
53
+ /** Longest first, so `above-left` is not read as `above` followed by `-left`. */
54
+ function alternation(words) {
55
+ return [...new Set(words)].sort((a, b) => b.length - a.length).join('|');
56
+ }
57
+ /**
58
+ * The patterns, as source strings, so a generator can emit them into another
59
+ * editor's grammar without reaching into a compiled RegExp. Each is written to
60
+ * match at the point the scanner has reached; the scanner adds the sticky flag.
61
+ *
62
+ * A name may contain dots (containment) and hyphens, which is why the word
63
+ * pattern is what it is rather than `\w+`.
64
+ */
65
+ export const PATTERNS = {
66
+ comment: '\\/\\/.*',
67
+ // The closing quote is optional: every string is unterminated for as long as
68
+ // it is being typed, and a highlighter that waits for the quote repaints the
69
+ // rest of the file on every keystroke.
70
+ string: '"(?:\\\\.|[^"\\\\])*"?',
71
+ keyword: `(?:${alternation(STATEMENT_KEYWORDS)})\\b`,
72
+ // `<->` first, or `<-` would match its opening half and leave a stray `>`.
73
+ arrow: '<->|->|<-',
74
+ attribute: '[A-Za-z][A-Za-z0-9_-]*:',
75
+ color: '#[0-9A-Fa-f]{3,8}\\b',
76
+ relation: `(?:${alternation(RELATION_WORDS)})\\b`,
77
+ bracket: '[()]',
78
+ // Matches what `tokenizeLine` treats as one bare token, and the awkwardness is
79
+ // load-bearing rather than accidental. A `(` counts as punctuation only where
80
+ // a token starts, so `rgb(20,20,20)` is one word — hence the first character
81
+ // being spelled differently from the rest. A lone `/` is ordinary and only a
82
+ // doubled one opens a comment, which is what the lookahead is for.
83
+ word: '(?:[^\\s()"\\/]|\\/(?!\\/))(?:[^\\s"\\/]|\\/(?!\\/))*',
84
+ /**
85
+ * The same, inside an open bracket, where `)` closes the group instead of
86
+ * being an ordinary character. An editor grammar that cannot count brackets
87
+ * should use this one throughout: mistaking `rgb(20,20,20)` for three tokens
88
+ * is a smaller wrong than swallowing the `)` that ends `(gap: tight)`.
89
+ */
90
+ wordInGroup: '(?:[^\\s()"\\/]|\\/(?!\\/))(?:[^\\s)"\\/]|\\/(?!\\/))*',
91
+ };
92
+ /**
93
+ * The rules that are pure regex, in priority order. Three things are missing and
94
+ * each for a reason: a comment wins over all of them and is tried first, a
95
+ * bracket is punctuation only at certain depths so the scanner counts them
96
+ * itself, and the catch-all word rule varies with that same depth and is
97
+ * appended per call.
98
+ */
99
+ const RULES = [
100
+ { kind: 'string', re: sticky(PATTERNS.string) },
101
+ { kind: 'arrow', re: sticky(PATTERNS.arrow) },
102
+ // Before `relation`, because the trailing colon is what tells `left: …` from
103
+ // the `left` of a placement, and after `arrow` so `->` is never a word.
104
+ { kind: 'attribute', re: sticky(PATTERNS.attribute) },
105
+ { kind: 'color', re: sticky(PATTERNS.color) },
106
+ { kind: 'relation', re: sticky(PATTERNS.relation) },
107
+ ];
108
+ const SPACE = sticky('[ \\t]+');
109
+ const COMMENT = sticky(PATTERNS.comment);
110
+ const KEYWORD = sticky(PATTERNS.keyword);
111
+ const WORD = sticky(PATTERNS.word);
112
+ const WORD_IN_GROUP = sticky(PATTERNS.wordInGroup);
113
+ function sticky(source) {
114
+ return new RegExp(source, 'y');
115
+ }
116
+ function match(re, line, at) {
117
+ re.lastIndex = at;
118
+ const found = re.exec(line);
119
+ return found ? found[0] : null;
120
+ }
121
+ /**
122
+ * Classify one line. Always returns spans covering it end to end, in order, so a
123
+ * caller can rebuild the text by concatenation and can trust that nothing was
124
+ * dropped — which is what a highlighter drawn *behind* a textarea needs, since
125
+ * a lost character would slide every following one out of register.
126
+ */
127
+ export function highlightLine(line) {
128
+ const spans = [];
129
+ let at = 0;
130
+ /** Nothing but whitespace seen yet, so the next word is the statement keyword. */
131
+ let opening = true;
132
+ /** The last thing emitted was a `key:`, so the next word is its value. */
133
+ let expectingValue = false;
134
+ /** How many brackets are open, which is what makes a `)` punctuation. */
135
+ let depth = 0;
136
+ const push = (kind, end) => {
137
+ spans.push({ kind, start: at, end });
138
+ at = end;
139
+ };
140
+ while (at < line.length) {
141
+ const gap = match(SPACE, line, at);
142
+ if (gap !== null) {
143
+ push('plain', at + gap.length);
144
+ continue;
145
+ }
146
+ // A comment wins everywhere, including in the middle of a statement.
147
+ const comment = match(COMMENT, line, at);
148
+ if (comment !== null) {
149
+ push('comment', at + comment.length);
150
+ continue;
151
+ }
152
+ if (opening) {
153
+ opening = false;
154
+ const keyword = match(KEYWORD, line, at);
155
+ if (keyword !== null) {
156
+ push('keyword', at + keyword.length);
157
+ if (DECLARES_NAME.includes(keyword)) {
158
+ const space = match(SPACE, line, at);
159
+ if (space !== null) {
160
+ push('plain', at + space.length);
161
+ const name = match(WORD, line, at);
162
+ // `style backup border: …` declares a name; `box fill: red` is a
163
+ // half-typed line whose second word is already an attribute, and
164
+ // coloring that as a name would be a lie about what it is.
165
+ if (name !== null && !name.endsWith(':'))
166
+ push('name', at + name.length);
167
+ }
168
+ }
169
+ continue;
170
+ }
171
+ }
172
+ // Brackets before the rest, and only where they are really punctuation: an
173
+ // opening one wherever a token starts, a closing one only while a group is
174
+ // open. That is `tokenizeLine`'s rule, and it is what keeps an unquoted
175
+ // `rgb(20,20,20)` a single word rather than three.
176
+ const ch = line[at];
177
+ if (ch === '(') {
178
+ depth += 1;
179
+ expectingValue = false;
180
+ push('bracket', at + 1);
181
+ continue;
182
+ }
183
+ if (ch === ')' && depth > 0) {
184
+ depth -= 1;
185
+ expectingValue = false;
186
+ push('bracket', at + 1);
187
+ continue;
188
+ }
189
+ let matched = false;
190
+ for (const rule of [...RULES, { kind: 'plain', re: depth > 0 ? WORD_IN_GROUP : WORD }]) {
191
+ const text = match(rule.re, line, at);
192
+ if (text === null)
193
+ continue;
194
+ // An attribute's value is whatever single token follows it, whatever it
195
+ // would otherwise have been called: `to: right` is a value, not a
196
+ // direction, and `style: wide` is a style name, not a gap.
197
+ const kind = expectingValue && rule.kind !== 'string' && rule.kind !== 'color' && rule.kind !== 'bracket'
198
+ ? 'value'
199
+ : rule.kind;
200
+ expectingValue = rule.kind === 'attribute';
201
+ push(kind, at + text.length);
202
+ matched = true;
203
+ break;
204
+ }
205
+ // Nothing in `RULES` can fail on a non-space character, but a scanner that
206
+ // could loop forever is not worth the saved line.
207
+ if (!matched)
208
+ push('plain', at + 1);
209
+ }
210
+ return spans;
211
+ }
212
+ /** Every line of a source file, classified. Line endings are not included. */
213
+ export function highlight(source) {
214
+ return source.split(/\r?\n/).map(highlightLine);
215
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The built-in icon set.
3
+ *
4
+ * Every glyph is path data written into the SVG, and that is the whole reason
5
+ * this file exists rather than a dependency. The output contract is a standalone
6
+ * document: an icon font would render as blank boxes on any machine that does
7
+ * not have the font, and an `<image href>` would need the file to travel beside
8
+ * the SVG. Inline paths cost a few hundred bytes each and always arrive.
9
+ *
10
+ * A name here says what the thing *is*, never what the picture looks like. The
11
+ * same discipline as `gap: wide` over `gap: 110` and `muted` over a hex value:
12
+ * the word is the whole interface, so it has to carry meaning rather than
13
+ * geometry, and naming the meaning is what lets the drawing be improved later
14
+ * without every diagram that uses it changing sense.
15
+ *
16
+ * The set is deliberately small. In a drawing tool you pick a shape out of a
17
+ * visual palette and hundreds are browsable; here you type the word from memory,
18
+ * which caps the useful vocabulary at something that fits in a head. Add a name
19
+ * when a diagram asks for a distinction it cannot otherwise make.
20
+ */
21
+ /**
22
+ * Icons carry three tones rather than colors. `ink` is the drawn line, `shade`
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 colors is what
25
+ * lets one glyph sit correctly on a dark theme and a light one.
26
+ */
27
+ export type IconTone = 'ink' | 'shade' | 'void';
28
+ export interface IconPath {
29
+ d: string;
30
+ fill?: IconTone;
31
+ stroke?: IconTone;
32
+ }
33
+ export interface Icon {
34
+ /** Side of the square the paths are drawn on. Scaled to the drawn size. */
35
+ readonly grid: number;
36
+ readonly paths: readonly IconPath[];
37
+ }
38
+ /** Line width on the 24-unit grid, scaled with everything else. */
39
+ export declare const ICON_STROKE = 1.1;
40
+ export declare const ICONS: Record<string, Icon>;
41
+ export declare const ICON_NAMES: string[];
42
+ /**
43
+ * The icon a node asks for, or nothing. Called by the resolver, which reserves
44
+ * the room, and by the renderer, which fills it, so the two cannot disagree
45
+ * about whether there is an icon at all.
46
+ *
47
+ * An unknown name is refused rather than dropped. That is the same rule
48
+ * `DIAGRAM_KEYS` follows and it is here for the same reason: a misspelt
49
+ * `icon: laptp` that quietly draws nothing is indistinguishable from the tool
50
+ * being broken, and an author will stare at the file looking for the mistake in
51
+ * the wrong place. Since the vocabulary is closed and short, the error can list
52
+ * the whole of it.
53
+ */
54
+ /**
55
+ * The outlines a box can take. `box` is the plain rectangle and needs no word.
56
+ *
57
+ * Named for what a node *is*, never for the geometry, which is the same rule the
58
+ * icon names follow: `document` and not `folded-corner`. A shape carrying a
59
+ * conventional meaning is a second channel alongside color, and a stronger one
60
+ * — a fill is whatever the author assigned and has to be learnt from the
61
+ * diagram, while a folded corner has meant "an artifact, not a process" in
62
+ * flowcharts for decades and reads with no legend at all.
63
+ */
64
+ export declare const BOX_SHAPES: readonly ["document"];
65
+ export type BoxShape = 'box' | (typeof BOX_SHAPES)[number];
66
+ export interface NodeShape {
67
+ outline: BoxShape;
68
+ /**
69
+ * Set when the node is drawn as a glyph rather than as a box. There is then
70
+ * no outline, no fill and no padding: the node *is* the picture, and its size
71
+ * is the picture's. `icon:` decorates a box, this replaces it.
72
+ */
73
+ body?: Icon;
74
+ }
75
+ /**
76
+ * What a node is drawn as. Shared by the resolver, which sizes it, and the
77
+ * renderer, which draws it.
78
+ *
79
+ * The value is either a box outline or the name of a glyph. Those are the two
80
+ * things "what is this drawn as" can answer, and the author has no reason to
81
+ * care which category their answer fell into. The test that separates them is
82
+ * whether the node still sizes itself from its label: a `document` does, a
83
+ * glyph does not.
84
+ */
85
+ export declare function shapeFor(appearance: Record<string, string>, line: number): NodeShape;
86
+ export declare function iconFor(appearance: Record<string, string>, line: number): Icon | undefined;
package/dist/icons.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The built-in icon set.
3
+ *
4
+ * Every glyph is path data written into the SVG, and that is the whole reason
5
+ * this file exists rather than a dependency. The output contract is a standalone
6
+ * document: an icon font would render as blank boxes on any machine that does
7
+ * not have the font, and an `<image href>` would need the file to travel beside
8
+ * the SVG. Inline paths cost a few hundred bytes each and always arrive.
9
+ *
10
+ * A name here says what the thing *is*, never what the picture looks like. The
11
+ * same discipline as `gap: wide` over `gap: 110` and `muted` over a hex value:
12
+ * the word is the whole interface, so it has to carry meaning rather than
13
+ * geometry, and naming the meaning is what lets the drawing be improved later
14
+ * without every diagram that uses it changing sense.
15
+ *
16
+ * The set is deliberately small. In a drawing tool you pick a shape out of a
17
+ * visual palette and hundreds are browsable; here you type the word from memory,
18
+ * which caps the useful vocabulary at something that fits in a head. Add a name
19
+ * when a diagram asks for a distinction it cannot otherwise make.
20
+ */
21
+ import { SourceError } from './errors.js';
22
+ const GRID = 24;
23
+ /** Line width on the 24-unit grid, scaled with everything else. */
24
+ export const ICON_STROKE = 1.1;
25
+ /** A full circle as one path, so the data below can stay declarative. */
26
+ function circle(cx, cy, r) {
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
+ }
29
+ /** The three visible faces of an isometric cube, top face centered on `cx, cy`. */
30
+ function cube(cx, cy, s) {
31
+ const half = s / 2;
32
+ return [
33
+ { d: `M${cx} ${cy} L${cx + s} ${cy + half} L${cx} ${cy + s} L${cx - s} ${cy + half} Z`, fill: 'shade', stroke: 'ink' },
34
+ { d: `M${cx - s} ${cy + half} L${cx} ${cy + s} L${cx} ${cy + s * 2} L${cx - s} ${cy + s * 1.5} Z`, fill: 'shade', stroke: 'ink' },
35
+ { d: `M${cx + s} ${cy + half} L${cx} ${cy + s} L${cx} ${cy + s * 2} L${cx + s} ${cy + s * 1.5} Z`, fill: 'shade', stroke: 'ink' },
36
+ ];
37
+ }
38
+ export const ICONS = {
39
+ /** A spinning disk: the physical drive, not the filesystem on it. */
40
+ disk: {
41
+ grid: GRID,
42
+ paths: [
43
+ { d: 'M4 2 h16 a1.6 1.6 0 0 1 1.6 1.6 v16.8 a1.6 1.6 0 0 1 -1.6 1.6 h-16 a1.6 1.6 0 0 1 -1.6 -1.6 v-16.8 a1.6 1.6 0 0 1 1.6 -1.6 Z', fill: 'shade', stroke: 'ink' },
44
+ { d: circle(12, 10.6, 6.2), fill: 'void', stroke: 'ink' },
45
+ { d: circle(12, 10.6, 1.9), fill: 'ink' },
46
+ { d: 'M11.5 12.6 L12.9 13.4 L8.8 19.6 a1.25 1.25 0 0 1 -2.1 -1.35 Z', fill: 'ink' },
47
+ ],
48
+ },
49
+ /** A workstation: monitor, keyboard and tower. */
50
+ desktop: {
51
+ grid: GRID,
52
+ paths: [
53
+ { d: 'M1.2 2.6 h12.4 v9.2 h-12.4 Z', fill: 'shade', stroke: 'ink' },
54
+ { d: 'M6.3 11.8 h2.2 v1.7 h-2.2 Z', fill: 'ink' },
55
+ { d: 'M4.2 13.5 h6.4 v1.1 h-6.4 Z', fill: 'ink' },
56
+ { d: 'M1.2 16.4 h12.4 v3.2 h-12.4 Z', fill: 'shade', stroke: 'ink' },
57
+ { d: 'M2.6 17.6 h7.8 v0.9 h-7.8 Z', fill: 'ink' },
58
+ { d: 'M16.2 2.6 h6.6 v17 h-6.6 Z', fill: 'shade', stroke: 'ink' },
59
+ { d: circle(19.5, 5.2, 0.9), fill: 'ink' },
60
+ { d: 'M17.4 9.4 h4.2 v0.7 h-4.2 Z M17.4 11.4 h4.2 v0.7 h-4.2 Z M17.4 13.4 h4.2 v0.7 h-4.2 Z', fill: 'ink' },
61
+ ],
62
+ },
63
+ /** A portable machine. Open lid, so the screen is the page showing through. */
64
+ laptop: {
65
+ grid: GRID,
66
+ paths: [
67
+ { d: 'M4 3.6 h16 v11.4 h-16 Z', fill: 'void', stroke: 'ink' },
68
+ { d: 'M2.2 16 h19.6 l1.6 2.6 a0.7 0.7 0 0 1 -0.6 1.1 h-21.6 a0.7 0.7 0 0 1 -0.6 -1.1 Z', fill: 'ink' },
69
+ { d: 'M9.6 17.2 h4.8 v1 h-4.8 Z', fill: 'shade' },
70
+ ],
71
+ },
72
+ /** Something stored as a whole rather than run: an archive, a bucket, a sync root. */
73
+ package: {
74
+ grid: GRID,
75
+ paths: [
76
+ { d: 'M12 2.6 L22 7.4 L12 12.2 L2 7.4 Z', fill: 'shade', stroke: 'ink' },
77
+ { d: 'M2 7.4 L12 12.2 L12 21 L2 16.2 Z', fill: 'shade', stroke: 'ink' },
78
+ { d: 'M22 7.4 L12 12.2 L12 21 L22 16.2 Z', fill: 'shade', stroke: 'ink' },
79
+ ],
80
+ },
81
+ /**
82
+ * Several interchangeable units of the same kind, as a group. Three rather
83
+ * than any particular number: this is the symbol for "several", and at two
84
+ * line-heights square a literal count turns to mush. Where the count carries
85
+ * meaning — where one of them is the end of an arrow — they are nodes, and
86
+ * `shape: instance` is how you draw them.
87
+ */
88
+ cubes: {
89
+ grid: GRID,
90
+ paths: [...cube(6.6, 2.6, 4.4), ...cube(17.4, 2.6, 4.4), ...cube(12, 11.6, 4.4)],
91
+ },
92
+ /** One unit of the kind `cubes` shows several of. */
93
+ instance: {
94
+ grid: GRID,
95
+ paths: cube(12, 2.6, 8.4),
96
+ },
97
+ /** A store queried rather than read as files. */
98
+ database: {
99
+ grid: GRID,
100
+ paths: [
101
+ { d: 'M3 6.4 v11.2 a9 3.4 0 0 0 18 0 v-11.2 Z', fill: 'shade', stroke: 'ink' },
102
+ { d: 'M3 6.4 a9 3.4 0 0 1 18 0 a9 3.4 0 0 1 -18 0 Z', fill: 'shade', stroke: 'ink' },
103
+ { d: 'M3 11 a9 3.4 0 0 0 18 0', stroke: 'ink' },
104
+ { d: 'M3 15.6 a9 3.4 0 0 0 18 0', stroke: 'ink' },
105
+ ],
106
+ },
107
+ };
108
+ export const ICON_NAMES = Object.keys(ICONS);
109
+ /**
110
+ * The icon a node asks for, or nothing. Called by the resolver, which reserves
111
+ * the room, and by the renderer, which fills it, so the two cannot disagree
112
+ * about whether there is an icon at all.
113
+ *
114
+ * An unknown name is refused rather than dropped. That is the same rule
115
+ * `DIAGRAM_KEYS` follows and it is here for the same reason: a misspelt
116
+ * `icon: laptp` that quietly draws nothing is indistinguishable from the tool
117
+ * being broken, and an author will stare at the file looking for the mistake in
118
+ * the wrong place. Since the vocabulary is closed and short, the error can list
119
+ * the whole of it.
120
+ */
121
+ /**
122
+ * The outlines a box can take. `box` is the plain rectangle and needs no word.
123
+ *
124
+ * Named for what a node *is*, never for the geometry, which is the same rule the
125
+ * icon names follow: `document` and not `folded-corner`. A shape carrying a
126
+ * conventional meaning is a second channel alongside color, and a stronger one
127
+ * — a fill is whatever the author assigned and has to be learnt from the
128
+ * diagram, while a folded corner has meant "an artifact, not a process" in
129
+ * flowcharts for decades and reads with no legend at all.
130
+ */
131
+ export const BOX_SHAPES = ['document'];
132
+ const PLAIN = { outline: 'box' };
133
+ /**
134
+ * What a node is drawn as. Shared by the resolver, which sizes it, and the
135
+ * renderer, which draws it.
136
+ *
137
+ * The value is either a box outline or the name of a glyph. Those are the two
138
+ * things "what is this drawn as" can answer, and the author has no reason to
139
+ * care which category their answer fell into. The test that separates them is
140
+ * whether the node still sizes itself from its label: a `document` does, a
141
+ * glyph does not.
142
+ */
143
+ export function shapeFor(appearance, line) {
144
+ const named = appearance['shape'];
145
+ if (named === undefined)
146
+ return PLAIN;
147
+ if (named === 'box')
148
+ return PLAIN;
149
+ if (BOX_SHAPES.includes(named))
150
+ return { outline: named };
151
+ const glyph = ICONS[named];
152
+ if (glyph !== undefined)
153
+ return { outline: 'box', body: glyph };
154
+ throw new SourceError(`there is no shape called "${named}". The shapes are box, ${BOX_SHAPES.join(', ')}, ` +
155
+ `and any icon drawn as the node itself: ${ICON_NAMES.join(', ')}`, line);
156
+ }
157
+ export function iconFor(appearance, line) {
158
+ const named = appearance['icon'];
159
+ if (named === undefined)
160
+ return undefined;
161
+ const icon = ICONS[named];
162
+ if (icon === undefined) {
163
+ throw new SourceError(`there is no icon called "${named}". The icons are ${ICON_NAMES.join(', ')}`, line);
164
+ }
165
+ return icon;
166
+ }
@@ -0,0 +1,13 @@
1
+ export * from './ast.js';
2
+ export * from './constants.js';
3
+ export * from './errors.js';
4
+ export * from './grammar.js';
5
+ export * from './measure.js';
6
+ export * from './model.js';
7
+ export { parse } from './parser.js';
8
+ export { resolve, type ResolveOptions } from './resolve.js';
9
+ export { render, DARK_THEME, type RenderOptions, type Theme } from './render.js';
10
+ import { type RenderOptions } from './render.js';
11
+ import { type ResolveOptions } from './resolve.js';
12
+ /** Source text in, SVG out. The whole pipeline in one call. */
13
+ export declare function compile(source: string, options?: ResolveOptions & RenderOptions): string;
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ export * from './ast.js';
2
+ export * from './constants.js';
3
+ export * from './errors.js';
4
+ export * from './grammar.js';
5
+ export * from './measure.js';
6
+ export * from './model.js';
7
+ export { parse } from './parser.js';
8
+ export { resolve } from './resolve.js';
9
+ export { render, DARK_THEME } from './render.js';
10
+ import { parse } from './parser.js';
11
+ import { render } from './render.js';
12
+ import { resolve } from './resolve.js';
13
+ /** Source text in, SVG out. The whole pipeline in one call. */
14
+ export function compile(source, options = {}) {
15
+ return render(resolve(parse(source), options), options);
16
+ }
@@ -0,0 +1,25 @@
1
+ export interface Token {
2
+ text: string;
3
+ /** True when the token came from a quoted string, so `foo:` inside it is literal. */
4
+ quoted: boolean;
5
+ }
6
+ /**
7
+ * Split one line into tokens. Whitespace separates; double quotes group, with
8
+ * `\"` and `\\` as the only escapes. A `//` outside quotes starts a comment and
9
+ * runs to the end of the line, so a comment may trail a statement.
10
+ *
11
+ * A lone `/` is an ordinary character, which keeps a path or a ratio writable
12
+ * unquoted. `#` is ordinary too: it opens a hex color, which is why comments
13
+ * are spelled `//` rather than the `#` an earlier version used.
14
+ *
15
+ * Parentheses group the modifiers on a placement — `left of hub (gap: wide)` —
16
+ * and are tokens in their own right so that `(gap:` does not read as one word
17
+ * ending in a colon. They are deliberately *not* punctuation everywhere: an
18
+ * opening bracket counts only where a token starts, and a closing one only
19
+ * while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
20
+ *
21
+ * Returns an empty array for a blank or comment-only line.
22
+ */
23
+ export declare function tokenizeLine(line: string, lineNumber: number): Token[];
24
+ /** A bare token ending in `:` opens the attribute section of a statement. */
25
+ export declare function isAttrKey(token: Token): boolean;
package/dist/lexer.js ADDED
@@ -0,0 +1,91 @@
1
+ import { SourceError } from './errors.js';
2
+ /**
3
+ * Split one line into tokens. Whitespace separates; double quotes group, with
4
+ * `\"` and `\\` as the only escapes. A `//` outside quotes starts a comment and
5
+ * runs to the end of the line, so a comment may trail a statement.
6
+ *
7
+ * A lone `/` is an ordinary character, which keeps a path or a ratio writable
8
+ * unquoted. `#` is ordinary too: it opens a hex color, which is why comments
9
+ * are spelled `//` rather than the `#` an earlier version used.
10
+ *
11
+ * Parentheses group the modifiers on a placement — `left of hub (gap: wide)` —
12
+ * and are tokens in their own right so that `(gap:` does not read as one word
13
+ * ending in a colon. They are deliberately *not* punctuation everywhere: an
14
+ * opening bracket counts only where a token starts, and a closing one only
15
+ * while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
16
+ *
17
+ * Returns an empty array for a blank or comment-only line.
18
+ */
19
+ export function tokenizeLine(line, lineNumber) {
20
+ const tokens = [];
21
+ let i = 0;
22
+ let depth = 0;
23
+ while (i < line.length) {
24
+ const ch = line[i];
25
+ if (ch === ' ' || ch === '\t') {
26
+ i += 1;
27
+ continue;
28
+ }
29
+ if (ch === '/' && line[i + 1] === '/')
30
+ break;
31
+ if (ch === '(') {
32
+ depth += 1;
33
+ tokens.push({ text: '(', quoted: false });
34
+ i += 1;
35
+ continue;
36
+ }
37
+ if (ch === ')' && depth > 0) {
38
+ depth -= 1;
39
+ tokens.push({ text: ')', quoted: false });
40
+ i += 1;
41
+ continue;
42
+ }
43
+ if (ch === '"') {
44
+ let text = '';
45
+ i += 1;
46
+ let closed = false;
47
+ while (i < line.length) {
48
+ const c = line[i];
49
+ if (c === '\\' && i + 1 < line.length) {
50
+ const next = line[i + 1];
51
+ // `\/` is the one escape that must survive tokenizing. The line break
52
+ // it escapes is not resolved until `splitLines`, long after this, so
53
+ // collapsing it to a bare `/` here would lose the fact that the
54
+ // author asked for a literal. Every other escape resolves now.
55
+ text += next === '/' ? '\\/' : next;
56
+ i += 2;
57
+ continue;
58
+ }
59
+ if (c === '"') {
60
+ closed = true;
61
+ i += 1;
62
+ break;
63
+ }
64
+ text += c;
65
+ i += 1;
66
+ }
67
+ if (!closed)
68
+ throw new SourceError('unterminated string', lineNumber);
69
+ tokens.push({ text, quoted: true });
70
+ continue;
71
+ }
72
+ let text = '';
73
+ while (i < line.length) {
74
+ const c = line[i];
75
+ if (c === ' ' || c === '\t' || c === '"')
76
+ break;
77
+ if (c === '/' && line[i + 1] === '/')
78
+ break;
79
+ if (c === ')' && depth > 0)
80
+ break;
81
+ text += c;
82
+ i += 1;
83
+ }
84
+ tokens.push({ text, quoted: false });
85
+ }
86
+ return tokens;
87
+ }
88
+ /** A bare token ending in `:` opens the attribute section of a statement. */
89
+ export function isAttrKey(token) {
90
+ return !token.quoted && token.text.length > 1 && token.text.endsWith(':');
91
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Text measurement, kept behind one interface on purpose.
3
+ *
4
+ * The parser and resolver are pure — text in, geometry out — and the only part
5
+ * of the system that needs to know about fonts is this. In Node there is no
6
+ * rendering engine to ask, so the default implementation assumes a monospace
7
+ * face, where every glyph has the same advance width and the answer is
8
+ * arithmetic. In a browser the same interface can be backed by the DOM, which
9
+ * measures exactly.
10
+ */
11
+ export interface TextBox {
12
+ width: number;
13
+ height: number;
14
+ lines: string[];
15
+ }
16
+ export interface Measurer {
17
+ /** Font family written into the SVG. Must match what was measured. */
18
+ readonly fontFamily: string;
19
+ measure(text: string, fontSize: number): TextBox;
20
+ lineHeight(fontSize: number): number;
21
+ }
22
+ /**
23
+ * A slash with whitespace on both sides marks a line break, so a label is
24
+ * really a short stack of lines. Each line is trimmed; empty ones are dropped.
25
+ *
26
+ * The whitespace is what makes the marker safe. Splitting on a bare `/` meant
27
+ * no label could contain one, so `TCP/IP` came out as two lines, and so did
28
+ * `16/9`, `I/O` and every path or URL. Requiring the spaces keeps the marker
29
+ * legible where it is meant — `"Computer 1 / Ubuntu"` — while a
30
+ * slash inside a word stays an ordinary character.
31
+ *
32
+ * That leaves the label that wants a spaced slash and no break — `Before / After`
33
+ * — which writes it `\/`. The lexer preserves that escape rather than resolving
34
+ * it, so the backslash is still here to suppress the split, and is dropped once
35
+ * the splitting is done.
36
+ */
37
+ export declare function splitLines(text: string): string[];
38
+ export declare function monospaceMeasurer(fontFamily?: string, advanceRatio?: number): Measurer;