@olierjs/fugue-cli 0.0.0-stage → 0.1.2
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/LICENSE +201 -0
- package/README.md +99 -2
- package/THIRD_PARTY_NOTICES +467 -0
- package/bin/fugue-cli.mjs +23 -0
- package/dist/darwin-arm64/fugue-cli +0 -0
- package/dist/darwin-x64/fugue-cli +0 -0
- package/dist/linux-arm64/fugue-cli +0 -0
- package/dist/linux-x64/fugue-cli +0 -0
- package/dist/win32-arm64/fugue-cli.exe +0 -0
- package/dist/win32-x64/fugue-cli.exe +0 -0
- package/package.json +27 -4
- package/scripts/postinstall.mjs +13 -0
- package/scripts/skills.mjs +104 -0
- package/skills/fugue-show-me/SKILL.md +16 -0
- package/skills/fugue-stack/SKILL.md +10 -0
- package/skills/plan-with-remote/SKILL.md +60 -0
- package/skills/plan-with-remote/references/configuration.md +43 -0
- package/skills/plan-with-remote/scripts/client.mjs +61 -0
- package/skills/plan-with-remote/scripts/config.mjs +53 -0
- package/skills/plan-with-remote/scripts/planning.mjs +279 -0
- package/skills/plan-with-remote/scripts/protocol.mjs +147 -0
- package/skills/plan-with-remote/scripts/service.mjs +146 -0
- package/skills/plan-with-remote/scripts/storage.mjs +74 -0
- package/skills/show-me/SKILL.md +128 -0
- package/skills/show-me/agents/openai.yaml +2 -0
- package/skills/statechart/SKILL.md +36 -0
- package/skills/statechart/scripts/bug-states.mjs +81 -0
- package/skills/statechart/scripts/chart-edges.mjs +68 -0
- package/skills/statechart/scripts/chart-parts.mjs +21 -0
- package/skills/statechart/scripts/chart-to-tla.mjs +205 -0
- package/skills/statechart/scripts/compare-charts.mjs +74 -0
- package/skills/statechart/scripts/lib.mjs +970 -0
- package/skills/statechart/scripts/map-view.mjs +92 -0
- package/skills/statechart/scripts/property-loop.mjs +60 -0
- package/skills/statechart/scripts/read-check.mjs +79 -0
- package/skills/statechart/scripts/regions-chart.mjs +307 -0
- package/skills/statechart/scripts/render.mjs +531 -0
- package/skills/statechart/scripts/rule-book.mjs +84 -0
- package/skills/statechart/scripts/second-replay.mjs +122 -0
- package/skills/statechart/scripts/snapshot.mjs +74 -0
- package/skills/statechart/scripts/spec-text.mjs +126 -0
- package/skills/statechart/scripts/statechart-help.mjs +350 -0
- package/skills/statechart/scripts/statechart.mjs +903 -0
- package/skills/statechart/scripts/test/band.mmd +15 -0
- package/skills/statechart/scripts/test/regions.mmd +62 -0
- package/skills/statechart/scripts/test/statechart.test.mjs +6854 -0
- package/skills/statechart/scripts/test/sync.mmd +19 -0
- package/skills/statechart/scripts/test/upload.mmd +16 -0
- package/skills/statechart/scripts/tlc-to-chart-args.mjs +52 -0
- package/skills/statechart/scripts/tlc-to-chart.mjs +802 -0
- package/skills/statechart/scripts/tlc-tools.mjs +69 -0
- package/skills/statechart/scripts/ways-in.mjs +69 -0
- package/upstream-skills.json +18 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { GROUP_PALETTE, PLAIN_FILL } from "./lib.mjs";
|
|
8
|
+
|
|
9
|
+
export const PACKAGES = ["pele@1.8.0", "@resvg/resvg-js@2.6.2"];
|
|
10
|
+
export const CACHE_NAME = PACKAGES.map((p) => p.replace(/^@[^/]+\//, "").replace("@", "-")).join(
|
|
11
|
+
"-",
|
|
12
|
+
);
|
|
13
|
+
const ENTRY = 'export { render } from "pele";\nexport { Resvg } from "@resvg/resvg-js";\n';
|
|
14
|
+
|
|
15
|
+
export const cacheDir = () =>
|
|
16
|
+
path.join(
|
|
17
|
+
process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"),
|
|
18
|
+
"statechart",
|
|
19
|
+
CACHE_NAME,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
function install(dir) {
|
|
23
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
24
|
+
const tmp = fs.mkdtempSync(`${dir}-`);
|
|
25
|
+
try {
|
|
26
|
+
fs.writeFileSync(path.join(tmp, "package.json"), '{ "private": true }\n');
|
|
27
|
+
console.error(`Installing ${PACKAGES.join(" and ")} with npm in ${dir}. This happens once.`);
|
|
28
|
+
const r = spawnSync(
|
|
29
|
+
"npm",
|
|
30
|
+
["install", "--no-audit", "--no-fund", "--ignore-scripts", "--save-exact", ...PACKAGES],
|
|
31
|
+
{
|
|
32
|
+
cwd: tmp,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
env: {
|
|
35
|
+
...process.env,
|
|
36
|
+
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
37
|
+
},
|
|
38
|
+
shell: process.platform === "win32",
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
if (r.status !== 0) {
|
|
42
|
+
const said =
|
|
43
|
+
r.error?.message ??
|
|
44
|
+
(`${r.stderr ?? ""}${r.stdout ?? ""}`.trim().split("\n").slice(-20).join("\n") ||
|
|
45
|
+
`npm install exited ${r.status}, with no output`);
|
|
46
|
+
throw new Error(
|
|
47
|
+
`cannot install ${PACKAGES.join(" and ")} with npm:\n${said.replace(/^/gm, " ")}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
fs.writeFileSync(path.join(tmp, "entry.mjs"), ENTRY);
|
|
51
|
+
try {
|
|
52
|
+
fs.renameSync(tmp, dir);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (!fs.existsSync(path.join(dir, "entry.mjs"))) throw e;
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadRenderer() {
|
|
62
|
+
const dir = cacheDir();
|
|
63
|
+
const entry = path.join(dir, "entry.mjs");
|
|
64
|
+
if (!fs.existsSync(entry)) install(dir);
|
|
65
|
+
try {
|
|
66
|
+
return await import(pathToFileURL(entry).href);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`cannot load Pele and resvg from ${dir}: ${e.message}\nDelete that folder to install them again.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const STYLE = { line: 1.5, text: 0.4, arrow: 1.25, zoom: 2 };
|
|
75
|
+
const MAX_PIXELS = 100_000_000;
|
|
76
|
+
const MARK = "2.5px";
|
|
77
|
+
const FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
|
|
78
|
+
|
|
79
|
+
export const THEMES = {
|
|
80
|
+
light: {
|
|
81
|
+
background: "#ffffff",
|
|
82
|
+
stateFill: "#fcfcfc",
|
|
83
|
+
stateBorder: "#c9c9c9",
|
|
84
|
+
stateText: "#1c1c1c",
|
|
85
|
+
edge: "#808080",
|
|
86
|
+
edgeLabel: "#656565",
|
|
87
|
+
region: { fill: "#f1f1f1", border: "#c9c9c9", text: "#1c1c1c" },
|
|
88
|
+
groups: ["#c3e0ff", "#f0d3bf", "#e3d5ff", "#f0dcbf", "#bfe3df", "#e5d9ce", "#ffcbe6"],
|
|
89
|
+
groupBorder: "#c9c9c9",
|
|
90
|
+
wrong: { fill: "#ffecee", border: "#e72e3b", text: "#cd2834" },
|
|
91
|
+
fixed: { fill: "#eaf9f2", border: "#10ab61" },
|
|
92
|
+
mixed: { fill: "#fff3e6", border: "#e8730c", text: "#b85a06" },
|
|
93
|
+
after: { fill: "#ffffff", border: "#9a9a9a", text: "#656565" },
|
|
94
|
+
worse: { fill: "#f7edfd", border: "#9b35d6", text: "#8424b8" },
|
|
95
|
+
path: "#1680f3",
|
|
96
|
+
},
|
|
97
|
+
dark: {
|
|
98
|
+
background: "#1f1f1f",
|
|
99
|
+
stateFill: "#121212",
|
|
100
|
+
stateBorder: "#4e4e4e",
|
|
101
|
+
stateText: "#f5f5f5",
|
|
102
|
+
edge: "#939393",
|
|
103
|
+
edgeLabel: "#acacac",
|
|
104
|
+
region: { fill: "#2c2c2c", border: "#4e4e4e", text: "#f5f5f5" },
|
|
105
|
+
groups: ["#1b3857", "#573117", "#3c2e57", "#573d17", "#174641", "#3d3127", "#57233e"],
|
|
106
|
+
groupBorder: "#4e4e4e",
|
|
107
|
+
wrong: { fill: "#2e2021", border: "#cf2b35", text: "#ff6463" },
|
|
108
|
+
fixed: { fill: "#1e2a25", border: "#0e9f5a" },
|
|
109
|
+
mixed: { fill: "#2e2619", border: "#d0700f", text: "#ffa257" },
|
|
110
|
+
after: { fill: "#1f1f1f", border: "#6e6e6e", text: "#acacac" },
|
|
111
|
+
worse: { fill: "#29202e", border: "#9a3fcf", text: "#d38cff" },
|
|
112
|
+
path: "#116dd2",
|
|
113
|
+
},
|
|
114
|
+
classic: {
|
|
115
|
+
background: "#ffffff",
|
|
116
|
+
stateFill: PLAIN_FILL,
|
|
117
|
+
stateBorder: "#566573",
|
|
118
|
+
stateText: "#1b2631",
|
|
119
|
+
edge: "#1f1f1f",
|
|
120
|
+
edgeLabel: "#1f1f1f",
|
|
121
|
+
region: { fill: "#e6e6e6", border: "#8a8a8a", text: "#1f1f1f" },
|
|
122
|
+
groups: null,
|
|
123
|
+
groupBorder: null,
|
|
124
|
+
wrong: { fill: "#fadbd8", border: "#922b21", text: "#7b241c" },
|
|
125
|
+
fixed: { fill: "#d5f5e3", border: "#196f3d" },
|
|
126
|
+
mixed: { fill: "#fdebd0", border: "#d35400", text: "#7e5109" },
|
|
127
|
+
after: { fill: "#ffffff", border: "#808b96", text: "#566573" },
|
|
128
|
+
worse: { fill: "#ebdef0", border: "#6c3483", text: "#4a235a" },
|
|
129
|
+
path: "#1f3a93",
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const hex6 = (c) => {
|
|
134
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c?.trim() ?? "");
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
const h = m[1].toLowerCase();
|
|
137
|
+
return `#${h.length === 3 ? [...h].map((x) => x + x).join("") : h}`;
|
|
138
|
+
};
|
|
139
|
+
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
140
|
+
const lab = (hex) => {
|
|
141
|
+
const [r, g, b] = rgb(hex).map((c) =>
|
|
142
|
+
(c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
|
|
143
|
+
);
|
|
144
|
+
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
|
145
|
+
const [x, y, z] = [
|
|
146
|
+
(0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047,
|
|
147
|
+
0.2126 * r + 0.7152 * g + 0.0722 * b,
|
|
148
|
+
(0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883,
|
|
149
|
+
].map(f);
|
|
150
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
151
|
+
};
|
|
152
|
+
const deltaE = (a, b) => Math.hypot(...lab(a).map((v, i) => v - lab(b)[i]));
|
|
153
|
+
|
|
154
|
+
const STATE_CLASS =
|
|
155
|
+
/^(plain|group\d+|works|wrong|fixed|mixed|after|worse)(Wrong|Fixed|Mixed|After|Worse|Path)?$/;
|
|
156
|
+
const CLASS_DEF = /^(\s*classDef\s+)(\w+)\s+(\S+?)\s*$/gm;
|
|
157
|
+
const rawFill = (body) => /(?:^|,)fill:([^,]+)/.exec(body)?.[1];
|
|
158
|
+
const STATUSES = ["wrong", "fixed", "mixed", "after", "worse"];
|
|
159
|
+
|
|
160
|
+
function markOf(name) {
|
|
161
|
+
const m = STATE_CLASS.exec(name);
|
|
162
|
+
if (!m) return null;
|
|
163
|
+
const [, base, mark = ""] = m;
|
|
164
|
+
const own = mark.toLowerCase();
|
|
165
|
+
if (STATUSES.includes(own)) return own;
|
|
166
|
+
return STATUSES.includes(base) ? base : own;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function paletteSlots(fills) {
|
|
170
|
+
const slot = new Map();
|
|
171
|
+
const taken = new Set();
|
|
172
|
+
for (const f of fills) {
|
|
173
|
+
const k = GROUP_PALETTE.indexOf(f);
|
|
174
|
+
if (k >= 0) {
|
|
175
|
+
slot.set(f, k);
|
|
176
|
+
taken.add(k);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const all = GROUP_PALETTE.map((_, k) => k);
|
|
180
|
+
for (const f of fills) {
|
|
181
|
+
if (f === PLAIN_FILL || slot.has(f)) continue;
|
|
182
|
+
const free = all.filter((k) => !taken.has(k));
|
|
183
|
+
const [k] = (free.length ? free : all).sort(
|
|
184
|
+
(a, b) => deltaE(f, GROUP_PALETTE[a]) - deltaE(f, GROUP_PALETTE[b]),
|
|
185
|
+
);
|
|
186
|
+
slot.set(f, k);
|
|
187
|
+
taken.add(k);
|
|
188
|
+
}
|
|
189
|
+
return slot;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const marked = ({ fill, border, text }) =>
|
|
193
|
+
`fill:${fill},stroke:${border},stroke-width:${MARK},color:${text}`;
|
|
194
|
+
const STYLES = {
|
|
195
|
+
wrong: ({ wrong }) => marked(wrong),
|
|
196
|
+
mixed: ({ mixed }) => marked(mixed),
|
|
197
|
+
after: ({ after }) => marked(after),
|
|
198
|
+
worse: ({ worse }) => marked(worse),
|
|
199
|
+
fixed: ({ fixed: f, stateText }) =>
|
|
200
|
+
`fill:${f.fill},stroke:${f.border},stroke-width:${MARK},color:${stateText}`,
|
|
201
|
+
path: ({ path, stateText }, fill) =>
|
|
202
|
+
`${fill ? `fill:${fill},` : ""}stroke:${path},stroke-width:${MARK},color:${stateText}`,
|
|
203
|
+
"": ({ groups, stateFill, stateBorder, groupBorder, stateText }, fill) =>
|
|
204
|
+
groups &&
|
|
205
|
+
`${fill ? `fill:${fill},` : ""}stroke:${fill === stateFill ? stateBorder : groupBorder},color:${stateText}`,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function themed(src, theme) {
|
|
209
|
+
const own = new Set();
|
|
210
|
+
for (const [, , name, body] of src.matchAll(CLASS_DEF)) {
|
|
211
|
+
const fill = hex6(rawFill(body));
|
|
212
|
+
if (fill && (markOf(name) === "" || markOf(name) === "path")) own.add(fill);
|
|
213
|
+
}
|
|
214
|
+
const slot = paletteSlots([...own]);
|
|
215
|
+
const fillFor = (body) => {
|
|
216
|
+
const raw = rawFill(body);
|
|
217
|
+
const fill = hex6(raw);
|
|
218
|
+
if (!theme.groups || !fill) return raw;
|
|
219
|
+
if (fill === PLAIN_FILL) return theme.stateFill;
|
|
220
|
+
return slot.has(fill) ? theme.groups[slot.get(fill)] : raw;
|
|
221
|
+
};
|
|
222
|
+
return src.replace(CLASS_DEF, (line, head, name, body) => {
|
|
223
|
+
const mark = markOf(name);
|
|
224
|
+
const style = mark === null ? null : STYLES[mark](theme, fillFor(body));
|
|
225
|
+
return style ? `${head}${name} ${style}` : line;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function withRootDirection(src) {
|
|
230
|
+
const lines = src.split("\n");
|
|
231
|
+
let at = -1;
|
|
232
|
+
let depth = 0;
|
|
233
|
+
let regions = false;
|
|
234
|
+
let direction = false;
|
|
235
|
+
for (const [i, line] of lines.entries()) {
|
|
236
|
+
const code = line
|
|
237
|
+
.replace(/"[^"]*"/g, '""')
|
|
238
|
+
.replace(/:.*$/, "")
|
|
239
|
+
.trim();
|
|
240
|
+
if (at < 0) {
|
|
241
|
+
if (/^stateDiagram/.test(code)) at = i;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (code.startsWith("%%")) continue;
|
|
245
|
+
if (code === "--") regions = true;
|
|
246
|
+
if (depth === 0 && /^direction\s/.test(code)) direction = true;
|
|
247
|
+
depth += (code.match(/\{/g) ?? []).length - (code.match(/\}/g) ?? []).length;
|
|
248
|
+
}
|
|
249
|
+
if (at < 0 || !regions || direction) return src;
|
|
250
|
+
lines.splice(at + 1, 0, " direction LR");
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const SYSTEM_KEYWORD = /^(system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif)$/i;
|
|
255
|
+
|
|
256
|
+
export function fcMatch(pattern) {
|
|
257
|
+
try {
|
|
258
|
+
const [file, name] = execFileSync("fc-match", ["-f", "%{file}\n%{family[0]}", pattern], {
|
|
259
|
+
encoding: "utf8",
|
|
260
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
261
|
+
}).split("\n");
|
|
262
|
+
return file ? { file, name } : null;
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let systemFont;
|
|
269
|
+
export function systemFamily() {
|
|
270
|
+
if (systemFont === undefined) systemFont = fcMatch("system-ui")?.name ?? null;
|
|
271
|
+
return systemFont;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function pngStack(sys) {
|
|
275
|
+
if (!sys) return FONT;
|
|
276
|
+
const names = FONT.split(",")
|
|
277
|
+
.map((f) => f.trim().replace(/^'|'$/g, ""))
|
|
278
|
+
.map((f) => (SYSTEM_KEYWORD.test(f) ? sys : f));
|
|
279
|
+
return [...new Set(names)].map((f) => (/^[\w-]+$/.test(f) ? f : `'${f}'`)).join(", ");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
283
|
+
|
|
284
|
+
const measurers = new Map();
|
|
285
|
+
export function fontMeasurer(Resvg, family) {
|
|
286
|
+
if (measurers.has(family)) return measurers.get(family);
|
|
287
|
+
const ink = (face, text, size, style) => {
|
|
288
|
+
const svg =
|
|
289
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="200"><text x="10" y="100" font-size="${size}"` +
|
|
290
|
+
` font-family="'${face.name}'"${style & 1 ? ' font-weight="bold"' : ""}${style & 2 ? ' font-style="italic"' : ""}` +
|
|
291
|
+
` xml:space="preserve">${xml(text)}</text></svg>`;
|
|
292
|
+
const font = face.file
|
|
293
|
+
? { loadSystemFonts: false, fontFiles: [face.file] }
|
|
294
|
+
: { loadSystemFonts: true };
|
|
295
|
+
return new Resvg(svg, { font }).getBBox()?.width;
|
|
296
|
+
};
|
|
297
|
+
const draws = (face, style) => ink(face, "x", 16, style) > 0;
|
|
298
|
+
const matchFace = (style) => {
|
|
299
|
+
const name = style & 4 ? "monospace" : family;
|
|
300
|
+
return fcMatch(`${name}${style & 1 ? ":bold" : ""}${style & 2 ? ":italic" : ""}`) ?? { name };
|
|
301
|
+
};
|
|
302
|
+
const regular = matchFace(0);
|
|
303
|
+
if (!draws(regular, 0)) {
|
|
304
|
+
measurers.set(family, null);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const faces = new Map([[0, regular]]);
|
|
308
|
+
const faceFor = (style) => {
|
|
309
|
+
if (!faces.has(style)) {
|
|
310
|
+
const face = matchFace(style);
|
|
311
|
+
faces.set(style, draws(face, style) ? face : regular);
|
|
312
|
+
}
|
|
313
|
+
return faces.get(style);
|
|
314
|
+
};
|
|
315
|
+
const widths = new Map();
|
|
316
|
+
const bars = new Map();
|
|
317
|
+
const measurer = {
|
|
318
|
+
width(text, size, style = 0) {
|
|
319
|
+
const key = `${size}|${style}|${text}`;
|
|
320
|
+
if (!widths.has(key)) {
|
|
321
|
+
const face = faceFor(style);
|
|
322
|
+
const bar = `${size}|${style}`;
|
|
323
|
+
if (!bars.has(bar)) bars.set(bar, ink(face, "||", size, style));
|
|
324
|
+
widths.set(key, text ? ink(face, `|${text}|`, size, style) - bars.get(bar) : 0);
|
|
325
|
+
}
|
|
326
|
+
return widths.get(key);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
measurers.set(family, measurer);
|
|
330
|
+
return measurer;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const STATE_PAD = 16;
|
|
334
|
+
const DEFAULT_WRAP = 200;
|
|
335
|
+
const labelLines = (src) =>
|
|
336
|
+
[...src.matchAll(/^\s*state "([^"]+)" as|^[^%\n]*-->[^:\n]*:\s*([^\n]+)$/gm)]
|
|
337
|
+
.flatMap((m) => (m[1] ?? m[2]).split(/<br\s*\/?>/))
|
|
338
|
+
.map((l) => l.trim())
|
|
339
|
+
.filter((l) => l && !/["\\]/.test(l));
|
|
340
|
+
|
|
341
|
+
function widthRatio(render, src, measurer) {
|
|
342
|
+
const lines = [...new Set(labelLines(src))];
|
|
343
|
+
if (!lines.length) return 1;
|
|
344
|
+
const probe = `---\nconfig:\n state:\n wrappingWidth: 2000\n---\nstateDiagram-v2\n${lines.map((l, i) => ` state "${l}" as p${i}`).join("\n")}\n`;
|
|
345
|
+
const builtin = new Map(
|
|
346
|
+
[...render(probe).svg.matchAll(/data-id="p(\d+)"[^>]*><rect\b[^>]*\swidth="([\d.]+)"/g)].map(
|
|
347
|
+
([, i, w]) => [lines[+i], +w - 2 * STATE_PAD],
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
const sum = (f) => lines.reduce((s, l) => s + f(l), 0);
|
|
351
|
+
return (
|
|
352
|
+
sum((l) => measurer.width(l, 16, 0)) / sum((l) => builtin.get(l) ?? measurer.width(l, 16, 0))
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function setWrap(src, width) {
|
|
357
|
+
const fm = /^---\n([\s\S]*?\n)---\n/.exec(src);
|
|
358
|
+
if (!fm) return `---\nconfig:\n state:\n wrappingWidth: ${width}\n---\n${src}`;
|
|
359
|
+
let body = fm[1];
|
|
360
|
+
if (/^ {4}wrappingWidth:.*$/m.test(body))
|
|
361
|
+
body = body.replace(/^ {4}wrappingWidth:.*$/m, ` wrappingWidth: ${width}`);
|
|
362
|
+
else if (/^ {2}state:\n/m.test(body))
|
|
363
|
+
body = body.replace(/^( {2}state:\n)/m, `$1 wrappingWidth: ${width}\n`);
|
|
364
|
+
else if (/^config:\n/m.test(body))
|
|
365
|
+
body = body.replace(/^(config:\n)/m, `$1 state:\n wrappingWidth: ${width}\n`);
|
|
366
|
+
else body += `config:\n state:\n wrappingWidth: ${width}\n`;
|
|
367
|
+
return `---\n${body}---\n${src.slice(fm[0].length)}`;
|
|
368
|
+
}
|
|
369
|
+
const wrapOf = (src) =>
|
|
370
|
+
+(/^---\n[\s\S]*?^ {4}wrappingWidth:\s*(\d+)[\s\S]*?^---$/m.exec(src)?.[1] ?? DEFAULT_WRAP);
|
|
371
|
+
|
|
372
|
+
const toHex = (c) => `#${c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
|
373
|
+
|
|
374
|
+
function splitTop(s) {
|
|
375
|
+
const out = [];
|
|
376
|
+
let depth = 0;
|
|
377
|
+
let cur = "";
|
|
378
|
+
for (const ch of s) {
|
|
379
|
+
if (ch === "(") depth++;
|
|
380
|
+
if (ch === ")") depth--;
|
|
381
|
+
if (ch === "," && depth === 0) {
|
|
382
|
+
out.push(cur);
|
|
383
|
+
cur = "";
|
|
384
|
+
} else cur += ch;
|
|
385
|
+
}
|
|
386
|
+
out.push(cur);
|
|
387
|
+
return out.map((x) => x.trim());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function resolveCss(svg) {
|
|
391
|
+
const defs = new Map();
|
|
392
|
+
for (const m of svg.matchAll(/(--[\w-]+)\s*:\s*([^;"}]+)/g))
|
|
393
|
+
if (!defs.has(m[1])) defs.set(m[1], m[2].trim());
|
|
394
|
+
const resolve = (value) => {
|
|
395
|
+
let changed = true;
|
|
396
|
+
while (changed) {
|
|
397
|
+
changed = false;
|
|
398
|
+
value = value.replace(
|
|
399
|
+
/var\((--[\w-]+)\s*(?:,\s*((?:[^()]|\([^()]*\))*))?\)/g,
|
|
400
|
+
(_, name, fallback) => {
|
|
401
|
+
changed = true;
|
|
402
|
+
const d = defs.get(name);
|
|
403
|
+
if (d !== undefined && d !== `var(${name})`) return resolve(d);
|
|
404
|
+
return fallback !== undefined ? fallback : "currentColor";
|
|
405
|
+
},
|
|
406
|
+
);
|
|
407
|
+
value = value.replace(/color-mix\(\s*in srgb\s*,((?:[^()]|\([^()]*\))*)\)/g, (_, args) => {
|
|
408
|
+
changed = true;
|
|
409
|
+
const [a, b] = splitTop(args);
|
|
410
|
+
const pa = /(.*?)\s+([\d.]+)%$/.exec(a);
|
|
411
|
+
const ca = hex6(pa ? pa[1] : a);
|
|
412
|
+
const cb = hex6(b.replace(/\s+[\d.]+%$/, ""));
|
|
413
|
+
const p = pa ? Number(pa[2]) / 100 : 0.5;
|
|
414
|
+
if (!ca || !cb) return pa ? pa[1] : a;
|
|
415
|
+
const [x, y] = [rgb(ca), rgb(cb)];
|
|
416
|
+
return toHex(x.map((v, i) => v * p + y[i] * (1 - p)));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return value;
|
|
420
|
+
};
|
|
421
|
+
for (const [k, v] of defs) defs.set(k, resolve(v));
|
|
422
|
+
return resolve(svg.replace(/@import[^;]+;/g, ""));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function themeSvg(svg, theme) {
|
|
426
|
+
const vars =
|
|
427
|
+
`--pele-bg:${theme.background};--pele-fg:${theme.region.text};--pele-muted:${theme.edgeLabel};--pele-line:${theme.edge};` +
|
|
428
|
+
`--pele-surface:${theme.stateFill};--pele-surface-alt:${theme.region.fill};--pele-border:${theme.region.border};`;
|
|
429
|
+
return svg
|
|
430
|
+
.replace(/(<svg\b[^>]*?\sstyle=")/, `$1${vars}`)
|
|
431
|
+
.replace(
|
|
432
|
+
/(<g class="pele-edge-label"[^>]*><rect[^>]*\/><text\b)/g,
|
|
433
|
+
`$1 fill="${theme.edgeLabel}"`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const LINES =
|
|
438
|
+
`<style>.pele-edges,.pele-dividers{stroke-width:${STYLE.line}px}` +
|
|
439
|
+
`.pele-node rect,.pele-node path,.pele-node circle,.pele-cluster rect,.pele-cluster path,.pele-note rect,.pele-note path{stroke-width:${STYLE.line}px}</style>`;
|
|
440
|
+
const round = (v) => +v.toFixed(2);
|
|
441
|
+
|
|
442
|
+
function heavier(svg) {
|
|
443
|
+
svg = resolveCss(svg)
|
|
444
|
+
.replace(/style="([^"]*)"/g, (_, css) => `style="${css.replace(/--[\w-]+:[^;]*;?/g, "")}"`)
|
|
445
|
+
.replace(/font:\s*([\d.]+px)\s+([^;"]+);/, "font-size:$1;font-family:$2;");
|
|
446
|
+
const rootFill = /<svg[^>]*\sfill="([^"]+)"/.exec(svg)?.[1] ?? "#1f1f1f";
|
|
447
|
+
return svg
|
|
448
|
+
.replace(/(<svg[^>]*>)/, `$1${LINES}`)
|
|
449
|
+
.replace(/<text\b([^>]*)>/g, (_, attrs) => {
|
|
450
|
+
const fill =
|
|
451
|
+
/style="[^"]*fill:\s*([^;"]+)/.exec(attrs)?.[1] ??
|
|
452
|
+
/\sfill="([^"]+)"/.exec(attrs)?.[1] ??
|
|
453
|
+
rootFill;
|
|
454
|
+
return `<text${attrs} stroke="${fill.trim()}" stroke-width="${STYLE.text}" stroke-linejoin="round">`;
|
|
455
|
+
})
|
|
456
|
+
.replace(
|
|
457
|
+
/(class="pele-marker" d=")M([\d.-]+),([\d.-]+)((?:L[\d.-]+,[\d.-]+)+)Z/g,
|
|
458
|
+
(_, head, tx, ty, rest) => {
|
|
459
|
+
const [x0, y0] = [+tx, +ty];
|
|
460
|
+
const points = [...rest.matchAll(/L([\d.-]+),([\d.-]+)/g)].map(
|
|
461
|
+
([, x, y]) =>
|
|
462
|
+
`L${round(x0 + (x - x0) * STYLE.arrow)},${round(y0 + (y - y0) * STYLE.arrow)}`,
|
|
463
|
+
);
|
|
464
|
+
return `${head}M${tx},${ty}${points.join("")}Z`;
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function maskBands(svg) {
|
|
470
|
+
const pad = STYLE.line / 2;
|
|
471
|
+
// The holes set their own fill because mask content inherits the root <svg> fill, which is not black.
|
|
472
|
+
const holes = [
|
|
473
|
+
...svg.matchAll(
|
|
474
|
+
/<g class="pele-cluster\b[^"]*"[^>]*><rect x="([\d.-]+)" y="([\d.-]+)" width="([\d.]+)"[^>]*\/><path d="M[\d.-]+,([\d.-]+)H/g,
|
|
475
|
+
),
|
|
476
|
+
]
|
|
477
|
+
.map(
|
|
478
|
+
([, x, y, w, divider]) =>
|
|
479
|
+
`<rect x="${round(x - pad)}" y="${round(y - pad)}" width="${round(+w + 2 * pad)}" height="${round(divider - y + 2 * pad)}" fill="#000"/>`,
|
|
480
|
+
)
|
|
481
|
+
.join("");
|
|
482
|
+
if (!holes) return svg;
|
|
483
|
+
return svg.replace(
|
|
484
|
+
/<g class="pele-edges"/,
|
|
485
|
+
`<mask id="pele-bands" maskUnits="userSpaceOnUse"><rect width="100%" height="100%" fill="#fff"/>${holes}</mask>$& mask="url(#pele-bands)"`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function renderChart(lib, src, { theme = THEMES.light, systemFont = systemFamily() } = {}) {
|
|
490
|
+
let text = withRootDirection(themed(src, theme));
|
|
491
|
+
const measurer = systemFont ? fontMeasurer(lib.Resvg, systemFont) : undefined;
|
|
492
|
+
let wrap = wrapOf(text);
|
|
493
|
+
if (measurer) {
|
|
494
|
+
wrap = Math.ceil(wrap * Math.max(1, widthRatio(lib.render, text, measurer)));
|
|
495
|
+
text = setWrap(text, wrap);
|
|
496
|
+
}
|
|
497
|
+
const r = lib.render(text, { fontFamily: FONT, measurer });
|
|
498
|
+
return {
|
|
499
|
+
svg: heavier(maskBands(themeSvg(r.svg, theme))),
|
|
500
|
+
width: r.width,
|
|
501
|
+
height: r.height,
|
|
502
|
+
wrap,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function toPng(
|
|
507
|
+
lib,
|
|
508
|
+
{ svg, width, height },
|
|
509
|
+
{ theme = THEMES.light, systemFont = systemFamily() } = {},
|
|
510
|
+
) {
|
|
511
|
+
return new lib.Resvg(svg.split(FONT).join(pngStack(systemFont)), {
|
|
512
|
+
background: theme.background,
|
|
513
|
+
fitTo: { mode: "zoom", value: Math.min(STYLE.zoom, Math.sqrt(MAX_PIXELS / (width * height))) },
|
|
514
|
+
font: systemFont
|
|
515
|
+
? { loadSystemFonts: true, defaultFontFamily: systemFont, sansSerifFamily: systemFont }
|
|
516
|
+
: { loadSystemFonts: true },
|
|
517
|
+
})
|
|
518
|
+
.render()
|
|
519
|
+
.asPng();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function renderPng(lib, src, options = {}) {
|
|
523
|
+
try {
|
|
524
|
+
return toPng(lib, renderChart(lib, src, options), options);
|
|
525
|
+
} catch (e) {
|
|
526
|
+
if (e.code !== "limit") throw e;
|
|
527
|
+
throw new Error(
|
|
528
|
+
`${e.message} The chart is too big for one PNG, and the .mmd file still has all of it. A chart with fewer states fits. Run node statechart.mjs from-spec SPEC.tla --cover --png to draw a smaller overview of the spec.`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
blankStrings,
|
|
5
|
+
definitions,
|
|
6
|
+
ruleSentences,
|
|
7
|
+
splitCommentsKeepingOffsets,
|
|
8
|
+
tlaVariables,
|
|
9
|
+
} from "./lib.mjs";
|
|
10
|
+
|
|
11
|
+
const EXTENDS_STATEMENT = /\bEXTENDS\s+(\w+(?:\s*,\s*\w+)*)/g;
|
|
12
|
+
const CONSTANT_STATEMENT = /\bCONSTANTS?\s+(\w+(?:\([^)]*\))?(?:\s*,\s*\w+(?:\([^)]*\))?)*)/g;
|
|
13
|
+
const DECLARED_NAME = /(\w+)(?:\([^)]*\))?/g;
|
|
14
|
+
const LOCAL_DEFINITION = /^LOCAL\s/;
|
|
15
|
+
const INSTANCE_STATEMENT =
|
|
16
|
+
/^(LOCAL[ \t]+)?(?:([A-Za-z_]\w*)[ \t]*==\s*)?INSTANCE\s+(\w+)((?:[^\n]|\n(?=\s))*)/gm;
|
|
17
|
+
const SUBSTITUTION = /(\w+)\s*<-([\s\S]*?)(?=\w+\s*<-|$)/g;
|
|
18
|
+
const ALIAS = /^\s*([A-Za-z_]\w*(?:![A-Za-z_]\w*)*)\s*$/;
|
|
19
|
+
|
|
20
|
+
export function ruleBook(spec) {
|
|
21
|
+
const modules = new Map();
|
|
22
|
+
const moduleOf = (file) => {
|
|
23
|
+
if (modules.has(file)) return modules.get(file);
|
|
24
|
+
const module = { rules: new Map(), parameters: [] };
|
|
25
|
+
modules.set(file, module);
|
|
26
|
+
const text = fs.readFileSync(file, "utf8");
|
|
27
|
+
const code = blankStrings(splitCommentsKeepingOffsets(text).code);
|
|
28
|
+
const fileOf = (name) => path.join(path.dirname(file), `${name}.tla`);
|
|
29
|
+
const inherited = new Map();
|
|
30
|
+
const instancedReads = new Map();
|
|
31
|
+
const localImports = new Set();
|
|
32
|
+
const take = (imported, { name, local, substitutions = new Map() }) => {
|
|
33
|
+
for (const [rule, entry] of moduleOf(imported).rules) {
|
|
34
|
+
if (entry.local) continue;
|
|
35
|
+
const key = name ? `${name}!${rule}` : rule;
|
|
36
|
+
if (local) localImports.add(key);
|
|
37
|
+
if (entry.sentence) inherited.set(key, entry.sentence);
|
|
38
|
+
instancedReads.set(
|
|
39
|
+
key,
|
|
40
|
+
[...entry.reads].map((v) => substitutions.get(v) ?? v),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const parameters = [];
|
|
45
|
+
for (const [, names] of code.matchAll(EXTENDS_STATEMENT))
|
|
46
|
+
for (const extended of names
|
|
47
|
+
.split(/\s*,\s*/)
|
|
48
|
+
.map(fileOf)
|
|
49
|
+
.filter(fs.existsSync)) {
|
|
50
|
+
parameters.push(...moduleOf(extended).parameters);
|
|
51
|
+
take(extended, {});
|
|
52
|
+
}
|
|
53
|
+
for (const [, names] of code.matchAll(CONSTANT_STATEMENT))
|
|
54
|
+
parameters.push(...[...names.matchAll(DECLARED_NAME)].map(([, constant]) => constant));
|
|
55
|
+
for (const [, local, name, instanced, clause] of code.matchAll(INSTANCE_STATEMENT))
|
|
56
|
+
if (fs.existsSync(fileOf(instanced)))
|
|
57
|
+
take(fileOf(instanced), {
|
|
58
|
+
name,
|
|
59
|
+
local,
|
|
60
|
+
substitutions: new Map([...clause.matchAll(SUBSTITUTION)].map(([, v, e]) => [v, e])),
|
|
61
|
+
});
|
|
62
|
+
const written = ruleSentences(text);
|
|
63
|
+
const { bare, defs, own, through } = definitions(text, instancedReads, parameters);
|
|
64
|
+
module.parameters.push(...parameters, ...tlaVariables(bare));
|
|
65
|
+
const sentenceOf = (rule, seen = new Set()) => {
|
|
66
|
+
if (written.has(rule)) return written.get(rule);
|
|
67
|
+
if (seen.has(rule)) return undefined;
|
|
68
|
+
const d = defs.get(rule);
|
|
69
|
+
if (!d) return inherited.get(rule);
|
|
70
|
+
const alias = ALIAS.exec(bare.slice(d.from, d.to))?.[1];
|
|
71
|
+
return alias ? sentenceOf(alias, seen.add(rule)) : undefined;
|
|
72
|
+
};
|
|
73
|
+
for (const [rule, entry] of own) {
|
|
74
|
+
const d = defs.get(rule);
|
|
75
|
+
module.rules.set(rule, {
|
|
76
|
+
sentence: sentenceOf(rule),
|
|
77
|
+
reads: through(entry).reads,
|
|
78
|
+
local: d ? LOCAL_DEFINITION.test(bare.slice(d.at, d.from)) : localImports.has(rule),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return module;
|
|
82
|
+
};
|
|
83
|
+
return moduleOf(path.resolve(spec)).rules;
|
|
84
|
+
}
|