reladraw 0.26.1 → 0.28.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/.claude/skills/reladraw/SKILL.md +7 -5
- package/.claude/skills/reladraw/reference/syntax.md +55 -7
- package/README.md +2 -2
- package/SYNTAX.md +55 -7
- package/dist/cli.js +15 -1
- package/dist/examples.d.ts +26 -0
- package/dist/examples.js +56 -0
- package/dist/page-data.d.ts +130 -0
- package/dist/page-data.js +226 -0
- package/dist/page.d.ts +46 -2
- package/dist/page.js +1992 -252
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +544 -0
- package/dist/viewer.js +1 -1
- package/examples/arch.reladraw +95 -0
- package/examples/around.reladraw +101 -0
- package/examples/borders.reladraw +28 -0
- package/examples/bundles.reladraw +49 -0
- package/examples/circles.reladraw +37 -0
- package/examples/coincident.reladraw +66 -0
- package/examples/contents.reladraw +52 -0
- package/examples/corridors.reladraw +48 -0
- package/examples/deck.reladraw +26 -0
- package/examples/flowchart.reladraw +23 -0
- package/examples/frames.reladraw +79 -0
- package/examples/gap.reladraw +21 -0
- package/examples/gaps.reladraw +25 -0
- package/examples/icons.reladraw +35 -0
- package/examples/lanes.reladraw +54 -0
- package/examples/lines.reladraw +73 -0
- package/examples/linetexts.reladraw +48 -0
- package/examples/marks.reladraw +39 -0
- package/examples/names.reladraw +23 -0
- package/examples/outlines.reladraw +56 -0
- package/examples/overhang.reladraw +24 -0
- package/examples/overlays.reladraw +75 -0
- package/examples/parts.reladraw +31 -0
- package/examples/passes.reladraw +56 -0
- package/examples/points.reladraw +28 -0
- package/examples/regions.reladraw +14 -0
- package/examples/routing.reladraw +88 -0
- package/examples/shapes.reladraw +38 -0
- package/examples/snug.reladraw +8 -0
- package/examples/text.reladraw +69 -0
- package/examples/themes.reladraw +16 -0
- package/examples/urls.reladraw +35 -0
- package/examples/venn.reladraw +15 -0
- package/package.json +2 -1
- package/dist/search.d.ts +0 -104
- package/dist/search.js +0 -490
|
@@ -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 {
|
|
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;
|