flowfig 0.6.0 → 0.8.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/README.md +28 -8
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +67 -59
- package/dist/check.d.ts +5 -3
- package/dist/check.js +24 -17
- package/dist/cli.js +43 -49
- package/dist/code.d.ts +26 -0
- package/dist/code.js +568 -0
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/edges.d.ts +7 -0
- package/dist/edges.js +510 -0
- package/dist/geometry.d.ts +6 -8
- package/dist/geometry.js +75 -82
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +1 -4
- package/dist/guide.js +10 -11
- package/dist/imports.d.ts +11 -0
- package/dist/imports.js +235 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -78
- package/dist/init.d.ts +3 -13
- package/dist/init.js +13 -18
- package/dist/load.d.ts +1 -3
- package/dist/load.js +2 -5
- package/dist/mcp.d.ts +1 -2
- package/dist/mcp.js +15 -15
- package/dist/model.d.ts +57 -109
- package/dist/model.js +20 -89
- package/dist/open.d.ts +2 -5
- package/dist/open.js +6 -11
- package/dist/page.d.ts +0 -1
- package/dist/page.js +1 -3
- package/dist/picker.d.ts +2 -4
- package/dist/picker.js +11 -14
- package/dist/png.js +9 -10
- package/dist/rail.d.ts +1 -3
- package/dist/rail.js +2 -16
- package/dist/scene.d.ts +8 -8
- package/dist/source.d.ts +1 -2
- package/dist/source.js +5 -5
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +50 -112
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/type-index.d.ts +55 -0
- package/dist/type-index.js +383 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +733 -0
- package/dist/verify.d.ts +26 -4
- package/dist/verify.js +68 -26
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/dist/cli.js
CHANGED
|
@@ -7,29 +7,6 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
}
|
|
8
8
|
return path;
|
|
9
9
|
};
|
|
10
|
-
/**
|
|
11
|
-
* Render a figure as one animated SVG — for a README, a PR, an issue, a blog post — and check it first.
|
|
12
|
-
*
|
|
13
|
-
* flowfig - out.svg < spec.json # a spec on stdin: nothing is left on disk
|
|
14
|
-
* flowfig spec.json out.svg # a spec file
|
|
15
|
-
* flowfig figure.ts out.svg # a module whose default export is a spec
|
|
16
|
-
* flowfig --spec out.svg # print back the spec the SVG carries
|
|
17
|
-
* flowfig check <input> [--json] # list the faults; the input can also be an SVG this wrote
|
|
18
|
-
* flowfig verify <input>... [--root dir] # check that the code each figure links to still exists
|
|
19
|
-
* flowfig diff <old> <new> [--json|--md] # list the spec changes between two figures
|
|
20
|
-
* flowfig docs # print the full guide (Markdown)
|
|
21
|
-
* flowfig mcp # serve check, render, verify, diff and docs over MCP (stdio)
|
|
22
|
-
* flowfig init [dir] # write flowfig instructions for the coding agents of a repo
|
|
23
|
-
* flowfig draw "<question>" [--out out.svg] # ask Claude Code for a figure, then check it
|
|
24
|
-
* flowfig open figure.svg # show the figure in the default browser
|
|
25
|
-
* flowfig gif figure.svg [out.gif] # write an animated GIF of the figure (needs Chrome, Edge, Chromium or Brave)
|
|
26
|
-
*
|
|
27
|
-
* A render checks first and writes nothing on an error (--no-check skips that). --strict makes warnings errors; --width and
|
|
28
|
-
* --min-text set the page width and the smallest text the reader should get.
|
|
29
|
-
* --open opens the rendered SVG in the default browser (render and draw).
|
|
30
|
-
*
|
|
31
|
-
* Every rendered SVG carries its own spec in <metadata>, so a figure is editable later without anyone having to keep the JSON.
|
|
32
|
-
*/
|
|
33
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
34
11
|
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
35
12
|
import { tmpdir } from 'node:os';
|
|
@@ -46,8 +23,9 @@ import { pageHtml } from './page.js';
|
|
|
46
23
|
import { decodePng } from './png.js';
|
|
47
24
|
import { diff, formatDiff } from './diff.js';
|
|
48
25
|
import { loadSpec, reportLines, sortFindings, specOf, svgWithSpec } from './load.js';
|
|
49
|
-
import { check, toSvg } from './svg.js';
|
|
50
|
-
import {
|
|
26
|
+
import { check, render, toSvg } from './svg.js';
|
|
27
|
+
import { checkRendered } from './check.js';
|
|
28
|
+
import { coverageLine, links, unsureLines, verifyReport } from './verify.js';
|
|
51
29
|
const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
|
|
52
30
|
flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
|
|
53
31
|
flowfig --spec figure.svg print the spec the SVG carries
|
|
@@ -60,7 +38,6 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] rende
|
|
|
60
38
|
flowfig open <figure.svg> [--html <path>] show the figure in the default browser
|
|
61
39
|
flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
|
|
62
40
|
flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only)`;
|
|
63
|
-
/** Bad use, not a bad figure: exit 2 with a message, not a stack trace. A declaration, so TypeScript narrows after a call. */
|
|
64
41
|
function usage(message) {
|
|
65
42
|
console.error(message);
|
|
66
43
|
process.exit(2);
|
|
@@ -90,7 +67,7 @@ if (args[0] === 'docs') {
|
|
|
90
67
|
}
|
|
91
68
|
if (args[0] === 'mcp') {
|
|
92
69
|
await serve(process.stdin, process.stdout);
|
|
93
|
-
// A pipe write is asynchronous: wait
|
|
70
|
+
// A pipe write is asynchronous: wait for the queued lines before the exit.
|
|
94
71
|
await new Promise((done) => process.stdout.write('', () => done(undefined)));
|
|
95
72
|
process.exit(0);
|
|
96
73
|
}
|
|
@@ -118,7 +95,6 @@ if (args[0] === '--spec') {
|
|
|
118
95
|
}
|
|
119
96
|
process.exit(0);
|
|
120
97
|
}
|
|
121
|
-
/** A spec from stdin, a JSON file, an SVG this wrote, or a module. Exit 2 with a message on any failure. */
|
|
122
98
|
async function load(input) {
|
|
123
99
|
try {
|
|
124
100
|
if (input.endsWith('.json') || input.endsWith('.svg'))
|
|
@@ -144,20 +120,28 @@ if (args[0] === 'verify') {
|
|
|
144
120
|
usage('verify needs at least one figure');
|
|
145
121
|
const findings = [];
|
|
146
122
|
const all = [];
|
|
123
|
+
const coverage = [];
|
|
147
124
|
for (const input of args) {
|
|
148
125
|
const props = await load(input);
|
|
149
|
-
const
|
|
126
|
+
const report = verifyReport(props, { root });
|
|
127
|
+
const found = sortFindings([...check(props), ...report.findings], strict).map((f) => ({ ...f, figure: input }));
|
|
150
128
|
findings.push(...found);
|
|
129
|
+
coverage.push({ figure: input, ...report.coverage });
|
|
151
130
|
all.push(...links(props).map((l) => ({ ...l, figure: input })));
|
|
152
131
|
}
|
|
153
132
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
154
133
|
if (json)
|
|
155
|
-
console.log(JSON.stringify({ findings, links: all }, null, 2));
|
|
134
|
+
console.log(JSON.stringify({ findings, links: all, coverage }, null, 2));
|
|
156
135
|
else {
|
|
157
136
|
const n = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
|
|
158
137
|
for (const f of findings)
|
|
159
138
|
console.log(`${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.figure}: ${f.message}`);
|
|
160
139
|
console.log(`${n(errors, 'error')}, ${n(findings.length - errors, 'warning')}`);
|
|
140
|
+
for (const c of coverage) {
|
|
141
|
+
console.log(coverageLine(c.figure, c));
|
|
142
|
+
for (const l of unsureLines(c))
|
|
143
|
+
console.log(l);
|
|
144
|
+
}
|
|
161
145
|
}
|
|
162
146
|
process.exit(errors ? 1 : 0);
|
|
163
147
|
}
|
|
@@ -195,7 +179,6 @@ if (args[0] === 'gif') {
|
|
|
195
179
|
usage(`${path}: ${e.message}`);
|
|
196
180
|
}
|
|
197
181
|
if (step !== undefined) {
|
|
198
|
-
// One step only: render the spec again with that step, so the loop holds that step alone.
|
|
199
182
|
const props = (() => {
|
|
200
183
|
try {
|
|
201
184
|
return loadSpec(path);
|
|
@@ -222,24 +205,22 @@ if (args[0] === 'gif') {
|
|
|
222
205
|
usage(['gif needs Chrome, Edge, Chromium or Brave. Checked:', ...found.checked, 'Set CHROME_PATH to the browser program.'].join('\n'));
|
|
223
206
|
const line = (file, frames, bytes) => `${file} — ${frames} frame${frames === 1 ? '' : 's'}, ${(frames / fps).toFixed(1)} s, ${(bytes / 1048576).toFixed(1)} MB`;
|
|
224
207
|
const temp = mkdtempSync(join(tmpdir(), 'flowfig-gif-'));
|
|
225
|
-
// A Ctrl-C can come while the browser starts, so cleanup waits for the start before it stops the browser.
|
|
226
208
|
let launching;
|
|
227
209
|
let cleaning;
|
|
228
210
|
const cleanup = () => (cleaning ??= (async () => {
|
|
229
211
|
await (await launching?.catch(() => undefined))?.close();
|
|
230
212
|
try {
|
|
231
|
-
//
|
|
213
|
+
// Windows can hold a file lock briefly after the exit, so retry.
|
|
232
214
|
rmSync(temp, { recursive: true, force: true, maxRetries: 10 });
|
|
233
215
|
}
|
|
234
216
|
catch (e) {
|
|
235
217
|
console.error(`warning: could not remove ${temp}: ${e.message}`);
|
|
236
218
|
}
|
|
237
219
|
})());
|
|
238
|
-
// The output files of this run: a stop removes them, so no partial file stays.
|
|
239
220
|
const written = [];
|
|
240
221
|
let ffmpeg, ffmpegDone = Promise.resolve(), stopped = 0;
|
|
241
222
|
const yieldLoop = () => new Promise((done) => setImmediate(done));
|
|
242
|
-
//
|
|
223
|
+
// Keep the handlers: a second signal must not end the process mid-cleanup.
|
|
243
224
|
const onSignal = (signal) => {
|
|
244
225
|
if (stopped)
|
|
245
226
|
return;
|
|
@@ -252,7 +233,7 @@ if (args[0] === 'gif') {
|
|
|
252
233
|
};
|
|
253
234
|
process.on('SIGINT', onSignal);
|
|
254
235
|
process.on('SIGTERM', onSignal);
|
|
255
|
-
//
|
|
236
|
+
// A write comes right after this check, so a signal must run its handler first.
|
|
256
237
|
const proceed = async () => {
|
|
257
238
|
await yieldLoop();
|
|
258
239
|
if (stopped)
|
|
@@ -265,7 +246,7 @@ if (args[0] === 'gif') {
|
|
|
265
246
|
const viewport = { width: Math.ceil(Number(size[1])), height: Math.ceil(Number(size[2])) };
|
|
266
247
|
const { pngs } = await captureFrames(browser.cdp, pageHtml(svg, basename(path)), { ...viewport, scale, fps, dark });
|
|
267
248
|
const wait = delays(pngs.length, fps);
|
|
268
|
-
// The GIF keeps the PNG bytes
|
|
249
|
+
// The GIF keeps the PNG bytes: a decoded 2400 x 1600 frame is 15 MB.
|
|
269
250
|
const gif = await encodeGif(pngs.map((png, i) => ({ load: () => decodePng(png), delay: wait[i], same: i > 0 && png.equals(pngs[i - 1]) })));
|
|
270
251
|
await proceed();
|
|
271
252
|
written.push(out);
|
|
@@ -275,21 +256,37 @@ if (args[0] === 'gif') {
|
|
|
275
256
|
console.error(`warning: ${out} is ${(gif.length / 1048576).toFixed(1)} MB, over 10 MB. Try --step <n>, a lower --fps or a lower --scale.`);
|
|
276
257
|
if (mp4) {
|
|
277
258
|
const mp4Path = out.replace(/(\.gif)?$/, '.mp4');
|
|
278
|
-
// FLOWFIG_FFMPEG replaces the PATH lookup, so a test can hide ffmpeg and keep the full PATH.
|
|
279
259
|
const ffmpegBin = process.env.FLOWFIG_FFMPEG ?? 'ffmpeg';
|
|
280
260
|
if (spawnSync(ffmpegBin, ['-version']).error)
|
|
281
261
|
console.error('gif: no ffmpeg on the PATH, so no MP4. The GIF is written.');
|
|
282
262
|
else {
|
|
283
263
|
await proceed();
|
|
284
264
|
written.push(mp4Path);
|
|
285
|
-
// yuv420p needs an even width and height, so the pad filter adds a pixel
|
|
265
|
+
// yuv420p needs an even width and height, so the pad filter adds a pixel.
|
|
286
266
|
ffmpeg = spawn(ffmpegBin, [
|
|
287
|
-
|
|
288
|
-
|
|
267
|
+
'-y',
|
|
268
|
+
'-loglevel',
|
|
269
|
+
'error',
|
|
270
|
+
'-f',
|
|
271
|
+
'image2pipe',
|
|
272
|
+
'-framerate',
|
|
273
|
+
String(fps),
|
|
274
|
+
'-c:v',
|
|
275
|
+
'png',
|
|
276
|
+
'-i',
|
|
277
|
+
'-',
|
|
278
|
+
'-vf',
|
|
279
|
+
'pad=ceil(iw/2)*2:ceil(ih/2)*2',
|
|
280
|
+
'-c:v',
|
|
281
|
+
'libx264',
|
|
282
|
+
'-pix_fmt',
|
|
283
|
+
'yuv420p',
|
|
284
|
+
mp4Path,
|
|
289
285
|
]);
|
|
290
286
|
let stderr = '';
|
|
291
287
|
ffmpeg.stderr.on('data', (d) => (stderr += d));
|
|
292
|
-
|
|
288
|
+
// ffmpeg can exit early; the exit code reports that.
|
|
289
|
+
ffmpeg.stdin.on('error', () => { });
|
|
293
290
|
ffmpeg.stdin.end(Buffer.concat(pngs));
|
|
294
291
|
const status = await (ffmpegDone = new Promise((done) => {
|
|
295
292
|
ffmpeg.once('error', () => done(-1));
|
|
@@ -312,7 +309,7 @@ if (args[0] === 'gif') {
|
|
|
312
309
|
finally {
|
|
313
310
|
await cleanup();
|
|
314
311
|
}
|
|
315
|
-
// A signal
|
|
312
|
+
// A late signal runs its handler in this await, before the exit.
|
|
316
313
|
await yieldLoop();
|
|
317
314
|
if (stopped)
|
|
318
315
|
written.forEach((f) => rmSync(f, { force: true }));
|
|
@@ -330,12 +327,10 @@ const [input, out] = args;
|
|
|
330
327
|
if (!input)
|
|
331
328
|
usage(USAGE);
|
|
332
329
|
const props = await load(input);
|
|
333
|
-
const
|
|
330
|
+
const rendered = skip ? undefined : render(props, opts);
|
|
331
|
+
const findings = rendered ? sortFindings(checkRendered(props, opts, rendered.scene), strict) : [];
|
|
334
332
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
335
|
-
/** The findings for a person: one line each, colored only on a terminal, then the counts. A render prints them to stderr, next to
|
|
336
|
-
* its own output. A clean render still prints `0 errors, 0 warnings`: the agent copies that line into its reply. */
|
|
337
333
|
const report = (print, tty) => {
|
|
338
|
-
// The severity field is 8 characters wide, so the painted field keeps the same width.
|
|
339
334
|
const paint = (s) => tty && !process.env.NO_COLOR ? s.replace(/^(error {3}|warning )/, (w) => `\x1b[${w[0] === 'e' ? 31 : 33}m${w}\x1b[0m`) : s;
|
|
340
335
|
for (const line of reportLines(props, findings, !skip))
|
|
341
336
|
print(paint(line));
|
|
@@ -351,8 +346,7 @@ report(console.error, process.stderr.isTTY);
|
|
|
351
346
|
if (errors)
|
|
352
347
|
process.exit(1);
|
|
353
348
|
const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/, '') + '.svg');
|
|
354
|
-
|
|
355
|
-
const withSpec = svgWithSpec(props, opts);
|
|
349
|
+
const withSpec = svgWithSpec(props, opts, rendered?.svg);
|
|
356
350
|
writeFileSync(dest, withSpec);
|
|
357
351
|
console.log(`${dest} — ${(withSpec.length / 1024).toFixed(1)} kB`);
|
|
358
352
|
if (open) {
|
package/dist/code.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type Lang = 'ts' | 'py' | 'go' | 'java' | 'cs' | 'rs';
|
|
2
|
+
export type Read = {
|
|
3
|
+
(full: string): string | null;
|
|
4
|
+
files?: (root: string) => string[];
|
|
5
|
+
};
|
|
6
|
+
export type CodeFile = {
|
|
7
|
+
path: string;
|
|
8
|
+
lang: Lang;
|
|
9
|
+
text: string;
|
|
10
|
+
code: string;
|
|
11
|
+
keep: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const langOf: (path: string) => Lang | null;
|
|
14
|
+
export declare const readFile: Read;
|
|
15
|
+
type Extent = [number, number];
|
|
16
|
+
export declare const esc: (s: string) => string;
|
|
17
|
+
export declare function matchClose(code: string, open: number): number;
|
|
18
|
+
export declare function pyBody(code: string, start: number): Extent;
|
|
19
|
+
export declare function outside(root: string, path: string): boolean;
|
|
20
|
+
export declare function codeFile(root: string, path: string, read?: Read, cache?: Map<string, CodeFile | null>): CodeFile | null;
|
|
21
|
+
export declare function locate(file: CodeFile, symbol: string): {
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
} | null;
|
|
25
|
+
export declare const isDefined: (file: CodeFile, symbol: string) => boolean;
|
|
26
|
+
export {};
|