reladraw 0.24.0 → 0.25.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.
@@ -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
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
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;