reladraw 0.15.1 → 0.17.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/cli.js CHANGED
@@ -1,31 +1,62 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { dirname, resolve as resolvePath } from 'node:path';
5
+ import { nearMatch, readChecks } from './checks.js';
5
6
  import { SourceError } from './errors.js';
6
- import { compile } from './index.js';
7
+ import { draw } from './index.js';
7
8
  import { THEME_NAMES, THEMES } from './themes.js';
8
- const USAGE = `reladraw — render a diagram from stated placement
9
+ const USAGE = `reladraw — draw a diagram from stated placement
9
10
 
10
- reladraw <input.reladraw> [-o <output.svg>] [--theme <name>]
11
+ reladraw render <input.reladraw> [-o <output.svg>] [options]
12
+ reladraw check <input.reladraw> [options]
11
13
 
12
- -o, --out where to write the SVG. Defaults to the input path with
14
+ render draw the diagram to an SVG, and print any findings: places
15
+ where the picture, drawn as written, may not read as meant.
16
+ check lay the diagram out and print only the findings. No SVG is
17
+ written, so a good one is left alone while you iterate.
18
+
19
+ -o, --out where render writes the SVG. Defaults to the input path with
13
20
  its extension replaced by .svg. Use - for standard output.
14
21
  --theme render in this theme, whatever the file's \`diagram theme:\`
15
22
  says. One of ${THEME_NAMES.join(', ')}.
23
+ --guides draw the guides: everything written with \`guide\` in front,
24
+ dashed, to see how the picture is built.
25
+ --checks turn kinds of finding on or off, over what the file says:
26
+ --checks "edge-crossings: on, node-overlap: off"
27
+ --no-checks print no findings. Errors still print.
16
28
  -h, --help print this.
17
29
  `;
30
+ const COMMANDS = ['render', 'check'];
18
31
  async function main(argv) {
19
32
  if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
20
33
  process.stdout.write(USAGE);
21
34
  return argv.length === 0 ? 1 : 0;
22
35
  }
36
+ const verb = argv[0];
37
+ if (!COMMANDS.includes(verb)) {
38
+ // A file where the command goes is the old form, and is told the new one;
39
+ // anything else is a command that does not exist, with the near one.
40
+ if (/\.reladraw$/.test(verb) || existsSync(verb)) {
41
+ process.stderr.write(`reladraw: a command comes first — reladraw render ${argv.join(' ')}\n`);
42
+ }
43
+ else {
44
+ const near = nearMatch(verb, COMMANDS);
45
+ process.stderr.write(`reladraw: there is no command "${verb}"` +
46
+ (near ? ` — did you mean ${near}?\n` : ` — the commands are ${COMMANDS.join(', ')}\n`));
47
+ }
48
+ return 1;
49
+ }
50
+ const command = verb;
23
51
  let input;
24
52
  let out;
25
53
  let theme;
26
- for (let i = 0; i < argv.length; i += 1) {
54
+ let checks;
55
+ let noChecks = false;
56
+ let guides = false;
57
+ for (let i = 1; i < argv.length; i += 1) {
27
58
  const arg = argv[i];
28
- if (arg === '-o' || arg === '--out') {
59
+ if ((arg === '-o' || arg === '--out') && command === 'render') {
29
60
  out = argv[i + 1];
30
61
  if (out === undefined) {
31
62
  process.stderr.write('reladraw: -o needs a path\n');
@@ -33,6 +64,10 @@ async function main(argv) {
33
64
  }
34
65
  i += 1;
35
66
  }
67
+ else if (arg === '-o' || arg === '--out') {
68
+ process.stderr.write('reladraw: check writes no SVG, so it takes no -o; reladraw render writes one\n');
69
+ return 1;
70
+ }
36
71
  else if (arg === '--theme') {
37
72
  theme = argv[i + 1];
38
73
  if (theme === undefined) {
@@ -45,6 +80,20 @@ async function main(argv) {
45
80
  }
46
81
  i += 1;
47
82
  }
83
+ else if (arg === '--checks') {
84
+ checks = argv[i + 1];
85
+ if (checks === undefined) {
86
+ process.stderr.write('reladraw: --checks needs a list, such as --checks "edge-crossings: on"\n');
87
+ return 1;
88
+ }
89
+ i += 1;
90
+ }
91
+ else if (arg === '--no-checks') {
92
+ noChecks = true;
93
+ }
94
+ else if (arg === '--guides') {
95
+ guides = true;
96
+ }
48
97
  else if (arg.startsWith('-') && arg !== '-') {
49
98
  process.stderr.write(`reladraw: unknown option ${arg}\n`);
50
99
  return 1;
@@ -58,17 +107,40 @@ async function main(argv) {
58
107
  }
59
108
  }
60
109
  if (input === undefined) {
61
- process.stderr.write('reladraw: no input file\n');
110
+ process.stderr.write(`reladraw: no input file — reladraw ${command} <input.reladraw>\n`);
111
+ return 1;
112
+ }
113
+ if (noChecks && checks !== undefined) {
114
+ process.stderr.write('reladraw: --no-checks and --checks say opposite things; give one\n');
115
+ return 1;
116
+ }
117
+ if (noChecks && command === 'check') {
118
+ process.stderr.write('reladraw: check with --no-checks would print nothing; reladraw render draws\n');
62
119
  return 1;
63
120
  }
121
+ // Read once here, so a mistake in --checks is reported before the file is drawn.
122
+ let overChecks;
123
+ if (checks !== undefined) {
124
+ const written = checks;
125
+ try {
126
+ readChecks(written, {});
127
+ }
128
+ catch (error) {
129
+ process.stderr.write(`reladraw: --checks: ${error.message}\n`);
130
+ return 1;
131
+ }
132
+ overChecks = (fromFile) => readChecks(written, fromFile);
133
+ }
64
134
  const source = await readFile(input, 'utf8');
65
- let svg;
135
+ let drawn;
66
136
  try {
67
- svg = compile(source, {
137
+ drawn = draw(source, {
68
138
  ...(theme === undefined ? {} : { theme: THEMES[theme] }),
139
+ ...(guides ? { guides: true } : {}),
69
140
  // An icon's file is found from the diagram, not from wherever the tool
70
141
  // happens to be run.
71
142
  readIconFile: (path) => readFileSync(resolvePath(dirname(input), path), 'utf8'),
143
+ ...(noChecks ? { checks: false } : overChecks ? { checks: overChecks } : {}),
72
144
  });
73
145
  }
74
146
  catch (error) {
@@ -78,13 +150,17 @@ async function main(argv) {
78
150
  }
79
151
  throw error;
80
152
  }
153
+ for (const finding of drawn.findings)
154
+ process.stderr.write(`${finding.format(input)}\n`);
155
+ if (command === 'check')
156
+ return 0;
81
157
  if (out === '-') {
82
- process.stdout.write(svg);
158
+ process.stdout.write(drawn.svg);
83
159
  return 0;
84
160
  }
85
161
  const target = resolvePath(out ?? input.replace(/\.[^.]+$/, '') + '.svg');
86
162
  await mkdir(dirname(target), { recursive: true });
87
- await writeFile(target, svg, 'utf8');
163
+ await writeFile(target, drawn.svg, 'utf8');
88
164
  process.stderr.write(`${target}\n`);
89
165
  return 0;
90
166
  }
@@ -4,21 +4,24 @@ import type { Measurer } from './measure.js';
4
4
  import { type Line } from './text.js';
5
5
  /** Inside a box, between its border and its contents. */
6
6
  export declare const PAD = 14;
7
- /** Between stacked children of one container. */
8
- export declare const CHILD_GAP = 10;
7
+ /**
8
+ * The file's unit, in pixels: about a quarter of an ordinary one-line box, so
9
+ * the gap words land near whole numbers. Findings measure in it. Provisional
10
+ * until the gap words are measured against it.
11
+ */
12
+ export declare const UNIT = 12;
9
13
  /** Between a container's own text and its first child. */
10
14
  export declare const HEADER_GAP = 10;
11
15
  /**
12
- * How far each deck copy is offset behind the front face. It has to clear a
13
- * whole line of text plus the padding above it, or a copy's text is drawn and
14
- * then immediately covered by the copy in front of it.
16
+ * How far each card of a deck sits up and to the left of the one in front. It
17
+ * has to clear a whole line of text plus the padding above it, or a card's
18
+ * text is drawn and then immediately covered by the card in front of it.
15
19
  */
16
20
  export declare const DECK_STEP = 34;
17
21
  /**
18
- * The named gaps a placement may ask for, each a *minimum* distance rather than a
19
- * fixed one. Anything the author puts between two things widens the space
20
- * between them on its own, so no gap here ever has to be chosen large enough
21
- * to leave room for something else.
22
+ * The named gaps a placement may ask for, each an exact distance in whole
23
+ * units. Chosen 2026-10-14 against today's spacing: 2, 4 and 8 units keep the
24
+ * old look, where 1, 2 and 3 left a line barely longer than its arrowhead.
22
25
  */
23
26
  export declare const GAPS: Record<string, number>;
24
27
  /**
@@ -59,43 +62,6 @@ export declare const THICKNESS: Record<string, number>;
59
62
  * thickness is a size, like a text's, and a size is allowed to take room.
60
63
  */
61
64
  export declare function arrowLength(thickness: number): number;
62
- /**
63
- * Line left showing behind the head of an edge with no text, so a gap an arrow
64
- * crosses is at least this plus its heads. Stacked children sit `CHILD_GAP`
65
- * apart, which is less than one head, and an arrow between them drew as a head
66
- * with no line. This is what the tightest named gap already leaves behind a
67
- * normal head, so any gap an author could have asked for holds it unchanged.
68
- */
69
- export declare const BARE_EDGE_RUN: number;
70
- /**
71
- * Line left showing between an edge's text and the box at that end of the
72
- * corridor it crosses.
73
- *
74
- * Unlike `SEPARATION_GAP` and `ATTACH_MARGIN` this is not "small on purpose".
75
- * Those two keep two things from touching, and the least distance that reads as
76
- * "not touching" is the right one. This one has something to show: a text sits
77
- * in a knockout that erases the line behind it, so whatever is left either side
78
- * is the entire evidence that the text belongs to an edge at all. At ten pixels
79
- * it did not read as a line — the seed diagram in the playground drew as a word
80
- * with a dash beside it — so it is the length of a run of line, not a margin.
81
- *
82
- * Say `gap:` if you want the corridor wider than its contents.
83
- */
84
- export declare const TEXT_CLEARANCE = 20;
85
- /**
86
- * How much room an edge's text takes along one axis.
87
- *
88
- * The knockout rectangle drawn behind a text is the text plus five either side,
89
- * so that rectangle, not the glyphs, is what must not overlap anything.
90
- *
91
- * Shared by the resolver, which widens a corridor to hold a text, and the
92
- * renderer, which spaces the lanes of a channel by it, so the two cannot
93
- * disagree about how much room a text needs. The two ask different questions of
94
- * it and both are right: the resolver measures *along* the run, so an edge
95
- * traveling horizontally needs the text's width; the renderer measures *across*
96
- * the channel, so an edge traveling horizontally down one needs its height.
97
- */
98
- export declare function textExtent(lines: Line[], textAttrs: Attrs, axis: 'x' | 'y', measurer: Measurer, fontSize: number, line: number): number;
99
65
  /**
100
66
  * The widest of these lines, which is how wide the block of them is. Shared by
101
67
  * the resolver, which sizes a node to hold its text, and the renderer, which
@@ -116,9 +82,9 @@ export declare const ICON_GAP = 10;
116
82
  export declare const DEFAULT_FONT_SIZE = 14;
117
83
  /**
118
84
  * The named text sizes, each a multiple of the document's own size. A plain
119
- * number of pixels is accepted too, as it is for a gap: nothing else in the
120
- * diagram moving can make it wrong. The names stay the default because they
121
- * follow the document's size when that is retuned, and a number does not.
85
+ * number is a multiple too, and `16px` a size in pixels: nothing else in the
86
+ * diagram moving can make either wrong. The names stay preferred because they
87
+ * say why one text is bigger than another.
122
88
  *
123
89
  * `small` is sampled rather than chosen. In
124
90
  * `examples/reference/arch.png` the box and container texts run 25
@@ -169,9 +135,3 @@ export interface TextStyle {
169
135
  * cannot disagree about which end the band is at.
170
136
  */
171
137
  export declare function textStyleFor(attrs: Attrs, line: number, fallbackAlign?: 'start' | 'middle', fallbackAt?: Position): TextStyle;
172
- /**
173
- * Where a leaf's text or badge starts vertically, given which end it sits at.
174
- * Shared, because the resolver works out the text's box and the renderer draws
175
- * it, and the two must not be able to disagree.
176
- */
177
- export declare function leafTop(end: 'top' | 'center' | 'bottom', y: number, height: number, own: number): number;
package/dist/constants.js CHANGED
@@ -3,29 +3,32 @@ import { SourceError } from './errors.js';
3
3
  import { plain } from './text.js';
4
4
  /** Inside a box, between its border and its contents. */
5
5
  export const PAD = 14;
6
- /** Between stacked children of one container. */
7
- export const CHILD_GAP = 10;
6
+ /**
7
+ * The file's unit, in pixels: about a quarter of an ordinary one-line box, so
8
+ * the gap words land near whole numbers. Findings measure in it. Provisional
9
+ * until the gap words are measured against it.
10
+ */
11
+ export const UNIT = 12;
8
12
  /** Between a container's own text and its first child. */
9
13
  export const HEADER_GAP = 10;
10
14
  /**
11
- * How far each deck copy is offset behind the front face. It has to clear a
12
- * whole line of text plus the padding above it, or a copy's text is drawn and
13
- * then immediately covered by the copy in front of it.
15
+ * How far each card of a deck sits up and to the left of the one in front. It
16
+ * has to clear a whole line of text plus the padding above it, or a card's
17
+ * text is drawn and then immediately covered by the card in front of it.
14
18
  */
15
19
  export const DECK_STEP = 34;
16
20
  /**
17
- * The named gaps a placement may ask for, each a *minimum* distance rather than a
18
- * fixed one. Anything the author puts between two things widens the space
19
- * between them on its own, so no gap here ever has to be chosen large enough
20
- * to leave room for something else.
21
+ * The named gaps a placement may ask for, each an exact distance in whole
22
+ * units. Chosen 2026-10-14 against today's spacing: 2, 4 and 8 units keep the
23
+ * old look, where 1, 2 and 3 left a line barely longer than its arrowhead.
21
24
  */
22
25
  export const GAPS = {
23
26
  // A zero gap turns an offset into edge-to-edge contact, so "my top edge
24
27
  // against Docker's bottom edge" needs no vocabulary of its own.
25
28
  none: 0,
26
- tight: 24,
27
- normal: 56,
28
- wide: 110,
29
+ tight: 2 * UNIT,
30
+ normal: 4 * UNIT,
31
+ wide: 8 * UNIT,
29
32
  };
30
33
  /**
31
34
  * How far apart two boxes are pushed when they would otherwise overlap. Small
@@ -71,47 +74,6 @@ export const THICKNESS = {
71
74
  export function arrowLength(thickness) {
72
75
  return ARROW_MARKER_WIDTH * thickness;
73
76
  }
74
- /**
75
- * Line left showing behind the head of an edge with no text, so a gap an arrow
76
- * crosses is at least this plus its heads. Stacked children sit `CHILD_GAP`
77
- * apart, which is less than one head, and an arrow between them drew as a head
78
- * with no line. This is what the tightest named gap already leaves behind a
79
- * normal head, so any gap an author could have asked for holds it unchanged.
80
- */
81
- export const BARE_EDGE_RUN = GAPS['tight'] - ARROW_LENGTH;
82
- /**
83
- * Line left showing between an edge's text and the box at that end of the
84
- * corridor it crosses.
85
- *
86
- * Unlike `SEPARATION_GAP` and `ATTACH_MARGIN` this is not "small on purpose".
87
- * Those two keep two things from touching, and the least distance that reads as
88
- * "not touching" is the right one. This one has something to show: a text sits
89
- * in a knockout that erases the line behind it, so whatever is left either side
90
- * is the entire evidence that the text belongs to an edge at all. At ten pixels
91
- * it did not read as a line — the seed diagram in the playground drew as a word
92
- * with a dash beside it — so it is the length of a run of line, not a margin.
93
- *
94
- * Say `gap:` if you want the corridor wider than its contents.
95
- */
96
- export const TEXT_CLEARANCE = 20;
97
- /**
98
- * How much room an edge's text takes along one axis.
99
- *
100
- * The knockout rectangle drawn behind a text is the text plus five either side,
101
- * so that rectangle, not the glyphs, is what must not overlap anything.
102
- *
103
- * Shared by the resolver, which widens a corridor to hold a text, and the
104
- * renderer, which spaces the lanes of a channel by it, so the two cannot
105
- * disagree about how much room a text needs. The two ask different questions of
106
- * it and both are right: the resolver measures *along* the run, so an edge
107
- * traveling horizontally needs the text's width; the renderer measures *across*
108
- * the channel, so an edge traveling horizontally down one needs its height.
109
- */
110
- export function textExtent(lines, textAttrs, axis, measurer, fontSize, line) {
111
- const size = fontSizeFor('edge', textAttrs, fontSize, line);
112
- const width = lines.reduce((widest, drawn) => Math.max(widest, measurer.measure(plain(drawn), size).width), 0);
113
- return axis === 'x' ? width + 10 : lines.length * measurer.lineHeight(size);
114
- }
115
77
  /**
116
78
  * The widest of these lines, which is how wide the block of them is. Shared by
117
79
  * the resolver, which sizes a node to hold its text, and the renderer, which
@@ -134,9 +96,9 @@ export const ICON_GAP = 10;
134
96
  export const DEFAULT_FONT_SIZE = 14;
135
97
  /**
136
98
  * The named text sizes, each a multiple of the document's own size. A plain
137
- * number of pixels is accepted too, as it is for a gap: nothing else in the
138
- * diagram moving can make it wrong. The names stay the default because they
139
- * follow the document's size when that is retuned, and a number does not.
99
+ * number is a multiple too, and `16px` a size in pixels: nothing else in the
100
+ * diagram moving can make either wrong. The names stay preferred because they
101
+ * say why one text is bigger than another.
140
102
  *
141
103
  * `small` is sampled rather than chosen. In
142
104
  * `examples/reference/arch.png` the box and container texts run 25
@@ -168,15 +130,13 @@ export function fontSizeFor(kind, textAttrs, fontSize, line) {
168
130
  const scale = TEXT_SIZES[named];
169
131
  if (scale !== undefined)
170
132
  return Math.round(fontSize * scale);
171
- if (/^\d+(\.\d+)?$/.test(named) && Number(named) > 0)
172
- return Number(named);
173
- const unit = named.match(/^(\d+(?:\.\d+)?)px$/);
174
- const hint = unit
175
- ? `; write "size: ${unit[1]}", a size's number is already in pixels`
176
- : named.startsWith('-') || /^0+(\.0+)?$/.test(named)
177
- ? '; a text size has to be more than zero'
178
- : '';
179
- throw new SourceError(`size takes ${Object.keys(TEXT_SIZES).join(', ')} or a number of pixels, not "${named}"${hint}`, line);
133
+ // A bare number is a multiple of normal; `px` fixes it in pixels (design.org 3.8).
134
+ const number = /^(\d*\.?\d+)(px)?$/.exec(named);
135
+ if (number && Number(number[1]) > 0) {
136
+ return number[2] ? Number(number[1]) : Math.round(fontSize * Number(number[1]));
137
+ }
138
+ const hint = named.startsWith('-') || /^0*\.?0+(px)?$/.test(named) ? '; a text size has to be more than zero' : '';
139
+ throw new SourceError(`size takes ${Object.keys(TEXT_SIZES).join(', ')}, a multiple of normal such as 1.2, or pixels such as 16px, not "${named}"${hint}`, line);
180
140
  }
181
141
  export const DEFAULT_MARGIN = 40;
182
142
  /**
@@ -206,7 +166,11 @@ export function textStyleFor(attrs, line, fallbackAlign = 'start', fallbackAt =
206
166
  : `a text's at takes one of ${POSITIONS.join(', ')}, not "${written}"`, line);
207
167
  }
208
168
  const at = written ?? fallbackAt;
209
- const align = attrs['align'];
169
+ // On a left or right side, align: says where along it (top, center or
170
+ // bottom), which the resolver has checked; the lines keep their default.
171
+ const onLeftOrRight = attrs['place']?.endsWith(' left') === true || attrs['place']?.endsWith(' right') === true;
172
+ const along = onLeftOrRight && (attrs['align'] === 'top' || attrs['align'] === 'bottom');
173
+ const align = along ? undefined : attrs['align'];
210
174
  if (align !== undefined && TEXT_ALIGNMENTS[align] === undefined) {
211
175
  throw new SourceError(`a text's align takes left, center or right, not "${align}"`, line);
212
176
  }
@@ -218,15 +182,3 @@ export function textStyleFor(attrs, line, fallbackAlign = 'start', fallbackAt =
218
182
  align: align === undefined ? fallbackAlign : TEXT_ALIGNMENTS[align],
219
183
  };
220
184
  }
221
- /**
222
- * Where a leaf's text or badge starts vertically, given which end it sits at.
223
- * Shared, because the resolver works out the text's box and the renderer draws
224
- * it, and the two must not be able to disagree.
225
- */
226
- export function leafTop(end, y, height, own) {
227
- if (end === 'top')
228
- return y + PAD;
229
- if (end === 'bottom')
230
- return y + height - PAD - own;
231
- return y + (height - own) / 2;
232
- }
package/dist/grammar.d.ts CHANGED
@@ -40,7 +40,7 @@ export type TokenKind =
40
40
  | 'name'
41
41
  /** `->`, `<-`, `<->`, and `--` or `-` for a plain line. */
42
42
  | 'arrow'
43
- /** Placement and edge vocabulary: `right`, `of`, `level`, `with`, `and`, `between`, … */
43
+ /** Placement and line vocabulary: `right`, `of`, `even`, `with`, `and`, `between`, `via`'s stops, … */
44
44
  | 'relation'
45
45
  /** A `key:` opening an attribute or a bracketed modifier. */
46
46
  | 'attribute'
@@ -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", "icon"];
67
+ export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "diagram", "default", "icon", "point", "region", "bundle", "guide"];
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
package/dist/grammar.js CHANGED
@@ -26,16 +26,23 @@
26
26
  * editor formats need and the reason a `.reladraw` grammar is small in all of
27
27
  * them.
28
28
  */
29
- import { DIRECTIONS, MARKS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
29
+ import { DEFAULT_TARGETS, DIRECTIONS, MARKS, PARTS, POINT_TEXT_SIDES, POSITIONS, SIDES } from './ast.js';
30
30
  /**
31
31
  * The words a statement may open with. `parseStatement` in `parser.ts` is the
32
32
  * authority — its switch is what actually accepts them — and this list mirrors
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', 'icon'];
36
+ export const STATEMENT_KEYWORDS = ['node', 'edge', 'style', 'diagram', 'default', 'icon', 'point', 'region', 'bundle', 'guide'];
37
37
  /** Statements whose second word declares a name. `diagram` has none. */
38
- const DECLARES_NAME = ['node', 'style', 'icon'];
38
+ const DECLARES_NAME = ['node', 'style', 'icon', 'point', 'region', 'bundle'];
39
+ /**
40
+ * Keywords that take another after them: `guide region channel`, `default
41
+ * leaf`, `default guide edge`. The words that may follow are the kinds a
42
+ * default is written for, so `leaf` and `container` color as keywords there.
43
+ */
44
+ const LEADS = ['guide', 'default'];
45
+ const FOLLOWERS = [...new Set(DEFAULT_TARGETS.flatMap((target) => target.split(' ')))];
39
46
  /**
40
47
  * Every word that says something about where a thing goes. Assembled from the
41
48
  * lists the parser itself reads, so a direction or a passage axis added there
@@ -45,19 +52,45 @@ export const RELATION_WORDS = [
45
52
  ...DIRECTIONS,
46
53
  ...SIDES,
47
54
  ...POSITIONS,
48
- ...Object.keys(PASSAGE_AXES),
55
+ // `tall text`, `server top-left`: the part of a node a placement names.
56
+ ...PARTS,
57
+ ...POINT_TEXT_SIDES,
49
58
  // The connecting words. `of` is optional after a direction, `and` joins
50
- // targets, `between` opens a passage, `level with` is the alignment, and
51
- // `on ... at ...` is the overlay. Note `of` and `at` are what tell the two
52
- // position vocabularies apart: a direction is always followed by `of`, a
53
- // named position always preceded by `at`.
59
+ // targets, `between` names the point between two things, `even with` is the
60
+ // alignment, and `on ... at ...` is the overlay. Note `of` and `at` are what
61
+ // tell the two position vocabularies apart: a direction is always followed
62
+ // by `of`, a named position always preceded by `at`.
54
63
  'of',
55
64
  'at',
56
65
  'on',
57
66
  'and',
58
- 'level',
67
+ 'even',
59
68
  'with',
60
69
  'between',
70
+ // Points (design.org 4): a fraction of the way, a point from two things,
71
+ // a region's corners.
72
+ 'down',
73
+ 'up',
74
+ 'across',
75
+ 'from',
76
+ 'to',
77
+ // `(inside top)`, `outside router right`, `via: inside p top`.
78
+ 'inside',
79
+ 'outside',
80
+ // Lines (design.org 6): `straight down from z3`, `each child of pool`,
81
+ // `leg climb`, `via: through slot horizontal`.
82
+ 'straight',
83
+ 'each',
84
+ 'child',
85
+ 'leg',
86
+ 'through',
87
+ 'horizontal',
88
+ 'vertical',
89
+ // A line's text (design.org 7.3): `(left of edge)`, `(on: longest level)`.
90
+ // `edge` here is the line the text belongs to; at the start of a line it is
91
+ // still the keyword, since the keyword is matched first.
92
+ 'edge',
93
+ 'level',
61
94
  ];
62
95
  /** Longest first, so `above-left` is not read as `above` followed by `-left`. */
63
96
  function alternation(words) {
@@ -137,6 +170,11 @@ const COMMENT = sticky(PATTERNS.comment);
137
170
  const KEYWORD = sticky(PATTERNS.keyword);
138
171
  const WORD = sticky(PATTERNS.word);
139
172
  const WORD_IN_GROUP = sticky(PATTERNS.wordInGroup);
173
+ const ARROW = new RegExp(`^${PATTERNS.arrow}$`);
174
+ /** Whether a whole word is an arrow, as the parser's `looksLikeArrow` asks. */
175
+ function isArrow(word) {
176
+ return ARROW.test(word);
177
+ }
140
178
  function sticky(source) {
141
179
  return new RegExp(source, 'y');
142
180
  }
@@ -178,9 +216,21 @@ export function highlightLine(line) {
178
216
  }
179
217
  if (opening) {
180
218
  opening = false;
181
- const keyword = match(KEYWORD, line, at);
219
+ let keyword = match(KEYWORD, line, at);
182
220
  if (keyword !== null) {
183
221
  push('keyword', at + keyword.length);
222
+ // `default guide edge` leads twice, so this loops.
223
+ while (LEADS.includes(keyword)) {
224
+ const space = match(SPACE, line, at);
225
+ if (space === null)
226
+ break;
227
+ const next = match(WORD, line, at + space.length);
228
+ if (next === null || !FOLLOWERS.includes(next))
229
+ break;
230
+ push('plain', at + space.length);
231
+ push('keyword', at + next.length);
232
+ keyword = next;
233
+ }
184
234
  if (DECLARES_NAME.includes(keyword)) {
185
235
  const space = match(SPACE, line, at);
186
236
  if (space !== null) {
@@ -193,6 +243,16 @@ export function highlightLine(line) {
193
243
  push('name', at + name.length);
194
244
  }
195
245
  }
246
+ else if (keyword === 'edge') {
247
+ // `edge req client -> api`: a word before the ends names the line,
248
+ // told apart by the arrow coming third rather than second, which is
249
+ // the parser's own test.
250
+ const head = /^([ \t]+)([^\s"]+)[ \t]+([^\s"]+)[ \t]+(\S+)/.exec(line.slice(at));
251
+ if (head && !isArrow(head[3]) && isArrow(head[4]) && !RELATION_WORDS.includes(head[2])) {
252
+ push('plain', at + head[1].length);
253
+ push('name', at + head[2].length);
254
+ }
255
+ }
196
256
  continue;
197
257
  }
198
258
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './ast.js';
2
+ export * from './checks.js';
2
3
  export * from './constants.js';
3
4
  export * from './errors.js';
4
5
  export * from './grammar.js';
@@ -8,6 +9,7 @@ export { parse } from './parser.js';
8
9
  export { resolve, type ResolveOptions } from './resolve.js';
9
10
  export { render, type RenderOptions } from './render.js';
10
11
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES, type Theme } from './themes.js';
12
+ import { type Checks, type Finding } from './checks.js';
11
13
  import { type RenderOptions } from './render.js';
12
14
  import { type ResolveOptions } from './resolve.js';
13
15
  /**
@@ -16,3 +18,21 @@ import { type ResolveOptions } from './resolve.js';
16
18
  * the statement starts on; see `blame`.
17
19
  */
18
20
  export declare function compile(source: string, options?: ResolveOptions & RenderOptions): string;
21
+ export interface DrawOptions extends ResolveOptions, RenderOptions {
22
+ /**
23
+ * Which kinds of finding to look for. Absent: what the file's
24
+ * `diagram checks: (…)` says over the defaults. A function: given that, it
25
+ * returns the checks to run, which is how `--checks` lays itself over the
26
+ * file. `false`: none, as `--no-checks`.
27
+ */
28
+ checks?: false | ((fromFile: Checks) => Checks);
29
+ }
30
+ /**
31
+ * Source text in; the SVG and the findings about it out. A finding never stops
32
+ * the drawing: it says where the picture, drawn as written, may not read as
33
+ * meant.
34
+ */
35
+ export declare function draw(source: string, options?: DrawOptions): {
36
+ svg: string;
37
+ findings: Finding[];
38
+ };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './ast.js';
2
+ export * from './checks.js';
2
3
  export * from './constants.js';
3
4
  export * from './errors.js';
4
5
  export * from './grammar.js';
@@ -8,6 +9,7 @@ export { parse } from './parser.js';
8
9
  export { resolve } from './resolve.js';
9
10
  export { render } from './render.js';
10
11
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES } from './themes.js';
12
+ import { fileChecks, findings } from './checks.js';
11
13
  import { SourceError } from './errors.js';
12
14
  import { blame, parse } from './parser.js';
13
15
  import { render } from './render.js';
@@ -18,7 +20,22 @@ import { resolve } from './resolve.js';
18
20
  * the statement starts on; see `blame`.
19
21
  */
20
22
  export function compile(source, options = {}) {
21
- const run = (text) => render(resolve(parse(text), options), options);
23
+ return draw(source, { ...options, checks: false }).svg;
24
+ }
25
+ /**
26
+ * Source text in; the SVG and the findings about it out. A finding never stops
27
+ * the drawing: it says where the picture, drawn as written, may not read as
28
+ * meant.
29
+ */
30
+ export function draw(source, options = {}) {
31
+ const run = (text) => {
32
+ const layout = resolve(parse(text), options);
33
+ const svg = render(layout, options);
34
+ if (options.checks === false)
35
+ return { svg, findings: [] };
36
+ const fromFile = fileChecks(layout);
37
+ return { svg, findings: findings(layout, options.checks ? options.checks(fromFile) : fromFile) };
38
+ };
22
39
  try {
23
40
  return run(source);
24
41
  }