@delurio/cli 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +63 -2
- package/SPEC.md +363 -0
- package/dist/changes.js +69 -0
- package/dist/check.js +266 -0
- package/dist/cli.js +114 -0
- package/dist/compare.js +143 -0
- package/dist/diff-flow.js +547 -0
- package/dist/diff-nav.js +391 -0
- package/dist/diff.js +316 -0
- package/dist/edges.js +120 -0
- package/dist/expr.js +157 -0
- package/dist/graph.js +67 -0
- package/dist/html.js +284 -0
- package/dist/kit.js +710 -0
- package/dist/mermaid.js +28 -0
- package/dist/model.js +178 -0
- package/dist/nav-page.js +271 -0
- package/dist/nav.js +566 -0
- package/dist/spine.js +149 -0
- package/dist/starts.js +81 -0
- package/dist/story.js +109 -0
- package/dist/wireframe.js +252 -0
- package/package.json +25 -4
package/dist/spine.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import ElkModule from 'elkjs/lib/elk.bundled.js';
|
|
2
|
+
import { collectEdges, kindOf, nodeLine, wrap } from './edges.js';
|
|
3
|
+
import { titleOf } from './graph.js';
|
|
4
|
+
// elkjs ships CommonJS: under Node ESM the default import is module.exports, which is the constructor
|
|
5
|
+
const Elk = ElkModule;
|
|
6
|
+
const START = '__start';
|
|
7
|
+
const CHAR_TITLE = 8.4; // 14px semibold sans
|
|
8
|
+
const CHAR_LINE = 6.9; // 11.5px monospace
|
|
9
|
+
const CHAR_LABEL = 7.1; // 13px sans
|
|
10
|
+
const LABEL_WRAP = { main: 26, full: 22 };
|
|
11
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
12
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
13
|
+
function labelLines(e, view) {
|
|
14
|
+
return e.labels.flatMap((l) => wrap(l, LABEL_WRAP[view]));
|
|
15
|
+
}
|
|
16
|
+
export async function renderSpine(g, ex, view, story) {
|
|
17
|
+
const all = collectEdges(g, ex);
|
|
18
|
+
// main view: the story without failure branches and without "the user closes the page"
|
|
19
|
+
const edges = view === 'full' ? all : all.filter((e) => e.kind !== 'fail' && !e.leave);
|
|
20
|
+
const visible = new Set([g.flow.initial]);
|
|
21
|
+
for (const e of edges) {
|
|
22
|
+
visible.add(e.from);
|
|
23
|
+
visible.add(e.to);
|
|
24
|
+
}
|
|
25
|
+
const names = Object.keys(g.flow.states).filter((n) => visible.has(n));
|
|
26
|
+
const mainEdges = new Set();
|
|
27
|
+
const mainNodes = new Set();
|
|
28
|
+
(story ?? []).forEach((s, k) => {
|
|
29
|
+
mainNodes.add(s.state);
|
|
30
|
+
if (k > 0)
|
|
31
|
+
mainEdges.add(`${story?.[k - 1].state}>${s.state}`);
|
|
32
|
+
});
|
|
33
|
+
const children = [{ id: START, width: 14, height: 14 }];
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const width = Math.ceil(Math.max(150, titleOf(g.flow, name).length * CHAR_TITLE, nodeLine(g, name).length * CHAR_LINE) + 36);
|
|
36
|
+
const node = { id: name, width, height: 58 };
|
|
37
|
+
if (kindOf(g, name) === 'exit')
|
|
38
|
+
node.layoutOptions = { 'elk.layered.layering.layerConstraint': 'LAST' };
|
|
39
|
+
children.push(node);
|
|
40
|
+
}
|
|
41
|
+
const elkEdges = [{ id: 'e_start', sources: [START], targets: [g.flow.initial] }];
|
|
42
|
+
edges.forEach((e, i) => {
|
|
43
|
+
const lines = labelLines(e, view);
|
|
44
|
+
elkEdges.push({
|
|
45
|
+
id: `e${i}`,
|
|
46
|
+
sources: [e.from],
|
|
47
|
+
targets: [e.to],
|
|
48
|
+
labels: lines.length === 0
|
|
49
|
+
? []
|
|
50
|
+
: [{ text: lines.join('\n'), width: Math.ceil(Math.max(...lines.map((l) => l.length)) * CHAR_LABEL + 14), height: lines.length * 17 + 6 }],
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const root = {
|
|
54
|
+
id: 'root',
|
|
55
|
+
layoutOptions: {
|
|
56
|
+
'elk.algorithm': 'layered',
|
|
57
|
+
'elk.direction': 'DOWN',
|
|
58
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
59
|
+
'elk.padding': '[top=24,left=24,bottom=24,right=24]',
|
|
60
|
+
'elk.spacing.nodeNode': '36',
|
|
61
|
+
'elk.spacing.edgeNode': '22',
|
|
62
|
+
'elk.spacing.edgeEdge': '16',
|
|
63
|
+
'elk.spacing.edgeLabel': '6',
|
|
64
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '56',
|
|
65
|
+
'elk.layered.spacing.edgeNodeBetweenLayers': '22',
|
|
66
|
+
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
|
67
|
+
},
|
|
68
|
+
children,
|
|
69
|
+
edges: elkEdges,
|
|
70
|
+
};
|
|
71
|
+
const laid = await new Elk().layout(root);
|
|
72
|
+
const width = Math.ceil(laid.width ?? 800);
|
|
73
|
+
const height = Math.ceil(laid.height ?? 400);
|
|
74
|
+
const out = [];
|
|
75
|
+
out.push(`<svg class="spine" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="${view === 'full' ? `width:${width}px;max-width:none` : `max-width:${width}px`}" role="img" aria-label="Flowchart of ${esc(g.flow.title ?? g.flow.id)}${view === 'main' ? ' without failure paths' : ' with failure paths'}. The text sections of this page describe every step.">`);
|
|
76
|
+
out.push('<defs>');
|
|
77
|
+
for (const kind of ['user', 'system', 'route', 'fail', 'main']) {
|
|
78
|
+
out.push(`<marker id="arrow-${view}-${kind}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="arrow arrow-${kind}" d="M0 0 L10 5 L0 10 z"/></marker>`);
|
|
79
|
+
}
|
|
80
|
+
out.push('</defs>');
|
|
81
|
+
const byId = new Map();
|
|
82
|
+
edges.forEach((e, i) => byId.set(`e${i}`, e));
|
|
83
|
+
const drawn = (laid.edges ?? []).filter((e) => e.sections?.[0] !== undefined);
|
|
84
|
+
const isMain = (e) => e !== undefined && mainEdges.has(`${e.from}>${e.to}`);
|
|
85
|
+
const path = (e) => {
|
|
86
|
+
const section = e.sections?.[0];
|
|
87
|
+
if (section === undefined)
|
|
88
|
+
return '';
|
|
89
|
+
const pts = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint];
|
|
90
|
+
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${r1(p.x)} ${r1(p.y)}`).join(' ');
|
|
91
|
+
};
|
|
92
|
+
for (const e of drawn) {
|
|
93
|
+
if (e.id === 'e_start')
|
|
94
|
+
out.push(`<path class="edge edge-system" d="${path(e)}" marker-end="url(#arrow-${view}-system)"/>`);
|
|
95
|
+
}
|
|
96
|
+
for (const pass of [false, true]) {
|
|
97
|
+
for (const e of drawn) {
|
|
98
|
+
const viz = byId.get(e.id);
|
|
99
|
+
if (viz === undefined || isMain(viz) !== pass)
|
|
100
|
+
continue;
|
|
101
|
+
const kind = pass ? 'main' : viz.kind;
|
|
102
|
+
out.push(`<path class="edge edge-${kind}${viz.unused ? ' edge-unused' : ''}" d="${path(e)}" marker-end="url(#arrow-${view}-${kind})"/>`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const e of drawn) {
|
|
106
|
+
const viz = byId.get(e.id);
|
|
107
|
+
const label = e.labels?.[0];
|
|
108
|
+
if (viz === undefined || label === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
const lines = labelLines(viz, view);
|
|
111
|
+
const x = label.x ?? 0;
|
|
112
|
+
const y = label.y ?? 0;
|
|
113
|
+
out.push(`<g class="elabel${isMain(viz) ? ' elabel-main' : ''}${viz.kind === 'fail' ? ' elabel-fail' : ''}"><rect x="${r1(x)}" y="${r1(y)}" width="${label.width ?? 0}" height="${label.height ?? 0}" rx="4"/>`);
|
|
114
|
+
lines.forEach((l, i) => out.push(`<text x="${r1(x + 7)}" y="${r1(y + 17 + i * 17)}">${esc(l)}</text>`));
|
|
115
|
+
out.push('</g>');
|
|
116
|
+
}
|
|
117
|
+
for (const n of laid.children ?? []) {
|
|
118
|
+
const x = r1(n.x ?? 0);
|
|
119
|
+
const y = r1(n.y ?? 0);
|
|
120
|
+
const w = n.width ?? 0;
|
|
121
|
+
const h = n.height ?? 0;
|
|
122
|
+
if (n.id === START) {
|
|
123
|
+
out.push(`<circle class="start" cx="${r1(x + 7)}" cy="${r1(y + 7)}" r="7"><title>start</title></circle>`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const kind = kindOf(g, n.id);
|
|
127
|
+
const st = g.flow.states[n.id];
|
|
128
|
+
const exitClass = kind === 'exit' ? ` exit-${st.leavesPage === true ? 'left' : (st.redirect ?? 'error')}` : '';
|
|
129
|
+
const unreachable = !ex.atState.has(n.id);
|
|
130
|
+
const open = kind === 'screen' ? `<a href="#screen-${esc(n.id)}">` : '';
|
|
131
|
+
const close = kind === 'screen' ? '</a>' : '';
|
|
132
|
+
const by = st.by === undefined ? '' : ` by-${st.by}`;
|
|
133
|
+
const tip = [st.description ?? n.id, ...(st.includes ?? []).map((s) => `+ ${s}`)].join('\n');
|
|
134
|
+
out.push(`${open}<g class="node node-${kind}${by}${st.goal === true ? ' goal' : ''}${exitClass}${mainNodes.has(n.id) ? ' on-main' : ''}${unreachable ? ' node-unreachable' : ''}"><title>${esc(tip)}</title>`);
|
|
135
|
+
const rx = kind === 'exit' || st.by === 'ai' ? 28 : st.by === 'ci' ? 0 : 8;
|
|
136
|
+
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}"/>`);
|
|
137
|
+
if (st.by === 'agent')
|
|
138
|
+
out.push(`<rect class="inner" x="${r1(x + 4)}" y="${r1(y + 4)}" width="${w - 8}" height="${h - 8}" rx="5"/>`);
|
|
139
|
+
if ((st.includes ?? []).length > 0)
|
|
140
|
+
out.push(`<text class="nmore" x="${r1(x + w - 8)}" y="${r1(y + 14)}" text-anchor="end">+${st.includes?.length}</text>`);
|
|
141
|
+
if (kind === 'subflow')
|
|
142
|
+
out.push(`<rect class="inner" x="${r1(x + 4)}" y="${r1(y + 4)}" width="${w - 8}" height="${h - 8}" rx="5"/>`);
|
|
143
|
+
out.push(`<text class="nname" x="${r1(x + w / 2)}" y="${r1(y + 25)}" text-anchor="middle">${esc(titleOf(g.flow, n.id))}</text>`);
|
|
144
|
+
out.push(`<text class="nline" x="${r1(x + w / 2)}" y="${r1(y + 44)}" text-anchor="middle">${esc(nodeLine(g, n.id))}${unreachable ? ' · unreachable' : ''}</text>`);
|
|
145
|
+
out.push(`</g>${close}`);
|
|
146
|
+
}
|
|
147
|
+
out.push('</svg>');
|
|
148
|
+
return out.join('\n');
|
|
149
|
+
}
|
package/dist/starts.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `starts:` on a transition: this flow starts another one and does not wait for it (SPEC.md §2, "Several flows").
|
|
2
|
+
// The started flow is looked up by id among the graphs beside this one. When it is found, every situation in which the
|
|
3
|
+
// transition is taken must give it a beginning it accepts; when it is not written yet, that is a note, not a failure.
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { parse } from 'yaml';
|
|
7
|
+
import { alternatives } from './graph.js';
|
|
8
|
+
const SKIP = new Set(['viz', 'video', 'node_modules', '.git']);
|
|
9
|
+
// the root to search: the nearest folder called `graphs` above the graph, else the graph's parent
|
|
10
|
+
function rootOf(dir) {
|
|
11
|
+
for (let d = dir, i = 0; i < 5; i++, d = dirname(d))
|
|
12
|
+
if (basename(d) === 'graphs')
|
|
13
|
+
return d;
|
|
14
|
+
return dirname(dir);
|
|
15
|
+
}
|
|
16
|
+
function flowsUnder(root, depth = 4, out = new Map()) {
|
|
17
|
+
const file = join(root, 'flow.yaml');
|
|
18
|
+
if (existsSync(file)) {
|
|
19
|
+
const f = parse(readFileSync(file, 'utf8'));
|
|
20
|
+
if (typeof f?.id === 'string')
|
|
21
|
+
out.set(f.id, { ...f, context: f.context ?? {} });
|
|
22
|
+
}
|
|
23
|
+
if (depth === 0)
|
|
24
|
+
return out;
|
|
25
|
+
for (const name of readdirSync(root)) {
|
|
26
|
+
if (SKIP.has(name))
|
|
27
|
+
continue;
|
|
28
|
+
const sub = join(root, name);
|
|
29
|
+
if (statSync(sub).isDirectory())
|
|
30
|
+
flowsUnder(sub, depth - 1, out);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
export function checkStarts(g, m, ex) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let flows = null;
|
|
37
|
+
for (const [name, st] of Object.entries(g.flow.states)) {
|
|
38
|
+
const ways = [
|
|
39
|
+
...(st.always ?? []).map((t, i) => [null, i, t]),
|
|
40
|
+
...Object.entries(st.on ?? {}).flatMap(([event, spec]) => alternatives(spec).map((t, i) => [event, i, t])),
|
|
41
|
+
];
|
|
42
|
+
for (const [event, i, t] of ways) {
|
|
43
|
+
if (t.starts === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
flows ??= flowsUnder(rootOf(g.dir));
|
|
46
|
+
const given = t.starts.given ?? {};
|
|
47
|
+
const started = flows.get(t.starts.flow);
|
|
48
|
+
const hand = { from: name, event, flow: t.starts.flow, given, found: started !== undefined, problems: [] };
|
|
49
|
+
out.push(hand);
|
|
50
|
+
if (started === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
const problem = (p) => {
|
|
53
|
+
if (!hand.problems.includes(p))
|
|
54
|
+
hand.problems.push(p);
|
|
55
|
+
};
|
|
56
|
+
for (const k of Object.keys(given))
|
|
57
|
+
if (started.context[k] === undefined)
|
|
58
|
+
problem(`${k} is not a variable of ${started.id}`);
|
|
59
|
+
const taken = `${name}|${event ?? 'always'}|${i}`;
|
|
60
|
+
for (const c of ex.configs) {
|
|
61
|
+
if (c.state !== name)
|
|
62
|
+
continue;
|
|
63
|
+
for (const s of m.steps(c.state, c.ctx)) {
|
|
64
|
+
if (s.taken !== taken)
|
|
65
|
+
continue;
|
|
66
|
+
for (const [k, expr] of Object.entries(given)) {
|
|
67
|
+
const cv = started.context[k];
|
|
68
|
+
if (cv === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const v = typeof expr === 'string' ? m.value(expr, name, s.ctx) : expr;
|
|
71
|
+
if (!cv.values.includes(v))
|
|
72
|
+
problem(`${k} would be ${JSON.stringify(v)}, which is not a value of ${started.id}'s ${k}`);
|
|
73
|
+
else if (cv.initial !== 'any' && cv.initial !== v)
|
|
74
|
+
problem(`${started.id} begins with ${k} = ${String(cv.initial)}, and here it would be ${String(v)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
package/dist/story.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { alternatives, titleOf } from './graph.js';
|
|
2
|
+
import { kindOf, pressLabel } from './edges.js';
|
|
3
|
+
export function stepsTo(m, ex, index) {
|
|
4
|
+
const chain = [];
|
|
5
|
+
for (let i = index; i >= 0; i = ex.configs[i].parent)
|
|
6
|
+
chain.push(i);
|
|
7
|
+
chain.reverse();
|
|
8
|
+
return chain.map((ci, k) => {
|
|
9
|
+
const c = ex.configs[ci];
|
|
10
|
+
let taken = null;
|
|
11
|
+
if (k > 0) {
|
|
12
|
+
const prev = ex.configs[chain[k - 1]];
|
|
13
|
+
const hit = m.steps(prev.state, prev.ctx).find((s) => s.target === c.state && s.event === c.event && s.ctx.join(',') === c.ctx.join(','));
|
|
14
|
+
taken = hit?.taken ?? null;
|
|
15
|
+
}
|
|
16
|
+
return { state: c.state, ctx: c.ctx, event: c.event, taken };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// The main story the graph declares: from a starting situation, send the events in order (`always` rules run by themselves).
|
|
20
|
+
export function stepsByEvents(m, main) {
|
|
21
|
+
const events = Array.isArray(main) ? main : main.events;
|
|
22
|
+
const given = Array.isArray(main) ? {} : (main.given ?? {});
|
|
23
|
+
for (const init of m.initials()) {
|
|
24
|
+
if (Object.entries(given).some(([k, v]) => init[m.vars.indexOf(k)] !== v))
|
|
25
|
+
continue;
|
|
26
|
+
const path = walk(m, [{ state: m.flow.initial, ctx: init, event: null, taken: null }], events, 0);
|
|
27
|
+
if (path !== null)
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// Depth first, in document order, so the first walk that sends every event wins; when an effect leaves several
|
|
33
|
+
// situations (havoc), one that cannot go on gives way to the next.
|
|
34
|
+
function walk(m, path, events, i) {
|
|
35
|
+
if (path.length > 500)
|
|
36
|
+
return null;
|
|
37
|
+
const cur = path[path.length - 1];
|
|
38
|
+
const steps = m.steps(cur.state, cur.ctx);
|
|
39
|
+
const autos = steps.filter((s) => s.event === null);
|
|
40
|
+
const next = autos.length > 0 ? autos : i < events.length ? steps.filter((s) => s.event === events[i]) : [];
|
|
41
|
+
if (next.length === 0)
|
|
42
|
+
return i === events.length ? path : null;
|
|
43
|
+
for (const s of next) {
|
|
44
|
+
const found = walk(m, [...path, { state: s.target, ctx: s.ctx, event: s.event, taken: s.taken }], events, autos.length > 0 ? i : i + 1);
|
|
45
|
+
if (found !== null)
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// The first success exit reachable through at least one screen: the story a person expects to read first.
|
|
51
|
+
export function mainStoryIndex(g, ex) {
|
|
52
|
+
const viaScreen = [];
|
|
53
|
+
ex.configs.forEach((c, i) => {
|
|
54
|
+
viaScreen[i] = kindOf(g, c.state) === 'screen' || (c.parent >= 0 && viaScreen[c.parent]);
|
|
55
|
+
});
|
|
56
|
+
for (const [name, st] of Object.entries(g.flow.states)) {
|
|
57
|
+
if (st.type !== 'final' || st.redirect !== 'success')
|
|
58
|
+
continue;
|
|
59
|
+
const i = ex.configs.findIndex((c, idx) => c.state === name && viaScreen[idx]);
|
|
60
|
+
if (i >= 0)
|
|
61
|
+
return i;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function sayOf(g, taken) {
|
|
66
|
+
if (taken === null)
|
|
67
|
+
return null;
|
|
68
|
+
const [state, event, idx] = taken.split('|');
|
|
69
|
+
const st = g.flow.states[state];
|
|
70
|
+
const a = event === 'always' ? st.always?.[Number(idx)] : alternatives(st.on?.[event] ?? []).at(Number(idx));
|
|
71
|
+
return a?.say ?? null;
|
|
72
|
+
}
|
|
73
|
+
const capital = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
74
|
+
export function narrate(g, m, steps) {
|
|
75
|
+
const start = steps[0];
|
|
76
|
+
const parts = [];
|
|
77
|
+
m.vars.forEach((v, i) => {
|
|
78
|
+
const cv = g.flow.context[v];
|
|
79
|
+
if (cv.initial !== 'any')
|
|
80
|
+
return;
|
|
81
|
+
const said = cv.say?.[String(start.ctx[i])];
|
|
82
|
+
if (said === undefined)
|
|
83
|
+
parts.push(`${v} is ${start.ctx[i]}`);
|
|
84
|
+
else if (said !== '')
|
|
85
|
+
parts.push(said);
|
|
86
|
+
});
|
|
87
|
+
const lines = [];
|
|
88
|
+
steps.forEach((s, k) => {
|
|
89
|
+
if (g.flow.states[s.state].type === 'final')
|
|
90
|
+
return;
|
|
91
|
+
const next = steps[k + 1];
|
|
92
|
+
let line = titleOf(g.flow, s.state);
|
|
93
|
+
if (next !== undefined) {
|
|
94
|
+
const say = sayOf(g, next.taken);
|
|
95
|
+
const press = next.event === null ? null : pressLabel(g, s.state, next.event);
|
|
96
|
+
if (press !== null)
|
|
97
|
+
line += `: the user presses “${press}”`;
|
|
98
|
+
else if (say !== null)
|
|
99
|
+
line += next.event?.startsWith('FAIL') === true ? `: it fails, because ${say}` : `: ${say}`;
|
|
100
|
+
}
|
|
101
|
+
lines.push(line);
|
|
102
|
+
});
|
|
103
|
+
const last = steps[steps.length - 1];
|
|
104
|
+
return {
|
|
105
|
+
given: parts.length > 0 ? capital(parts.join(', ')) : 'Any request',
|
|
106
|
+
steps: lines,
|
|
107
|
+
result: titleOf(g.flow, last.state),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { titleOf } from './graph.js';
|
|
2
|
+
import { drawWith, mark } from './kit.js';
|
|
3
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
4
|
+
function fill(g, key, warn) {
|
|
5
|
+
const raw = g.catalog[key];
|
|
6
|
+
if (raw === undefined) {
|
|
7
|
+
warn(`catalog key ${key} is missing`);
|
|
8
|
+
return `⟨missing ${key}⟩`;
|
|
9
|
+
}
|
|
10
|
+
return raw.replace(/\{(\w+)\}/g, (_m, p) => {
|
|
11
|
+
const sample = g.ui.samples[p];
|
|
12
|
+
if (sample === undefined) {
|
|
13
|
+
warn(`no sample value for {${p}} in ${key}`);
|
|
14
|
+
return `⟨${p}⟩`;
|
|
15
|
+
}
|
|
16
|
+
return sample;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function texts(g, n, warn) {
|
|
20
|
+
const t = n.text;
|
|
21
|
+
if (t === undefined)
|
|
22
|
+
return [];
|
|
23
|
+
if (t.key !== undefined)
|
|
24
|
+
return [fill(g, t.key, warn)];
|
|
25
|
+
if (t.oneOf !== undefined)
|
|
26
|
+
return t.oneOf.map((k) => fill(g, k, warn));
|
|
27
|
+
if (t.literal !== undefined)
|
|
28
|
+
return [t.literal];
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
function accessibleName(g, n, warn) {
|
|
32
|
+
const nm = n.name;
|
|
33
|
+
if (nm?.key !== undefined)
|
|
34
|
+
return fill(g, nm.key, warn);
|
|
35
|
+
if (nm?.literal !== undefined)
|
|
36
|
+
return nm.literal;
|
|
37
|
+
if (nm?.labelledBy !== undefined) {
|
|
38
|
+
const ref = g.ui.nodes[nm.labelledBy];
|
|
39
|
+
return ref === undefined ? undefined : texts(g, ref, warn).join(' ');
|
|
40
|
+
}
|
|
41
|
+
if (n.role === 'button' || n.role === 'heading' || n.role === 'status' || n.role === 'alert')
|
|
42
|
+
return texts(g, n, warn)[0];
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
function shownIn(n, state) {
|
|
46
|
+
return (n.in ?? []).includes(state) && !(n.unless ?? []).includes(state);
|
|
47
|
+
}
|
|
48
|
+
function chips(it) {
|
|
49
|
+
const n = it.node;
|
|
50
|
+
const parts = [n.role ?? n.type];
|
|
51
|
+
if (n.emits !== undefined)
|
|
52
|
+
parts.push(`emits ${n.emits}`);
|
|
53
|
+
if (it.disabled)
|
|
54
|
+
parts.push('disabled');
|
|
55
|
+
if (n.when !== undefined)
|
|
56
|
+
parts.push(`when ${n.when}`);
|
|
57
|
+
if (it.focus)
|
|
58
|
+
parts.push('first focus');
|
|
59
|
+
return `<span class="chip">${esc(parts.join(' · '))}</span>`;
|
|
60
|
+
}
|
|
61
|
+
function renderWireframe(g, state, items, warn, clean = false, kit = null) {
|
|
62
|
+
// clean: no annotation chips and no dashed or focus outlines, for the tutorial stage
|
|
63
|
+
const item = (it, inner) => {
|
|
64
|
+
const cls = clean ? 'wf-item' : ['wf-item', it.node.when !== undefined ? 'wf-cond' : '', it.focus ? 'wf-focus' : ''].filter((c) => c !== '').join(' ');
|
|
65
|
+
return `<div class="${cls}" data-node="${esc(it.id)}">${inner}${clean ? '' : chips(it)}</div>`;
|
|
66
|
+
};
|
|
67
|
+
const out = [];
|
|
68
|
+
let inList = false;
|
|
69
|
+
let inRow = false;
|
|
70
|
+
let kitList = null; // a List drawn with the kit waits for its items
|
|
71
|
+
const closeList = () => {
|
|
72
|
+
if (kitList !== null)
|
|
73
|
+
out.push(drawKitList(kitList.it, kitList.items));
|
|
74
|
+
else if (inList)
|
|
75
|
+
out.push('</ul>');
|
|
76
|
+
kitList = null;
|
|
77
|
+
inList = false;
|
|
78
|
+
};
|
|
79
|
+
const fill = (it, text) => {
|
|
80
|
+
const n = it.node;
|
|
81
|
+
const name = accessibleName(g, n, warn);
|
|
82
|
+
// the role the expected tree gives the node, which picks a template from the component's `roles`
|
|
83
|
+
const role = n.role ?? (n.type === 'text' ? undefined : n.type);
|
|
84
|
+
return { id: it.id, text, name: name ?? text, intent: n.intent, role, disabled: it.disabled, named: name !== undefined && name !== text, conditional: n.when !== undefined };
|
|
85
|
+
};
|
|
86
|
+
const drawKitList = (it, drawn) => {
|
|
87
|
+
const f = { ...fill(it, texts(g, it.node, warn)[0] ?? ''), children: drawn.map((d) => `\n${d}`).join('') + '\n' };
|
|
88
|
+
return mark(drawWith(kit, 'List', f, warn) ?? '', it.id, '');
|
|
89
|
+
};
|
|
90
|
+
const closeRow = () => {
|
|
91
|
+
if (inRow)
|
|
92
|
+
out.push('</div>');
|
|
93
|
+
inRow = false;
|
|
94
|
+
};
|
|
95
|
+
for (const it of items) {
|
|
96
|
+
const n = it.node;
|
|
97
|
+
const t = texts(g, n, warn);
|
|
98
|
+
const text = esc(t[0] ?? '');
|
|
99
|
+
if (n.component !== 'ListItem')
|
|
100
|
+
closeList();
|
|
101
|
+
if (n.component !== 'Button')
|
|
102
|
+
closeRow();
|
|
103
|
+
if (kit !== null && n.component !== undefined && n.component !== 'Page') {
|
|
104
|
+
if (kit.components[n.component] !== undefined) {
|
|
105
|
+
if (n.component === 'List') {
|
|
106
|
+
kitList = { it, items: [] };
|
|
107
|
+
inList = true;
|
|
108
|
+
}
|
|
109
|
+
else if (n.component === 'ListItem') {
|
|
110
|
+
if (!inList)
|
|
111
|
+
warn(`${it.id}: ListItem outside a List in ${state}`);
|
|
112
|
+
for (const line of t)
|
|
113
|
+
(kitList?.items ?? out).push(mark(drawWith(kit, 'ListItem', fill(it, line), warn) ?? '', it.id, clean ? '' : chips(it)));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
if (n.component === 'Button' && !inRow)
|
|
117
|
+
out.push('<div class="wf-row">');
|
|
118
|
+
if (n.component === 'Button')
|
|
119
|
+
inRow = true;
|
|
120
|
+
out.push(item(it, drawWith(kit, n.component, fill(it, t[0] ?? ''), warn) ?? ''));
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
warn(`kit ${kit.id} has no template for ${n.component} (node ${it.id}); drawn with the fixed component`);
|
|
125
|
+
}
|
|
126
|
+
switch (n.component) {
|
|
127
|
+
case 'Page':
|
|
128
|
+
break;
|
|
129
|
+
case 'Heading':
|
|
130
|
+
out.push(item(it, `<h4 class="wf-h">${text}</h4>`));
|
|
131
|
+
break;
|
|
132
|
+
case 'Text':
|
|
133
|
+
out.push(item(it, `<p class="wf-t">${text}</p>`));
|
|
134
|
+
break;
|
|
135
|
+
case 'Button':
|
|
136
|
+
if (!inRow)
|
|
137
|
+
out.push('<div class="wf-row">');
|
|
138
|
+
inRow = true;
|
|
139
|
+
out.push(item(it, `<button type="button" class="wf-btn wf-${esc(n.intent ?? 'secondary')}"${it.disabled ? ' disabled' : ''}>${text}</button>`));
|
|
140
|
+
break;
|
|
141
|
+
case 'LinkButton':
|
|
142
|
+
out.push(item(it, `<button type="button" class="wf-link"${it.disabled ? ' disabled' : ''}>${text}</button>`));
|
|
143
|
+
break;
|
|
144
|
+
case 'TextField':
|
|
145
|
+
// the visible text is the label; the input is named by it, as a <label> around it does
|
|
146
|
+
out.push(item(it, `<label class="wf-field"><span>${text}</span><input type="text" class="wf-input" tabindex="-1"${it.disabled ? ' disabled' : ''}></label>`));
|
|
147
|
+
break;
|
|
148
|
+
case 'List':
|
|
149
|
+
out.push(`<ul class="wf-list" data-node="${esc(it.id)}">`);
|
|
150
|
+
inList = true;
|
|
151
|
+
break;
|
|
152
|
+
case 'ListItem':
|
|
153
|
+
if (!inList)
|
|
154
|
+
warn(`${it.id}: ListItem outside a List in ${state}`);
|
|
155
|
+
for (const line of t)
|
|
156
|
+
(kitList?.items ?? out).push(`<li data-node="${esc(it.id)}">${esc(line)}${clean ? '' : chips(it)}</li>`);
|
|
157
|
+
break;
|
|
158
|
+
case 'Status':
|
|
159
|
+
out.push(item(it, `<div class="wf-status" role="status">${text}</div>`));
|
|
160
|
+
break;
|
|
161
|
+
case 'Alert':
|
|
162
|
+
out.push(item(it, `<div class="wf-alert" role="alert">${text}</div>`));
|
|
163
|
+
break;
|
|
164
|
+
default:
|
|
165
|
+
warn(`${it.id}: component ${n.component ?? '(none)'} has no wireframe renderer`);
|
|
166
|
+
out.push(item(it, `<div class="wf-unmapped">${esc(n.component ?? n.type)} ${esc(it.id)}</div>`));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
closeList();
|
|
170
|
+
closeRow();
|
|
171
|
+
return out.join('\n');
|
|
172
|
+
}
|
|
173
|
+
function renderTree(g, items, warn) {
|
|
174
|
+
const lines = [];
|
|
175
|
+
for (const it of items) {
|
|
176
|
+
const n = it.node;
|
|
177
|
+
const depth = n.component === 'Page' ? 0 : n.component === 'ListItem' ? 2 : 1;
|
|
178
|
+
const flags = [it.disabled ? 'disabled' : '', n.when !== undefined ? `when ${n.when}` : '', it.focus ? 'first focus' : '', n.emits !== undefined ? `→ ${n.emits}` : '']
|
|
179
|
+
.filter((f) => f !== '')
|
|
180
|
+
.join(', ');
|
|
181
|
+
const suffix = flags === '' ? '' : ` [${flags}]`;
|
|
182
|
+
const pad = ' '.repeat(depth);
|
|
183
|
+
if (n.component === 'ListItem') {
|
|
184
|
+
for (const line of texts(g, n, warn))
|
|
185
|
+
lines.push(`${pad}listitem "${line}"${suffix}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const role = n.role ?? (n.type === 'text' ? 'text' : n.type);
|
|
189
|
+
const name = role === 'text' ? texts(g, n, warn)[0] : accessibleName(g, n, warn);
|
|
190
|
+
lines.push(`${pad}${role}${name === undefined ? '' : ` "${name}"`}${suffix}`);
|
|
191
|
+
}
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|
|
194
|
+
export function renderScreens(g, warn, kit = null) {
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const [state, st] of Object.entries(g.flow.states)) {
|
|
197
|
+
const shown = Object.entries(g.ui.nodes).filter(([, n]) => shownIn(n, state));
|
|
198
|
+
if (shown.length === 0)
|
|
199
|
+
continue;
|
|
200
|
+
const focusTargets = new Set(shown.map(([, n]) => n.initialFocus).filter((f) => f !== undefined));
|
|
201
|
+
const items = shown.map(([id, node]) => ({
|
|
202
|
+
id,
|
|
203
|
+
node,
|
|
204
|
+
disabled: (node.disabledIn ?? []).includes(state),
|
|
205
|
+
focus: focusTargets.has(id),
|
|
206
|
+
}));
|
|
207
|
+
out.push(`<article class="screen" id="screen-${esc(state)}"><header><h3>${esc(titleOf(g.flow, state))}</h3><span class="tag">${st.type === 'final' ? 'ends here, page shown' : 'screen'}</span><span class="mono muted">${esc(state)}</span></header>`);
|
|
208
|
+
if (st.description !== undefined)
|
|
209
|
+
out.push(`<p class="muted">${esc(st.description)}</p>`);
|
|
210
|
+
out.push(`<div class="wf${kit === null ? '' : ' kit'}" role="group" aria-label="Wireframe of ${esc(titleOf(g.flow, state))}">`);
|
|
211
|
+
out.push(renderWireframe(g, state, items, warn, false, kit));
|
|
212
|
+
out.push('</div>');
|
|
213
|
+
out.push(`<details class="ax-wrap"><summary>Expected accessibility tree</summary><pre class="ax">${esc(renderTree(g, items, warn))}</pre></details>`);
|
|
214
|
+
out.push('</article>');
|
|
215
|
+
}
|
|
216
|
+
return out.join('\n');
|
|
217
|
+
}
|
|
218
|
+
// One state's screen for the tutorial stage. Nodes that depend on a flag (`when`) appear only when named in `reveal`.
|
|
219
|
+
export function renderStageScreen(g, state, reveal, warn, kit = null) {
|
|
220
|
+
const shown = Object.entries(g.ui.nodes).filter(([id, n]) => shownIn(n, state) && (n.when === undefined || reveal.includes(id)));
|
|
221
|
+
const items = shown.map(([id, node]) => ({ id, node, disabled: (node.disabledIn ?? []).includes(state), focus: false }));
|
|
222
|
+
return `<div class="wf${kit === null ? '' : ' kit'}" role="group" aria-label="Screen: ${esc(titleOf(g.flow, state))}">${renderWireframe(g, state, items, warn, true, kit)}</div>`;
|
|
223
|
+
}
|
|
224
|
+
// For `kit check`: every screen with all its nodes (flags revealed, no annotations), and what each node must expose.
|
|
225
|
+
export function renderCheckScreens(g, kit, warn) {
|
|
226
|
+
const html = [];
|
|
227
|
+
const expected = [];
|
|
228
|
+
for (const state of Object.keys(g.flow.states)) {
|
|
229
|
+
const shown = Object.entries(g.ui.nodes).filter(([, n]) => shownIn(n, state));
|
|
230
|
+
if (shown.length === 0)
|
|
231
|
+
continue;
|
|
232
|
+
const items = shown.map(([id, node]) => ({ id, node, disabled: (node.disabledIn ?? []).includes(state), focus: false }));
|
|
233
|
+
html.push(`<section class="screen" data-state="${esc(state)}"><div class="wf${kit === null ? '' : ' kit'}">${renderWireframe(g, state, items, warn, true, kit)}</div></section>`);
|
|
234
|
+
for (const it of items) {
|
|
235
|
+
const n = it.node;
|
|
236
|
+
if (n.component === 'Page')
|
|
237
|
+
continue; // the screen itself: nothing is drawn for it
|
|
238
|
+
if (n.component === 'ListItem') {
|
|
239
|
+
texts(g, n, warn).forEach((line, index) => expected.push({ state, id: it.id, index, role: n.role ?? 'listitem', name: line, disabled: it.disabled }));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const role = n.role ?? (n.type === 'text' ? 'text' : n.type);
|
|
243
|
+
expected.push({ state, id: it.id, index: 0, role, name: role === 'text' ? texts(g, n, warn)[0] : accessibleName(g, n, warn), disabled: it.disabled });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { html: html.join('\n'), expected };
|
|
247
|
+
}
|
|
248
|
+
// the visible words of a node, for narration: a node with several texts gives several entries
|
|
249
|
+
export function nodeTexts(g, id, warn) {
|
|
250
|
+
const n = g.ui.nodes[id];
|
|
251
|
+
return n === undefined ? [] : texts(g, n, warn);
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delurio/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The open checker and page of delurio-graph/0: check every graph in a project's own CI, offline, with GNU error lines and SARIF.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "delurio": "dist/cli.js" },
|
|
8
|
+
"files": ["dist", "SPEC.md", "NOTICE"],
|
|
9
|
+
"engines": { "node": ">=22.18" },
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/delurio-labs/delurio.git" },
|
|
11
|
+
"homepage": "https://delur.io",
|
|
12
|
+
"bugs": "https://github.com/delurio-labs/delurio/issues",
|
|
13
|
+
"keywords": ["statechart", "graph", "spec", "checker", "sarif", "ci"],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"check": "tsc --noEmit",
|
|
16
|
+
"build": "tsc -p tsconfig.build.json",
|
|
17
|
+
"test": "node src/cli.ts check examples --strict"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"elkjs": "0.12.0",
|
|
21
|
+
"yaml": "2.9.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "22.20.5",
|
|
25
|
+
"typescript": "7.0.2"
|
|
26
|
+
}
|
|
27
|
+
}
|