reladraw 0.22.1 → 0.23.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/parser.js CHANGED
@@ -3,6 +3,7 @@ import { CHECK_DEFAULTS, CHECK_KINDS, setCheck } from './checks.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { STATEMENT_KEYWORDS } from './grammar.js';
5
5
  import { isAttrKey, nameMark, OpenString, tokenizeLine } from './lexer.js';
6
+ import { LINKS_KEY } from './links.js';
6
7
  import { SHAPE_SETTINGS } from './outline.js';
7
8
  import { checkOpacity } from './place.js';
8
9
  /** The settings a shape's bracket takes as a word on its own: `cylinder (lying)`. */
@@ -711,17 +712,16 @@ function readAttr(tokens, at, attrs, line, subject) {
711
712
  return read.next;
712
713
  }
713
714
  if (key === 'url') {
714
- // `url: https://example.com` loses everything from the `//` onwards, because
715
- // `//` opens a comment — so the value is either missing entirely or is the
716
- // bare scheme, which reads as another attribute key. Neither report says
717
- // what is wrong, and the remedy is punctuation rather than a missing word.
718
- const value = tokens[at + 1];
719
- if (!value || !value.quoted) {
720
- throw new SourceError(`${subject}: a url is written in quotes — \`url: "https://example.com"\`. Without them ` +
721
- 'everything from the `//` onwards is read as a comment', line);
715
+ // Several links are several addresses after the one key, as `cards:` lists
716
+ // its cards, each named in a bracket after it. The first stays under `url`,
717
+ // so whatever follows one link follows the first of several; the whole
718
+ // list is kept beside it only when there is more than a bare address.
719
+ const read = readLinks(tokens, at + 1, subject, line);
720
+ setOnce(attrs, key, read.links[0].href, subject, line, { key, value: quoteOf });
721
+ if (read.links.length > 1 || read.links.some((link) => link.name !== undefined || link.icon !== undefined)) {
722
+ attrs[LINKS_KEY] = JSON.stringify(read.links);
722
723
  }
723
- setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
724
- return at + 2;
724
+ return read.next;
725
725
  }
726
726
  if (key === 'title') {
727
727
  // A title is a text, and every text is quoted. Unquoted, its first word
@@ -1918,6 +1918,42 @@ function parseStyle(head, line) {
1918
1918
  }
1919
1919
  return { kind: 'style', name, attrs, line };
1920
1920
  }
1921
+ /**
1922
+ * `icon gh letters: "GH" fill: #24292f text: white`. The characters have a
1923
+ * key of their own because a quoted string after an icon's name is a file to
1924
+ * read, and `text:` is the letters' color, as it is a node's.
1925
+ */
1926
+ function readLetterIcon(head, start, name, line) {
1927
+ const read = {};
1928
+ const subject = `icon "${name}"`;
1929
+ for (let i = start; i < head.length; i += 2) {
1930
+ const keyToken = head[i];
1931
+ clauseStarts?.add(keyToken);
1932
+ const key = keyToken.text.slice(0, -1);
1933
+ if (!isAttrKey(keyToken) || (key !== 'letters' && key !== 'fill' && key !== 'text')) {
1934
+ throw new SourceError(`${subject}: a letter icon takes letters, fill and text, found "${keyToken.text}"`, line);
1935
+ }
1936
+ if (read[key] !== undefined)
1937
+ throw new SourceError(`${subject}: "${key}" is written twice — keep one`, line);
1938
+ const value = head[i + 1];
1939
+ if (value === undefined || isAttrKey(value))
1940
+ throw new SourceError(`${subject}: "${key}" has no value`, line);
1941
+ if (key === 'letters') {
1942
+ if (!value.quoted)
1943
+ throw new SourceError(`${subject}: letters are a text, written in quotes — \`letters: "${value.text}"\``, line);
1944
+ if (value.text.trim() === '')
1945
+ throw new SourceError(`${subject}: \`letters:\` is empty — write the letters to show, such as \`letters: "GH"\``, line);
1946
+ read.letters = value.text;
1947
+ }
1948
+ else {
1949
+ if (value.quoted)
1950
+ throw new SourceError(`${subject}: a color is written without quotes — \`${key}: ${value.text}\``, line);
1951
+ checkColorWord(value.text, subject, line);
1952
+ read[key] = value.text;
1953
+ }
1954
+ }
1955
+ return { letters: read.letters, ...(read.fill === undefined ? {} : { fill: read.fill }), ...(read.text === undefined ? {} : { text: read.text }) };
1956
+ }
1921
1957
  /**
1922
1958
  * `icon <name> "<svg>…</svg>"`, or `icon <name> ./file.svg`. What the picture
1923
1959
  * is — pasted markup or a file to read — is worked out when the diagram is
@@ -1926,6 +1962,12 @@ function parseStyle(head, line) {
1926
1962
  function parseIcon(head, line) {
1927
1963
  const name = requireName(head[1], 'icon', line);
1928
1964
  const picture = head[2];
1965
+ if (picture !== undefined && isAttrKey(picture) && picture.text === 'letters:') {
1966
+ return { kind: 'icon', name, source: '', pasted: false, letters: readLetterIcon(head, 2, name, line), line };
1967
+ }
1968
+ if (picture !== undefined && isAttrKey(picture) && (picture.text === 'fill:' || picture.text === 'text:')) {
1969
+ throw new SourceError(`icon "${name}" has colors and no letters — a letter icon starts \`icon ${name} letters: "GH"\``, line);
1970
+ }
1929
1971
  if (picture === undefined || isAttrKey(picture)) {
1930
1972
  throw new SourceError(`icon "${name}" needs its picture: the SVG itself between """ marks, or the name of an .svg file`, line);
1931
1973
  }
@@ -2460,6 +2502,113 @@ function checkColorWord(value, subject, line) {
2460
2502
  throw new SourceError(`${subject}: \`${value}\` is not a color — ${fix} \`#${value}\``, line);
2461
2503
  }
2462
2504
  }
2505
+ const URL_QUOTES = 'a url is written in quotes — `url: "https://example.com"`. Without them everything from the `//` onwards is read as a comment';
2506
+ /**
2507
+ * `url: "https://a" (name: "A", icon: "A" (fill: red)) "https://b"`: one or
2508
+ * more quoted addresses, each with an optional bracket after it. The values in
2509
+ * a link's bracket are a name (a text, so quoted) and a badge, which is quoted
2510
+ * letters or the unquoted name of a picture. `readBracket` takes only unquoted
2511
+ * values and no bracket inside a bracket, so links are read here.
2512
+ */
2513
+ function readLinks(tokens, start, subject, line) {
2514
+ // `url: https://example.com` loses everything from the `//` onwards, because
2515
+ // `//` opens a comment — so the value is either missing entirely or is the
2516
+ // bare scheme, which reads as another attribute key. Neither report says
2517
+ // what is wrong, and the remedy is punctuation rather than a missing word.
2518
+ if (!tokens[start]?.quoted)
2519
+ throw new SourceError(`${subject}: ${URL_QUOTES}`, line);
2520
+ const links = [];
2521
+ let i = start;
2522
+ while (tokens[i]?.quoted) {
2523
+ const link = { href: tokens[i].text };
2524
+ i += 1;
2525
+ if (follows(tokens, i, '(')) {
2526
+ const read = readLinkBracket(tokens, i, link.href, subject, line);
2527
+ Object.assign(link, read.link);
2528
+ i = read.next;
2529
+ }
2530
+ if (links.some((had) => had.href === link.href)) {
2531
+ throw new SourceError(`${subject}: "${link.href}" is linked twice in one \`url:\` — keep one`, line);
2532
+ }
2533
+ links.push(link);
2534
+ }
2535
+ return { links, next: i };
2536
+ }
2537
+ function readLinkBracket(tokens, start, href, subject, line) {
2538
+ const link = {};
2539
+ const what = `the link "${href}"`;
2540
+ let i = start + 1;
2541
+ for (;;) {
2542
+ if (follows(tokens, i, ',')) {
2543
+ i += 1;
2544
+ continue;
2545
+ }
2546
+ if (follows(tokens, i, ')'))
2547
+ break;
2548
+ const keyToken = tokens[i];
2549
+ if (!keyToken)
2550
+ throw new SourceError(`${subject}: ${what} opens a "(" and never closes it`, line);
2551
+ if (!isAttrKey(keyToken)) {
2552
+ throw new SourceError(`${subject}: ${what} takes \`name:\` and \`icon:\` in its brackets, found "${keyToken.text}"`, line);
2553
+ }
2554
+ const key = keyToken.text.slice(0, -1);
2555
+ if (key !== 'name' && key !== 'icon') {
2556
+ throw new SourceError(`a link has no "${key}" — it takes name and icon`, line);
2557
+ }
2558
+ if (link[key] !== undefined) {
2559
+ throw new SourceError(`${subject}: "${key}" is written twice in the brackets after ${what} — keep one`, line);
2560
+ }
2561
+ const value = tokens[i + 1];
2562
+ if (!value || isAttrKey(value) || (!value.quoted && (value.text === ')' || value.text === '('))) {
2563
+ throw new SourceError(`${subject}: "${key}" has no value`, line);
2564
+ }
2565
+ if (key === 'name') {
2566
+ if (!value.quoted) {
2567
+ throw new SourceError(`${subject}: a link's name is a text, written in quotes — \`name: "${value.text.replace(/,$/, '')}"\``, line);
2568
+ }
2569
+ link.name = value.text;
2570
+ i += 2;
2571
+ continue;
2572
+ }
2573
+ // A badge: quoted letters, which may take their colors in a bracket of
2574
+ // their own, or the name of a picture, which keeps the colors it has.
2575
+ if (value.quoted) {
2576
+ if (value.text.trim() === '')
2577
+ throw new SourceError(`${subject}: a link's letter badge has no letters — write \`icon: "GH"\``, line);
2578
+ const icon = { letters: value.text };
2579
+ i += 2;
2580
+ if (follows(tokens, i, '(')) {
2581
+ const read = readBracket(tokens, i, ['fill', 'text'], {
2582
+ subject,
2583
+ what: `the badge "${value.text}"`,
2584
+ kind: 'a letter badge',
2585
+ example: 'fill: #24292f',
2586
+ line,
2587
+ });
2588
+ if (Object.keys(read.values).length === 0) {
2589
+ throw new SourceError(`${subject}: the badge "${value.text}" opens empty brackets`, line);
2590
+ }
2591
+ for (const [color, set] of Object.entries(read.values)) {
2592
+ checkColorWord(set, subject, line);
2593
+ icon[color] = set;
2594
+ }
2595
+ i = read.next;
2596
+ }
2597
+ link.icon = icon;
2598
+ continue;
2599
+ }
2600
+ const picture = value.text.replace(/,$/, '');
2601
+ i += 2;
2602
+ if (follows(tokens, i, '(')) {
2603
+ throw new SourceError(`${subject}: the picture "${picture}" keeps its own colors, so it takes no bracket — ` +
2604
+ `letters do: \`icon: "${picture.slice(0, 2).toUpperCase()}" (fill: …)\``, line);
2605
+ }
2606
+ link.icon = { picture };
2607
+ }
2608
+ if (Object.keys(link).length === 0)
2609
+ throw new SourceError(`${subject}: ${what} opens empty brackets`, line);
2610
+ return { link, next: i + 1 };
2611
+ }
2463
2612
  function follows(tokens, at, word) {
2464
2613
  const token = tokens[at];
2465
2614
  return token !== undefined && !token.quoted && token.text === word;
package/dist/render.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Icon, type LetterIcon } from './icons.js';
1
2
  import { type Measurer } from './measure.js';
2
3
  import { type Layout } from './model.js';
3
4
  import { type Theme } from './themes.js';
@@ -27,3 +28,15 @@ export declare function renderTagged(layout: Layout, options?: RenderOptions): {
27
28
  * they are folded in afterwards and everything downstream sees one theme.
28
29
  */
29
30
  export declare function themeOf(layout: Layout, options?: RenderOptions): Theme;
31
+ /** An icon on its own, as a small SVG of `side` pixels: what the page puts beside a link. */
32
+ export declare function iconSvg(icon: Icon, side: number, theme: Theme): string;
33
+ /**
34
+ * A letter badge's circle and letters. Unsaid, the circle is one of the
35
+ * theme's two accents, picked from the letters so the same letters always get
36
+ * the same one, and the letters are that accent's own text color; a stated
37
+ * fill with no text color takes the theme's text color.
38
+ */
39
+ export declare function letterColors(icon: LetterIcon, theme: Theme): {
40
+ fill: string;
41
+ text: string;
42
+ };
package/dist/render.js CHANGED
@@ -8,6 +8,7 @@ import { DARK_THEME, THEMES, overlapFill, regionTint, textOnFill, themeColor } f
8
8
  import { crossingPoints, planLines, traceRoute, visibleRuns } from './lines.js';
9
9
  import { patchPadding, placeLineTexts } from './linetexts.js';
10
10
  import { plain } from './text.js';
11
+ import { linkLabel, linksOf } from './links.js';
11
12
  const CORNER = 8;
12
13
  /** Turn solved geometry into a standalone SVG document. */
13
14
  export function render(layout, options = {}) {
@@ -104,7 +105,10 @@ function drawing(layout, options) {
104
105
  edge.ink = union(edge.ink, grow(extentOfPoints([crossing.at]), crossing.reach + edge.edge.look.thickness));
105
106
  }
106
107
  }
107
- lines.push({ key: keyOf(edge), svg: linked(parts.join('\n'), edge.edge.attrs['url']) });
108
+ const links = linksOf(edge.edge.attrs);
109
+ if (links !== undefined && edge.edge.texts.length === 0)
110
+ parts.push(lineTarget(edge.line));
111
+ lines.push({ key: keyOf(edge), svg: linked(parts.join('\n'), links) });
108
112
  ink = union(ink, grow(edge.ink, layout.margin));
109
113
  }
110
114
  const lineSvg = new Map(lines.map((line) => [line.key, line.svg]));
@@ -196,7 +200,13 @@ function tag(layout, item, svg) {
196
200
  return svg;
197
201
  ({ name, line } = mark);
198
202
  }
199
- return `<g data-name="${escapeXml(name).replace(/"/g, '&quot;')}" data-line="${line}">\n${svg}\n</g>`;
203
+ // What the page selects: a node by its name, a line by its place in the list.
204
+ const what = item.node ? ` data-node="${escapeXml(item.node.name).replace(/"/g, '&quot;')}"` : item.key.startsWith('edge ') ? ` data-edge="${item.key.slice('edge '.length)}"` : '';
205
+ return `<g data-name="${escapeXml(name).replace(/"/g, '&quot;')}" data-line="${line}"${what}>\n${svg}\n</g>`;
206
+ }
207
+ /** An icon on its own, as a small SVG of `side` pixels: what the page puts beside a link. */
208
+ export function iconSvg(icon, side, theme) {
209
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${side}" height="${side}" viewBox="0 0 ${side} ${side}" font-family="system-ui, sans-serif">\n${drawIcon(icon, 0, 0, side, theme)}\n</svg>`;
200
210
  }
201
211
  /**
202
212
  * Gives every id in a drawing a prefix of its own, taken from a hash of the
@@ -254,12 +264,28 @@ function ownIds(svg) {
254
264
  * have given — the container catches every click its children do not — reached
255
265
  * by repeating the destination rather than by wrapping.
256
266
  */
257
- function linked(svg, url) {
258
- if (url === undefined || svg.length === 0)
267
+ function linked(svg, links) {
268
+ const first = links?.[0];
269
+ if (first === undefined || svg.length === 0)
259
270
  return svg;
271
+ // A plain SVG can follow one address, so a click follows the first; the
272
+ // tooltip names every link, so a reader learns there are others. A single
273
+ // bare address gets no tooltip, so a file written before several links
274
+ // draws exactly as it did.
275
+ const named = links.length > 1 || first.name !== undefined;
276
+ const title = named ? `\n <title>${escapeXml(links.map((link) => linkLabel(link, links)).join('\n'))}</title>` : '';
260
277
  // An `&` between query parameters is ordinary in a url and illegal raw in an
261
278
  // attribute, so the value is escaped as markup rather than merely quoted.
262
- return ` <a href=${quote(escapeXml(url))} target="_blank" rel="noopener">\n${svg}\n </a>`;
279
+ return ` <a href=${quote(escapeXml(first.href))} target="_blank" rel="noopener">${title}\n${svg}\n </a>`;
280
+ }
281
+ /**
282
+ * A line with a link and no text is its own target, and a line is too thin to
283
+ * hit, so an unseen wider copy of its path catches the click.
284
+ */
285
+ function lineTarget(line) {
286
+ const straight = line.match(/ x1="([^"]*)" y1="([^"]*)" x2="([^"]*)" y2="([^"]*)"/);
287
+ const d = straight ? `M${straight[1]} ${straight[2]} L${straight[3]} ${straight[4]}` : line.match(/ d="([^"]*)"/)?.[1];
288
+ return d === undefined ? '' : ` <path d="${d}" fill="none" stroke="#000" stroke-opacity="0" stroke-width="12" pointer-events="stroke"/>`;
263
289
  }
264
290
  /** Whether a node holds anything besides its heading and what is joined to its text: what a theme draws as a container. */
265
291
  function holdsContents(node) {
@@ -314,7 +340,7 @@ const GUIDE_DASH = '4 3';
314
340
  * a destination never encloses a child's.
315
341
  */
316
342
  function drawNode(node, theme, measurer, fontSize, markup, guides, inherited) {
317
- const url = node.attrs['url'] ?? inherited;
343
+ const url = linksOf(node.attrs) ?? inherited;
318
344
  let own = [];
319
345
  if (!node.guide) {
320
346
  own = nodeSvg(node, theme, measurer, fontSize, markup);
@@ -383,9 +409,9 @@ function drawVenn(node, theme, measurer, fontSize, markup, guides, url) {
383
409
  const leftFill = left.appearance['fill'] ?? 'theme-primary-subtle';
384
410
  const rightFill = right.appearance['fill'] ?? 'theme-secondary-subtle';
385
411
  const sharedFill = shared.appearance['fill'] ?? overlapFill(themeColor(leftFill, theme), theme);
386
- const item = (part, key, svg, fills = []) => ({ key, node: part, svg: linked(svg.join('\n'), part.attrs['url'] ?? url), fills });
412
+ const item = (part, key, svg, fills = []) => ({ key, node: part, svg: linked(svg.join('\n'), linksOf(part.attrs) ?? url), fills });
387
413
  const filled = (part, fill) => item(part, part.name, nodeSvg(plain(part, fill), theme, measurer, fontSize, markup), nodeFills(plain(part, fill), theme));
388
- const rest = (part) => part.children.flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, part.attrs['url'] ?? url));
414
+ const rest = (part) => part.children.flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, linksOf(part.attrs) ?? url));
389
415
  return [
390
416
  { key: node.name, node, svg: linked(own.join('\n'), url), fills: nodeFills(plain(node, node.appearance['fill'] ?? 'none'), theme) },
391
417
  filled(left, leftFill),
@@ -781,8 +807,31 @@ function outlineDetail(shape, x, y, w, h, look = {}) {
781
807
  return [];
782
808
  return shapeOf(shape, look, w, h).detail.map((d) => placePath(d, x, y));
783
809
  }
810
+ /**
811
+ * A letter badge's circle and letters. Unsaid, the circle is one of the
812
+ * theme's two accents, picked from the letters so the same letters always get
813
+ * the same one, and the letters are that accent's own text color; a stated
814
+ * fill with no text color takes the theme's text color.
815
+ */
816
+ export function letterColors(icon, theme) {
817
+ const sum = [...icon.letters].reduce((total, char) => total + char.codePointAt(0), 0);
818
+ const accent = sum % 2 === 0 ? theme.primary : theme.secondary;
819
+ const fill = icon.fill === undefined ? accent.color : themeColor(icon.fill, theme);
820
+ const text = icon.text !== undefined ? themeColor(icon.text, theme) : icon.fill === undefined ? accent.on : theme.text;
821
+ return { fill, text };
822
+ }
784
823
  /** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
785
824
  function drawIcon(icon, x, y, side, theme) {
825
+ if ('letters' in icon) {
826
+ const colors = letterColors(icon, theme);
827
+ const r = side / 2;
828
+ // Letters shrink as there are more of them, so two or three still fit the circle.
829
+ const size = round(side * (icon.letters.length <= 1 ? 0.55 : icon.letters.length === 2 ? 0.42 : 0.32));
830
+ return [
831
+ ` <circle cx="${round(x + r)}" cy="${round(y + r)}" r="${round(r)}" fill="${colors.fill}"/>`,
832
+ ` <text x="${round(x + r)}" y="${round(y + r)}" text-anchor="middle" dominant-baseline="central" font-size="${size}px" font-weight="700" fill="${colors.text}">${escapeXml(icon.letters)}</text>`,
833
+ ].join('\n');
834
+ }
786
835
  if ('inner' in icon) {
787
836
  // Its own viewBox fits it to the square, centered and in proportion.
788
837
  // `color` is set only when the drawing does not set it, so `currentColor`
package/dist/resolve.js CHANGED
@@ -2,7 +2,8 @@ import { TEXT_GAP } from './linetexts.js';
2
2
  import { POINT_KEYS, REGION_KEYS, ALL_ATTR_KEYS, ATTR_KEYS, MARKS, COLOR_KEYS, COLOR_PARTS, CHILD_ALIGNMENTS, GATHER_PLACES, CONTENT_WIDTHS, SIZE_KEY_NAMES, LINE_VALUES, NODE_CORNERS, PADDING_SIDES, describePlacement, nameTarget, partWordHint, } from './ast.js';
3
3
  import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, THICKNESS, BORDER_THICKNESS, BORDER_WIDTH, fontSizeFor, widestLine, } from './constants.js';
4
4
  import { SourceError } from './errors.js';
5
- import { bodyFor, pastedIcon } from './icons.js';
5
+ import { bodyFor, iconNamed, pastedIcon } from './icons.js';
6
+ import { linksOf } from './links.js';
6
7
  import { monospaceMeasurer } from './measure.js';
7
8
  import { isBoxy, middleOf, sizeFor, SUBPROCESS_INSET } from './outline.js';
8
9
  import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
@@ -302,6 +303,18 @@ export function resolve(doc, options = {}) {
302
303
  checkStyleKeys(doc.statements);
303
304
  const defaults = collectDefaults(doc.statements, styles);
304
305
  const icons = collectIcons(doc.statements, options.readIconFile);
306
+ // A link's badge may name a picture, which has to be one the file can draw.
307
+ // The page draws badges, so the pictures they name ride along in the layout.
308
+ const linkIcons = {};
309
+ for (const stmt of doc.statements) {
310
+ if (!('attrs' in stmt) || stmt.attrs === undefined)
311
+ continue;
312
+ for (const link of linksOf(stmt.attrs) ?? []) {
313
+ if (link.icon !== undefined && 'picture' in link.icon) {
314
+ linkIcons[link.icon.picture] = iconNamed(link.icon.picture, stmt.line, icons);
315
+ }
316
+ }
317
+ }
305
318
  const { nodes, byName, roots } = buildTree(writeOutCards(doc.statements), styles, defaults, icons);
306
319
  // From here on, `edge each child of a -> b` and `edge a -> b, c` are the lines they stand for.
307
320
  const statements = expandEach(doc.statements, byName);
@@ -333,6 +346,7 @@ export function resolve(doc, options = {}) {
333
346
  roots,
334
347
  edges,
335
348
  markup: markupColors(nodes, edges, styles, marks),
349
+ linkIcons,
336
350
  diagram: collectDiagram(doc.statements),
337
351
  width: Math.ceil(extent.maxX + margin),
338
352
  height: Math.ceil(extent.maxY + margin),
@@ -592,6 +606,10 @@ function collectIcons(statements, readIconFile) {
592
606
  if (icons.has(stmt.name)) {
593
607
  throw new SourceError(`icon "${stmt.name}" is declared twice`, stmt.line);
594
608
  }
609
+ if (stmt.letters !== undefined) {
610
+ icons.set(stmt.name, stmt.letters);
611
+ continue;
612
+ }
595
613
  let svg = stmt.source.trim();
596
614
  if (!stmt.pasted) {
597
615
  if (readIconFile === undefined) {
@@ -1422,14 +1440,9 @@ function buildEdges(statements, byName, named, styles, defaults, measurer, fontS
1422
1440
  const what = `${stmt.from} ${stmt.arrow} ${stmt.to}`;
1423
1441
  checkAttrs('edge', what, stmt.attrs, stmt.line);
1424
1442
  checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
1425
- if (stmt.attrs['url'] !== undefined && stmt.texts.length === 0) {
1426
- // Wrapping the line works, and `LINE_WIDTH` is 1.5 — a destination that
1427
- // technically has a target and practically has none. That is the silent
1428
- // defect shape the language refuses everywhere else, so it is refused
1429
- // here rather than shipped and explained.
1430
- throw new SourceError(`edge ${what} has a url and no text. A destination needs something to click, and a line ` +
1431
- 'is too thin to be it — give the edge a text, or put the url on one of the nodes', stmt.line);
1432
- }
1443
+ // A line with a link and no text was once refused, as too thin to click.
1444
+ // The render now gives it an unseen wider target along its path, and the
1445
+ // page selects a line by the same, so it is accepted.
1433
1446
  const texts = stmt.texts.map(({ text, textAttrs: own, place }) => {
1434
1447
  const textAttrs = { ...bracketOf('text', appearance), ...own };
1435
1448
  const lines = linesFor(text, textAttrs, `edge ${what}`, stmt.line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {