emdash-plugin-sitegraph 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.
@@ -0,0 +1,562 @@
1
+ import { Button, Input, Loader } from "@cloudflare/kumo";
2
+ import * as React from "react";
3
+
4
+ import { DOCUMENTED_NODE_TYPES, NODE_TYPES, type NodeType } from "../domain/graph.js";
5
+ import {
6
+ api,
7
+ type Edge,
8
+ type Impact,
9
+ isBroken,
10
+ type LastScan,
11
+ message,
12
+ type Node,
13
+ type Overview,
14
+ PROVENANCE_LABEL,
15
+ RELATION_LABEL,
16
+ TYPE_LABEL,
17
+ } from "./api.js";
18
+ import { Details } from "./details.js";
19
+ import { GraphCanvas } from "./graph-canvas.js";
20
+ import { type Point, relax, seedRing } from "./layout.js";
21
+ import { useNodeSearch } from "./node-picker.js";
22
+
23
+ interface Graph {
24
+ nodes: Map<string, Node>;
25
+ edges: Map<string, Edge>;
26
+ pos: Map<string, Point>;
27
+ expanded: Set<string>;
28
+ }
29
+
30
+ const EMPTY: Graph = { nodes: new Map(), edges: new Map(), pos: new Map(), expanded: new Set() };
31
+
32
+ function merge(graph: Graph, around: string, nodes: Node[], edges: Edge[]): Graph {
33
+ const next: Graph = {
34
+ nodes: new Map(graph.nodes),
35
+ edges: new Map(graph.edges),
36
+ pos: new Map(graph.pos),
37
+ expanded: new Set(graph.expanded).add(around),
38
+ };
39
+ for (const n of nodes) next.nodes.set(n.id, n);
40
+ for (const e of edges) next.edges.set(e.id, e);
41
+ if (!next.pos.has(around)) next.pos.set(around, { x: 0, y: 0 });
42
+ const fresh = seedRing(next.pos.get(around)!, nodes.map((n) => n.id), next.pos);
43
+ if (fresh.size === 0) return next;
44
+ for (const [id, p] of fresh) next.pos.set(id, p);
45
+ // Only the newly shown nodes move, so what's already on screen stays where it was.
46
+ next.pos = relax(next.pos, [...next.edges.values()], new Set(fresh.keys()));
47
+ return next;
48
+ }
49
+
50
+ function useOverview() {
51
+ const [overview, setOverview] = React.useState<Overview>();
52
+ const [error, setError] = React.useState<string>();
53
+ const load = React.useCallback(() => {
54
+ api.overview().then(setOverview, (e: unknown) => setError(message(e)));
55
+ }, []);
56
+ React.useEffect(load, [load]);
57
+ return { overview, error, reload: load };
58
+ }
59
+
60
+ export function Explorer() {
61
+ const { overview, error: overviewError, reload } = useOverview();
62
+ const [graph, setGraph] = React.useState<Graph>(EMPTY);
63
+ const [centerId, setCenterId] = React.useState<string | null>(null);
64
+ const [selectedId, setSelectedId] = React.useState<string | null>(null);
65
+ const [view, setView] = React.useState<"graph" | "table">("graph");
66
+ const [error, setError] = React.useState<string>();
67
+ const [busy, setBusy] = React.useState(false);
68
+
69
+ const focus = React.useCallback(async (id: string) => {
70
+ setBusy(true);
71
+ setError(undefined);
72
+ try {
73
+ const hood = await api.neighborhood(id);
74
+ setGraph(merge(EMPTY, id, [hood.center, ...hood.nodes], hood.edges));
75
+ setCenterId(id);
76
+ setSelectedId(id);
77
+ if (hood.truncated) setError(`Showing ${hood.edges.length} of ${hood.total} connections. Expand neighbours to explore further.`);
78
+ } catch (e) {
79
+ setError(message(e));
80
+ } finally {
81
+ setBusy(false);
82
+ }
83
+ }, []);
84
+
85
+ const expand = React.useCallback(async (id: string) => {
86
+ try {
87
+ const hood = await api.neighborhood(id);
88
+ setGraph((g) => merge(g, id, [hood.center, ...hood.nodes], hood.edges));
89
+ } catch (e) {
90
+ setError(message(e));
91
+ }
92
+ }, []);
93
+
94
+ // Latest graph for async callbacks, so a scan that finishes later doesn't act on a stale copy.
95
+ const graphRef = React.useRef(graph);
96
+ graphRef.current = graph;
97
+
98
+ /** After a scan: re-read every expanded neighbourhood, keeping where things were drawn. */
99
+ const refreshAll = React.useCallback(async () => {
100
+ reload();
101
+ const current = graphRef.current;
102
+ const ids = [...current.expanded];
103
+ const hoods = await Promise.all(ids.map((id) => api.neighborhood(id).catch(() => null)));
104
+ let next: Graph = { ...EMPTY, pos: current.pos };
105
+ hoods.forEach((hood, i) => {
106
+ if (hood) next = merge(next, ids[i]!, [hood.center, ...hood.nodes], hood.edges);
107
+ });
108
+ setGraph(next);
109
+ setSelectedId((sel) => (sel && next.nodes.has(sel) ? sel : null));
110
+ }, [reload]);
111
+
112
+ /** After an edit to one node: refresh just that node and its connections; drop it if deleted. */
113
+ const refreshNode = React.useCallback(
114
+ async (id: string) => {
115
+ reload();
116
+ try {
117
+ const hood = await api.neighborhood(id);
118
+ setGraph((g) => {
119
+ const edges = new Map([...g.edges].filter(([, e]) => e.sourceNodeId !== id && e.targetNodeId !== id));
120
+ return merge({ ...g, edges }, id, [hood.center, ...hood.nodes], hood.edges);
121
+ });
122
+ } catch {
123
+ setGraph((g) => ({
124
+ ...g,
125
+ nodes: new Map([...g.nodes].filter(([nodeId]) => nodeId !== id)),
126
+ edges: new Map([...g.edges].filter(([, e]) => e.sourceNodeId !== id && e.targetNodeId !== id)),
127
+ }));
128
+ setSelectedId((sel) => (sel === id ? null : sel));
129
+ }
130
+ },
131
+ [reload],
132
+ );
133
+
134
+ const showImpact = React.useCallback((impact: Impact) => {
135
+ const depthOf = new Map(impact.hits.map((h) => [h.nodeId, h.depth]));
136
+ const pos = new Map<string, Point>([[impact.start, { x: 0, y: 0 }]]);
137
+ const rings = new Map<number, string[]>();
138
+ for (const n of impact.nodes) {
139
+ if (n.id === impact.start) continue;
140
+ const d = depthOf.get(n.id) ?? 1;
141
+ rings.set(d, [...(rings.get(d) ?? []), n.id]);
142
+ }
143
+ for (const [d, ids] of rings) {
144
+ ids.forEach((id, i) => {
145
+ const angle = (2 * Math.PI * i) / ids.length - Math.PI / 2 + d * 0.6;
146
+ const r = 260 * Math.max(d, 0.6);
147
+ pos.set(id, { x: r * Math.cos(angle), y: r * Math.sin(angle) });
148
+ });
149
+ }
150
+ const movable = new Set([...pos.keys()].filter((id) => id !== impact.start));
151
+ setGraph({
152
+ nodes: new Map(impact.nodes.map((n) => [n.id, n])),
153
+ edges: new Map(impact.edges.map((e) => [e.id, e])),
154
+ pos: relax(pos, impact.edges, movable),
155
+ expanded: new Set([impact.start]),
156
+ });
157
+ setCenterId(impact.start);
158
+ setView("graph");
159
+ }, []);
160
+
161
+ const nodes = [...graph.nodes.values()];
162
+ const edges = [...graph.edges.values()];
163
+ const selected = selectedId ? graph.nodes.get(selectedId) : undefined;
164
+ const hasData = overview ? Object.values(overview.counts).some((c) => c > 0) : false;
165
+
166
+ return (
167
+ <div className="sg-root">
168
+ <header className="sg-top">
169
+ <div>
170
+ <h1>SiteGraph</h1>
171
+ <p className="sg-muted">How your pages, links and the services behind them depend on each other.</p>
172
+ </div>
173
+ <ScanButton overview={overview} onDone={() => void refreshAll()}>
174
+ <ExportButton disabled={!hasData} />
175
+ </ScanButton>
176
+ </header>
177
+
178
+ {overviewError ? <p role="alert" className="sg-error">{overviewError}</p> : null}
179
+ {overview ? <Stats overview={overview} /> : <Loader />}
180
+
181
+ {overview && !hasData && !overview.scan.running ? (
182
+ <div className="sg-empty">
183
+ <h2>Map your site</h2>
184
+ <p>
185
+ Run a scan to read your published entries and the links between them. It only reads content; nothing on
186
+ your site changes. After that, the map updates itself whenever an entry is saved.
187
+ </p>
188
+ </div>
189
+ ) : (
190
+ <div className="sg-workspace" data-has-details={selected ? "" : undefined}>
191
+ <Finder key={overview?.scan.last?.finishedAt ?? "never"} onPick={(id) => void focus(id)} onCreated={(id) => void focus(id)} brokenCount={overview?.brokenLinks ?? 0} />
192
+
193
+ <section className="sg-stage" aria-label="Graph">
194
+ <div className="sg-stage-bar">
195
+ <div className="sg-tabs" role="tablist">
196
+ <button role="tab" aria-selected={view === "graph"} onClick={() => setView("graph")}>
197
+ Graph
198
+ </button>
199
+ <button role="tab" aria-selected={view === "table"} onClick={() => setView("table")}>
200
+ Table
201
+ </button>
202
+ </div>
203
+ <Legend />
204
+ </div>
205
+ {error ? <p role="status" className="sg-warn sg-stage-note">{error}</p> : null}
206
+ {busy ? (
207
+ <div className="sg-stage-empty">
208
+ <Loader />
209
+ </div>
210
+ ) : nodes.length === 0 ? (
211
+ <StageEmpty brokenCount={overview?.brokenLinks ?? 0} onPick={(id) => void focus(id)} />
212
+ ) : view === "graph" ? (
213
+ <>
214
+ <GraphCanvas
215
+ nodes={nodes}
216
+ edges={edges}
217
+ positions={graph.pos}
218
+ centerId={centerId}
219
+ selectedId={selectedId}
220
+ onSelect={setSelectedId}
221
+ onExpand={(id) => void expand(id)}
222
+ onMove={(id, p) => setGraph((g) => ({ ...g, pos: new Map(g.pos).set(id, p) }))}
223
+ />
224
+ <p className="sg-hint">Click to inspect. Double-click to show its connections. Drag to rearrange.</p>
225
+ </>
226
+ ) : (
227
+ <GraphTable nodes={nodes} edges={edges} onSelect={setSelectedId} />
228
+ )}
229
+ </section>
230
+
231
+ {selected ? (
232
+ <Details
233
+ node={selected}
234
+ edges={edges}
235
+ lookup={(id) => graph.nodes.get(id)}
236
+ siteUrl={overview?.siteUrl ?? null}
237
+ onChanged={() => void refreshNode(selected.id)}
238
+ onFocus={(id) => (graph.nodes.has(id) ? setSelectedId(id) : void focus(id))}
239
+ onShowImpact={showImpact}
240
+ />
241
+ ) : null}
242
+ </div>
243
+ )}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ function Stats({ overview }: { overview: Overview }) {
249
+ const c = overview.counts;
250
+ const documented = c.FORM + c.SERVICE + c.WORKFLOW + c.TEAM_MEMBER;
251
+ return (
252
+ <dl className="sg-stats">
253
+ <div>
254
+ <dt>Entries</dt>
255
+ <dd>{c.CONTENT}</dd>
256
+ </div>
257
+ <div>
258
+ <dt>Connections</dt>
259
+ <dd>{overview.edges}</dd>
260
+ </div>
261
+ <div data-alert={overview.brokenLinks > 0 || undefined}>
262
+ <dt>Broken links</dt>
263
+ <dd>{overview.brokenLinks}</dd>
264
+ </div>
265
+ <div>
266
+ <dt>Documented things</dt>
267
+ <dd>{documented}</dd>
268
+ </div>
269
+ {!overview.siteUrl ? (
270
+ <p className="sg-warn">
271
+ Your site URL isn't set, so only relative links (like /about) are recognised. Set it in Settings.
272
+ </p>
273
+ ) : null}
274
+ </dl>
275
+ );
276
+ }
277
+
278
+ function StageEmpty({ brokenCount, onPick }: { brokenCount: number; onPick: (id: string) => void }) {
279
+ const [error, setError] = React.useState<string>();
280
+ const showBroken = async () => {
281
+ try {
282
+ const first = (await api.search("", { broken: true })).items[0];
283
+ if (first) onPick(first.id);
284
+ } catch (e) {
285
+ setError(message(e));
286
+ }
287
+ };
288
+ return (
289
+ <div className="sg-stage-empty">
290
+ <div className="sg-stage-empty-body">
291
+ <h2>How to use SiteGraph</h2>
292
+ <ol className="sg-steps">
293
+ <li>
294
+ <strong>Pick something on the left.</strong> Its connections appear here. Double-click any box to follow
295
+ its connections further.
296
+ </li>
297
+ <li>
298
+ <strong>Add what your CMS can't see.</strong> Forms, services like your CRM, workflows and owners, with{" "}
299
+ <em>Add a form, service or workflow</em>, then <em>Add a relationship</em> on a page.
300
+ </li>
301
+ <li>
302
+ <strong>Before you change something, open its Impact tab.</strong> It lists everything that depends on it,
303
+ step by step.
304
+ </li>
305
+ </ol>
306
+ <p>The map keeps itself up to date when entries are saved. Rescan after bulk imports.</p>
307
+ {brokenCount > 0 ? (
308
+ <Button onClick={() => void showBroken()}>
309
+ {brokenCount === 1 ? "Show the broken link" : `Show a broken link (${brokenCount})`}
310
+ </Button>
311
+ ) : null}
312
+ {error ? <p role="alert" className="sg-error">{error}</p> : null}
313
+ </div>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ function scanSummary(last: LastScan): string {
319
+ const read = `Read ${last.processed} ${last.processed === 1 ? "entry" : "entries"}`;
320
+ const retired =
321
+ last.retired === 0
322
+ ? "nothing was out of date"
323
+ : `${last.retired} out-of-date ${last.retired === 1 ? "link or page" : "links or pages"} cleared from the map`;
324
+ return `${read}; ${retired}.`;
325
+ }
326
+
327
+ function ScanButton({ overview, onDone, children }: { overview?: Overview; onDone: () => void; children?: React.ReactNode }) {
328
+ const [progress, setProgress] = React.useState<string>();
329
+ const [finished, setFinished] = React.useState<LastScan>();
330
+ const [error, setError] = React.useState<string>();
331
+ const last = overview?.scan.last;
332
+
333
+ const run = async () => {
334
+ setError(undefined);
335
+ setFinished(undefined);
336
+ setProgress("Starting scan…");
337
+ try {
338
+ const { state } = await api.startScan();
339
+ for (;;) {
340
+ const step = await api.scanStep(state.id);
341
+ if (step.done) {
342
+ if (step.last) setFinished(step.last);
343
+ break;
344
+ }
345
+ setProgress(`${step.state?.phase === "collect" ? "Reading entries" : "Tidying up"}: ${step.state?.processed ?? 0} read`);
346
+ }
347
+ setProgress(undefined);
348
+ onDone();
349
+ } catch (e) {
350
+ setProgress(undefined);
351
+ setError(message(e));
352
+ }
353
+ };
354
+
355
+ return (
356
+ <div className="sg-scan">
357
+ <div className="sg-row">
358
+ {children}
359
+ <Button variant="primary" onClick={() => void run()} loading={!!progress} disabled={!!progress}>
360
+ {last ? "Rescan site" : "Scan site"}
361
+ </Button>
362
+ </div>
363
+ <span className="sg-muted" role="status">
364
+ {progress ??
365
+ (finished
366
+ ? `Scan complete. ${scanSummary(finished)}`
367
+ : last
368
+ ? `${last.status === "PARTIAL" ? "Last scan was incomplete" : "Last scanned"} ${new Date(last.finishedAt).toLocaleString()}`
369
+ : "Never scanned")}
370
+ </span>
371
+ {last?.status === "PARTIAL" ? (
372
+ <details className="sg-muted">
373
+ <summary>{last.errors.length} entries couldn't be read</summary>
374
+ <ul>
375
+ {last.errors.map((e) => (
376
+ <li key={e}>{e}</li>
377
+ ))}
378
+ </ul>
379
+ </details>
380
+ ) : null}
381
+ {error ? <p role="alert" className="sg-error">{error}</p> : null}
382
+ </div>
383
+ );
384
+ }
385
+
386
+ function ExportButton({ disabled }: { disabled: boolean }) {
387
+ const [error, setError] = React.useState<string>();
388
+ const run = async () => {
389
+ try {
390
+ const data = await api.export();
391
+ const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
392
+ const a = Object.assign(document.createElement("a"), { href: url, download: `sitegraph-${new Date().toISOString().slice(0, 10)}.json` });
393
+ a.click();
394
+ URL.revokeObjectURL(url);
395
+ } catch (e) {
396
+ setError(message(e));
397
+ }
398
+ };
399
+ return (
400
+ <>
401
+ <Button onClick={() => void run()} disabled={disabled}>
402
+ Export JSON
403
+ </Button>
404
+ {error ? <p role="alert" className="sg-error">{error}</p> : null}
405
+ </>
406
+ );
407
+ }
408
+
409
+ function Finder({ onPick, onCreated, brokenCount }: { onPick: (id: string) => void; onCreated: (id: string) => void; brokenCount: number }) {
410
+ const [query, setQuery] = React.useState("");
411
+ const [type, setType] = React.useState<NodeType | "">("");
412
+ const [broken, setBroken] = React.useState(false);
413
+ const { items, loading, error } = useNodeSearch(query, { type: type || undefined, broken });
414
+
415
+ return (
416
+ <nav className="sg-finder" aria-label="Find in graph">
417
+ <Input label="Search" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Title or /path" />
418
+ <div className="sg-filters">
419
+ <select aria-label="Type" value={type} onChange={(e) => setType(e.target.value as NodeType | "")} disabled={broken}>
420
+ <option value="">All types</option>
421
+ {NODE_TYPES.map((t) => (
422
+ <option key={t} value={t}>
423
+ {TYPE_LABEL[t]}
424
+ </option>
425
+ ))}
426
+ </select>
427
+ <label className="sg-check">
428
+ <input type="checkbox" checked={broken} onChange={(e) => setBroken(e.target.checked)} />
429
+ Broken links ({brokenCount})
430
+ </label>
431
+ </div>
432
+ {error ? <p role="alert" className="sg-error">{error}</p> : null}
433
+ <ul className="sg-results" aria-busy={loading}>
434
+ {items.map((n) => (
435
+ <li key={n.id}>
436
+ <button type="button" onClick={() => onPick(n.id)}>
437
+ <span className="sg-result-label">{n.label}</span>
438
+ <span className="sg-kind" data-type={n.type} data-broken={isBroken(n) || undefined}>
439
+ {isBroken(n) ? "Broken link" : TYPE_LABEL[n.type]}
440
+ </span>
441
+ </button>
442
+ </li>
443
+ ))}
444
+ {!loading && items.length === 0 ? <li className="sg-muted">{query ? "No matches. Names match from their first letter." : "Nothing here yet."}</li> : null}
445
+ </ul>
446
+ <AddNode onCreated={onCreated} />
447
+ </nav>
448
+ );
449
+ }
450
+
451
+ function AddNode({ onCreated }: { onCreated: (id: string) => void }) {
452
+ const [open, setOpen] = React.useState(false);
453
+ const [type, setType] = React.useState<(typeof DOCUMENTED_NODE_TYPES)[number]>("SERVICE");
454
+ const [label, setLabel] = React.useState("");
455
+ const [status, setStatus] = React.useState<{ busy?: boolean; error?: string }>({});
456
+
457
+ if (!open) {
458
+ return (
459
+ <Button size="sm" onClick={() => setOpen(true)}>
460
+ Add a form, service or workflow
461
+ </Button>
462
+ );
463
+ }
464
+
465
+ return (
466
+ <form
467
+ className="sg-form sg-add"
468
+ onSubmit={async (e) => {
469
+ e.preventDefault();
470
+ setStatus({ busy: true });
471
+ try {
472
+ const node = await api.saveNode({ type, label });
473
+ setLabel("");
474
+ setOpen(false);
475
+ setStatus({});
476
+ onCreated(node.id);
477
+ } catch (err) {
478
+ setStatus({ error: message(err) });
479
+ }
480
+ }}
481
+ >
482
+ <label className="sg-field">
483
+ <span>Kind</span>
484
+ <select value={type} onChange={(e) => setType(e.target.value as typeof type)}>
485
+ {DOCUMENTED_NODE_TYPES.map((t) => (
486
+ <option key={t} value={t}>
487
+ {TYPE_LABEL[t]}
488
+ </option>
489
+ ))}
490
+ </select>
491
+ </label>
492
+ <Input label="Name" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. HubSpot CRM" required maxLength={200} />
493
+ <div className="sg-row">
494
+ <Button type="submit" size="sm" variant="primary" loading={status.busy}>
495
+ Add
496
+ </Button>
497
+ <Button type="button" size="sm" variant="ghost" onClick={() => setOpen(false)}>
498
+ Cancel
499
+ </Button>
500
+ </div>
501
+ {status.error ? <p role="alert" className="sg-error">{status.error}</p> : null}
502
+ </form>
503
+ );
504
+ }
505
+
506
+ function Legend() {
507
+ return (
508
+ <ul className="sg-legend" aria-label="Line styles">
509
+ <li>
510
+ <svg width="28" height="8" aria-hidden="true"><line x1="0" y1="4" x2="28" y2="4" /></svg>
511
+ {PROVENANCE_LABEL.DISCOVERED}
512
+ </li>
513
+ <li>
514
+ <svg width="28" height="8" aria-hidden="true"><line x1="0" y1="4" x2="28" y2="4" strokeDasharray="7 5" /></svg>
515
+ {PROVENANCE_LABEL.DOCUMENTED}
516
+ </li>
517
+ </ul>
518
+ );
519
+ }
520
+
521
+ function GraphTable({ nodes, edges, onSelect }: { nodes: Node[]; edges: Edge[]; onSelect: (id: string) => void }) {
522
+ const name = new Map(nodes.map((n) => [n.id, n.label]));
523
+ return (
524
+ <div className="sg-table-wrap">
525
+ <table className="sg-table">
526
+ <caption>Connections shown on the graph</caption>
527
+ <thead>
528
+ <tr>
529
+ <th scope="col">From</th>
530
+ <th scope="col">Relationship</th>
531
+ <th scope="col">To</th>
532
+ <th scope="col">Source</th>
533
+ </tr>
534
+ </thead>
535
+ <tbody>
536
+ {edges.map((e) => (
537
+ <tr key={e.id}>
538
+ <td>
539
+ <button type="button" className="sg-link" onClick={() => onSelect(e.sourceNodeId)}>
540
+ {name.get(e.sourceNodeId) ?? e.sourceNodeId}
541
+ </button>
542
+ </td>
543
+ <td>
544
+ {RELATION_LABEL[e.relation]}
545
+ {e.label ? ` (${e.label})` : ""}
546
+ </td>
547
+ <td>
548
+ <button type="button" className="sg-link" onClick={() => onSelect(e.targetNodeId)}>
549
+ {name.get(e.targetNodeId) ?? e.targetNodeId}
550
+ </button>
551
+ </td>
552
+ <td>
553
+ {PROVENANCE_LABEL[e.provenance]}
554
+ {e.evidence?.fieldPath ? `, ${e.evidence.fieldPath}` : ""}
555
+ </td>
556
+ </tr>
557
+ ))}
558
+ </tbody>
559
+ </table>
560
+ </div>
561
+ );
562
+ }