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/.claude/skills/reladraw/SKILL.md +27 -4
- package/.claude/skills/reladraw/reference/syntax.md +106 -12
- package/README.md +6 -4
- package/SYNTAX.md +106 -12
- package/dist/ast.d.ts +47 -1
- package/dist/ast.js +12 -4
- package/dist/cli.js +108 -13
- package/dist/constants.d.ts +21 -1
- package/dist/constants.js +35 -2
- package/dist/format.js +5 -1
- package/dist/grammar.d.ts +1 -1
- package/dist/grammar.js +16 -3
- package/dist/linetexts.d.ts +4 -1
- package/dist/linetexts.js +13 -3
- package/dist/links.d.ts +4 -0
- package/dist/markdown.d.ts +14 -0
- package/dist/markdown.js +102 -0
- package/dist/model.d.ts +4 -3
- package/dist/page.js +21 -3
- package/dist/parser.d.ts +7 -0
- package/dist/parser.js +244 -27
- package/dist/place.js +3 -5
- package/dist/render.js +52 -28
- package/dist/resolve.js +207 -22
- package/dist/text.d.ts +15 -3
- package/dist/text.js +16 -7
- package/package.json +1 -2
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 [
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
67
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
518
|
+
// output is written.
|
|
424
519
|
main(process.argv.slice(2)).then((code) => {
|
|
425
520
|
process.exitCode = code;
|
|
426
521
|
}, (error) => {
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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);
|
package/dist/linetexts.d.ts
CHANGED
|
@@ -32,7 +32,10 @@ export interface PlacedText {
|
|
|
32
32
|
maxX: number;
|
|
33
33
|
maxY: number;
|
|
34
34
|
};
|
|
35
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/markdown.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
|
311
|
-
* renderer needs it because a marked run borrows its color from a
|
|
312
|
-
* rather than stating one, and styles are otherwise merged away by
|
|
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;
|