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/LICENSE +21 -0
- package/README.md +92 -2
- package/dist/index.d.mts +9 -0
- package/dist/index.mjs +957 -0
- package/package.json +49 -5
- package/src/admin/api.ts +115 -0
- package/src/admin/details.tsx +420 -0
- package/src/admin/explorer.tsx +562 -0
- package/src/admin/graph-canvas.tsx +222 -0
- package/src/admin/index.tsx +47 -0
- package/src/admin/layout.ts +114 -0
- package/src/admin/node-picker.tsx +69 -0
- package/src/admin/sitegraph.css +798 -0
- package/src/domain/graph.ts +95 -0
- package/src/domain/impact.ts +102 -0
- package/src/domain/links.ts +107 -0
- package/src/index.ts +68 -0
- package/src/routes.ts +294 -0
- package/src/scan.ts +379 -0
- package/src/store.ts +55 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Platform-neutral graph model. No EmDash, React or storage imports in src/domain/.
|
|
2
|
+
|
|
3
|
+
export const NODE_TYPES = ["CONTENT", "URL", "FORM", "SERVICE", "WORKFLOW", "TEAM_MEMBER"] as const;
|
|
4
|
+
export type NodeType = (typeof NODE_TYPES)[number];
|
|
5
|
+
|
|
6
|
+
/** Node types a person can create; CONTENT and URL only come from scans. */
|
|
7
|
+
export const DOCUMENTED_NODE_TYPES = ["FORM", "SERVICE", "WORKFLOW", "TEAM_MEMBER"] as const;
|
|
8
|
+
|
|
9
|
+
export const RELATION_TYPES = [
|
|
10
|
+
"PUBLISHES_AS",
|
|
11
|
+
"LINKS_TO",
|
|
12
|
+
"SUBMITS_TO",
|
|
13
|
+
"DEPENDS_ON",
|
|
14
|
+
"PART_OF",
|
|
15
|
+
"OWNED_BY",
|
|
16
|
+
"RELATED_TO",
|
|
17
|
+
] as const;
|
|
18
|
+
export type RelationType = (typeof RELATION_TYPES)[number];
|
|
19
|
+
|
|
20
|
+
/** Relations a person can document; PUBLISHES_AS and LINKS_TO only come from scans. */
|
|
21
|
+
export const DOCUMENTED_RELATION_TYPES = [
|
|
22
|
+
"SUBMITS_TO",
|
|
23
|
+
"DEPENDS_ON",
|
|
24
|
+
"PART_OF",
|
|
25
|
+
"OWNED_BY",
|
|
26
|
+
"RELATED_TO",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
// INFERRED is reserved: nothing produces it yet (SPEC D5).
|
|
30
|
+
export type Provenance = "DISCOVERED" | "DOCUMENTED" | "INFERRED";
|
|
31
|
+
|
|
32
|
+
export const CRITICALITIES = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] as const;
|
|
33
|
+
export type Criticality = (typeof CRITICALITIES)[number];
|
|
34
|
+
|
|
35
|
+
export const SCHEMA_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
/** Human context that scans must never overwrite. */
|
|
38
|
+
export interface Annotation {
|
|
39
|
+
description?: string;
|
|
40
|
+
criticality?: Criticality;
|
|
41
|
+
notes?: string;
|
|
42
|
+
docUrl?: string;
|
|
43
|
+
lastVerifiedAt?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GraphNode extends Annotation {
|
|
47
|
+
type: NodeType;
|
|
48
|
+
label: string;
|
|
49
|
+
/** Lowercased label, indexed for prefix search. */
|
|
50
|
+
search: string;
|
|
51
|
+
/** Stable outside reference: "posts/<id>" for content, the path for URLs. */
|
|
52
|
+
ref: string;
|
|
53
|
+
provenance: Provenance;
|
|
54
|
+
/** URL nodes: true when a published entry lives at this path. */
|
|
55
|
+
resolved?: boolean;
|
|
56
|
+
active: boolean;
|
|
57
|
+
firstSeenAt: string;
|
|
58
|
+
lastSeenAt: string;
|
|
59
|
+
schemaVersion: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Evidence {
|
|
63
|
+
sourceId?: string;
|
|
64
|
+
fieldPath?: string;
|
|
65
|
+
href?: string;
|
|
66
|
+
observedAt?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GraphEdge {
|
|
70
|
+
sourceNodeId: string;
|
|
71
|
+
targetNodeId: string;
|
|
72
|
+
relation: RelationType;
|
|
73
|
+
/** Required for RELATED_TO. */
|
|
74
|
+
label?: string;
|
|
75
|
+
provenance: Provenance;
|
|
76
|
+
evidence?: Evidence;
|
|
77
|
+
active: boolean;
|
|
78
|
+
/** Scan that last saw a discovered edge; reconciliation retires the rest. */
|
|
79
|
+
scanId?: string;
|
|
80
|
+
firstSeenAt: string;
|
|
81
|
+
lastSeenAt: string;
|
|
82
|
+
schemaVersion: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const contentNodeId = (collection: string, entryId: string): string =>
|
|
86
|
+
`content:${collection}:${entryId}`;
|
|
87
|
+
|
|
88
|
+
export const urlNodeId = (path: string): string => `url:${path}`;
|
|
89
|
+
|
|
90
|
+
export const edgeId = (
|
|
91
|
+
source: string,
|
|
92
|
+
relation: RelationType,
|
|
93
|
+
target: string,
|
|
94
|
+
fieldPath = "",
|
|
95
|
+
): string => `${source}|${relation}|${target}|${fieldPath}`;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { GraphEdge } from "./graph.js";
|
|
2
|
+
|
|
3
|
+
export type Direction = "inbound" | "outbound" | "both";
|
|
4
|
+
|
|
5
|
+
export const MAX_DEPTH = 3;
|
|
6
|
+
export const MAX_NODES = 500;
|
|
7
|
+
export const MAX_EDGES = 1000;
|
|
8
|
+
|
|
9
|
+
export interface EdgeRecord extends GraphEdge {
|
|
10
|
+
id: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Fetch active edges touching any of the given nodes, in the given direction. */
|
|
14
|
+
export type EdgeFetcher = (nodeIds: string[], direction: "inbound" | "outbound") => Promise<EdgeRecord[]>;
|
|
15
|
+
|
|
16
|
+
export interface ImpactHit {
|
|
17
|
+
nodeId: string;
|
|
18
|
+
depth: number;
|
|
19
|
+
/** Edges from the start node to this one, in order. */
|
|
20
|
+
path: EdgeRecord[];
|
|
21
|
+
/** True when every edge on the path is DISCOVERED or DOCUMENTED. */
|
|
22
|
+
confirmed: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ImpactResult {
|
|
26
|
+
start: string;
|
|
27
|
+
hits: ImpactHit[];
|
|
28
|
+
edges: EdgeRecord[];
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Most relations read "source depends on target" (a page links to a URL, a form submits to a
|
|
34
|
+
* CRM). PART_OF reads the other way: the workflow depends on its parts. RELATED_TO has no
|
|
35
|
+
* direction. So "inbound" (what depends on this) walks edges backwards, except PART_OF.
|
|
36
|
+
*/
|
|
37
|
+
function follows(relation: EdgeRecord["relation"], dir: "inbound" | "outbound", direction: "inbound" | "outbound"): boolean {
|
|
38
|
+
if (relation === "RELATED_TO") return true;
|
|
39
|
+
const dependencyDir = relation === "PART_OF" ? (dir === "inbound" ? "outbound" : "inbound") : dir;
|
|
40
|
+
return dependencyDir === direction;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Bounded breadth-first search. "inbound" finds what depends on the start node (what could
|
|
45
|
+
* break if it changes); "outbound" finds what it depends on. Each node is
|
|
46
|
+
* reached once, by its shortest path, so cycles end the walk. Reachability is potential
|
|
47
|
+
* impact, never proof of it.
|
|
48
|
+
*/
|
|
49
|
+
export async function impact(
|
|
50
|
+
start: string,
|
|
51
|
+
direction: Direction,
|
|
52
|
+
depth: number,
|
|
53
|
+
fetchEdges: EdgeFetcher,
|
|
54
|
+
): Promise<ImpactResult> {
|
|
55
|
+
const maxDepth = Math.max(1, Math.min(MAX_DEPTH, Math.floor(depth)));
|
|
56
|
+
const reached = new Map<string, ImpactHit>([[start, { nodeId: start, depth: 0, path: [], confirmed: true }]]);
|
|
57
|
+
const edges = new Map<string, EdgeRecord>();
|
|
58
|
+
let frontier = [start];
|
|
59
|
+
let truncated = false;
|
|
60
|
+
|
|
61
|
+
for (let level = 1; level <= maxDepth && frontier.length > 0 && !truncated; level++) {
|
|
62
|
+
const next: string[] = [];
|
|
63
|
+
// An entry and its URL are one page: PUBLISHES_AS is walked both ways and costs no
|
|
64
|
+
// depth, otherwise "inbound from an entry" would spend a hop before reaching linkers.
|
|
65
|
+
let batch = frontier;
|
|
66
|
+
while (batch.length > 0 && !truncated) {
|
|
67
|
+
const samePage: string[] = [];
|
|
68
|
+
for (const dir of ["outbound", "inbound"] as const) {
|
|
69
|
+
for (const edge of await fetchEdges(batch, dir)) {
|
|
70
|
+
const free = edge.relation === "PUBLISHES_AS";
|
|
71
|
+
if (direction !== "both" && !free && !follows(edge.relation, dir, direction)) continue;
|
|
72
|
+
const from = dir === "outbound" ? edge.sourceNodeId : edge.targetNodeId;
|
|
73
|
+
const to = dir === "outbound" ? edge.targetNodeId : edge.sourceNodeId;
|
|
74
|
+
const parent = reached.get(from);
|
|
75
|
+
if (!parent) continue;
|
|
76
|
+
if (edges.size >= MAX_EDGES) {
|
|
77
|
+
truncated = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
edges.set(edge.id, edge);
|
|
81
|
+
if (reached.has(to)) continue;
|
|
82
|
+
if (reached.size >= MAX_NODES) {
|
|
83
|
+
truncated = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
reached.set(to, {
|
|
87
|
+
nodeId: to,
|
|
88
|
+
depth: free ? parent.depth : level,
|
|
89
|
+
path: [...parent.path, edge],
|
|
90
|
+
confirmed: parent.confirmed && edge.provenance !== "INFERRED",
|
|
91
|
+
});
|
|
92
|
+
(free ? samePage : next).push(to);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
batch = samePage;
|
|
96
|
+
}
|
|
97
|
+
frontier = next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
reached.delete(start);
|
|
101
|
+
return { start, hits: [...reached.values()], edges: [...edges.values()], truncated };
|
|
102
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Link discovery over stored content. Parses only; never fetches anything (SPEC D3).
|
|
2
|
+
|
|
3
|
+
export interface FoundLink {
|
|
4
|
+
href: string;
|
|
5
|
+
/** Top-level field the link lives in; part of the edge identity. */
|
|
6
|
+
field: string;
|
|
7
|
+
/** Full JSON path, kept as evidence. */
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_DEPTH = 32;
|
|
12
|
+
const UNSAFE_SCHEME = /^\s*(javascript|data|vbscript|file|mailto|tel):/i;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find links in an entry's data: Portable Text link marks (`{_type: "link", href}`)
|
|
16
|
+
* anywhere in the tree, plus the values of the given `url`-type fields.
|
|
17
|
+
* Plain strings that merely look like URLs are ignored on purpose.
|
|
18
|
+
*/
|
|
19
|
+
export function extractLinks(
|
|
20
|
+
data: Record<string, unknown>,
|
|
21
|
+
urlFields: readonly string[] = [],
|
|
22
|
+
): FoundLink[] {
|
|
23
|
+
const found: FoundLink[] = [];
|
|
24
|
+
|
|
25
|
+
for (const field of urlFields) {
|
|
26
|
+
const value = data[field];
|
|
27
|
+
if (typeof value === "string" && value.trim()) found.push({ href: value.trim(), field, path: field });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const walk = (value: unknown, field: string, path: string, depth: number): void => {
|
|
31
|
+
if (depth > MAX_DEPTH || value === null || typeof value !== "object") return;
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
value.forEach((item, i) => walk(item, field, `${path}[${i}]`, depth + 1));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const obj = value as Record<string, unknown>;
|
|
37
|
+
if (obj._type === "link" && typeof obj.href === "string" && obj.href.trim()) {
|
|
38
|
+
found.push({ href: obj.href.trim(), field, path });
|
|
39
|
+
}
|
|
40
|
+
for (const [key, child] of Object.entries(obj)) walk(child, field, `${path}.${key}`, depth + 1);
|
|
41
|
+
};
|
|
42
|
+
for (const [field, value] of Object.entries(data)) walk(value, field, field, 0);
|
|
43
|
+
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Turn an href into the site-relative path that identifies a page, or null when the
|
|
49
|
+
* link is external, unsafe or not a page link. Fragments and query strings are dropped
|
|
50
|
+
* (`/pricing?plan=pro` is the pricing page; the raw href stays in the edge evidence) and
|
|
51
|
+
* trailing slashes are ignored so "/a/" and "/a" are the same page.
|
|
52
|
+
*/
|
|
53
|
+
export function internalPath(href: string, sourcePath: string, siteUrl: string): string | null {
|
|
54
|
+
if (UNSAFE_SCHEME.test(href) || href.trim().startsWith("#")) return null;
|
|
55
|
+
// Relative links resolve against the source page; a placeholder origin stands in when
|
|
56
|
+
// the site URL isn't configured, and then only relative links can be internal.
|
|
57
|
+
const origin = siteUrl ? new URL(siteUrl).origin : "http://sitegraph.invalid";
|
|
58
|
+
let url: URL;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(href, new URL(sourcePath || "/", origin));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
65
|
+
if (url.origin !== origin) return null;
|
|
66
|
+
let path = url.pathname.replace(/\/{2,}/g, "/");
|
|
67
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The public path of an entry from its collection's URL pattern, mirroring EmDash's
|
|
73
|
+
* `interpolateUrlPattern` for `{slug}` and `{id}`. Returns null for patterns using
|
|
74
|
+
* tokens we don't resolve (dates, locales), so we never invent a wrong URL.
|
|
75
|
+
*/
|
|
76
|
+
export function entryPath(
|
|
77
|
+
pattern: string | null,
|
|
78
|
+
collection: string,
|
|
79
|
+
slug: string,
|
|
80
|
+
id: string,
|
|
81
|
+
): string | null {
|
|
82
|
+
const base = pattern ?? `/${encodeURIComponent(collection)}/{slug}`;
|
|
83
|
+
let path = base.replaceAll("{slug}", encodeURIComponent(slug)).replaceAll("{id}", encodeURIComponent(id));
|
|
84
|
+
if (path.includes("{")) return null;
|
|
85
|
+
path = path.replace(/\/{2,}/g, "/");
|
|
86
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
87
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A matcher for paths an entry of this collection could live at, or null when the pattern
|
|
93
|
+
* uses tokens we don't resolve. Used to tell a broken link (looks like an entry, none is
|
|
94
|
+
* there) from a link to some other page of the site (home, listings, feeds).
|
|
95
|
+
*/
|
|
96
|
+
export function entryPathMatcher(pattern: string | null, collection: string): RegExp | null {
|
|
97
|
+
let base = pattern ?? `/${encodeURIComponent(collection)}/{slug}`;
|
|
98
|
+
if (/\{(?!slug\}|id\})[^}]*\}/.test(base)) return null;
|
|
99
|
+
base = base.replace(/\/{2,}/g, "/");
|
|
100
|
+
if (!base.startsWith("/")) base = `/${base}`;
|
|
101
|
+
if (base.length > 1 && base.endsWith("/")) base = base.slice(0, -1);
|
|
102
|
+
const source = base
|
|
103
|
+
.split(/(\{slug\}|\{id\})/)
|
|
104
|
+
.map((part) => (part === "{slug}" || part === "{id}" ? "[^/]+" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
|
105
|
+
.join("");
|
|
106
|
+
return new RegExp(`^${source}$`);
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { PluginDescriptor, ResolvedPlugin } from "emdash";
|
|
2
|
+
import { definePlugin } from "emdash";
|
|
3
|
+
|
|
4
|
+
import { routes } from "./routes.js";
|
|
5
|
+
import { refreshFromHook, retireFromHook } from "./scan.js";
|
|
6
|
+
import { STORAGE } from "./store.js";
|
|
7
|
+
|
|
8
|
+
const ID = "sitegraph";
|
|
9
|
+
const VERSION = "0.1.0";
|
|
10
|
+
const PACKAGE = "emdash-plugin-sitegraph";
|
|
11
|
+
const ADMIN_ENTRY = `${PACKAGE}/admin`;
|
|
12
|
+
|
|
13
|
+
const PAGES = [{ path: "/", label: "SiteGraph", icon: "graph" }];
|
|
14
|
+
const WIDGETS = [{ id: "overview", title: "SiteGraph", size: "half" as const }];
|
|
15
|
+
|
|
16
|
+
/** Build-time descriptor: register with `emdash({ plugins: [siteGraph()] })`. */
|
|
17
|
+
export function siteGraph(): PluginDescriptor {
|
|
18
|
+
return {
|
|
19
|
+
id: ID,
|
|
20
|
+
version: VERSION,
|
|
21
|
+
format: "native",
|
|
22
|
+
entrypoint: PACKAGE,
|
|
23
|
+
adminEntry: ADMIN_ENTRY,
|
|
24
|
+
adminPages: PAGES,
|
|
25
|
+
adminWidgets: WIDGETS,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const entryId = (content: Record<string, unknown>): string | null =>
|
|
30
|
+
typeof content.id === "string" ? content.id : null;
|
|
31
|
+
|
|
32
|
+
/** Runtime entry. EmDash imports this by name. */
|
|
33
|
+
export function createPlugin(): ResolvedPlugin {
|
|
34
|
+
return definePlugin({
|
|
35
|
+
id: ID,
|
|
36
|
+
version: VERSION,
|
|
37
|
+
// Read-only by design (SPEC D3): no content writes, no network.
|
|
38
|
+
capabilities: ["content:read", "schema:read"],
|
|
39
|
+
storage: STORAGE,
|
|
40
|
+
admin: { entry: ADMIN_ENTRY, pages: PAGES, widgets: WIDGETS },
|
|
41
|
+
// Hooks refresh only the one entry that changed (SPEC D8). They run after the
|
|
42
|
+
// response, so a failure is logged and the next full scan repairs it.
|
|
43
|
+
hooks: {
|
|
44
|
+
"content:afterSave": async (event, ctx) => {
|
|
45
|
+
const id = entryId(event.content);
|
|
46
|
+
if (id) await refreshFromHook(ctx, event.collection, id);
|
|
47
|
+
},
|
|
48
|
+
"content:afterPublish": async (event, ctx) => {
|
|
49
|
+
const id = entryId(event.content);
|
|
50
|
+
if (id) await refreshFromHook(ctx, event.collection, id);
|
|
51
|
+
},
|
|
52
|
+
"content:afterRestore": async (event, ctx) => {
|
|
53
|
+
const id = entryId(event.content);
|
|
54
|
+
if (id) await refreshFromHook(ctx, event.collection, id);
|
|
55
|
+
},
|
|
56
|
+
"content:afterUnpublish": async (event, ctx) => {
|
|
57
|
+
const id = entryId(event.content);
|
|
58
|
+
if (id) await retireFromHook(ctx, event.collection, id);
|
|
59
|
+
},
|
|
60
|
+
"content:afterDelete": async (event, ctx) => {
|
|
61
|
+
await retireFromHook(ctx, event.collection, event.id);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
routes,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default siteGraph;
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
import type { PluginContext, RouteContext } from "emdash";
|
|
3
|
+
import { PluginRouteError } from "emdash";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CRITICALITIES,
|
|
7
|
+
DOCUMENTED_NODE_TYPES,
|
|
8
|
+
DOCUMENTED_RELATION_TYPES,
|
|
9
|
+
edgeId,
|
|
10
|
+
type GraphEdge,
|
|
11
|
+
type GraphNode,
|
|
12
|
+
NODE_TYPES,
|
|
13
|
+
SCHEMA_VERSION,
|
|
14
|
+
} from "./domain/graph.js";
|
|
15
|
+
import { impact, MAX_DEPTH, MAX_EDGES } from "./domain/impact.js";
|
|
16
|
+
import { getScanStatus, scanStep, startScan } from "./scan.js";
|
|
17
|
+
import { activeEdges, edgesOf, nodesOf, queryAll } from "./store.js";
|
|
18
|
+
|
|
19
|
+
// Reads are for Editors and up (the graph can reveal unpublished structure);
|
|
20
|
+
// anything that changes the graph or runs a scan is admin-only.
|
|
21
|
+
const READ = "plugins:read" as const;
|
|
22
|
+
const MANAGE = "plugins:manage" as const;
|
|
23
|
+
|
|
24
|
+
const id = z.string().min(1).max(600);
|
|
25
|
+
const text = (max: number) => z.string().trim().max(max);
|
|
26
|
+
const safeUrl = z
|
|
27
|
+
.string()
|
|
28
|
+
.trim()
|
|
29
|
+
.max(2000)
|
|
30
|
+
.refine((v) => v === "" || /^https?:\/\//i.test(v), "Only http(s) links are allowed");
|
|
31
|
+
|
|
32
|
+
const annotation = {
|
|
33
|
+
description: text(2000).optional(),
|
|
34
|
+
criticality: z.enum(CRITICALITIES).nullable().optional(),
|
|
35
|
+
notes: text(5000).optional(),
|
|
36
|
+
docUrl: safeUrl.optional(),
|
|
37
|
+
lastVerifiedAt: z.iso.datetime().nullable().optional(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const nodeSaveInput = z.object({
|
|
41
|
+
id: id.optional(),
|
|
42
|
+
type: z.enum(DOCUMENTED_NODE_TYPES).optional(),
|
|
43
|
+
label: text(200).min(1).optional(),
|
|
44
|
+
...annotation,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const NEIGHBOUR_CAP = 200;
|
|
48
|
+
|
|
49
|
+
type Ctx<T = unknown> = RouteContext<T>;
|
|
50
|
+
|
|
51
|
+
async function nodesById(ctx: PluginContext, ids: string[]): Promise<Array<GraphNode & { id: string }>> {
|
|
52
|
+
const found = await nodesOf(ctx).getMany([...new Set(ids)]);
|
|
53
|
+
return [...found].map(([nodeId, data]) => ({ id: nodeId, ...data }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function requireNode(ctx: PluginContext, nodeId: string) {
|
|
57
|
+
const node = await nodesOf(ctx).get(nodeId);
|
|
58
|
+
if (!node || !node.active) throw PluginRouteError.notFound("That node doesn't exist (or a scan retired it).");
|
|
59
|
+
return node;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Copy only the annotation fields that were sent; empty strings and null clear a field. */
|
|
63
|
+
type AnnotationInput = z.infer<z.ZodObject<typeof annotation>>;
|
|
64
|
+
|
|
65
|
+
function applyAnnotation<T extends object>(target: T, input: AnnotationInput): T {
|
|
66
|
+
const out = { ...target } as Record<string, unknown>;
|
|
67
|
+
for (const key of Object.keys(annotation)) {
|
|
68
|
+
const value = input[key as keyof typeof annotation];
|
|
69
|
+
if (value === undefined) continue;
|
|
70
|
+
if (value === "" || value === null) delete out[key];
|
|
71
|
+
else out[key] = value;
|
|
72
|
+
}
|
|
73
|
+
return out as T;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const routes = {
|
|
77
|
+
health: {
|
|
78
|
+
permission: READ,
|
|
79
|
+
handler: async (ctx: Ctx) => ({ plugin: ctx.plugin.id, version: ctx.plugin.version, siteUrl: ctx.site.url || null }),
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
overview: {
|
|
83
|
+
permission: READ,
|
|
84
|
+
handler: async (ctx: Ctx) => {
|
|
85
|
+
const nodes = nodesOf(ctx);
|
|
86
|
+
const counts = Object.fromEntries(
|
|
87
|
+
await Promise.all(NODE_TYPES.map(async (type) => [type, await nodes.count({ type, active: true })] as const)),
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
siteUrl: ctx.site.url || null,
|
|
91
|
+
counts,
|
|
92
|
+
edges: await edgesOf(ctx).count({ active: true }),
|
|
93
|
+
brokenLinks: await nodes.count({ type: "URL", active: true, resolved: false }),
|
|
94
|
+
scan: await getScanStatus(ctx),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
"scan/start": {
|
|
100
|
+
permission: MANAGE,
|
|
101
|
+
handler: async (ctx: Ctx) => ({ state: await startScan(ctx) }),
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
"scan/step": {
|
|
105
|
+
permission: MANAGE,
|
|
106
|
+
input: z.object({ scanId: z.string().min(1).max(100) }),
|
|
107
|
+
handler: async (ctx: Ctx<{ scanId: string }>) => scanStep(ctx, ctx.input.scanId),
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
"nodes/search": {
|
|
111
|
+
permission: READ,
|
|
112
|
+
input: z.object({
|
|
113
|
+
q: text(200).default(""),
|
|
114
|
+
type: z.enum(NODE_TYPES).optional(),
|
|
115
|
+
broken: z.boolean().optional(),
|
|
116
|
+
cursor: z.string().optional(),
|
|
117
|
+
}),
|
|
118
|
+
handler: async (ctx: Ctx<{ q: string; type?: GraphNode["type"]; broken?: boolean; cursor?: string }>) => {
|
|
119
|
+
const { q, type, broken, cursor } = ctx.input;
|
|
120
|
+
// ponytail: prefix match on the lowercased label (storage has no substring index);
|
|
121
|
+
// a real search index is the upgrade if people search mid-title.
|
|
122
|
+
const where: Record<string, string | boolean | { startsWith: string }> = { active: true };
|
|
123
|
+
if (q) where.search = { startsWith: q.toLowerCase() };
|
|
124
|
+
if (type) where.type = type;
|
|
125
|
+
if (broken) Object.assign(where, { type: "URL", resolved: false });
|
|
126
|
+
const page = await nodesOf(ctx).query({ where, limit: 50, cursor });
|
|
127
|
+
return { items: page.items.map((row) => ({ id: row.id, ...row.data })), cursor: page.hasMore ? page.cursor : null };
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
"graph/neighborhood": {
|
|
132
|
+
permission: READ,
|
|
133
|
+
input: z.object({ nodeId: id }),
|
|
134
|
+
handler: async (ctx: Ctx<{ nodeId: string }>) => {
|
|
135
|
+
const center = await requireNode(ctx, ctx.input.nodeId);
|
|
136
|
+
const cap = NEIGHBOUR_CAP + 1;
|
|
137
|
+
const [outbound, inbound] = await Promise.all([
|
|
138
|
+
activeEdges(ctx, [ctx.input.nodeId], "outbound", cap),
|
|
139
|
+
activeEdges(ctx, [ctx.input.nodeId], "inbound", cap),
|
|
140
|
+
]);
|
|
141
|
+
// An entry and its URL are one page: also show what links to the entry's URL.
|
|
142
|
+
const ownUrls = outbound.filter((e) => e.relation === "PUBLISHES_AS").map((e) => e.targetNodeId);
|
|
143
|
+
const linkers = ownUrls.length ? await activeEdges(ctx, ownUrls, "inbound", cap) : [];
|
|
144
|
+
const all = [...new Map([...outbound, ...inbound, ...linkers].map((e) => [e.id, e])).values()];
|
|
145
|
+
const edges = all.slice(0, NEIGHBOUR_CAP);
|
|
146
|
+
const nodes = await nodesById(ctx, edges.flatMap((e) => [e.sourceNodeId, e.targetNodeId]));
|
|
147
|
+
return {
|
|
148
|
+
center: { id: ctx.input.nodeId, ...center },
|
|
149
|
+
nodes,
|
|
150
|
+
edges,
|
|
151
|
+
truncated: all.length > edges.length,
|
|
152
|
+
total: all.length,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
"graph/impact": {
|
|
158
|
+
permission: READ,
|
|
159
|
+
input: z.object({
|
|
160
|
+
nodeId: id,
|
|
161
|
+
direction: z.enum(["inbound", "outbound", "both"]).default("inbound"),
|
|
162
|
+
depth: z.number().int().min(1).max(MAX_DEPTH).default(2),
|
|
163
|
+
}),
|
|
164
|
+
handler: async (ctx: Ctx<{ nodeId: string; direction: "inbound" | "outbound" | "both"; depth: number }>) => {
|
|
165
|
+
await requireNode(ctx, ctx.input.nodeId);
|
|
166
|
+
const result = await impact(ctx.input.nodeId, ctx.input.direction, ctx.input.depth, (ids, dir) =>
|
|
167
|
+
activeEdges(ctx, ids, dir, MAX_EDGES),
|
|
168
|
+
);
|
|
169
|
+
const nodes = await nodesById(ctx, [result.start, ...result.hits.map((h) => h.nodeId)]);
|
|
170
|
+
return { ...result, nodes };
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
"nodes/save": {
|
|
175
|
+
permission: MANAGE,
|
|
176
|
+
input: nodeSaveInput,
|
|
177
|
+
handler: async (ctx: Ctx<z.infer<typeof nodeSaveInput>>) => {
|
|
178
|
+
const nodes = nodesOf(ctx);
|
|
179
|
+
const now = new Date().toISOString();
|
|
180
|
+
const { id: nodeId, type, label } = ctx.input;
|
|
181
|
+
|
|
182
|
+
if (nodeId) {
|
|
183
|
+
const existing = await requireNode(ctx, nodeId);
|
|
184
|
+
// Discovered nodes take annotations only; their type and label come from the site.
|
|
185
|
+
const renamed = existing.provenance === "DOCUMENTED" && label ? { label, search: label.toLowerCase() } : {};
|
|
186
|
+
const updated = applyAnnotation({ ...existing, ...renamed }, ctx.input);
|
|
187
|
+
await nodes.put(nodeId, updated);
|
|
188
|
+
return { id: nodeId, ...updated };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!type || !label) throw PluginRouteError.badRequest("A new node needs a type and a label.");
|
|
192
|
+
const created = applyAnnotation<GraphNode>(
|
|
193
|
+
{
|
|
194
|
+
type,
|
|
195
|
+
label,
|
|
196
|
+
search: label.toLowerCase(),
|
|
197
|
+
ref: label,
|
|
198
|
+
provenance: "DOCUMENTED",
|
|
199
|
+
active: true,
|
|
200
|
+
firstSeenAt: now,
|
|
201
|
+
lastSeenAt: now,
|
|
202
|
+
schemaVersion: SCHEMA_VERSION,
|
|
203
|
+
},
|
|
204
|
+
ctx.input,
|
|
205
|
+
);
|
|
206
|
+
const newId = `doc:${crypto.randomUUID()}`;
|
|
207
|
+
await nodes.put(newId, created);
|
|
208
|
+
return { id: newId, ...created };
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
"nodes/delete": {
|
|
213
|
+
permission: MANAGE,
|
|
214
|
+
input: z.object({ id }),
|
|
215
|
+
handler: async (ctx: Ctx<{ id: string }>) => {
|
|
216
|
+
const node = await requireNode(ctx, ctx.input.id);
|
|
217
|
+
if (node.provenance !== "DOCUMENTED") {
|
|
218
|
+
throw PluginRouteError.badRequest("Discovered nodes come from your content. Change the content instead.");
|
|
219
|
+
}
|
|
220
|
+
const touching = [
|
|
221
|
+
...(await queryAll(edgesOf(ctx), { sourceNodeId: ctx.input.id })),
|
|
222
|
+
...(await queryAll(edgesOf(ctx), { targetNodeId: ctx.input.id })),
|
|
223
|
+
];
|
|
224
|
+
await edgesOf(ctx).deleteMany(touching.map((e) => e.id));
|
|
225
|
+
await nodesOf(ctx).delete(ctx.input.id);
|
|
226
|
+
return { deleted: ctx.input.id, edgesDeleted: touching.length };
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
"edges/save": {
|
|
231
|
+
permission: MANAGE,
|
|
232
|
+
input: z.object({
|
|
233
|
+
sourceNodeId: id,
|
|
234
|
+
targetNodeId: id,
|
|
235
|
+
relation: z.enum(DOCUMENTED_RELATION_TYPES),
|
|
236
|
+
label: text(200).optional(),
|
|
237
|
+
}),
|
|
238
|
+
handler: async (
|
|
239
|
+
ctx: Ctx<{ sourceNodeId: string; targetNodeId: string; relation: GraphEdge["relation"]; label?: string }>,
|
|
240
|
+
) => {
|
|
241
|
+
const { sourceNodeId, targetNodeId, relation, label } = ctx.input;
|
|
242
|
+
if (sourceNodeId === targetNodeId) throw PluginRouteError.badRequest("A node can't depend on itself.");
|
|
243
|
+
if (relation === "RELATED_TO" && !label) {
|
|
244
|
+
throw PluginRouteError.badRequest("Say how they're related: RELATED_TO needs a label.");
|
|
245
|
+
}
|
|
246
|
+
await requireNode(ctx, sourceNodeId);
|
|
247
|
+
await requireNode(ctx, targetNodeId);
|
|
248
|
+
const now = new Date().toISOString();
|
|
249
|
+
const edgeKey = edgeId(sourceNodeId, relation, targetNodeId, "doc");
|
|
250
|
+
const existing = await edgesOf(ctx).get(edgeKey);
|
|
251
|
+
const edge: GraphEdge = {
|
|
252
|
+
sourceNodeId,
|
|
253
|
+
targetNodeId,
|
|
254
|
+
relation,
|
|
255
|
+
...(label ? { label } : {}),
|
|
256
|
+
provenance: "DOCUMENTED",
|
|
257
|
+
evidence: { observedAt: now, ...(ctx.user?.id ? { sourceId: `user:${ctx.user.id}` } : {}) },
|
|
258
|
+
active: true,
|
|
259
|
+
firstSeenAt: existing?.firstSeenAt ?? now,
|
|
260
|
+
lastSeenAt: now,
|
|
261
|
+
schemaVersion: SCHEMA_VERSION,
|
|
262
|
+
};
|
|
263
|
+
await edgesOf(ctx).put(edgeKey, edge);
|
|
264
|
+
return { id: edgeKey, ...edge };
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
"edges/delete": {
|
|
269
|
+
permission: MANAGE,
|
|
270
|
+
input: z.object({ id }),
|
|
271
|
+
handler: async (ctx: Ctx<{ id: string }>) => {
|
|
272
|
+
const edge = await edgesOf(ctx).get(ctx.input.id);
|
|
273
|
+
if (!edge) throw PluginRouteError.notFound("That relationship doesn't exist.");
|
|
274
|
+
if (edge.provenance !== "DOCUMENTED") {
|
|
275
|
+
throw PluginRouteError.badRequest("Discovered links come from your content. Edit the content instead.");
|
|
276
|
+
}
|
|
277
|
+
await edgesOf(ctx).delete(ctx.input.id);
|
|
278
|
+
return { deleted: ctx.input.id };
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
export: {
|
|
283
|
+
permission: READ,
|
|
284
|
+
handler: async (ctx: Ctx) => ({
|
|
285
|
+
format: "sitegraph",
|
|
286
|
+
schemaVersion: SCHEMA_VERSION,
|
|
287
|
+
plugin: { id: ctx.plugin.id, version: ctx.plugin.version },
|
|
288
|
+
site: ctx.site.url || null,
|
|
289
|
+
exportedAt: new Date().toISOString(),
|
|
290
|
+
nodes: (await queryAll(nodesOf(ctx), { active: true })).map((r) => ({ id: r.id, ...r.data })),
|
|
291
|
+
edges: (await queryAll(edgesOf(ctx), { active: true })).map((r) => ({ id: r.id, ...r.data })),
|
|
292
|
+
}),
|
|
293
|
+
},
|
|
294
|
+
};
|