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.
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
- "name": "emdash-plugin-sitegraph",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "emdash-plugin-sitegraph",
3
+ "version": "0.1.0",
4
+ "description": "A living dependency map for EmDash sites: content, URLs, internal links and documented dependencies, with change-impact preview.",
5
+ "type": "module",
6
+ "main": "./dist/index.mjs",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.mts",
10
+ "import": "./dist/index.mjs"
11
+ },
12
+ "./admin": "./src/admin/index.tsx"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsdown src/index.ts --format esm --dts --clean",
20
+ "dev": "tsdown src/index.ts --format esm --dts --watch",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "vitest run"
23
+ },
24
+ "peerDependencies": {
25
+ "@cloudflare/kumo": "*",
26
+ "astro": ">=6.0.0",
27
+ "emdash": ">=1.2.0",
28
+ "react": "^18.0.0 || ^19.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@cloudflare/kumo": "2.6.0",
32
+ "@types/react": "^19.0.0",
33
+ "astro": "7.3.8",
34
+ "emdash": "1.2.0",
35
+ "react": "19.2.4",
36
+ "tsdown": "^0.20.0",
37
+ "typescript": "^5.9.0",
38
+ "vitest": "^4.1.0"
39
+ },
40
+ "keywords": [
41
+ "emdash",
42
+ "emdash-plugin"
43
+ ],
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/ravalkrushna1/sitegraph.git"
47
+ },
48
+ "license": "MIT",
49
+ "packageManager": "pnpm@10.33.0"
50
+ }
@@ -0,0 +1,115 @@
1
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
2
+
3
+ import type { Criticality, GraphEdge, GraphNode, NodeType, RelationType } from "../domain/graph.js";
4
+ import type { Direction, ImpactHit } from "../domain/impact.js";
5
+
6
+ export type Node = GraphNode & { id: string };
7
+ export type Edge = GraphEdge & { id: string };
8
+
9
+ export interface Overview {
10
+ siteUrl: string | null;
11
+ counts: Record<NodeType, number>;
12
+ edges: number;
13
+ brokenLinks: number;
14
+ scan: { running: ScanState | null; last: LastScan | null };
15
+ }
16
+
17
+ export interface ScanState {
18
+ id: string;
19
+ phase: string;
20
+ processed: number;
21
+ index: number;
22
+ collections: unknown[];
23
+ }
24
+
25
+ export interface LastScan {
26
+ finishedAt: string;
27
+ status: "SUCCEEDED" | "PARTIAL";
28
+ processed: number;
29
+ retired: number;
30
+ errors: string[];
31
+ }
32
+
33
+ export interface Neighborhood {
34
+ center: Node;
35
+ nodes: Node[];
36
+ edges: Edge[];
37
+ truncated: boolean;
38
+ total: number;
39
+ }
40
+
41
+ export interface Impact {
42
+ start: string;
43
+ hits: Array<Omit<ImpactHit, "path"> & { path: Edge[] }>;
44
+ edges: Edge[];
45
+ nodes: Node[];
46
+ truncated: boolean;
47
+ }
48
+
49
+ export interface NodeInput {
50
+ id?: string;
51
+ type?: NodeType;
52
+ label?: string;
53
+ description?: string;
54
+ criticality?: Criticality | null;
55
+ notes?: string;
56
+ docUrl?: string;
57
+ lastVerifiedAt?: string | null;
58
+ }
59
+
60
+ async function call<T>(route: string, body?: unknown): Promise<T> {
61
+ const res = await apiFetch(`/_emdash/api/plugins/sitegraph/${route}`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(body ?? {}),
65
+ });
66
+ return parseApiResponse<T>(res, "SiteGraph couldn't complete that request.");
67
+ }
68
+
69
+ export const api = {
70
+ overview: () => call<Overview>("overview"),
71
+ startScan: () => call<{ state: ScanState }>("scan/start"),
72
+ scanStep: (scanId: string) =>
73
+ call<{ state: ScanState | null; last: LastScan | null; done: boolean }>("scan/step", { scanId }),
74
+ search: (q: string, opts: { type?: NodeType; broken?: boolean } = {}) =>
75
+ call<{ items: Node[]; cursor: string | null }>("nodes/search", { q, ...opts }),
76
+ neighborhood: (nodeId: string) => call<Neighborhood>("graph/neighborhood", { nodeId }),
77
+ impact: (nodeId: string, direction: Direction, depth: number) =>
78
+ call<Impact>("graph/impact", { nodeId, direction, depth }),
79
+ saveNode: (input: NodeInput) => call<Node>("nodes/save", input),
80
+ deleteNode: (id: string) => call<{ deleted: string }>("nodes/delete", { id }),
81
+ saveEdge: (input: { sourceNodeId: string; targetNodeId: string; relation: RelationType; label?: string }) =>
82
+ call<Edge>("edges/save", input),
83
+ deleteEdge: (id: string) => call<{ deleted: string }>("edges/delete", { id }),
84
+ export: () => call<unknown>("export"),
85
+ };
86
+
87
+ export const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
88
+
89
+ export const TYPE_LABEL: Record<NodeType, string> = {
90
+ CONTENT: "Entry",
91
+ URL: "URL",
92
+ FORM: "Form",
93
+ SERVICE: "Service",
94
+ WORKFLOW: "Workflow",
95
+ TEAM_MEMBER: "Person or team",
96
+ };
97
+
98
+ export const RELATION_LABEL: Record<RelationType, string> = {
99
+ PUBLISHES_AS: "is published at",
100
+ LINKS_TO: "links to",
101
+ SUBMITS_TO: "submits to",
102
+ DEPENDS_ON: "depends on",
103
+ PART_OF: "is part of",
104
+ OWNED_BY: "is owned by",
105
+ RELATED_TO: "is related to",
106
+ };
107
+
108
+ export const PROVENANCE_LABEL = {
109
+ DISCOVERED: "Found by scan",
110
+ DOCUMENTED: "Added by a person",
111
+ INFERRED: "Guessed",
112
+ } as const;
113
+
114
+ /** A URL node with no published entry behind it: an internal link that leads nowhere. */
115
+ export const isBroken = (node: Pick<Node, "type" | "resolved">): boolean => node.type === "URL" && node.resolved === false;
@@ -0,0 +1,420 @@
1
+ import { Badge, Button, Input } from "@cloudflare/kumo";
2
+ import * as React from "react";
3
+
4
+ import { CRITICALITIES, DOCUMENTED_RELATION_TYPES, type RelationType } from "../domain/graph.js";
5
+ import type { Direction } from "../domain/impact.js";
6
+ import {
7
+ api,
8
+ type Edge,
9
+ type Impact,
10
+ isBroken,
11
+ message,
12
+ type Node,
13
+ PROVENANCE_LABEL,
14
+ RELATION_LABEL,
15
+ TYPE_LABEL,
16
+ } from "./api.js";
17
+ import { NodePicker } from "./node-picker.js";
18
+
19
+ const formatDate = (iso?: string) => (iso ? new Date(iso).toLocaleDateString() : null);
20
+
21
+ /** Where to see or change the real thing this node stands for. */
22
+ function SourceLink({ node, siteUrl }: { node: Node; siteUrl: string | null }) {
23
+ if (node.type === "CONTENT") {
24
+ const [collection, id] = node.ref.split("/");
25
+ return <a href={`/_emdash/admin/content/${collection}/${id}`}>Open in editor</a>;
26
+ }
27
+ if (node.type === "URL" && !isBroken(node)) return <a href={`${siteUrl ?? ""}${node.ref}`} target="_blank" rel="noreferrer">View page</a>;
28
+ if (node.docUrl) return <a href={node.docUrl} target="_blank" rel="noreferrer">Open documentation</a>;
29
+ return null;
30
+ }
31
+
32
+ export function Details({
33
+ node,
34
+ edges,
35
+ lookup,
36
+ siteUrl,
37
+ onChanged,
38
+ onFocus,
39
+ onShowImpact,
40
+ }: {
41
+ node: Node;
42
+ edges: Edge[];
43
+ lookup: (id: string) => Node | undefined;
44
+ siteUrl: string | null;
45
+ onChanged: () => void;
46
+ onFocus: (id: string) => void;
47
+ onShowImpact: (impact: Impact) => void;
48
+ }) {
49
+ const [tab, setTab] = React.useState<"about" | "impact">("about");
50
+ React.useEffect(() => setTab("about"), [node.id]);
51
+
52
+ return (
53
+ <aside className="sg-details" aria-label={`Details for ${node.label}`}>
54
+ <header className="sg-details-head">
55
+ <span className="sg-kind" data-type={node.type}>
56
+ {isBroken(node) ? "Broken link" : TYPE_LABEL[node.type]}
57
+ </span>
58
+ <h2>{node.label}</h2>
59
+ <p className="sg-muted">
60
+ {PROVENANCE_LABEL[node.provenance]}
61
+ {node.lastSeenAt ? `, last seen ${formatDate(node.lastSeenAt)}` : ""}
62
+ </p>
63
+ {isBroken(node) ? (
64
+ <p className="sg-warn">No published entry lives at this path. The pages linking here lead to a 404.</p>
65
+ ) : null}
66
+ <SourceLink node={node} siteUrl={siteUrl} />
67
+ </header>
68
+
69
+ <div className="sg-tabs" role="tablist">
70
+ <button role="tab" aria-selected={tab === "about"} onClick={() => setTab("about")}>
71
+ About
72
+ </button>
73
+ <button role="tab" aria-selected={tab === "impact"} onClick={() => setTab("impact")}>
74
+ Impact
75
+ </button>
76
+ </div>
77
+
78
+ {tab === "about" ? (
79
+ <>
80
+ <AnnotationForm key={node.id} node={node} onSaved={onChanged} />
81
+ <Connections node={node} edges={edges} lookup={lookup} onChanged={onChanged} onFocus={onFocus} />
82
+ <AddRelationship node={node} onSaved={onChanged} />
83
+ {node.provenance === "DOCUMENTED" ? <DeleteNode node={node} onDeleted={onChanged} /> : null}
84
+ </>
85
+ ) : (
86
+ <ImpactView key={node.id} node={node} lookup={lookup} onFocus={onFocus} onShow={onShowImpact} />
87
+ )}
88
+ </aside>
89
+ );
90
+ }
91
+
92
+ function AnnotationForm({ node, onSaved }: { node: Node; onSaved: () => void }) {
93
+ const [label, setLabel] = React.useState(node.label);
94
+ const [description, setDescription] = React.useState(node.description ?? "");
95
+ const [criticality, setCriticality] = React.useState(node.criticality ?? "");
96
+ const [notes, setNotes] = React.useState(node.notes ?? "");
97
+ const [docUrl, setDocUrl] = React.useState(node.docUrl ?? "");
98
+ const [status, setStatus] = React.useState<{ busy?: boolean; error?: string; saved?: boolean }>({});
99
+
100
+ const save = async (verify = false) => {
101
+ setStatus({ busy: true });
102
+ try {
103
+ await api.saveNode({
104
+ id: node.id,
105
+ ...(node.provenance === "DOCUMENTED" ? { label } : {}),
106
+ description,
107
+ criticality: (criticality || null) as Node["criticality"] | null,
108
+ notes,
109
+ docUrl,
110
+ ...(verify ? { lastVerifiedAt: new Date().toISOString() } : {}),
111
+ });
112
+ setStatus({ saved: true });
113
+ onSaved();
114
+ } catch (error) {
115
+ setStatus({ error: message(error) });
116
+ }
117
+ };
118
+
119
+ return (
120
+ <form
121
+ className="sg-section sg-form"
122
+ onSubmit={(e) => {
123
+ e.preventDefault();
124
+ void save();
125
+ }}
126
+ >
127
+ <h3>What it is and why it matters</h3>
128
+ {node.provenance === "DOCUMENTED" ? <Input label="Name" value={label} onChange={(e) => setLabel(e.target.value)} required /> : null}
129
+ <label className="sg-field">
130
+ <span>Purpose</span>
131
+ <textarea rows={2} value={description} maxLength={2000} onChange={(e) => setDescription(e.target.value)} placeholder="What does this do for the business?" />
132
+ </label>
133
+ <label className="sg-field">
134
+ <span>Criticality</span>
135
+ <select value={criticality} onChange={(e) => setCriticality(e.target.value as typeof criticality)}>
136
+ <option value="">Not set</option>
137
+ {CRITICALITIES.map((c) => (
138
+ <option key={c} value={c}>
139
+ {c[0] + c.slice(1).toLowerCase()}
140
+ </option>
141
+ ))}
142
+ </select>
143
+ </label>
144
+ <label className="sg-field">
145
+ <span>Maintenance notes</span>
146
+ <textarea rows={3} value={notes} maxLength={5000} onChange={(e) => setNotes(e.target.value)} placeholder="Never paste passwords or API keys here. Link to your password manager instead." />
147
+ </label>
148
+ <Input label="Runbook or doc link" type="url" value={docUrl} onChange={(e) => setDocUrl(e.target.value)} placeholder="https://" />
149
+ <p className="sg-muted">
150
+ {node.lastVerifiedAt ? `Last checked by a person on ${formatDate(node.lastVerifiedAt)}.` : "Nobody has confirmed this yet."}
151
+ </p>
152
+ <div className="sg-row">
153
+ <Button type="submit" variant="primary" size="sm" loading={status.busy}>
154
+ Save
155
+ </Button>
156
+ <Button type="button" size="sm" onClick={() => void save(true)} disabled={status.busy}>
157
+ Save and mark checked
158
+ </Button>
159
+ </div>
160
+ {status.error ? <p role="alert" className="sg-error">{status.error}</p> : null}
161
+ {status.saved ? <p role="status" className="sg-muted">Saved.</p> : null}
162
+ </form>
163
+ );
164
+ }
165
+
166
+ function Connections({
167
+ node,
168
+ edges,
169
+ lookup,
170
+ onChanged,
171
+ onFocus,
172
+ }: {
173
+ node: Node;
174
+ edges: Edge[];
175
+ lookup: (id: string) => Node | undefined;
176
+ onChanged: () => void;
177
+ onFocus: (id: string) => void;
178
+ }) {
179
+ const [error, setError] = React.useState<string>();
180
+ const mine = edges.filter((e) => e.sourceNodeId === node.id || e.targetNodeId === node.id);
181
+
182
+ const remove = async (edge: Edge) => {
183
+ const other = lookup(edge.sourceNodeId === node.id ? edge.targetNodeId : edge.sourceNodeId);
184
+ if (!window.confirm(`Remove "${RELATION_LABEL[edge.relation]} ${other?.label ?? "this node"}"? This only changes SiteGraph, not your site.`)) return;
185
+ try {
186
+ await api.deleteEdge(edge.id);
187
+ onChanged();
188
+ } catch (e) {
189
+ setError(message(e));
190
+ }
191
+ };
192
+
193
+ return (
194
+ <section className="sg-section">
195
+ <h3>Connections ({mine.length})</h3>
196
+ {mine.length === 0 ? <p className="sg-muted">Nothing connects here yet.</p> : null}
197
+ <ul className="sg-list">
198
+ {mine.map((edge) => {
199
+ const outgoing = edge.sourceNodeId === node.id;
200
+ const other = lookup(outgoing ? edge.targetNodeId : edge.sourceNodeId);
201
+ return (
202
+ <li key={edge.id} className="sg-conn">
203
+ <span className="sg-conn-rel">
204
+ {outgoing ? RELATION_LABEL[edge.relation] : `← ${RELATION_LABEL[edge.relation]}`}
205
+ {edge.label ? ` (${edge.label})` : ""}
206
+ </span>
207
+ <button type="button" className="sg-link" onClick={() => onFocus(other?.id ?? (outgoing ? edge.targetNodeId : edge.sourceNodeId))}>
208
+ {other?.label ?? "Not loaded yet"}
209
+ </button>
210
+ <span className="sg-prov" data-prov={edge.provenance}>
211
+ {PROVENANCE_LABEL[edge.provenance]}
212
+ {edge.evidence?.fieldPath ? `, in ${edge.evidence.fieldPath}` : ""}
213
+ </span>
214
+ {edge.provenance === "DOCUMENTED" ? (
215
+ <button type="button" className="sg-link sg-danger" onClick={() => void remove(edge)}>
216
+ Remove
217
+ </button>
218
+ ) : null}
219
+ </li>
220
+ );
221
+ })}
222
+ </ul>
223
+ {error ? <p role="alert" className="sg-error">{error}</p> : null}
224
+ </section>
225
+ );
226
+ }
227
+
228
+ function AddRelationship({ node, onSaved }: { node: Node; onSaved: () => void }) {
229
+ const [relation, setRelation] = React.useState<RelationType>("DEPENDS_ON");
230
+ const [target, setTarget] = React.useState<Node | null>(null);
231
+ const [label, setLabel] = React.useState("");
232
+ const [status, setStatus] = React.useState<{ busy?: boolean; error?: string }>({});
233
+
234
+ const save = async () => {
235
+ if (!target) return;
236
+ setStatus({ busy: true });
237
+ try {
238
+ await api.saveEdge({ sourceNodeId: node.id, targetNodeId: target.id, relation, ...(label ? { label } : {}) });
239
+ setTarget(null);
240
+ setLabel("");
241
+ setStatus({});
242
+ onSaved();
243
+ } catch (error) {
244
+ setStatus({ error: message(error) });
245
+ }
246
+ };
247
+
248
+ return (
249
+ <form
250
+ className="sg-section sg-form"
251
+ onSubmit={(e) => {
252
+ e.preventDefault();
253
+ void save();
254
+ }}
255
+ >
256
+ <h3>Add a relationship</h3>
257
+ <p className="sg-muted">Record what the CMS can't see, like the CRM a form feeds.</p>
258
+ <label className="sg-field">
259
+ <span>This {TYPE_LABEL[node.type].toLowerCase()}</span>
260
+ <select value={relation} onChange={(e) => setRelation(e.target.value as RelationType)}>
261
+ {DOCUMENTED_RELATION_TYPES.map((r) => (
262
+ <option key={r} value={r}>
263
+ {RELATION_LABEL[r]}
264
+ </option>
265
+ ))}
266
+ </select>
267
+ </label>
268
+ <NodePicker value={target} onChange={setTarget} exclude={node.id} />
269
+ {relation === "RELATED_TO" ? (
270
+ <Input label="How are they related?" value={label} onChange={(e) => setLabel(e.target.value)} required maxLength={200} />
271
+ ) : null}
272
+ <Button type="submit" size="sm" disabled={!target} loading={status.busy}>
273
+ Add relationship
274
+ </Button>
275
+ {status.error ? <p role="alert" className="sg-error">{status.error}</p> : null}
276
+ </form>
277
+ );
278
+ }
279
+
280
+ function DeleteNode({ node, onDeleted }: { node: Node; onDeleted: () => void }) {
281
+ const [error, setError] = React.useState<string>();
282
+ return (
283
+ <section className="sg-section">
284
+ <Button
285
+ variant="destructive"
286
+ size="sm"
287
+ onClick={async () => {
288
+ if (!window.confirm(`Delete "${node.label}" and every relationship to it? Your site isn't affected.`)) return;
289
+ try {
290
+ await api.deleteNode(node.id);
291
+ onDeleted();
292
+ } catch (e) {
293
+ setError(message(e));
294
+ }
295
+ }}
296
+ >
297
+ Delete {TYPE_LABEL[node.type].toLowerCase()}
298
+ </Button>
299
+ {error ? <p role="alert" className="sg-error">{error}</p> : null}
300
+ </section>
301
+ );
302
+ }
303
+
304
+ const DIRECTIONS: Array<{ value: Direction; label: string }> = [
305
+ { value: "inbound", label: "What depends on this" },
306
+ { value: "outbound", label: "What this depends on" },
307
+ { value: "both", label: "Both" },
308
+ ];
309
+
310
+ function ImpactView({
311
+ node,
312
+ lookup,
313
+ onFocus,
314
+ onShow,
315
+ }: {
316
+ node: Node;
317
+ lookup: (id: string) => Node | undefined;
318
+ onFocus: (id: string) => void;
319
+ onShow: (impact: Impact) => void;
320
+ }) {
321
+ const [direction, setDirection] = React.useState<Direction>("inbound");
322
+ const [depth, setDepth] = React.useState(2);
323
+ const [result, setResult] = React.useState<Impact>();
324
+ const [status, setStatus] = React.useState<{ busy?: boolean; error?: string }>({});
325
+
326
+ const run = async () => {
327
+ setStatus({ busy: true });
328
+ try {
329
+ setResult(await api.impact(node.id, direction, depth));
330
+ setStatus({});
331
+ } catch (error) {
332
+ setStatus({ error: message(error) });
333
+ }
334
+ };
335
+
336
+ const names = new Map(result?.nodes.map((n) => [n.id, n]));
337
+ const name = (id: string) => names.get(id)?.label ?? lookup(id)?.label ?? id;
338
+ const hits = (result?.hits ?? []).filter((h) => names.get(h.nodeId)?.type !== "URL" || isBroken(names.get(h.nodeId)!));
339
+ const groups = [
340
+ { title: "Confirmed connections", items: hits.filter((h) => h.confirmed) },
341
+ { title: "Through a guessed link", items: hits.filter((h) => !h.confirmed) },
342
+ ];
343
+
344
+ return (
345
+ <section className="sg-section">
346
+ <h3>If “{node.label}” changes</h3>
347
+ <p className="sg-muted">Lists what's connected, step by step. A connection is potential impact, not proof something will break.</p>
348
+ <div className="sg-row">
349
+ <label className="sg-field">
350
+ <span>Follow</span>
351
+ <select value={direction} onChange={(e) => setDirection(e.target.value as Direction)}>
352
+ {DIRECTIONS.map((d) => (
353
+ <option key={d.value} value={d.value}>
354
+ {d.label}
355
+ </option>
356
+ ))}
357
+ </select>
358
+ </label>
359
+ <label className="sg-field">
360
+ <span>Steps</span>
361
+ <select value={depth} onChange={(e) => setDepth(Number(e.target.value))}>
362
+ {[1, 2, 3].map((d) => (
363
+ <option key={d} value={d}>
364
+ {d}
365
+ </option>
366
+ ))}
367
+ </select>
368
+ </label>
369
+ </div>
370
+ <div className="sg-row">
371
+ <Button size="sm" variant="primary" onClick={() => void run()} loading={status.busy}>
372
+ Check impact
373
+ </Button>
374
+ {result ? (
375
+ <Button size="sm" onClick={() => onShow(result)}>
376
+ Show on graph
377
+ </Button>
378
+ ) : null}
379
+ </div>
380
+ {status.error ? <p role="alert" className="sg-error">{status.error}</p> : null}
381
+
382
+ {result ? (
383
+ hits.length === 0 ? (
384
+ <p className="sg-muted">Nothing is connected in that direction within {depth} step{depth > 1 ? "s" : ""}.</p>
385
+ ) : (
386
+ groups
387
+ .filter((g) => g.items.length)
388
+ .map((group) => (
389
+ <div key={group.title} className="sg-impact-group">
390
+ <h4>
391
+ {group.title} ({group.items.length})
392
+ </h4>
393
+ <ol className="sg-list">
394
+ {group.items
395
+ .sort((a, b) => a.depth - b.depth)
396
+ .map((hit) => (
397
+ <li key={hit.nodeId} className="sg-hit">
398
+ <button type="button" className="sg-link" onClick={() => onFocus(hit.nodeId)}>
399
+ {name(hit.nodeId)}
400
+ </button>
401
+ <Badge variant={hit.depth <= 1 ? "warning" : "secondary"}>
402
+ {hit.depth <= 1 ? "Direct" : `${hit.depth} steps away`}
403
+ </Badge>
404
+ <p className="sg-path">
405
+ {hit.path
406
+ .filter((e) => e.relation !== "PUBLISHES_AS")
407
+ .map((e) => `${name(e.sourceNodeId)} ${RELATION_LABEL[e.relation]} ${name(e.targetNodeId)}`)
408
+ .join(", then ")}
409
+ </p>
410
+ </li>
411
+ ))}
412
+ </ol>
413
+ </div>
414
+ ))
415
+ )
416
+ ) : null}
417
+ {result?.truncated ? <p className="sg-warn">Stopped early: more than 500 connected items. Narrow the direction or steps.</p> : null}
418
+ </section>
419
+ );
420
+ }