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.
Files changed (56) hide show
  1. package/README.md +28 -8
  2. package/dist/browser.d.ts +1 -3
  3. package/dist/browser.js +67 -59
  4. package/dist/check.d.ts +5 -3
  5. package/dist/check.js +24 -17
  6. package/dist/cli.js +43 -49
  7. package/dist/code.d.ts +26 -0
  8. package/dist/code.js +568 -0
  9. package/dist/diff.js +0 -4
  10. package/dist/draw.d.ts +1 -3
  11. package/dist/draw.js +3 -7
  12. package/dist/edges.d.ts +7 -0
  13. package/dist/edges.js +510 -0
  14. package/dist/geometry.d.ts +6 -8
  15. package/dist/geometry.js +75 -82
  16. package/dist/gif.d.ts +1 -2
  17. package/dist/gif.js +115 -42
  18. package/dist/guide.d.ts +1 -4
  19. package/dist/guide.js +10 -11
  20. package/dist/imports.d.ts +11 -0
  21. package/dist/imports.js +235 -0
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +20 -78
  24. package/dist/init.d.ts +3 -13
  25. package/dist/init.js +13 -18
  26. package/dist/load.d.ts +1 -3
  27. package/dist/load.js +2 -5
  28. package/dist/mcp.d.ts +1 -2
  29. package/dist/mcp.js +15 -15
  30. package/dist/model.d.ts +57 -109
  31. package/dist/model.js +20 -89
  32. package/dist/open.d.ts +2 -5
  33. package/dist/open.js +6 -11
  34. package/dist/page.d.ts +0 -1
  35. package/dist/page.js +1 -3
  36. package/dist/picker.d.ts +2 -4
  37. package/dist/picker.js +11 -14
  38. package/dist/png.js +9 -10
  39. package/dist/rail.d.ts +1 -3
  40. package/dist/rail.js +2 -16
  41. package/dist/scene.d.ts +8 -8
  42. package/dist/source.d.ts +1 -2
  43. package/dist/source.js +5 -5
  44. package/dist/svg.d.ts +5 -8
  45. package/dist/svg.js +50 -112
  46. package/dist/text.d.ts +0 -2
  47. package/dist/text.js +1 -4
  48. package/dist/type-index.d.ts +55 -0
  49. package/dist/type-index.js +383 -0
  50. package/dist/types.d.ts +32 -0
  51. package/dist/types.js +733 -0
  52. package/dist/verify.d.ts +26 -4
  53. package/dist/verify.js +68 -26
  54. package/dist/version.d.ts +1 -2
  55. package/dist/version.js +2 -2
  56. 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 { links, verify } from './verify.js';
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 until the queued response lines are out before the exit.
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 found = sortFindings([...check(props), ...verify(props, { root })], strict).map((f) => ({ ...f, figure: input }));
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
- // The retries cover Windows, where the browser can hold a file lock for a short time after the exit.
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
- // The handlers stay for the whole run: a second signal during the cleanup must not end the process before the cleanup ends.
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
- // The encode and the ffmpeg run give the event loop a turn, so a signal handler can run. A write comes straight after this check.
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, not the pixels: a decoded 2400 x 1600 frame is 15 MB.
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 where needed.
265
+ // yuv420p needs an even width and height, so the pad filter adds a pixel.
286
266
  ffmpeg = spawn(ffmpegBin, [
287
- ...['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-'],
288
- ...['-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', mp4Path],
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
- ffmpeg.stdin.on('error', () => { }); // ffmpeg can exit before it reads all the input; the exit code reports that
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 that came in the last turn runs its handler here, before the exit.
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 findings = skip ? [] : sortFindings(check(props, opts), strict);
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
- // The spec rides along in <metadata>: an SVG is then its own source, and no JSON has to be kept.
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 {};