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/README.md +87 -24
- package/dist/check.js +29 -3
- package/dist/cli.js +112 -49
- package/dist/diff.d.ts +9 -0
- package/dist/diff.js +59 -0
- package/dist/draw.d.ts +12 -0
- package/dist/draw.js +178 -0
- package/dist/guide.d.ts +2 -2
- package/dist/guide.js +34 -12
- package/dist/index.js +74 -24
- package/dist/init.d.ts +15 -0
- package/dist/init.js +92 -6
- package/dist/load.d.ts +12 -0
- package/dist/load.js +59 -0
- package/dist/mcp.d.ts +139 -0
- package/dist/mcp.js +216 -0
- package/dist/model.d.ts +25 -3
- package/dist/model.js +14 -0
- package/dist/rail.d.ts +2 -1
- package/dist/rail.js +15 -1
- package/dist/scene.d.ts +2 -0
- package/dist/source.d.ts +18 -0
- package/dist/source.js +29 -0
- package/dist/svg.d.ts +2 -0
- package/dist/svg.js +113 -27
- package/dist/verify.d.ts +7 -0
- package/dist/verify.js +43 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +5 -1
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
|
-
|
|
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
|
|
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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c]);
|
|
23
25
|
const n2 = (v) => Math.round(v * 10) / 10;
|
|
24
|
-
|
|
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({
|
|
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 +
|
|
202
|
-
t += dur +
|
|
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 ===
|
|
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
|
|
266
|
-
const stroke = cls(
|
|
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="
|
|
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="
|
|
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="
|
|
276
|
-
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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;${
|
|
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
|
|
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,
|
|
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="
|
|
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],
|
package/dist/verify.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "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": {
|