reladraw 0.27.0 → 0.29.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.
Files changed (50) hide show
  1. package/.claude/skills/reladraw/SKILL.md +7 -5
  2. package/.claude/skills/reladraw/reference/syntax.md +56 -6
  3. package/README.md +2 -2
  4. package/SYNTAX.md +56 -6
  5. package/dist/cli.js +15 -1
  6. package/dist/examples.d.ts +26 -0
  7. package/dist/examples.js +56 -0
  8. package/dist/page-data.d.ts +130 -0
  9. package/dist/page-data.js +226 -0
  10. package/dist/page.d.ts +46 -2
  11. package/dist/page.js +1595 -265
  12. package/dist/search.d.ts +104 -0
  13. package/dist/search.js +490 -0
  14. package/dist/serve.d.ts +18 -0
  15. package/dist/serve.js +544 -0
  16. package/dist/viewer.js +3 -1
  17. package/examples/arch.reladraw +95 -0
  18. package/examples/around.reladraw +101 -0
  19. package/examples/borders.reladraw +28 -0
  20. package/examples/bundles.reladraw +49 -0
  21. package/examples/circles.reladraw +37 -0
  22. package/examples/coincident.reladraw +66 -0
  23. package/examples/contents.reladraw +52 -0
  24. package/examples/corridors.reladraw +48 -0
  25. package/examples/deck.reladraw +26 -0
  26. package/examples/flowchart.reladraw +23 -0
  27. package/examples/frames.reladraw +79 -0
  28. package/examples/gap.reladraw +21 -0
  29. package/examples/gaps.reladraw +25 -0
  30. package/examples/icons.reladraw +35 -0
  31. package/examples/lanes.reladraw +54 -0
  32. package/examples/lines.reladraw +73 -0
  33. package/examples/linetexts.reladraw +48 -0
  34. package/examples/marks.reladraw +39 -0
  35. package/examples/names.reladraw +23 -0
  36. package/examples/outlines.reladraw +56 -0
  37. package/examples/overhang.reladraw +24 -0
  38. package/examples/overlays.reladraw +75 -0
  39. package/examples/parts.reladraw +31 -0
  40. package/examples/passes.reladraw +56 -0
  41. package/examples/points.reladraw +28 -0
  42. package/examples/regions.reladraw +14 -0
  43. package/examples/routing.reladraw +88 -0
  44. package/examples/shapes.reladraw +38 -0
  45. package/examples/snug.reladraw +8 -0
  46. package/examples/text.reladraw +69 -0
  47. package/examples/themes.reladraw +16 -0
  48. package/examples/urls.reladraw +35 -0
  49. package/examples/venn.reladraw +15 -0
  50. package/package.json +2 -1
@@ -0,0 +1,226 @@
1
+ import { fileChecks, findings } from './checks.js';
2
+ import { SourceError } from './errors.js';
3
+ import { blame, parse } from './parser.js';
4
+ import { linkLabel, linksOf } from './links.js';
5
+ import { markdownHtml } from './markdown.js';
6
+ import { iconSvg, renderTagged, themeOf } from './render.js';
7
+ import { resolve } from './resolve.js';
8
+ import { DEFAULT_THEME, luminance, THEME_NAMES, THEMES } from './themes.js';
9
+ /**
10
+ * What a page shows of one diagram: its drawing in every theme, what can be
11
+ * selected in it, and its findings. `page.ts` writes these into a page; a page
12
+ * that redraws as it is edited makes them in the browser, so this module is
13
+ * kept apart from the page's own markup and script, which it has no need of.
14
+ */
15
+ /**
16
+ * The page's data for `source`, as a served page takes it when its file
17
+ * changes or its editor is typed in: the same parse, layout, drawings and
18
+ * findings `drawPage` puts in the page it writes. Runs in the browser too.
19
+ */
20
+ export function pageData(source, file, options = {}, at = new Date()) {
21
+ const parts = partsOf(source, file, options, at);
22
+ return { title: parts.title, drawn: parts.drawn, data: dataOf(parts) };
23
+ }
24
+ export function partsOf(source, file, options, at) {
25
+ const name = file.replace(/\.[^.]+$/, '') || file;
26
+ const drawn = `drawn from ${file}, ${stamp(at)}`;
27
+ const run = (text) => {
28
+ const layout = resolve(parse(text), options);
29
+ const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
30
+ const title = layout.diagram['title'] ?? name;
31
+ // The theme it was drawn in: the one --theme named, else the file's.
32
+ const named = layout.diagram['theme'];
33
+ const current = (options.theme && THEME_NAMES.find((one) => THEMES[one] === options.theme)) ??
34
+ (named !== undefined && THEMES[named] !== undefined ? named : DEFAULT_THEME);
35
+ // The same diagram drawn in every theme, for the page's theme picker. A
36
+ // theme moves nothing, so only the colors differ between them. A page or
37
+ // text color the file states still holds in each, as in the playground.
38
+ // Guides start as the file or --guides says, and the View menu's switch
39
+ // shows the drawing the other way. A diagram with no guides draws the
40
+ // same both ways, so it carries one drawing.
41
+ const guidesShown = options.guides ?? layout.diagram['guides'] === 'show';
42
+ const guideCount = layout.nodes.filter((node) => node.guide).length +
43
+ layout.edges.filter((edge) => edge.guide).length +
44
+ layout.marks.filter((mark) => mark.guide).length;
45
+ const drawAll = (guides) => {
46
+ const themes = {};
47
+ for (const one of THEME_NAMES) {
48
+ const drawnIn = { ...options, guides, theme: options.theme && one === current ? options.theme : THEMES[one] };
49
+ const theme = themeOf(layout, drawnIn);
50
+ themes[one] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
51
+ }
52
+ // A --theme that is not one of the named ones is drawn as given, and offered as such.
53
+ if (themes[current] === undefined) {
54
+ const drawnIn = { ...options, guides };
55
+ const theme = themeOf(layout, drawnIn);
56
+ themes[current] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
57
+ }
58
+ return themes;
59
+ };
60
+ const themes = drawAll(guidesShown);
61
+ const flipped = guideCount > 0 ? drawAll(!guidesShown) : null;
62
+ return {
63
+ title,
64
+ name,
65
+ drawn,
66
+ source,
67
+ findings: found,
68
+ themes,
69
+ flipped,
70
+ guidesShown,
71
+ guideCount,
72
+ current,
73
+ graph: graphOf(layout, themes),
74
+ };
75
+ };
76
+ try {
77
+ return run(source);
78
+ }
79
+ catch (error) {
80
+ throw error instanceof SourceError ? blame(source, error, run) : error;
81
+ }
82
+ }
83
+ /** A description as the panel shows it: its Markdown as HTML, or nothing. */
84
+ const describe = (attrs) => attrs['description'] === undefined ? {} : { description: markdownHtml(attrs['description']) };
85
+ const BADGE_SIDE = 20;
86
+ function graphOf(layout, themes) {
87
+ const views = (links) => (links ?? []).map((link) => {
88
+ const label = linkLabel(link, links);
89
+ const icon = link.icon === undefined
90
+ ? { letters: [...label.trim()][0]?.toUpperCase() ?? '?' }
91
+ : 'picture' in link.icon
92
+ ? layout.linkIcons[link.icon.picture]
93
+ : link.icon;
94
+ const badges = {};
95
+ for (const [one, view] of Object.entries(themes))
96
+ badges[one] = iconSvg(icon, BADGE_SIDE, view.theme);
97
+ return { href: link.href, label, badges };
98
+ });
99
+ // A node with no links of its own has its container's, as a click on it in
100
+ // the SVG follows its container's.
101
+ const linksFor = (node) => {
102
+ for (let at = node; at; at = at.parent) {
103
+ const own = linksOf(at.attrs);
104
+ if (own !== undefined)
105
+ return own;
106
+ }
107
+ return undefined;
108
+ };
109
+ // A badge's picture is joined to its box's text by `badge:`, not by a
110
+ // `join-with:` anyone wrote.
111
+ const isBadge = (node) => node.joinWith === 'text' && node.attrs['join-with'] === undefined;
112
+ const nodes = {};
113
+ for (const node of layout.nodes) {
114
+ const text = plainText(node.text);
115
+ nodes[node.name] = {
116
+ label: text === '' ? node.name.split('.').pop() : text,
117
+ links: views(linksFor(node)),
118
+ ...describe(node.attrs),
119
+ box: [node.x, node.y, node.width, node.height].map((n) => Math.round(n * 10) / 10),
120
+ ...(node.parent ? { parent: node.parent.name } : {}),
121
+ children: node.children.filter((child) => !isBadge(child)).map((child) => child.name),
122
+ styles: stylesOf(node.attrs),
123
+ ...(node.guide ? { guide: true } : {}),
124
+ ...(isBadge(node) ? { badge: true } : {}),
125
+ ...(node.kind === 'none' && node.children.length === 0 ? { bare: true } : {}),
126
+ };
127
+ }
128
+ const edges = layout.edges.map((edge) => {
129
+ // The upstream end is the one a line runs from: the end without a mark
130
+ // when only the other has one, and otherwise the end written first.
131
+ const [, start = '', , end = ''] = edge.arrow.match(/^(.*?)(-{1,2})(.*)$/) ?? [];
132
+ const backwards = start !== '' && end === '';
133
+ const [up, down] = backwards ? [edge.to, edge.from] : [edge.from, edge.to];
134
+ const text = edge.texts.map((one) => plainText(one.text)).filter((one) => one !== '').join(' · ');
135
+ return {
136
+ up: up.name,
137
+ down: down.name,
138
+ label: text,
139
+ links: views(linksOf(edge.attrs)),
140
+ ...describe(edge.attrs),
141
+ styles: stylesOf(edge.attrs),
142
+ ...(edge.guide ? { guide: true } : {}),
143
+ };
144
+ });
145
+ return { nodes, edges };
146
+ }
147
+ /** The styles a `style:` names; the parser joins them on a space. */
148
+ const stylesOf = (attrs) => (attrs['style'] === undefined ? [] : attrs['style'].split(' '));
149
+ /** A text as a reader sees it: markup such as `[dim]` taken out, its lines run together. */
150
+ function plainText(text) {
151
+ return text.replace(/\[\/?[a-z][a-z0-9-]*\]/gi, '').replace(/\s*\n\s*/g, ' ').trim();
152
+ }
153
+ /** A local time as `2026-11-05 14:32`, which sorts and reads the same everywhere. */
154
+ function stamp(at) {
155
+ const two = (n) => String(n).padStart(2, '0');
156
+ return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
157
+ }
158
+ const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
159
+ /**
160
+ * What may differ between one theme's drawing and another's: colors,
161
+ * opacities, and the ids made from a drawing's hash and an arrowhead's color.
162
+ * Split on, its matches are the odd pieces. Matching too much costs nothing;
163
+ * a drawing whose other pieces differ is carried whole.
164
+ */
165
+ const VARIES = /(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|opacity="[^"]*"|opacity:[^;"]*|\br[0-9a-f]{8}\b|\b[0-9a-f]{6}\b)/gi;
166
+ // The browser's own controls (scroll bars, focus rings) follow the page, so
167
+ // a dark diagram doesn't get a light one's. A color written by hand in a
168
+ // form that can't be measured leaves the choice to the browser.
169
+ function schemeOf(theme) {
170
+ return hex(theme.text) && hex(theme.background)
171
+ ? luminance(theme.text) > luminance(theme.background)
172
+ ? 'dark'
173
+ : 'light'
174
+ : 'light dark';
175
+ }
176
+ function colorsOf(theme) {
177
+ return {
178
+ '--page': theme.background,
179
+ '--ink': theme.text,
180
+ '--muted': theme.mutedText,
181
+ '--rule': theme.boxStroke,
182
+ '--accent': theme.primary.color,
183
+ '--on-accent': theme.primary.on,
184
+ };
185
+ }
186
+ export function dataOf({ name, source, findings, themes, flipped, guidesShown, guideCount, current, graph }) {
187
+ // The theme list: first the diagram's own theme, which follows the file;
188
+ // then every theme as the reader's choice, dark ones together and light
189
+ // together, as the playground's picker has them, so stepping through the
190
+ // list doesn't flash between black and white.
191
+ const group = (light) => Object.keys(themes).filter((one) => (schemeOf(themes[one].theme) === 'light') === light);
192
+ // The shown theme goes in whole; another is only the colors that differ
193
+ // from it, when the rest of the drawing is the same, as it is since a theme
194
+ // moves nothing. That keeps the page near its size with one theme.
195
+ const pack = (other, base) => {
196
+ const theirs = other.split(VARIES);
197
+ const ours = base.split(VARIES);
198
+ const same = theirs.length === ours.length && theirs.every((piece, i) => i % 2 === 1 || piece === ours[i]);
199
+ return same ? theirs.filter((_, i) => i % 2 === 1) : other;
200
+ };
201
+ // Each set of drawings, with guides one way, packed against its own drawing
202
+ // in the page's first theme: guides add things, so the two sets differ in
203
+ // more than colors and can't be packed against each other.
204
+ const packAll = (set) => {
205
+ const base = set[current];
206
+ return Object.fromEntries(Object.entries(set).map(([one, view]) => [
207
+ one,
208
+ one === current
209
+ ? { svg: view.svg, tagged: view.tagged, colors: view.colors, scheme: view.scheme }
210
+ : { svg: pack(view.svg, base.svg), tagged: pack(view.tagged, base.tagged), colors: view.colors, scheme: view.scheme },
211
+ ]));
212
+ };
213
+ return {
214
+ name,
215
+ source,
216
+ current,
217
+ varies: VARIES.source,
218
+ themeGroups: { dark: group(false), light: group(true) },
219
+ themes: packAll(themes),
220
+ flipped: flipped && packAll(flipped),
221
+ guidesShown,
222
+ guideCount,
223
+ findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
224
+ graph,
225
+ };
226
+ }
package/dist/page.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Finding } from './checks.js';
1
+ import type { Finding } from './checks.js';
2
2
  import type { DrawOptions } from './index.js';
3
3
  /**
4
4
  * `reladraw render --page`: one diagram in one self-contained HTML page, made
@@ -11,7 +11,51 @@ import type { DrawOptions } from './index.js';
11
11
  * none, and the name everything saved from the page is given. The page is a
12
12
  * snapshot of the file at `at`, and says so under its title.
13
13
  */
14
- export declare function drawPage(source: string, file: string, options?: DrawOptions, at?: Date): {
14
+ export declare function drawPage(source: string, file: string, options?: DrawOptions, at?: Date, editing?: Editing): {
15
15
  html: string;
16
16
  findings: Finding[];
17
17
  };
18
+ /**
19
+ * How a page's source can be edited, when it can: served by `reladraw serve`
20
+ * tied to a file, by its path inside the folder served, which the page asks
21
+ * the server about; served with no file, keeping its text in the browser until
22
+ * a first Save names a file in `folder`; or the hosted playground, which keeps
23
+ * its text in the browser and saves by download. A page with no file offers
24
+ * the examples, the first of which a new visitor starts with.
25
+ */
26
+ export type Editing = {
27
+ kind: 'file';
28
+ file: string;
29
+ } | {
30
+ kind: 'local';
31
+ folder: string;
32
+ examples: Example[];
33
+ } | {
34
+ kind: 'hosted';
35
+ examples: Example[];
36
+ };
37
+ /** An example a page with no file offers: what it is called, one line about it, and its source. */
38
+ export interface Example {
39
+ name: string;
40
+ about: string;
41
+ source: string;
42
+ }
43
+ /** What makes a page with no file: the examples, and where it is. */
44
+ export interface Playground {
45
+ examples: Example[];
46
+ /** Served by `reladraw serve` with no file: the folder it serves, as the page shows it. */
47
+ folder?: string;
48
+ /** The hosted playground: what goes in its `<head>`, such as its description for search engines. */
49
+ head?: string;
50
+ /**
51
+ * The hosted playground: the library as one classic script that sets
52
+ * `globalThis.reladraw`, which the page draws with, since a page opened
53
+ * from disk or a static host can't import modules the way a served one does.
54
+ */
55
+ library?: string;
56
+ }
57
+ /**
58
+ * The page with no file: the playground. It opens on the first example and
59
+ * draws whatever its visitor last had in that browser, with the source shown.
60
+ */
61
+ export declare function playgroundPage(playground: Playground): string;