reladraw 0.14.4 → 0.15.1

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 CHANGED
@@ -82,7 +82,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
82
82
 
83
83
  ## Status
84
84
 
85
- Version 0.14.4. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
85
+ Version 0.15.1. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
86
86
 
87
87
  The language isn't stable yet, so expect the syntax to change.
88
88
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.14.4
1
+ # Syntax reference — 0.15.1
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -212,7 +212,7 @@ A circle is an ordinary node in every other way, and the file means the same thi
212
212
 
213
213
  A name says what the thing *is*, never what the picture looks like: naming the meaning is what lets the drawing be improved later without every diagram that uses it changing sense. `document`, not `folded-corner`. The one place that rule stops is a picture with no single meaning — the cube stands for a container in one diagram, a VM in another, a service in a third — which is why it is called `cube` and not `instance`. An unrecognized name is an error listing the whole set, rather than a node that quietly draws nothing.
214
214
 
215
- The icon set is small on purpose, and it is not the trade a drawing tool makes. There you pick a shape out of a visual palette and hundreds are browsable; here you type the word from memory, which caps the useful vocabulary at what fits in a head. Adding your own is not possible yet — see "Not built yet".
215
+ The icon set is small on purpose, and it is not the trade a drawing tool makes. There you pick a shape out of a visual palette and hundreds are browsable; here you type the word from memory, which caps the useful vocabulary at what fits in a head. A picture outside it is one you declare yourself — see [Your own icons](#your-own-icons).
216
216
 
217
217
  `cubes` draws three whatever the number, because it is the symbol for "several" and not a count. Where the number matters — where one of them is the end of an arrow — they are separate nodes, each with `icon: cube`.
218
218
 
@@ -220,7 +220,7 @@ A node drawn as a picture is an ordinary node in every other way. It takes place
220
220
 
221
221
  **A picture with no text of its own shows none.** Everywhere else a node with no text is labelled with its name, because `node a` and `node b right of a` mean the two boxes to read "a" and "b". A picture usually *is* the statement, so the default flips: `node svc icon: cube` draws the cube and no caption, and a row of five of them does not come out reading a, b, c, d, e. Write the text if you want one.
222
222
 
223
- Icons are drawn from path data inside the tool, never from a font or a linked file. The output is a standalone SVG and has to stay one — an icon font renders as blank boxes on a machine that does not have it, and a linked image has to travel beside the file.
223
+ The built-in icons are drawn from path data inside the tool, never from a font or a linked file. The output is a standalone SVG and has to stay one — an icon font renders as blank boxes on a machine that does not have it, and a linked image has to travel beside the file.
224
224
 
225
225
  ### Badges
226
226
 
@@ -258,6 +258,36 @@ style store fill: #142814 border: #486544 badge: database
258
258
  node records "Records" style: store
259
259
  ```
260
260
 
261
+ ### Your own icons
262
+
263
+ `icon <name>` declares a picture of your own, and from then on `icon: <name>` and `badge: <name>` use it exactly as they would a built-in one. The picture is SVG, pasted between `"""` marks or read from a file:
264
+
265
+ ```
266
+ icon crate """
267
+ <svg viewBox="0 0 24 24">
268
+ <path d="M3 7 L12 3 L21 7 L21 17 L12 21 L3 17 Z" fill="none" stroke="currentColor"/>
269
+ </svg>
270
+ """
271
+
272
+ icon rack "./rack.svg"
273
+
274
+ node store "Warehouse" badge: crate
275
+ node servers icon: rack right of store
276
+ ```
277
+
278
+ Between `"""` marks everything is taken as written, double quotes and line breaks included, so an SVG can be pasted in unchanged. The opening `"""` may start the next line rather than end the `icon` line. A `"""` string is accepted only in an `icon` declaration for now.
279
+
280
+ A file is found relative to the diagram's own file. Only the command-line tool can read one; in the playground or a web page, paste the SVG instead.
281
+
282
+ - **It keeps its own colors.** A pasted drawing looks the same on every theme. What it leaves to `currentColor` takes the theme's icon color, which is how a one-color icon follows the theme.
283
+ - **It is fitted to the icon's square,** in proportion and centered, by its `viewBox`. With no `viewBox`, its `width` and `height` stand in; with neither it is refused, since nothing says how big the drawing is.
284
+ - **Its ids and class names are renamed** on the way in, so two icons that both call a gradient `a` don't pick up each other's.
285
+ - **Script is refused:** a `<script>` element or an `on…` attribute.
286
+
287
+ An icon of your own with a built-in's name replaces the built-in throughout the diagram, so `icon disk "./my-disk.svg"` redraws every disk. Declaring one name twice is an error.
288
+
289
+ The drawing is written into the output, so the SVG stays standalone however the icon was supplied.
290
+
261
291
  ## Placement
262
292
 
263
293
  A *placement* says one thing about where a node goes. A node carries as many as it needs.
@@ -1066,8 +1096,6 @@ Designed, decided, and absent from the code. Written down so the next version ha
1066
1096
 
1067
1097
  **Nothing keeps an edge clear of a node on its own.** Non-overlap applies to nodes only. A line may still cut across a node it has nothing to do with, and an edge text may still land on top of one. `between` and `below <node>` are how you say where a line goes when that matters, and nothing checks the ones where you have not said. A check belongs on the diagnostics list, but finding a route by itself does not — see "What the language refuses".
1068
1098
 
1069
- **An icon outside the built-in seven.** The set is closed, and a diagram wanting a picture that is not in it has nowhere to go. The two shapes this could take are a declaration in the file, `icon <name> "<path data>"` beside `style`, and `icon: ./thing.svg` inlined by the tool at render time. Either keeps the output standalone, which is the constraint any answer has to meet.
1070
-
1071
1099
  **A named channel cannot make room for itself.** Lines through a `between` gap too narrow for them crowd together silently, in exactly the way attachments on a too-short side do. An edge with text *does* now open the gap between its own two ends — see "A text makes room for itself" — and it does so by the measure-then-constrain route that region alignments already use, which is the route this wants too. What is missing is the harder case: several edges sharing a channel between two nodes neither of them is an end of, where the room needed is the whole stack of lanes rather than one text.
1072
1100
 
1073
1101
  ## Known to be wrong
@@ -1112,6 +1140,18 @@ That one was found by testing the lexer, not by rendering — and it could not h
1112
1140
 
1113
1141
  Pre-1.0, so the minor number is where a breaking change goes. Every removal below is refused by name with the replacement quoted, rather than dropped in silence — an older file stops with an error saying what to write instead.
1114
1142
 
1143
+ **0.15.1**
1144
+
1145
+ No syntax changes, and no file that worked is refused. No example diagram draws differently.
1146
+
1147
+ - A fix: many copies of one line slowed down sharply past fifty-five. With fifty-six or more copies of `edge a -> b from: left to: right`, the ends on each side sat under half a pixel apart, and every copy was then routed on its own instead of being drawn as a copy, so sixty took five and a half seconds. They now draw as copies however close their ends sit, and sixty take under a second.
1148
+
1149
+ **0.15.0**
1150
+
1151
+ No file that worked is refused. No example diagram draws differently.
1152
+
1153
+ - Icons of your own. `icon crate """<svg …>…</svg>"""` declares one from pasted SVG, or `icon crate "./crate.svg"` from a file, and `icon: crate` and `badge: crate` then use it like a built-in. One with a built-in's name replaces it. See [Your own icons](#your-own-icons).
1154
+
1115
1155
  **0.14.4**
1116
1156
 
1117
1157
  No syntax changes, and no file that worked is refused. No example diagram draws differently.
package/dist/ast.d.ts CHANGED
@@ -442,7 +442,23 @@ export interface StyleStmt {
442
442
  attrs: Attrs;
443
443
  line: number;
444
444
  }
445
- export type Stmt = NodeStmt | EdgeStmt | StyleStmt | DiagramStmt | DefaultStmt;
445
+ /**
446
+ * `icon <name> <picture>`: a picture of the author's own, used by name wherever
447
+ * a built-in one could be. `source` is as written — the SVG itself, or the path
448
+ * of a file holding it.
449
+ */
450
+ export interface IconStmt {
451
+ kind: 'icon';
452
+ name: string;
453
+ source: string;
454
+ /**
455
+ * True when `source` is the SVG itself: written between `"""` marks, or
456
+ * starting with `<`. Otherwise it names a file.
457
+ */
458
+ pasted: boolean;
459
+ line: number;
460
+ }
461
+ export type Stmt = NodeStmt | EdgeStmt | StyleStmt | DiagramStmt | DefaultStmt | IconStmt;
446
462
  export interface Document {
447
463
  statements: Stmt[];
448
464
  }
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
4
  import { dirname, resolve as resolvePath } from 'node:path';
4
5
  import { SourceError } from './errors.js';
@@ -63,7 +64,12 @@ async function main(argv) {
63
64
  const source = await readFile(input, 'utf8');
64
65
  let svg;
65
66
  try {
66
- svg = compile(source, theme === undefined ? {} : { theme: THEMES[theme] });
67
+ svg = compile(source, {
68
+ ...(theme === undefined ? {} : { theme: THEMES[theme] }),
69
+ // An icon's file is found from the diagram, not from wherever the tool
70
+ // happens to be run.
71
+ readIconFile: (path) => readFileSync(resolvePath(dirname(input), path), 'utf8'),
72
+ });
67
73
  }
68
74
  catch (error) {
69
75
  if (error instanceof SourceError) {
package/dist/grammar.d.ts CHANGED
@@ -64,7 +64,7 @@ export interface Span {
64
64
  * it. A word missing here is a word that draws in the plain color, which is a
65
65
  * dull page rather than a wrong one.
66
66
  */
67
- export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "diagram", "default"];
67
+ export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "diagram", "default", "icon"];
68
68
  /**
69
69
  * Every word that says something about where a thing goes. Assembled from the
70
70
  * lists the parser itself reads, so a direction or a passage axis added there
@@ -74,7 +74,7 @@ export declare const RELATION_WORDS: string[];
74
74
  export declare const PATTERNS: {
75
75
  readonly comment: "\\/\\/.*";
76
76
  readonly string: "\"(?:\\\\.|[^\"\\\\])*\"?";
77
- readonly keyword: `(?:${string})\\b`;
77
+ readonly keyword: `(?:${string})\\b(?!:)`;
78
78
  readonly arrow: `${string}?-{1,2}${string}?(?=[ \\t]|$)`;
79
79
  readonly attribute: "[A-Za-z][A-Za-z0-9_-]*:";
80
80
  readonly color: "#[0-9A-Fa-f]{3,8}\\b";
package/dist/grammar.js CHANGED
@@ -33,9 +33,9 @@ import { DIRECTIONS, MARKS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
33
33
  * it. A word missing here is a word that draws in the plain color, which is a
34
34
  * dull page rather than a wrong one.
35
35
  */
36
- export const STATEMENT_KEYWORDS = ['node', 'edge', 'style', 'diagram', 'default'];
36
+ export const STATEMENT_KEYWORDS = ['node', 'edge', 'style', 'diagram', 'default', 'icon'];
37
37
  /** Statements whose second word declares a name. `diagram` has none. */
38
- const DECLARES_NAME = ['node', 'style'];
38
+ const DECLARES_NAME = ['node', 'style', 'icon'];
39
39
  /**
40
40
  * Every word that says something about where a thing goes. Assembled from the
41
41
  * lists the parser itself reads, so a direction or a passage axis added there
@@ -80,7 +80,9 @@ export const PATTERNS = {
80
80
  // it is being typed, and a highlighter that waits for the quote repaints the
81
81
  // rest of the file on every keystroke.
82
82
  string: '"(?:\\\\.|[^"\\\\])*"?',
83
- keyword: `(?:${alternation(STATEMENT_KEYWORDS)})\\b`,
83
+ // Not before a colon: `icon` is a keyword and `icon:` an attribute, and an
84
+ // indented line may open with the attribute.
85
+ keyword: `(?:${alternation(STATEMENT_KEYWORDS)})\\b(?!:)`,
84
86
  // A mark, one or two dashes, a mark — `->`, `o--odiamond`, `[dot]-`. It must
85
87
  // end the token, so a hyphenated name stays a word. A mark written apart in
86
88
  // brackets, `[dot] -- [arrow]`, colors as plain words either side of `--`.
package/dist/icons.d.ts CHANGED
@@ -30,11 +30,25 @@ export interface IconPath {
30
30
  fill?: IconTone;
31
31
  stroke?: IconTone;
32
32
  }
33
- export interface Icon {
33
+ /** A built-in picture, drawn in the theme's tones. */
34
+ export interface DrawnIcon {
34
35
  /** Side of the square the paths are drawn on. Scaled to the drawn size. */
35
36
  readonly grid: number;
36
37
  readonly paths: readonly IconPath[];
37
38
  }
39
+ /**
40
+ * A picture the author supplied as SVG, written into the output as a nested
41
+ * `<svg>` fitted to the icon's square. It keeps its own colors: a pasted
42
+ * drawing is drawn as it was drawn, on every theme. What it leaves to
43
+ * `currentColor` takes the theme's icon line color, which is how a
44
+ * one-color icon set follows the theme anyway.
45
+ */
46
+ export interface PastedIcon {
47
+ /** The root element's attributes, less the ones the placement sets. */
48
+ readonly attrs: string;
49
+ readonly inner: string;
50
+ }
51
+ export type Icon = DrawnIcon | PastedIcon;
38
52
  /** Line width on the 24-unit grid, scaled with everything else. */
39
53
  export declare const ICON_STROKE = 1.1;
40
54
  export declare const ICONS: Record<string, Icon>;
@@ -93,7 +107,7 @@ export type Body = {
93
107
  * has overridden is unused rather than wrong. Both keys from one level is a
94
108
  * genuine contradiction and is refused by name.
95
109
  */
96
- export declare function bodyFor(attrs: Record<string, string>, appearance: Record<string, string>, line: number): Body;
110
+ export declare function bodyFor(attrs: Record<string, string>, appearance: Record<string, string>, line: number, declared?: ReadonlyMap<string, Icon>): Body;
97
111
  /**
98
112
  * The icon of that name, or an error listing the set.
99
113
  *
@@ -101,7 +115,20 @@ export declare function bodyFor(attrs: Record<string, string>, appearance: Recor
101
115
  * `DIAGRAM_KEYS` follows and it is here for the same reason: a misspelt
102
116
  * `icon: laptp` that quietly draws nothing is indistinguishable from the tool
103
117
  * being broken, and an author will stare at the file looking for the mistake in
104
- * the wrong place. Since the vocabulary is closed and short, the error can list
105
- * the whole of it.
118
+ * the wrong place. The vocabulary is short enough for the error to list all of
119
+ * it, the file's own icons included.
120
+ *
121
+ * An icon the file declares wins over a built-in of the same name, so an author
122
+ * who dislikes the drawing of `disk` can replace it everywhere at once.
123
+ */
124
+ export declare function iconNamed(named: string, line: number, declared?: ReadonlyMap<string, Icon>): Icon;
125
+ /**
126
+ * Read an author's SVG into an icon. `name` makes the drawing's own ids and
127
+ * class names unique, so two pasted icons that both call a gradient `a` do not
128
+ * paint with each other's.
129
+ *
130
+ * Refused, each by name: text with no `<svg>` in it, a drawing with no way to
131
+ * tell its size, and anything that runs script, which a diagram has no use for
132
+ * and a browser showing the diagram might execute.
106
133
  */
107
- export declare function iconNamed(named: string, line: number): Icon;
134
+ export declare function pastedIcon(source: string, name: string, line: number): PastedIcon;
package/dist/icons.js CHANGED
@@ -149,7 +149,7 @@ const PLAIN = { kind: 'shape', outline: 'rectangle' };
149
149
  * has overridden is unused rather than wrong. Both keys from one level is a
150
150
  * genuine contradiction and is refused by name.
151
151
  */
152
- export function bodyFor(attrs, appearance, line) {
152
+ export function bodyFor(attrs, appearance, line, declared = new Map()) {
153
153
  const wroteShape = attrs['shape'] !== undefined;
154
154
  const wroteIcon = attrs['icon'] !== undefined;
155
155
  let shape = wroteIcon && !wroteShape ? undefined : appearance['shape'];
@@ -159,7 +159,7 @@ export function bodyFor(attrs, appearance, line) {
159
159
  'body — `shape:` is the outline it is drawn with, `icon:` is the picture it is drawn as', line);
160
160
  }
161
161
  if (icon !== undefined)
162
- return { kind: 'icon', icon: iconNamed(icon, line) };
162
+ return { kind: 'icon', icon: iconNamed(icon, line, declared) };
163
163
  if (shape === undefined)
164
164
  return PLAIN;
165
165
  if (shape === 'none')
@@ -194,14 +194,101 @@ function refuse(message, line) {
194
194
  * `DIAGRAM_KEYS` follows and it is here for the same reason: a misspelt
195
195
  * `icon: laptp` that quietly draws nothing is indistinguishable from the tool
196
196
  * being broken, and an author will stare at the file looking for the mistake in
197
- * the wrong place. Since the vocabulary is closed and short, the error can list
198
- * the whole of it.
197
+ * the wrong place. The vocabulary is short enough for the error to list all of
198
+ * it, the file's own icons included.
199
+ *
200
+ * An icon the file declares wins over a built-in of the same name, so an author
201
+ * who dislikes the drawing of `disk` can replace it everywhere at once.
199
202
  */
200
- export function iconNamed(named, line) {
201
- const icon = ICONS[named];
203
+ export function iconNamed(named, line, declared = new Map()) {
204
+ const icon = declared.get(named) ?? ICONS[named];
202
205
  if (icon === undefined) {
203
206
  const was = named === 'instance' ? ' — the cube was called `instance` until 0.3.0' : '';
204
- throw new SourceError(`there is no icon called "${named}". The icons are ${ICON_NAMES.join(', ')}${was}`, line);
207
+ const all = [...new Set([...ICON_NAMES, ...declared.keys()])];
208
+ throw new SourceError(`there is no icon called "${named}". The icons are ${all.join(', ')}${was}`, line);
205
209
  }
206
210
  return icon;
207
211
  }
212
+ /**
213
+ * Read an author's SVG into an icon. `name` makes the drawing's own ids and
214
+ * class names unique, so two pasted icons that both call a gradient `a` do not
215
+ * paint with each other's.
216
+ *
217
+ * Refused, each by name: text with no `<svg>` in it, a drawing with no way to
218
+ * tell its size, and anything that runs script, which a diagram has no use for
219
+ * and a browser showing the diagram might execute.
220
+ */
221
+ export function pastedIcon(source, name, line) {
222
+ const start = source.search(/<svg[\s>/]/);
223
+ if (start === -1) {
224
+ throw new SourceError(`icon "${name}" is not an SVG — it should contain an <svg> element`, line);
225
+ }
226
+ if (/<script[\s>]/i.test(source) || /\son[a-z]+\s*=/i.test(source)) {
227
+ throw new SourceError(`icon "${name}" contains script, which a diagram has no use for and a browser showing it ` +
228
+ 'might run — remove the <script> element or the on… attribute', line);
229
+ }
230
+ const open = /^<svg((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)\s*(\/?)>/.exec(source.slice(start));
231
+ if (open === null) {
232
+ throw new SourceError(`icon "${name}" has an <svg> tag that does not close — it needs its >`, line);
233
+ }
234
+ const bodyStart = start + open[0].length;
235
+ const end = source.lastIndexOf('</svg>');
236
+ if (open[2] !== '/' && end < bodyStart) {
237
+ throw new SourceError(`icon "${name}" has no </svg> to end it`, line);
238
+ }
239
+ let inner = open[2] === '/' ? '' : source.slice(bodyStart, end);
240
+ const attrs = new Map();
241
+ for (const [, key, value] of open[1].matchAll(/([^\s=]+)(?:\s*=\s*("[^"]*"|'[^']*'))?/g)) {
242
+ attrs.set(key, value ?? '""');
243
+ }
244
+ // The placement sets where the drawing goes and how big it is, and a nested
245
+ // <svg> needs no namespace of its own.
246
+ const width = attrs.get('width');
247
+ const height = attrs.get('height');
248
+ for (const key of [...attrs.keys()]) {
249
+ if (['x', 'y', 'width', 'height', 'version'].includes(key) || key === 'xmlns' || key.startsWith('xmlns:')) {
250
+ attrs.delete(key);
251
+ }
252
+ }
253
+ if (!attrs.has('viewBox')) {
254
+ const number = (value) => Number(value?.slice(1, -1).replace(/px$/, ''));
255
+ const w = number(width);
256
+ const h = number(height);
257
+ if (!(w > 0 && h > 0)) {
258
+ throw new SourceError(`icon "${name}" has no viewBox and no width and height to take one from, so there is no ` +
259
+ 'telling how big its drawing is — add viewBox="0 0 <width> <height>"', line);
260
+ }
261
+ attrs.set('viewBox', `"0 0 ${w} ${h}"`);
262
+ }
263
+ // The output declares no xlink namespace, and SVG 2 reads a plain `href`.
264
+ inner = inner.replace(/\sxlink:href=/g, ' href=');
265
+ const prefix = `rd-${name.replace(/[^A-Za-z0-9_-]/g, '_')}-`;
266
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
267
+ // An id is renamed where it is defined and where it is referred to, which is
268
+ // `url(#…)` or `href="#…"` — never a bare `#…`, which may be a color.
269
+ for (const [, , double, single] of inner.matchAll(/\sid\s*=\s*("([^"]*)"|'([^']*)')/g)) {
270
+ const id = double ?? single;
271
+ const pattern = escape(id);
272
+ inner = inner
273
+ .replace(new RegExp(`(\\sid\\s*=\\s*["'])${pattern}(["'])`, 'g'), `$1${prefix}${id}$2`)
274
+ .replace(new RegExp(`(url\\(\\s*['"]?#)${pattern}(?=['"]?\\s*\\))`, 'g'), `$1${prefix}${id}`)
275
+ .replace(new RegExp(`(\\shref\\s*=\\s*["']#)${pattern}(?=["'])`, 'g'), `$1${prefix}${id}`);
276
+ }
277
+ // A class is renamed on the elements that wear it and in the drawing's own
278
+ // <style> blocks, since a stylesheet in an SVG applies to the whole document.
279
+ const classes = new Set();
280
+ for (const [, , double, single] of inner.matchAll(/\sclass\s*=\s*("([^"]*)"|'([^']*)')/g)) {
281
+ for (const each of (double ?? single).split(/\s+/))
282
+ if (each !== '')
283
+ classes.add(each);
284
+ }
285
+ inner = inner.replace(/(\sclass\s*=\s*["'])([^"']*)/g, (_, key, value) => key + value.replace(/[^\s]+/g, (each) => prefix + each));
286
+ inner = inner.replace(/(<style[^>]*>)([\s\S]*?)(<\/style>)/g, (_, open, css, close) => {
287
+ for (const each of classes) {
288
+ css = css.replace(new RegExp(`\\.${escape(each)}(?![\\w-])`, 'g'), `.${prefix}${each}`);
289
+ }
290
+ return open + css + close;
291
+ });
292
+ const written = [...attrs].map(([key, value]) => `${key}=${value}`).join(' ');
293
+ return { attrs: written, inner: inner.trim() };
294
+ }
package/dist/lexer.d.ts CHANGED
@@ -1,7 +1,17 @@
1
+ import { SourceError } from './errors.js';
1
2
  export interface Token {
2
3
  text: string;
3
4
  /** True when the token came from a quoted string, so `foo:` inside it is literal. */
4
5
  quoted: boolean;
6
+ /** True when the string was written between `"""` marks. */
7
+ triple?: boolean;
8
+ }
9
+ /**
10
+ * A `"""` string still open at the end of the text given. `parse` catches this
11
+ * one, adds the next line and tries again, which is how a string runs over
12
+ * several lines without the lexer having to know about lines.
13
+ */
14
+ export declare class OpenString extends SourceError {
5
15
  }
6
16
  /**
7
17
  * Split one line into tokens. Whitespace separates; double quotes group, with
@@ -21,6 +31,9 @@ export interface Token {
21
31
  * `depth` is how many brackets are still open from the lines before, when a
22
32
  * statement continues onto this one.
23
33
  *
34
+ * `line` may hold line breaks, but only inside a `"""` string: `parse` joins
35
+ * the lines such a string spans before handing them over.
36
+ *
24
37
  * Returns an empty array for a blank or comment-only line.
25
38
  */
26
39
  export declare function tokenizeLine(line: string, lineNumber: number, depth?: number): Token[];
package/dist/lexer.js CHANGED
@@ -1,4 +1,11 @@
1
1
  import { SourceError } from './errors.js';
2
+ /**
3
+ * A `"""` string still open at the end of the text given. `parse` catches this
4
+ * one, adds the next line and tries again, which is how a string runs over
5
+ * several lines without the lexer having to know about lines.
6
+ */
7
+ export class OpenString extends SourceError {
8
+ }
2
9
  /**
3
10
  * Split one line into tokens. Whitespace separates; double quotes group, with
4
11
  * `\"` and `\\` as the only escapes. A `//` outside quotes starts a comment and
@@ -17,6 +24,9 @@ import { SourceError } from './errors.js';
17
24
  * `depth` is how many brackets are still open from the lines before, when a
18
25
  * statement continues onto this one.
19
26
  *
27
+ * `line` may hold line breaks, but only inside a `"""` string: `parse` joins
28
+ * the lines such a string spans before handing them over.
29
+ *
20
30
  * Returns an empty array for a blank or comment-only line.
21
31
  */
22
32
  export function tokenizeLine(line, lineNumber, depth = 0) {
@@ -42,6 +52,17 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
42
52
  i += 1;
43
53
  continue;
44
54
  }
55
+ // Between `"""` marks everything is taken as written, quotes and line breaks
56
+ // included, with no escapes. That is what makes an SVG pasteable: it is full
57
+ // of double quotes and never has three in a row.
58
+ if (line.startsWith('"""', i)) {
59
+ const end = line.indexOf('"""', i + 3);
60
+ if (end === -1)
61
+ throw new OpenString('this """ string is never closed — end it with """', lineNumber);
62
+ tokens.push({ text: line.slice(i + 3, end), quoted: true, triple: true });
63
+ i = end + 3;
64
+ continue;
65
+ }
45
66
  if (ch === '"') {
46
67
  let text = '';
47
68
  i += 1;
package/dist/parser.d.ts CHANGED
@@ -10,6 +10,10 @@ import { SourceError } from './errors.js';
10
10
  * it. An indented line with nothing open above it is refused rather than read
11
11
  * as a statement of its own: it is a stray indent or a deleted first line, and
12
12
  * both are worth hearing about.
13
+ *
14
+ * A `"""` string is the one thing that crosses these rules: the lines it spans
15
+ * are its own, blank or not, and a line opening with one continues the
16
+ * statement above without being indented.
13
17
  */
14
18
  export declare function parse(source: string): Document;
15
19
  /**
package/dist/parser.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { STATEMENT_KEYWORDS } from './grammar.js';
4
- import { isAttrKey, tokenizeLine } from './lexer.js';
4
+ import { isAttrKey, OpenString, tokenizeLine } from './lexer.js';
5
5
  import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
6
6
  /**
7
7
  * Parse a whole source file. A statement starts on an unindented line and runs
@@ -13,6 +13,10 @@ import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
13
13
  * it. An indented line with nothing open above it is refused rather than read
14
14
  * as a statement of its own: it is a stray indent or a deleted first line, and
15
15
  * both are worth hearing about.
16
+ *
17
+ * A `"""` string is the one thing that crosses these rules: the lines it spans
18
+ * are its own, blank or not, and a line opening with one continues the
19
+ * statement above without being indented.
16
20
  */
17
21
  export function parse(source) {
18
22
  const statements = [];
@@ -27,26 +31,53 @@ export function parse(source) {
27
31
  open = null;
28
32
  depth = 0;
29
33
  };
30
- source.split(/\r?\n/).forEach((line, index) => {
34
+ const lines = source.split(/\r?\n/);
35
+ for (let index = 0; index < lines.length; index++) {
31
36
  const lineNumber = index + 1;
37
+ let line = lines[index];
32
38
  if (line.trim() === '') {
33
39
  close();
34
- return;
40
+ continue;
35
41
  }
42
+ // A line opening with `"""` continues the statement above without being
43
+ // indented, because nothing else could start with one: no statement begins
44
+ // with a string, so `icon rack` on one line and the SVG from the next is
45
+ // unambiguous. The exception is that narrow on purpose — a statement that
46
+ // could carry on through any line would turn a forgotten word into an error
47
+ // about some other line.
36
48
  const indented = line[0] === ' ' || line[0] === '\t';
37
- if (!indented)
49
+ const continues = indented || line.startsWith('"""');
50
+ if (!continues)
38
51
  close();
39
- const tokens = tokenizeLine(line, lineNumber, depth);
52
+ // A `"""` string may run on over the lines after it, blank ones included,
53
+ // and every line it covers belongs to it rather than to the statement
54
+ // structure above.
55
+ let tokens;
56
+ for (;;) {
57
+ try {
58
+ tokens = tokenizeLine(line, lineNumber, depth);
59
+ break;
60
+ }
61
+ catch (error) {
62
+ if (!(error instanceof OpenString) || index + 1 >= lines.length)
63
+ throw error;
64
+ index += 1;
65
+ line += '\n' + lines[index];
66
+ }
67
+ }
40
68
  if (tokens.length === 0)
41
- return;
42
- if (!indented) {
69
+ continue;
70
+ if (!continues) {
43
71
  open = { tokens, line: lineNumber };
44
72
  }
45
73
  else {
46
74
  const first = tokens[0];
47
75
  if (!open) {
48
- throw new SourceError('this line is indented, which continues the statement above, but there is none to ' +
49
- 'continue — remove the indentation to start a new statement', lineNumber);
76
+ throw new SourceError(indented
77
+ ? 'this line is indented, which continues the statement above, but there is none to ' +
78
+ 'continue — remove the indentation to start a new statement'
79
+ : 'a """ string at the start of a line belongs to the statement above, but there is ' +
80
+ 'none — write it after the words it goes with, such as `icon rack`', lineNumber);
50
81
  }
51
82
  if (!first.quoted && STATEMENT_KEYWORDS.includes(first.text)) {
52
83
  throw new SourceError(`\`${first.text}\` cannot continue the statement above — an indented line continues ` +
@@ -62,7 +93,7 @@ export function parse(source) {
62
93
  else if (token.text === ')')
63
94
  depth -= 1;
64
95
  }
65
- });
96
+ }
66
97
  close();
67
98
  return { statements };
68
99
  }
@@ -108,7 +139,16 @@ function parseStatement(tokens, line) {
108
139
  if (!keyword || keyword.quoted) {
109
140
  throw new SourceError('a statement must begin with a keyword', line);
110
141
  }
142
+ // What a line break inside a node's or an edge's text should mean has not
143
+ // been decided, so until it is a `"""` string is refused anywhere but the one
144
+ // place it was made for, rather than drawn in some way that might change.
145
+ if (keyword.text !== 'icon' && tokens.some((token) => token.triple)) {
146
+ throw new SourceError('a """ string holds the SVG of an `icon` declaration and is not accepted anywhere else yet — ' +
147
+ 'use ordinary quotes here', line);
148
+ }
111
149
  switch (keyword.text) {
150
+ case 'icon':
151
+ return parseIcon(tokens, line);
112
152
  case 'node':
113
153
  return parseNode(tokens, line);
114
154
  case 'edge':
@@ -712,6 +752,22 @@ function parseStyle(head, line) {
712
752
  }
713
753
  return { kind: 'style', name, attrs, line };
714
754
  }
755
+ /**
756
+ * `icon <name> "<svg>…</svg>"`, or `icon <name> ./file.svg`. What the picture
757
+ * is — pasted markup or a file to read — is worked out when the diagram is
758
+ * resolved, since only the caller knows whether files can be read at all.
759
+ */
760
+ function parseIcon(head, line) {
761
+ const name = requireName(head[1], 'icon', line);
762
+ const picture = head[2];
763
+ if (picture === undefined || isAttrKey(picture)) {
764
+ throw new SourceError(`icon "${name}" needs its picture: the SVG itself between """ marks, or the name of an .svg file`, line);
765
+ }
766
+ if (head.length > 3) {
767
+ throw new SourceError(`icon "${name}" takes one picture and nothing else — "${head[3].text}" is left over`, line);
768
+ }
769
+ return { kind: 'icon', name, source: picture.text, pasted: picture.triple === true || picture.text.trim().startsWith('<'), line };
770
+ }
715
771
  /**
716
772
  * `diagram <attributes>` — no name, because a file holds one diagram. Unknown
717
773
  * keys are refused rather than ignored: a misspelt diagram-wide setting that
package/dist/render.js CHANGED
@@ -335,6 +335,17 @@ function outlineDetail(shape, x, y, w, h) {
335
335
  }
336
336
  /** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
337
337
  function drawIcon(icon, x, y, side, theme) {
338
+ if ('inner' in icon) {
339
+ // Its own viewBox fits it to the square, centered and in proportion.
340
+ // `color` is set only when the drawing does not set it, so `currentColor`
341
+ // means the theme's icon line unless the author said otherwise.
342
+ const color = /(^|\s)color=/.test(icon.attrs) ? '' : ` color="${theme.iconInk}"`;
343
+ return [
344
+ ` <svg x="${round(x)}" y="${round(y)}" width="${round(side)}" height="${round(side)}"${color} ${icon.attrs}>`,
345
+ icon.inner,
346
+ ' </svg>',
347
+ ].join('\n');
348
+ }
338
349
  const scale = side / icon.grid;
339
350
  const color = (tone) => tone === 'ink' ? theme.iconInk : tone === 'shade' ? theme.iconShade : theme.background;
340
351
  const paths = icon.paths.map((path) => {
@@ -1877,7 +1888,10 @@ function planWays(edges, nodes, ends, corridors, measurer, fontSize) {
1877
1888
  const normal = heading.map((one) => ({ x: -one.y, y: one.x }));
1878
1889
  const along = (from, to, axis) => (to.x - from.x) * axis.x + (to.y - from.y) * axis.y;
1879
1890
  const shift = along(base[0], start, normal[0]);
1880
- if (Math.abs(shift) < 0.5 || Math.abs(along(base[0], start, heading[0])) > 0.5)
1891
+ // Any shift will do, however small: where more lines leave a side than it
1892
+ // has room for, their ends sit under half a pixel apart, and refusing those
1893
+ // sent fifty-six copies to the search and took seconds.
1894
+ if (Math.abs(shift) < 1e-6 || Math.abs(along(base[0], start, heading[0])) > 0.5)
1881
1895
  return undefined;
1882
1896
  if (Math.abs(along(base[count], end, normal[count - 1]) - shift) > 0.5 || Math.abs(along(base[count], end, heading[count - 1])) > 0.5) {
1883
1897
  return undefined;
package/dist/resolve.d.ts CHANGED
@@ -6,6 +6,12 @@ export interface ResolveOptions {
6
6
  fontSize?: number;
7
7
  /** Blank space kept around the whole diagram. */
8
8
  margin?: number;
9
+ /**
10
+ * Read the file an `icon` declaration names, as text. The command-line tool
11
+ * supplies it, reading relative to the diagram's own file; without it, as in
12
+ * a browser, an icon has to be pasted in rather than named by file.
13
+ */
14
+ readIconFile?: (path: string) => string;
9
15
  }
10
16
  /**
11
17
  * Turn a parsed document into solved geometry.
package/dist/resolve.js CHANGED
@@ -2,7 +2,7 @@ import { ALL_ATTR_KEYS, ATTR_KEYS, MARKS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, C
2
2
  import { BARE_EDGE_RUN, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
3
3
  import { fix, reachability, tightest } from './constrain.js';
4
4
  import { SourceError } from './errors.js';
5
- import { bodyFor } from './icons.js';
5
+ import { bodyFor, pastedIcon } from './icons.js';
6
6
  import { monospaceMeasurer } from './measure.js';
7
7
  import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
8
8
  /**
@@ -24,7 +24,8 @@ export function resolve(doc, options = {}) {
24
24
  const styles = collectStyles(doc.statements);
25
25
  checkStyleKeys(doc.statements);
26
26
  const defaults = collectDefaults(doc.statements, styles);
27
- const { nodes, byName, roots } = buildTree(doc.statements, styles, defaults);
27
+ const icons = collectIcons(doc.statements, options.readIconFile);
28
+ const { nodes, byName, roots } = buildTree(doc.statements, styles, defaults, icons);
28
29
  // Edges are resolved to nodes before anything is sized, because a labeled
29
30
  // edge claims room in the gap it crosses and so has to be in hand while the
30
31
  // gaps are being worked out. Nothing here reads geometry.
@@ -59,6 +60,33 @@ function collectStyles(statements) {
59
60
  }
60
61
  return styles;
61
62
  }
63
+ /** The file's own icons, by name, each pasted in or read from its file. */
64
+ function collectIcons(statements, readIconFile) {
65
+ const icons = new Map();
66
+ for (const stmt of statements) {
67
+ if (stmt.kind !== 'icon')
68
+ continue;
69
+ if (icons.has(stmt.name)) {
70
+ throw new SourceError(`icon "${stmt.name}" is declared twice`, stmt.line);
71
+ }
72
+ let svg = stmt.source.trim();
73
+ if (!stmt.pasted) {
74
+ if (readIconFile === undefined) {
75
+ throw new SourceError(`icon "${stmt.name}" names a file, "${svg}", and files can be read only by the ` +
76
+ 'command-line tool — paste the SVG itself between """ marks instead', stmt.line);
77
+ }
78
+ try {
79
+ svg = readIconFile(svg);
80
+ }
81
+ catch (error) {
82
+ const why = error instanceof Error ? error.message : String(error);
83
+ throw new SourceError(`icon "${stmt.name}" could not read "${svg}": ${why}`, stmt.line);
84
+ }
85
+ }
86
+ icons.set(stmt.name, pastedIcon(svg, stmt.name, stmt.line));
87
+ }
88
+ return icons;
89
+ }
62
90
  /**
63
91
  * Each kind's default, with any style it names already folded in beneath its
64
92
  * own words, so a default arrives at a node as one flat bundle. The same kind
@@ -105,7 +133,7 @@ function collectDiagram(statements) {
105
133
  }
106
134
  return found ?? {};
107
135
  }
108
- function buildTree(statements, styles, defaults) {
136
+ function buildTree(statements, styles, defaults, icons) {
109
137
  const nodes = [];
110
138
  const byName = new Map();
111
139
  const roots = [];
@@ -140,7 +168,7 @@ function buildTree(statements, styles, defaults) {
140
168
  // Weakest first: every node's default, then the leaf's or the container's,
141
169
  // then the node's own styles and words over both.
142
170
  const appearance = over(over(defaults.get('node') ?? {}, defaults.get(parents.has(stmt.name) ? 'container' : 'leaf') ?? {}), appearanceOf(stmt.attrs, styles, stmt.line));
143
- const body = bodyFor(stmt.attrs, appearance, stmt.line);
171
+ const body = bodyFor(stmt.attrs, appearance, stmt.line, icons);
144
172
  const kind = KIND_OF_BODY[body.kind];
145
173
  // A node with no text of its own is labelled with its name, because the
146
174
  // first lines anybody types are `node a` and `node b right of a` and they
@@ -208,7 +236,7 @@ function buildTree(statements, styles, defaults) {
208
236
  // the other kinds refuse the word in `checkAttrs`, and a style's is unused.
209
237
  const named = appearance['badge'];
210
238
  if (named !== undefined && body.kind === 'shape') {
211
- const badge = badgeChild(node, named);
239
+ const badge = badgeChild(node, named, icons);
212
240
  badges.set(badge.name, node.name);
213
241
  node.children.push(badge);
214
242
  nodes.push(badge);
@@ -218,13 +246,13 @@ function buildTree(statements, styles, defaults) {
218
246
  return { nodes, byName, roots };
219
247
  }
220
248
  /** The child `badge:` writes out, as `buildTree` describes. */
221
- function badgeChild(parent, named) {
249
+ function badgeChild(parent, named, icons) {
222
250
  const icon = { icon: named };
223
251
  const size = parent.textAttrs['size'];
224
252
  return {
225
253
  name: `${parent.name}.badge`,
226
254
  kind: 'icon',
227
- body: bodyFor(icon, icon, parent.line),
255
+ body: bodyFor(icon, icon, parent.line, icons),
228
256
  text: '',
229
257
  lines: linesFor('', {}, `"${parent.name}.badge"`, parent.line),
230
258
  children: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.14.4",
3
+ "version": "0.15.1",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {