reladraw 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ast.js CHANGED
@@ -462,10 +462,10 @@ export const PADDING_SIDE_KEYS = PADDING_SIDES.map((side) => `padding-${side}`);
462
462
  /** `right-edge-order: ae, ad, ac`: the lines on one side of a box, in reading order. */
463
463
  export const EDGE_ORDER_KEYS = ['top-edge-order', 'bottom-edge-order', 'left-edge-order', 'right-edge-order'];
464
464
  export const ATTR_KEYS = {
465
- shape: ['style', 'overlap', 'layout', 'gather-children', 'padding', ...PADDING_SIDE_KEYS, ...SIZE_KEY_NAMES, 'heading', 'join-with', 'badge', 'cards', 'shape', 'fill', 'border', 'corners', 'text', 'url', 'depth', ...EDGE_ORDER_KEYS],
466
- icon: ['style', 'overlap', 'icon', 'join-with', 'text', 'url', 'depth', ...EDGE_ORDER_KEYS],
467
- none: ['style', 'overlap', 'layout', 'gather-children', 'padding', ...SIZE_KEY_NAMES, 'heading', 'join-with', 'shape', 'text', 'url', 'depth', ...EDGE_ORDER_KEYS],
468
- edge: ['style', 'from', 'to', 'via', 'from-mark', 'to-mark', 'line', 'text', 'url', 'depth'],
465
+ shape: ['style', 'overlap', 'layout', 'gather-children', 'padding', ...PADDING_SIDE_KEYS, ...SIZE_KEY_NAMES, 'heading', 'join-with', 'badge', 'cards', 'shape', 'fill', 'border', 'corners', 'text', 'url', 'description', 'depth', ...EDGE_ORDER_KEYS],
466
+ icon: ['style', 'overlap', 'icon', 'join-with', 'text', 'url', 'description', 'depth', ...EDGE_ORDER_KEYS],
467
+ none: ['style', 'overlap', 'layout', 'gather-children', 'padding', ...SIZE_KEY_NAMES, 'heading', 'join-with', 'shape', 'text', 'url', 'description', 'depth', ...EDGE_ORDER_KEYS],
468
+ edge: ['style', 'from', 'to', 'via', 'from-mark', 'to-mark', 'line', 'text', 'url', 'description', 'depth'],
469
469
  };
470
470
  /**
471
471
  * What a node's `corners:` takes, the default first: a box is drawn rounded
@@ -488,3 +488,11 @@ export const NODE_CORNERS = ['rounded', 'sharp'];
488
488
  export const ALL_ATTR_KEYS = [
489
489
  ...new Set([...Object.values(ATTR_KEYS).flat(), ...DIAGRAM_KEYS, ...REGION_KEYS]),
490
490
  ];
491
+ /** Where `description: notes` keeps the name until `resolve` swaps in the text. */
492
+ export const DESCRIPTION_NAME_KEY = 'description.name';
493
+ /**
494
+ * Where a `url:` that names declared links keeps its list, declared names and
495
+ * quoted addresses in the order written, until `resolve` swaps each name for
496
+ * the links it declares.
497
+ */
498
+ export const LINK_NAMES_KEY = 'url.names';
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ const USAGE = `reladraw — draw a diagram from stated placement
19
19
  reladraw format <input.reladraw>... [--check]
20
20
  reladraw format - [--check]
21
21
  reladraw format --init
22
- reladraw install-skill [skills options]
22
+ reladraw install-skill [--claude] [--agents]
23
23
 
24
24
  render draw the diagram to an SVG, and print any findings: places
25
25
  where the picture, drawn as written, may not read as meant.
@@ -37,9 +37,10 @@ const USAGE = `reladraw — draw a diagram from stated placement
37
37
  default with what it does.
38
38
  install-skill
39
39
  install the agent skill that came with this version of
40
- reladraw, so it teaches what this tool accepts. It runs
41
- npx skills add with --copy, and passes on what follows:
42
- -g for every project, -a claude-code for one agent.
40
+ reladraw, so it teaches what this tool accepts, for every
41
+ project: --claude in ~/.claude/skills, --agents in
42
+ ~/.agents/skills (Codex and others), or both. With neither,
43
+ it asks.
43
44
 
44
45
  -o, --out where render writes the SVG. Defaults to the input path with
45
46
  its extension replaced by .svg. Use - for standard output.
@@ -62,17 +63,113 @@ const USAGE = `reladraw — draw a diagram from stated placement
62
63
  const COMMANDS = ['render', 'check', 'format', 'install-skill'];
63
64
  // The version of skills this was tried with, so a change there can't break it.
64
65
  const SKILLS_PACKAGE = 'skills@1.7.6';
66
+ // The two places the skill is installed, each picked by its flag. skills
67
+ // knows a folder by an agent that reads it: ~/.agents/skills is its "codex",
68
+ // and other agents read it too. Left to itself, skills installs for every
69
+ // agent it knows of, more than fifty, so the agents are always named.
70
+ const SKILL_TARGETS = [
71
+ { flag: '--claude', agent: 'claude-code', label: 'Claude Code', dir: '~/.claude/skills' },
72
+ { flag: '--agents', agent: 'codex', label: 'Codex and others', dir: '~/.agents/skills' },
73
+ ];
74
+ function ownVersion() {
75
+ // package.json ships beside dist/, so the number is the installed one.
76
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
77
+ }
78
+ // For any other agent, or a single project, skills is run by hand on this
79
+ // install's skill, so it still matches this reladraw.
80
+ function skillsCommand(skills, agent = '<agent>') {
81
+ const path = /\s/.test(skills) ? JSON.stringify(skills) : skills;
82
+ return `npx ${SKILLS_PACKAGE} add ${path} --copy -g -a ${agent}`;
83
+ }
84
+ const NAME_THE_TARGETS = SKILL_TARGETS.map((t) => `${t.flag} (${t.dir}, for ${t.label})`).join(', ') + ', or both';
85
+ // In a terminal, a bare install-skill asks; otherwise, as when an agent runs
86
+ // it, the flags must say, because there is no one to ask.
87
+ async function askTargets(skills) {
88
+ const { createInterface } = await import('node:readline/promises');
89
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
90
+ const closed = new Promise((done) => rl.once('close', () => done(undefined)));
91
+ const both = SKILL_TARGETS.length + 1;
92
+ process.stdout.write(`Install the reladraw ${ownVersion()} skill for which agents?\n` +
93
+ SKILL_TARGETS.map((t, i) => ` ${i + 1} ${t.label.padEnd(17)} ${t.dir}\n`).join('') +
94
+ ` ${both} both\n ${both + 1} another agent\n`);
95
+ try {
96
+ for (;;) {
97
+ const answer = await Promise.race([rl.question(`Choose [${both}]: `), closed]);
98
+ if (answer === undefined) {
99
+ process.stdout.write('\n');
100
+ return undefined;
101
+ }
102
+ const n = Number(answer.trim() || both);
103
+ if (n >= 1 && n <= SKILL_TARGETS.length)
104
+ return [SKILL_TARGETS[n - 1].agent];
105
+ if (n === both)
106
+ return SKILL_TARGETS.map((t) => t.agent);
107
+ if (n === both + 1)
108
+ return 'other';
109
+ process.stdout.write(`Type a number from 1 to ${both + 1}.\n`);
110
+ }
111
+ }
112
+ finally {
113
+ rl.close();
114
+ }
115
+ }
65
116
  // The skill ships in the package beside dist/, as the one in the repository.
66
- // skills does the work of knowing where each agent keeps its skills; --copy
67
- // because an npx run leaves the package in a cache, where a link would break.
68
- function installSkill(args) {
117
+ // skills does the work of copying it in; --copy because an npx run leaves the
118
+ // package in a cache, where a link would break.
119
+ async function installSkill(args) {
69
120
  const skills = fileURLToPath(new URL('../.claude/skills/', import.meta.url));
70
121
  if (!existsSync(skills)) {
71
122
  process.stderr.write(`reladraw: the skill isn't in this install of reladraw (looked in ${skills})\n`);
72
123
  return 1;
73
124
  }
125
+ let agents = [];
126
+ for (let i = 0; i < args.length; i++) {
127
+ const arg = args[i];
128
+ const target = SKILL_TARGETS.find((t) => t.flag === arg);
129
+ if (target) {
130
+ if (!agents.includes(target.agent))
131
+ agents.push(target.agent);
132
+ }
133
+ else if (arg === '-g' || arg === '--global') {
134
+ // Always global now; skills from before 0.24 tell the user to write -g.
135
+ }
136
+ else if (arg === '-h' || arg === '--help') {
137
+ process.stdout.write(`reladraw install-skill [--claude] [--agents]\n\n` +
138
+ `Installs the skill that came with reladraw ${ownVersion()}, for every project:\n` +
139
+ SKILL_TARGETS.map((t) => ` ${t.flag.padEnd(9)} ${t.dir}, for ${t.label}\n`).join('') +
140
+ `With neither, it asks. For another agent, run:\n ${skillsCommand(skills)}\n`);
141
+ return 0;
142
+ }
143
+ else if (arg === '-a' || arg === '--agent') {
144
+ const agent = args[i + 1];
145
+ process.stderr.write(`reladraw: install-skill takes ${NAME_THE_TARGETS}, not -a. ` +
146
+ `For ${agent ? `the agent ${agent}` : 'another agent'}, run:\n ${skillsCommand(skills, agent)}\n`);
147
+ return 1;
148
+ }
149
+ else {
150
+ process.stderr.write(`reladraw: install-skill doesn't take ${arg}; it takes ${NAME_THE_TARGETS}. ` +
151
+ `For another agent, run:\n ${skillsCommand(skills)}\n`);
152
+ return 1;
153
+ }
154
+ }
155
+ if (agents.length === 0) {
156
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
157
+ process.stderr.write(`reladraw: say which agents to install the skill for: ${NAME_THE_TARGETS}. ` +
158
+ `For another agent, run:\n ${skillsCommand(skills)}\n`);
159
+ return 1;
160
+ }
161
+ const picked = await askTargets(skills);
162
+ if (picked === undefined)
163
+ return 1;
164
+ if (picked === 'other') {
165
+ process.stdout.write(`For another agent, run this, with its name from skills' list:\n ${skillsCommand(skills)}\n`);
166
+ return 0;
167
+ }
168
+ agents = picked;
169
+ }
74
170
  const windows = process.platform === 'win32';
75
- const command = [SKILLS_PACKAGE, 'add', skills, '--copy', ...args];
171
+ // -y because the choice is already made; skills would otherwise ask again.
172
+ const command = [SKILLS_PACKAGE, 'add', skills, '--copy', '-g', '-y', '-a', ...agents];
76
173
  // On Windows npx is a .cmd, which Node runs only through a shell.
77
174
  const result = spawnSync(windows ? 'npx.cmd' : 'npx', windows ? command.map((a) => `"${a}"`) : command, {
78
175
  stdio: 'inherit',
@@ -85,7 +182,7 @@ function installSkill(args) {
85
182
  return result.status ?? 1;
86
183
  }
87
184
  async function main(argv) {
88
- // Everything after install-skill is for skills, -h and -v included.
185
+ // install-skill reads its own flags, -h included.
89
186
  if (argv[0] === 'install-skill')
90
187
  return installSkill(argv.slice(1));
91
188
  if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
@@ -93,9 +190,7 @@ async function main(argv) {
93
190
  return argv.length === 0 ? 1 : 0;
94
191
  }
95
192
  if (argv.includes('-v') || argv.includes('--version')) {
96
- // package.json ships beside dist/, so the number is the installed one.
97
- const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
98
- process.stdout.write(`${pkg.version}\n`);
193
+ process.stdout.write(`${ownVersion()}\n`);
99
194
  return 0;
100
195
  }
101
196
  const verb = argv[0];
@@ -420,7 +515,7 @@ function nearestSettings(folder, found) {
420
515
  // The exit code is set, not exited with: process.exit ends the process at
421
516
  // once, and a write to a pipe that hasn't drained yet is lost, which cut
422
517
  // `format -` and `render -o -` off at 64 KB. Node exits on its own once the
423
- // output is written. `./dev.sh pipe` checks it.
518
+ // output is written.
424
519
  main(process.argv.slice(2)).then((code) => {
425
520
  process.exitCode = code;
426
521
  }, (error) => {
@@ -72,6 +72,26 @@ export declare function arrowLength(thickness: number): number;
72
72
  * places that block in the room the node gave it.
73
73
  */
74
74
  export declare function widestLine(lines: Line[], measurer: Measurer, fontSize: number): number;
75
+ /**
76
+ * How wide one line is. A line all at the text's own size is measured whole;
77
+ * a line with a marked run of another size is the runs side by side, each at
78
+ * its own size.
79
+ */
80
+ export declare function lineWidth(line: Line, measurer: Measurer, fontSize: number): number;
81
+ /**
82
+ * The largest size on a line: its own, or a marked run's. Only runs with
83
+ * something to draw count, so a plain space between two small marked words
84
+ * doesn't make their line a normal one.
85
+ */
86
+ export declare function lineSize(line: Line, fontSize: number): number;
87
+ /**
88
+ * How tall one line is: as tall as its largest text (design.org 7.1), so a
89
+ * line that is all small takes a small line's spacing and a line mixing sizes
90
+ * takes the largest's.
91
+ */
92
+ export declare function lineTall(line: Line, measurer: Measurer, fontSize: number): number;
93
+ /** How tall a block of lines is: each line as tall as its own largest text. */
94
+ export declare function linesTall(lines: Line[], measurer: Measurer, fontSize: number): number;
75
95
  /**
76
96
  * An icon is two lines of the text tall, and that ratio is what makes it a
77
97
  * *text-sized* ornament rather than a picture with a size of its own. It is
@@ -93,7 +113,7 @@ export declare const DEFAULT_FONT_SIZE = 14;
93
113
  * `small` is sampled rather than chosen. In
94
114
  * `examples/reference/arch.png` the box and container texts run 25
95
115
  * pixels baseline to baseline and every annotation runs 21, which is this
96
- * ratio; `./dev.sh textrows` is how that was read off. `large` is the same step
116
+ * ratio. `large` is the same step
97
117
  * taken the other way, so the scale is symmetric about the document size.
98
118
  */
99
119
  export declare const TEXT_SIZES: Record<string, number>;
package/dist/constants.js CHANGED
@@ -88,7 +88,40 @@ export function arrowLength(thickness) {
88
88
  * places that block in the room the node gave it.
89
89
  */
90
90
  export function widestLine(lines, measurer, fontSize) {
91
- return lines.reduce((widest, line) => Math.max(widest, measurer.measure(plain(line), fontSize).width), 0);
91
+ return lines.reduce((widest, line) => Math.max(widest, lineWidth(line, measurer, fontSize)), 0);
92
+ }
93
+ /**
94
+ * How wide one line is. A line all at the text's own size is measured whole;
95
+ * a line with a marked run of another size is the runs side by side, each at
96
+ * its own size.
97
+ */
98
+ export function lineWidth(line, measurer, fontSize) {
99
+ if (line.every((run) => run.size === undefined))
100
+ return measurer.measure(plain(line), fontSize).width;
101
+ return line.reduce((sum, run) => sum + measurer.measure(run.text, run.size ?? fontSize).width, 0);
102
+ }
103
+ /**
104
+ * The largest size on a line: its own, or a marked run's. Only runs with
105
+ * something to draw count, so a plain space between two small marked words
106
+ * doesn't make their line a normal one.
107
+ */
108
+ export function lineSize(line, fontSize) {
109
+ const inked = line.filter((run) => run.text.trim().length > 0);
110
+ if (inked.length === 0)
111
+ return fontSize;
112
+ return Math.max(...inked.map((run) => run.size ?? fontSize));
113
+ }
114
+ /**
115
+ * How tall one line is: as tall as its largest text (design.org 7.1), so a
116
+ * line that is all small takes a small line's spacing and a line mixing sizes
117
+ * takes the largest's.
118
+ */
119
+ export function lineTall(line, measurer, fontSize) {
120
+ return measurer.lineHeight(lineSize(line, fontSize));
121
+ }
122
+ /** How tall a block of lines is: each line as tall as its own largest text. */
123
+ export function linesTall(lines, measurer, fontSize) {
124
+ return lines.reduce((sum, line) => sum + lineTall(line, measurer, fontSize), 0);
92
125
  }
93
126
  /**
94
127
  * An icon is two lines of the text tall, and that ratio is what makes it a
@@ -111,7 +144,7 @@ export const DEFAULT_FONT_SIZE = 14;
111
144
  * `small` is sampled rather than chosen. In
112
145
  * `examples/reference/arch.png` the box and container texts run 25
113
146
  * pixels baseline to baseline and every annotation runs 21, which is this
114
- * ratio; `./dev.sh textrows` is how that was read off. `large` is the same step
147
+ * ratio. `large` is the same step
115
148
  * taken the other way, so the scale is symmetric about the document size.
116
149
  */
117
150
  export const TEXT_SIZES = {
package/dist/format.js CHANGED
@@ -164,8 +164,12 @@ function statementCells(all, keywordCount, starts, settings) {
164
164
  const result = [];
165
165
  const keyword = all.slice(0, keywordCount);
166
166
  result.push({ text: joinTokens(keyword), kind: 'keyword' });
167
- const runs = clauseRuns(all.slice(keywordCount), starts);
167
+ let runs = clauseRuns(all.slice(keywordCount), starts);
168
168
  const head = runs[0] && !isAttrKey(runs[0][0].token) && !starts.has(runs[0][0].token) ? runs.shift() : undefined;
169
+ // A `url` declaration's links are one list, addresses and names alike, kept
170
+ // together after its name rather than spread over the columns of texts.
171
+ if (all[0].token.text === 'url' && runs.length > 1)
172
+ runs = [runs.flat()];
169
173
  if (head) {
170
174
  if (all[0].token.text === 'edge' && settings['edge-name-and-ends'] === 'own-columns')
171
175
  result.push(...edgeHeadCells(head));
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", "icon", "point", "region", "bundle", "guide"];
67
+ export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "diagram", "default", "icon", "description", "url", "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
@@ -33,9 +33,9 @@ import { DEFAULT_TARGETS, DIRECTIONS, MARKS, PARTS, POINT_TEXT_SIDES, POSITIONS,
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', 'point', 'region', 'bundle', 'guide'];
36
+ export const STATEMENT_KEYWORDS = ['node', 'edge', 'style', 'diagram', 'default', 'icon', 'description', 'url', 'point', 'region', 'bundle', 'guide'];
37
37
  /** Statements whose second word declares a name. `diagram` has none. */
38
- const DECLARES_NAME = ['node', 'style', 'icon', 'point', 'region', 'bundle'];
38
+ const DECLARES_NAME = ['node', 'style', 'icon', 'point', 'region', 'bundle', 'url'];
39
39
  /**
40
40
  * Keywords that take another after them: `guide region channel`, `default
41
41
  * leaf`, `default guide edge`. The words that may follow are the kinds a
@@ -233,7 +233,20 @@ export function highlightLine(line) {
233
233
  push('keyword', at + next.length);
234
234
  keyword = next;
235
235
  }
236
- if (DECLARES_NAME.includes(keyword)) {
236
+ if (keyword === 'description') {
237
+ // `description notes """…"""` declares a name; `description for a, b`
238
+ // names things declared elsewhere, so `for` colors as a keyword.
239
+ const space = match(SPACE, line, at);
240
+ if (space !== null) {
241
+ push('plain', at + space.length);
242
+ const word = match(WORD, line, at);
243
+ if (word === 'for')
244
+ push('keyword', at + word.length);
245
+ else if (word !== null && !word.endsWith(':'))
246
+ push('name', at + word.length);
247
+ }
248
+ }
249
+ else if (DECLARES_NAME.includes(keyword)) {
237
250
  const space = match(SPACE, line, at);
238
251
  if (space !== null) {
239
252
  push('plain', at + space.length);
@@ -32,7 +32,10 @@ export interface PlacedText {
32
32
  maxX: number;
33
33
  maxY: number;
34
34
  };
35
- /** Moved off its line by `right of edge` and the rest, rather than sitting on it. */
35
+ /**
36
+ * Moved off its line by `right of edge` and the rest, rather than sitting on it,
37
+ * and meant to be clear of it: not a stated distance or part of the way clear.
38
+ */
36
39
  beside: boolean;
37
40
  }
38
41
  /**
package/dist/linetexts.js CHANGED
@@ -335,9 +335,18 @@ export function placeLineTexts(edge, route) {
335
335
  const out = { above: [0, -1], below: [0, 1], 'left of': [-1, 0], 'right of': [1, 0] }[place.by.side];
336
336
  center = { x: spot.x + (out[0] * size.width) / 2, y: spot.y + (out[1] * size.height) / 2 };
337
337
  }
338
- if (place.beside) {
338
+ if (place.beside && place.rise?.distance !== undefined) {
339
+ // `2 above edge`: that far from its spot on the line, the stated way, whatever the line does.
340
+ const by = gapPixels(place.rise.distance, `${subject}: the text "${text.text}" ${place.rise.written}`, edge.line);
341
+ const out = { above: [0, -1], below: [0, 1], 'left of': [-1, 0], 'right of': [1, 0] }[place.beside];
342
+ center = { x: center.x + out[0] * by, y: center.y + out[1] * by };
343
+ }
344
+ else if (place.beside) {
339
345
  const gap = place.edgeGap === undefined ? TEXT_GAP : gapPixels(place.edgeGap, `${subject}: the text "${text.text}" edge-gap`, edge.line);
340
- center = besideLine(whole, s, center, size, place.beside, gap);
346
+ const clear = besideLine(whole, s, center, size, place.beside, gap);
347
+ // `1/2 clear above edge`: that fraction of the move that clears it.
348
+ const fraction = place.rise?.clear ?? 1;
349
+ center = { x: center.x + (clear.x - center.x) * fraction, y: center.y + (clear.y - center.y) * fraction };
341
350
  }
342
351
  const pad = patchPadding(text, subject, edge.line);
343
352
  const knockout = turned(text.width + pad.x * 2, text.height + pad.y * 2, angle);
@@ -359,7 +368,8 @@ export function placeLineTexts(edge, route) {
359
368
  maxX: center.x + knockout.width / 2,
360
369
  maxY: center.y + knockout.height / 2,
361
370
  },
362
- beside: place.beside !== undefined,
371
+ // A stated distance, or less than the whole way clear, may leave it across its line on purpose.
372
+ beside: place.beside !== undefined && place.rise?.distance === undefined && (place.rise?.clear ?? 1) >= 1,
363
373
  };
364
374
  });
365
375
  }
package/dist/links.d.ts CHANGED
@@ -21,6 +21,10 @@ export interface Link {
21
21
  name?: string;
22
22
  icon?: LinkIcon;
23
23
  }
24
+ /** One item in a `url:` list or a `url` declaration: an address with its bracket, or a declared link's name. */
25
+ export type LinkEntry = Link | {
26
+ ref: string;
27
+ };
24
28
  /** Every link a thing carries, in the order the source writes them. */
25
29
  export declare function linksOf(attrs: Attrs): Link[] | undefined;
26
30
  /**
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The Markdown a description is written in: paragraphs, lists (`-`, `*` or
3
+ * `1.`), `**bold**`, `*italic*` or `_italic_`, `` `code` `` and
4
+ * `[text](address)`. Anything else is shown as the characters written.
5
+ *
6
+ * Two readings of one text: HTML for the page's panel, and plain text for an
7
+ * SVG's hover text, which can't show formatting.
8
+ */
9
+ /** Every link address in a description that the page would refuse to follow. */
10
+ export declare function unsafeLinks(text: string): string[];
11
+ /** A description as HTML, for the page's panel. */
12
+ export declare function markdownHtml(text: string): string;
13
+ /** A description with its marks taken out, for an SVG's hover text. A link keeps its address after its text. */
14
+ export declare function markdownPlain(text: string): string;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The Markdown a description is written in: paragraphs, lists (`-`, `*` or
3
+ * `1.`), `**bold**`, `*italic*` or `_italic_`, `` `code` `` and
4
+ * `[text](address)`. Anything else is shown as the characters written.
5
+ *
6
+ * Two readings of one text: HTML for the page's panel, and plain text for an
7
+ * SVG's hover text, which can't show formatting.
8
+ */
9
+ const BULLET = /^\s*[-*]\s+(.*)$/;
10
+ const NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
11
+ function blocksOf(text) {
12
+ const blocks = [];
13
+ let open;
14
+ for (const line of text.split('\n')) {
15
+ if (line.trim() === '') {
16
+ open = undefined;
17
+ continue;
18
+ }
19
+ const bullet = line.match(BULLET);
20
+ const numbered = bullet ? null : line.match(NUMBERED);
21
+ const item = bullet ?? numbered;
22
+ if (item) {
23
+ const ordered = numbered !== null;
24
+ if (open?.kind !== 'list' || open.ordered !== ordered) {
25
+ open = { kind: 'list', ordered, items: [] };
26
+ blocks.push(open);
27
+ }
28
+ open.items.push(item[1].trim());
29
+ }
30
+ else if (open?.kind === 'list' && /^\s/.test(line)) {
31
+ // An indented line under an item continues it.
32
+ open.items[open.items.length - 1] += ` ${line.trim()}`;
33
+ }
34
+ else if (open?.kind === 'paragraph') {
35
+ open.lines.push(line.trim());
36
+ }
37
+ else {
38
+ open = { kind: 'paragraph', lines: [line.trim()] };
39
+ blocks.push(open);
40
+ }
41
+ }
42
+ return blocks;
43
+ }
44
+ const LINK = /\[([^\]]+)\]\(([^)\s]+)\)/g;
45
+ /** The addresses a description may link to: the web, mail, or a path with no scheme. */
46
+ const SAFE_ADDRESS = /^(https?:|mailto:|[^:]*$)/i;
47
+ /** Every link address in a description that the page would refuse to follow. */
48
+ export function unsafeLinks(text) {
49
+ return [...text.matchAll(LINK)].map((match) => match[2]).filter((href) => !SAFE_ADDRESS.test(href));
50
+ }
51
+ function escapeHtml(text) {
52
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
53
+ }
54
+ /** One line's marks as HTML. Code is set aside first, so nothing inside it is read as a mark. */
55
+ function inlineHtml(text) {
56
+ const codes = [];
57
+ let out = text.replace(/`([^`]+)`/g, (_, code) => {
58
+ codes.push(`<code>${escapeHtml(code)}</code>`);
59
+ return `\u0000${codes.length - 1}\u0000`;
60
+ });
61
+ const links = [];
62
+ out = out.replace(LINK, (_, label, href) => {
63
+ const safe = SAFE_ADDRESS.test(href) ? href : '#';
64
+ links.push(`<a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${marks(escapeHtml(label))}</a>`);
65
+ return `\u0001${links.length - 1}\u0001`;
66
+ });
67
+ out = marks(escapeHtml(out));
68
+ return out
69
+ .replace(/\u0001(\d+)\u0001/g, (_, index) => links[Number(index)])
70
+ .replace(/\u0000(\d+)\u0000/g, (_, index) => codes[Number(index)]);
71
+ }
72
+ function marks(html) {
73
+ return html
74
+ .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '<strong>$1</strong>')
75
+ .replace(/(^|[^*\w])\*(?=\S)(.+?)(?<=\S)\*(?![*\w])/g, '$1<em>$2</em>')
76
+ .replace(/(^|[^_\w])_(?=\S)(.+?)(?<=\S)_(?![_\w])/g, '$1<em>$2</em>');
77
+ }
78
+ /** A description as HTML, for the page's panel. */
79
+ export function markdownHtml(text) {
80
+ return blocksOf(text)
81
+ .map((block) => {
82
+ if (block.kind === 'paragraph')
83
+ return `<p>${block.lines.map(inlineHtml).join(' ')}</p>`;
84
+ const tag = block.ordered ? 'ol' : 'ul';
85
+ return `<${tag}>${block.items.map((item) => `<li>${inlineHtml(item)}</li>`).join('')}</${tag}>`;
86
+ })
87
+ .join('');
88
+ }
89
+ /** A description with its marks taken out, for an SVG's hover text. A link keeps its address after its text. */
90
+ export function markdownPlain(text) {
91
+ const inline = (line) => line
92
+ .replace(LINK, (_, label, href) => `${label} (${href})`)
93
+ .replace(/`([^`]+)`/g, '$1')
94
+ .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '$1')
95
+ .replace(/(^|[^*\w])\*(?=\S)(.+?)(?<=\S)\*(?![*\w])/g, '$1$2')
96
+ .replace(/(^|[^_\w])_(?=\S)(.+?)(?<=\S)_(?![_\w])/g, '$1$2');
97
+ return blocksOf(text)
98
+ .map((block) => block.kind === 'paragraph'
99
+ ? block.lines.map(inline).join(' ')
100
+ : block.items.map((item, index) => `${block.ordered ? `${index + 1}.` : '•'} ${inline(item)}`).join('\n'))
101
+ .join('\n\n');
102
+ }
package/dist/model.d.ts CHANGED
@@ -307,9 +307,10 @@ export interface Layout {
307
307
  roots: LayoutNode[];
308
308
  edges: LayoutEdge[];
309
309
  /**
310
- * Every style the file's markup names, resolved to the color it lends. The
311
- * renderer needs it because a marked run borrows its color from a style
312
- * rather than stating one, and styles are otherwise merged away by here.
310
+ * Every style the file's markup names that lends a color, resolved to that
311
+ * color. The renderer needs it because a marked run borrows its color from a
312
+ * style rather than stating one, and styles are otherwise merged away by
313
+ * here. A size a style lends is carried on the run itself.
313
314
  */
314
315
  markup: Record<string, string>;
315
316
  /** The pictures links' badges name, by name, for the page that draws the badges. */
package/dist/page.js CHANGED
@@ -2,6 +2,7 @@ import { fileChecks, findings } from './checks.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { blame, parse } from './parser.js';
4
4
  import { linkLabel, linksOf } from './links.js';
5
+ import { markdownHtml } from './markdown.js';
5
6
  import { iconSvg, renderTagged, themeOf } from './render.js';
6
7
  import { resolve } from './resolve.js';
7
8
  import { luminance } from './themes.js';
@@ -38,6 +39,8 @@ export function drawPage(source, file, options = {}, at = new Date()) {
38
39
  throw error instanceof SourceError ? blame(source, error, run) : error;
39
40
  }
40
41
  }
42
+ /** A description as the panel shows it: its Markdown as HTML, or nothing. */
43
+ const describe = (attrs) => attrs['description'] === undefined ? {} : { description: markdownHtml(attrs['description']) };
41
44
  const BADGE_SIDE = 20;
42
45
  function graphOf(layout, theme) {
43
46
  const views = (links) => (links ?? []).map((link) => {
@@ -62,7 +65,7 @@ function graphOf(layout, theme) {
62
65
  const nodes = {};
63
66
  for (const node of layout.nodes) {
64
67
  const text = plainText(node.text);
65
- nodes[node.name] = { label: text === '' ? node.name.split('.').pop() : text, links: views(linksFor(node)) };
68
+ nodes[node.name] = { label: text === '' ? node.name.split('.').pop() : text, links: views(linksFor(node)), ...describe(node.attrs) };
66
69
  }
67
70
  const edges = layout.edges.map((edge) => {
68
71
  // The upstream end is the one a line runs from: the end without a mark
@@ -71,7 +74,7 @@ function graphOf(layout, theme) {
71
74
  const backwards = start !== '' && end === '';
72
75
  const [up, down] = backwards ? [edge.to, edge.from] : [edge.from, edge.to];
73
76
  const text = edge.texts.map((one) => plainText(one.text)).filter((one) => one !== '').join(' · ');
74
- return { up: up.name, down: down.name, label: text, links: views(linksOf(edge.attrs)) };
77
+ return { up: up.name, down: down.name, label: text, links: views(linksOf(edge.attrs)), ...describe(edge.attrs) };
75
78
  });
76
79
  return { nodes, edges };
77
80
  }
@@ -202,6 +205,11 @@ const PAGE = `<!doctype html>
202
205
  }
203
206
  #selected h3 svg { flex: none; }
204
207
  #selected .none { color: var(--muted); font-size: 13px; }
208
+ #selected .description { font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; }
209
+ #selected .description p, #selected .description ul, #selected .description ol { margin: 0 0 8px; }
210
+ #selected .description ul, #selected .description ol { padding-left: 20px; }
211
+ #selected .description code { font-size: 12px; padding: 0 3px; border-radius: 3px; background: color-mix(in srgb, var(--ink) 12%, transparent); }
212
+ #selected .description a { color: inherit; text-decoration: underline; }
205
213
  #selected .link {
206
214
  display: flex; align-items: center; gap: 9px; padding: 4px 6px; margin: 0 -6px;
207
215
  border-radius: 5px; color: var(--ink); text-decoration: none;
@@ -689,11 +697,12 @@ const PAGE = `<!doctype html>
689
697
  selectedPanel.append(element('p', 'empty', 'Click a box or a line to see its links and what it connects to.'));
690
698
  return;
691
699
  }
692
- var heading, links, from, to;
700
+ var heading, links, from, to, description;
693
701
  if (selection.kind === 'node') {
694
702
  var node = graph.nodes[selection.key];
695
703
  heading = node ? node.label : selection.key;
696
704
  links = node ? node.links : [];
705
+ description = node && node.description;
697
706
  from = []; to = [];
698
707
  graph.edges.forEach(function (edge) {
699
708
  if (edge.down === selection.key && from.indexOf(edge.up) < 0) from.push(edge.up);
@@ -704,6 +713,7 @@ const PAGE = `<!doctype html>
704
713
  var name = function (key) { return graph.nodes[key] ? graph.nodes[key].label : key; };
705
714
  heading = edge.label || name(edge.up) + ' \\u2192 ' + name(edge.down);
706
715
  links = edge.links;
716
+ description = edge.description;
707
717
  from = [edge.up]; to = [edge.down];
708
718
  }
709
719
  selectedPanel.append(element('h2', '', heading));
@@ -730,6 +740,14 @@ const PAGE = `<!doctype html>
730
740
  all.setAttribute('aria-pressed', String(everything));
731
741
  all.onclick = function () { everything = !everything; select(selection); };
732
742
  selectedPanel.append(all);
743
+ // Last, so a long description never pushes the rest out of view, and the
744
+ // sections above stay where they are whatever is selected.
745
+ if (description) {
746
+ // Written by reladraw from the file's Markdown, every character escaped.
747
+ var about = element('div', 'description');
748
+ about.innerHTML = description;
749
+ selectedPanel.append(element('h3', '', 'Description'), about);
750
+ }
733
751
  }
734
752
 
735
753
  // The pan and zoom capture the pointer, so the click lands on the view; what
package/dist/parser.d.ts CHANGED
@@ -54,3 +54,10 @@ export declare function parse(source: string, seen?: (statement: Gathered) => vo
54
54
  export declare function blame(source: string, error: SourceError, run: (source: string) => unknown): SourceError;
55
55
  /** Whether a token is an arrow, or begins one: a bracketed mark, or a run of dashes with marks at its ends. */
56
56
  export declare function looksLikeArrow(token: Token): boolean;
57
+ /**
58
+ * A description's text as written between `"""` marks, without the line break
59
+ * after the opening marks, the blank lines at either end, or the indentation
60
+ * every line shares, so a description indented under its statement reads as
61
+ * Markdown from the left margin.
62
+ */
63
+ export declare function descriptionText(raw: string): string;