flowfig 0.1.0 → 0.3.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/dist/svg.js CHANGED
@@ -10,18 +10,22 @@ import { route } from './geometry.js';
10
10
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
11
11
  import { textWidth } from './text.js';
12
12
  import { checkScene, checkSpec, checkTheme } from './check.js';
13
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, } from './model.js';
13
+ export { parseSource, links } from './source.js';
14
+ export { diff, formatDiff } from './diff.js';
15
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, beatMs, STEP_HOLD_MS, } from './model.js';
14
16
  export { LIGHT, DARK } from './model.js';
15
17
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
16
18
  const LABEL_LINE = 18, SUB_LINE = 15, NODE_MIN_W = 100, NODE_MAX_W = 190;
17
19
  const FRAME_TOP = 37, FRAME_SIDE = 18, FRAME_BOTTOM = 18;
18
- // The card-on fills are fixed colors that approximate the player's 8% accent tint, so the SVG needs no color-mix.
19
- const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn};`;
20
+ // The card-on fills are fixed colors that approximate the player's 8% accent tint. The active tint is the player's color-mix.
21
+ const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
20
22
  /** What a card actually holds: rows, or its text. Only text survives outside React. */
21
23
  const content = (c) => (isRows(c) ? c : str(c));
22
24
  const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]);
23
25
  const n2 = (v) => Math.round(v * 10) / 10;
24
- const pct = (v) => n2(v * 100) + '%';
26
+ // Keyframe times are fractions of a loop that can last a minute: one decimal would round a 0.7 s hop away, and the packet would jump.
27
+ const n4 = (v) => Math.round(v * 10000) / 10000;
28
+ const pct = (v) => Math.round(v * 10000) / 100 + '%';
25
29
  /** Break a string into lines that fit `width`, keeping the newlines it already has. */
26
30
  function wrap(s, width, fontSize, mono = false) {
27
31
  const out = [];
@@ -189,17 +193,31 @@ export function render(fig, opts = {}) {
189
193
  // The timeline: every beat of every step, in order, with the player's hold at the end of each step.
190
194
  const segs = [];
191
195
  const hops = [];
196
+ const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
192
197
  let t = 0;
193
198
  beats.forEach((stepBeats, si) => {
194
199
  stepBeats.forEach((b, bi) => {
195
- const dur = (b.ms ?? fig.speed ?? 900) / 1000 / BASE_RATE;
196
200
  const last = bi === stepBeats.length - 1;
201
+ const dur = beatMs(b, opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
202
+ const hold = last ? STEP_HOLD_MS / 1000 / BASE_RATE : 0;
197
203
  b.hops.forEach((h, lane) => {
198
204
  if (byId[h.edge])
199
- hops.push({ si, bi, lane, edge: h.edge, back: h.back, data: h.data, t0: t, t1: t + dur });
205
+ hops.push({
206
+ si,
207
+ bi,
208
+ lane,
209
+ edge: h.edge,
210
+ dest: h.back ? edgeOf[h.edge].from : edgeOf[h.edge].to,
211
+ back: h.back,
212
+ data: h.data,
213
+ tone: h.tone && TONES[h.tone],
214
+ t0: t,
215
+ t1: t + speed,
216
+ tEnd: t + dur,
217
+ });
200
218
  });
201
- segs.push({ t0: t, t1: t + dur + (last ? speed * 1.5 : 0), si, bi });
202
- t += dur + (last ? speed * 1.5 : 0);
219
+ segs.push({ t0: t, t1: t + dur + hold, si, bi });
220
+ t += dur + hold;
203
221
  });
204
222
  });
205
223
  const total = t || 1;
@@ -240,8 +258,8 @@ export function render(fig, opts = {}) {
240
258
  return list ? ` class="${list}"` : '';
241
259
  };
242
260
  /** Like `anim`, for a property that takes a different value in each segment (a row that moves as phases fold). */
243
- const frames = (values, prefix) => {
244
- if (!segs.length || values.every((v) => v === values[0]))
261
+ const frames = (values, prefix, base = values[0]) => {
262
+ if (!segs.length || values.every((v) => v === base))
245
263
  return '';
246
264
  const key = prefix + values.join('|');
247
265
  if (!seen.has(key)) {
@@ -252,6 +270,59 @@ export function render(fig, opts = {}) {
252
270
  }
253
271
  return seen.get(key);
254
272
  };
273
+ /**
274
+ * The look of one box over the loop: off, trail (visited) or active (the packet arrived, until the segment ends).
275
+ * A segment ends at the beat end, or after the step hold for the last beat.
276
+ * Each frame holds one look. The gap before the frame that follows an active frame gives the FADE.
277
+ * FADE is in timeline seconds, which are wall seconds.
278
+ */
279
+ const FADE = 0.4;
280
+ // A box tone is a permanent state: a 1 px border and a light tint in the off and trail looks. The active look uses the hop tone, else the box tone.
281
+ const looks = (look, boxTone, hopTone) => {
282
+ const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
283
+ if (look === 'off')
284
+ return [tint, boxTone ?? 'var(--border)', 1, 'drop-shadow(0 0 0 transparent)'];
285
+ if (look === 'trail')
286
+ return [tint, boxTone ?? 'var(--accent)', 1, 'drop-shadow(0 0 0 transparent)'];
287
+ const c = hopTone ?? boxTone;
288
+ return c
289
+ ? [toneTint(c, 'var(--surface)', 10), c, 2, `drop-shadow(0 0 4px ${c})`]
290
+ : ['var(--tint)', 'var(--accent)', 2, 'drop-shadow(0 0 4px var(--accent))'];
291
+ };
292
+ const boxAnim = (id, shape, boxTone) => {
293
+ const pieces = [];
294
+ segs.forEach((s, i) => {
295
+ if (!litNodes[i].has(id))
296
+ return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
297
+ const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
298
+ const arrive = Math.min(...here.map((h) => h.t1), s.t1);
299
+ if (arrive > s.t0)
300
+ pieces.push({ a: s.t0, b: arrive, look: 'trail' });
301
+ if (arrive < s.t1)
302
+ pieces.push({ a: arrive, b: s.t1, look: 'active', hop: here.find((h) => h.tone)?.tone });
303
+ });
304
+ if (!pieces.length || pieces.every((q) => q.look === 'off'))
305
+ return '';
306
+ // The last frame is active: the fade ends at 100 %, so the loop wraps with no jump.
307
+ const end = pieces.at(-1);
308
+ if (end.look === 'active') {
309
+ end.b -= FADE;
310
+ pieces.push({ a: end.b, b: end.b + FADE, look: pieces[0].look, hop: pieces[0].hop, ramp: FADE - 0.0002 * total });
311
+ }
312
+ const kf = pieces.map((q, k) => {
313
+ const ramp = q.ramp ?? (q.look !== 'active' && pieces[k - 1]?.look === 'active' ? Math.min(FADE, (q.b - q.a) / 2) : 0);
314
+ const [fill, stroke, width, filter] = looks(q.look, boxTone, q.hop);
315
+ const css = `${shape ? `fill: ${fill}; ` : ''}stroke: ${stroke}; stroke-width: ${width}${shape ? `; filter: ${filter}` : ''}`;
316
+ return `${pct((q.a + ramp) / total)},${pct(q.b / total - 0.0001)} { ${css} }`;
317
+ });
318
+ const key = (shape ? 'B' : 'R') + kf.join(' ');
319
+ if (!seen.has(key)) {
320
+ const name = `a${seen.size}`;
321
+ seen.set(key, name);
322
+ css.push(`@keyframes ${name} { ${kf.join(' ')} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
323
+ }
324
+ return seen.get(key);
325
+ };
255
326
  const boxes = placed.map((p) => {
256
327
  const { item } = p;
257
328
  if (isGroup(item)) {
@@ -262,18 +333,21 @@ export function render(fig, opts = {}) {
262
333
  cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
263
334
  `/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
264
335
  }
265
- const on = segs.map((_, i) => litNodes[i].has(item.id));
266
- const stroke = cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n'));
336
+ const bt = item.tone && TONES[item.tone];
337
+ const stroke = cls(boxAnim(item.id, true, bt));
338
+ const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
339
+ const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
340
+ const stroke0 = bt ?? 'var(--border)';
267
341
  const cx = p.x + p.w / 2;
268
342
  const contents = cards.get(item.id);
269
343
  const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
270
344
  const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (item.sub ? -2 : 5);
271
345
  const shape = item.shape === 'decision'
272
- ? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="var(--bg)" stroke="var(--border)"${stroke}/>`
346
+ ? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
273
347
  : item.shape === 'store'
274
- ? `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(p.w)} 0 v ${n2(p.h - 24)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(-p.w)} 0 z" fill="var(--bg)" stroke="var(--border)"${stroke}/>` +
275
- `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 0 ${n2(p.w)} 0" fill="none" stroke="var(--border)"${stroke}/>`
276
- : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="var(--bg)" stroke="var(--border)"${stroke}/>`;
348
+ ? `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(p.w)} 0 v ${n2(p.h - 24)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(-p.w)} 0 z" fill="${fill0}" stroke="${stroke0}"${stroke}/>` +
349
+ `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 0 ${n2(p.w)} 0" fill="none" stroke="${stroke0}"${rim}/>`
350
+ : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
277
351
  const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
278
352
  const sub = item.sub ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>` : '';
279
353
  return shape + label + sub + (contents ? card(p, cardTop, contents) : '');
@@ -338,7 +412,15 @@ export function render(fig, opts = {}) {
338
412
  const on = segs.map((_, i) => litEdges[i].has(r.id));
339
413
  const hidden = e.quiet && !on.every(Boolean);
340
414
  const shown = hidden ? cls(anim(on, 'opacity: 1', 'opacity: 0', 'q')) : '';
341
- const lit = cls(anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, 'e'));
415
+ // A toned hop colors its edge for its own beat only; the trail after it is the accent.
416
+ const tone = segs.map(({ si, bi }) => (beats[si][bi].hops.find((h) => h.edge === r.id && h.tone)?.tone ?? undefined));
417
+ const col = tone.map((x) => (x ? TONES[x] : 'var(--accent)'));
418
+ const off = `stroke: var(--muted); stroke-width: ${EDGE_OFF}`;
419
+ // With no tone the map and the rail share one keyframe (same key); a toned edge needs its own values.
420
+ const toned = tone.some(Boolean);
421
+ const lit = cls(toned
422
+ ? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
423
+ : anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
342
424
  const path = `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
343
425
  // A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
344
426
  const label = e.label == null
@@ -348,17 +430,19 @@ export function render(fig, opts = {}) {
348
430
  labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
349
431
  fonts.push(11);
350
432
  return (`<rect x="${n2(r.mid.x - lw / 2)}" y="${n2(r.mid.y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
351
- cls(anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
433
+ cls(toned
434
+ ? frames(on.map((x, i) => x ? `fill: ${tone[i] ? toneFill(col[i]) : col[i]}; stroke: ${col[i]}` : 'fill: var(--bg); stroke: var(--border)'), 'l', 'fill: var(--bg); stroke: var(--border)')
435
+ : anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
352
436
  `/><text x="${n2(r.mid.x)}" y="${n2(r.mid.y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(str(e.label))}</text>`);
353
437
  })();
354
438
  return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
355
439
  });
356
- // A packet per hop: it waits offstage, crosses its edge during its beat, then leaves. Any `data`
440
+ // A packet per hop: it waits offstage, crosses its edge in `speed`, rests at the end of the edge to the end of its beat, then leaves. Any `data`
357
441
  // rides above it in a chip, which is how the figure says what is moving.
358
442
  const packets = hops.map((h, i) => {
359
- const t0 = h.t0 / total, t1 = h.t1 / total;
443
+ const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
360
444
  const name = `p${i}`;
361
- css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(t1 - 0.0001)} { opacity: 1 } ${pct(t1)},100% { opacity: 0 } }\n` +
445
+ css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(tEnd - 0.0001)} { opacity: 1 } ${pct(tEnd)},100% { opacity: 0 } }\n` +
362
446
  `.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
363
447
  const chip = (() => {
364
448
  const label = str(h.data);
@@ -368,11 +452,11 @@ export function render(fig, opts = {}) {
368
452
  const lines = wrap(label, 210, 11.5);
369
453
  const w = Math.max(...lines.map((l) => textWidth(l, 11.5))) + 18;
370
454
  const boxH = lines.length * 15 + 8;
371
- return (`<rect x="${n2(-w / 2)}" y="${n2(-boxH - 12)}" width="${n2(w)}" height="${n2(boxH)}" rx="8" fill="var(--accent)"/>` +
455
+ return (`<rect x="${n2(-w / 2)}" y="${n2(-boxH - 12)}" width="${n2(w)}" height="${n2(boxH)}" rx="8" fill="${h.tone ? toneFill(h.tone) : 'var(--accent)'}"/>` +
372
456
  lines.map((l, li) => `<text x="0" y="${n2(-boxH - 12 + 15 * li + 15)}" class="chip">${esc(l)}</text>`).join(''));
373
457
  })();
374
- return (`<g${cls(name)} opacity="0"><circle r="10" fill="var(--accent)" opacity="0.2"/><circle r="4.5" fill="var(--accent)"/>${chip}` +
375
- `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n2(t0)};${n2(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
458
+ return (`<g${cls(name)} opacity="0"><circle r="10" fill="${h.tone ?? 'var(--accent)'}" opacity="0.2"/><circle r="4.5" fill="${h.tone ?? 'var(--accent)'}"/>${chip}` +
459
+ `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
376
460
  `<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
377
461
  });
378
462
  const bounds = placed[0];
@@ -461,7 +545,8 @@ export function render(fig, opts = {}) {
461
545
  const next = segs.map((s) => s.si < row.step || (s.si === row.step && s.bi < row.beat));
462
546
  const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
463
547
  const ly = y + RAIL.row / 2;
464
- const lit = anim(now, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, 'e');
548
+ const col = row.tone ? TONES[row.tone] : 'var(--accent)';
549
+ const lit = anim(now, `stroke: ${col}; stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, row.tone ? 'e' + col : 'e');
465
550
  const tagW = ASYNC_TAG_W;
466
551
  // The tag sits on the tail side of the pill, so it never hides the arrowhead.
467
552
  const tagX = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
@@ -471,7 +556,7 @@ export function render(fig, opts = {}) {
471
556
  `<text x="${n2(tagX + tagW / 2)}" y="${n2(ly + 3)}" class="tag" fill="${TONES.gray}">ASYNC</text>`
472
557
  : '';
473
558
  const pill = row.pill
474
- ? `<rect x="${n2(row.pill.x)}" y="${n2(ly - 9)}" width="${n2(row.pill.w)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"${cls(anim(now, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l'))}/>` +
559
+ ? `<rect x="${n2(row.pill.x)}" y="${n2(ly - 9)}" width="${n2(row.pill.w)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"${cls(anim(now, `fill: ${row.tone ? toneFill(col) : col}; stroke: ${col}`, 'fill: var(--bg); stroke: var(--border)', row.tone ? 'l' + col : 'l'))}/>` +
475
560
  `<text x="${n2(row.pill.x + row.pill.w / 2)}" y="${n2(ly + 4)}"${cls('edgelabel', anim(now, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(row.text)}</text>`
476
561
  : '';
477
562
  parts.push(`<g${cls('railrow', moveRow(i))}><g${cls(anim(next, 'opacity: .45', 'opacity: 1', 'r'))}>` +
@@ -505,7 +590,7 @@ svg { ${vars(t0, '#eef5fd')} }
505
590
  .end { text-anchor: end; }
506
591
  ${css.join('\n')}
507
592
  </style>
508
- <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="var(--muted)"/></marker></defs>
593
+ <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="context-stroke"/></marker></defs>
509
594
  <rect width="100%" height="100%" fill="var(--bg)"/>
510
595
  ${only
511
596
  ? ''
@@ -541,6 +626,7 @@ ${said.join('\n')}
541
626
  return [
542
627
  {
543
628
  id: `rail:${row.n}`,
629
+ step: row.step,
544
630
  from: rail.columns[row.from].id,
545
631
  to: rail.columns[row.to].id,
546
632
  curve: [p, p, q, q],
@@ -0,0 +1,7 @@
1
+ import type { FlowProps } from './model.ts';
2
+ import type { Finding } from './scene.ts';
3
+ export { parseSource, headingSlug, owners, links, type Link } from './source.ts';
4
+ /** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
5
+ export declare function verify(fig: FlowProps, { root }?: {
6
+ root?: string;
7
+ }): Finding[];
package/dist/verify.js ADDED
@@ -0,0 +1,43 @@
1
+ // `flowfig verify`: checks that the code a link names is still there. The link parsing lives in source.ts, which has no Node import.
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { isAbsolute, relative, resolve } from 'node:path';
4
+ import { headingSlug, links } from './source.js';
5
+ export { parseSource, headingSlug, owners, links } from './source.js';
6
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ /** The file text, or null for a missing path, a folder or an unreadable file. */
8
+ function read(full) {
9
+ try {
10
+ return statSync(full).isFile() ? readFileSync(full, 'utf8') : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ /** True if a heading line of the Markdown text has this slug. */
17
+ const hasHeading = (text, slug) => text.split('\n').some((line) => {
18
+ const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
19
+ return m != null && headingSlug(m[1]) === slug;
20
+ });
21
+ /** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
22
+ export function verify(fig, { root = process.cwd() } = {}) {
23
+ const out = [];
24
+ const all = links(fig);
25
+ if (!all.length)
26
+ return [{ rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }];
27
+ const files = new Map();
28
+ for (const l of all) {
29
+ const full = resolve(root, l.path);
30
+ const rel = relative(root, full);
31
+ const outside = isAbsolute(l.path) || rel === '..' || rel.startsWith('../');
32
+ if (!files.has(full))
33
+ files.set(full, outside ? null : read(full));
34
+ const text = files.get(full);
35
+ if (text == null)
36
+ out.push({ rule: 'missing-file', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: file not found` });
37
+ else if (l.symbol &&
38
+ !new RegExp(`(^|[^\\w$])${escape(l.symbol)}(?![\\w$])`).test(text) &&
39
+ !(/\.(md|markdown)$/i.test(l.path) && hasHeading(text, l.symbol)))
40
+ out.push({ rule: 'missing-symbol', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: symbol not found` });
41
+ }
42
+ return out;
43
+ }
@@ -0,0 +1,2 @@
1
+ /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
+ export declare const VERSION = "0.3.0";
@@ -0,0 +1,2 @@
1
+ /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
+ export const VERSION = '0.3.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Your coding agent draws animated diagrams from your code, and flowfig checks them. One animated SVG for a GitHub README, PR or issue, and a React player. Set up Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro with npx flowfig init.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,10 @@
40
40
  "./svg": {
41
41
  "types": "./dist/svg.d.ts",
42
42
  "default": "./dist/svg.js"
43
+ },
44
+ "./verify": {
45
+ "types": "./dist/verify.d.ts",
46
+ "default": "./dist/verify.js"
43
47
  }
44
48
  },
45
49
  "bin": {