@amulet-laboratories/astrolabe 0.4.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/CHANGELOG.md +145 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/bin/astrolabe.mjs +136 -0
- package/package.json +78 -0
- package/src/adapters/nuxt/index.mjs +541 -0
- package/src/adapters/nuxt/project.mjs +174 -0
- package/src/adapters/registry.mjs +23 -0
- package/src/adapters/shared.mjs +191 -0
- package/src/adapters/vue/index.mjs +211 -0
- package/src/adapters/vue/project.mjs +116 -0
- package/src/core/client/app.js +662 -0
- package/src/core/client/overview.js +255 -0
- package/src/core/client/styles.css +669 -0
- package/src/core/diff.mjs +0 -0
- package/src/core/findings.mjs +147 -0
- package/src/core/graph.mjs +148 -0
- package/src/core/layout.mjs +141 -0
- package/src/core/render.mjs +92 -0
- package/src/core/schema.mjs +67 -0
- package/src/core/sfc.mjs +134 -0
- package/src/core/svg.mjs +102 -0
- package/src/core/syntax.mjs +59 -0
- package/src/core/views.mjs +304 -0
- package/src/index.d.ts +155 -0
- package/src/index.mjs +29 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings are things worth telling someone about a repo that the diagram
|
|
3
|
+
* alone won't make them notice: dead code, endpoints with no handler, render
|
|
4
|
+
* cycles. Pure function over the serialized graph so the CLI, --json output and
|
|
5
|
+
* the HTML report all say exactly the same thing.
|
|
6
|
+
*
|
|
7
|
+
* The bar for a finding is that it is *actionable* and *true*. Anything the
|
|
8
|
+
* analyzer can't be sure about belongs in the notes, not here — a confidently
|
|
9
|
+
* wrong finding costs more trust than a missing one earns.
|
|
10
|
+
*/
|
|
11
|
+
export function computeFindings(nodes, edges) {
|
|
12
|
+
const incoming = new Map()
|
|
13
|
+
const outgoing = new Map()
|
|
14
|
+
for (const e of edges) {
|
|
15
|
+
if (!incoming.has(e.to)) incoming.set(e.to, [])
|
|
16
|
+
incoming.get(e.to).push(e)
|
|
17
|
+
if (!outgoing.has(e.from)) outgoing.set(e.from, [])
|
|
18
|
+
outgoing.get(e.from).push(e)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isLocal = (n) => n.meta.origin !== 'library' && !n.meta.pkg
|
|
22
|
+
const unreferenced = (n) => !incoming.get(n.id)?.length
|
|
23
|
+
const found = []
|
|
24
|
+
const add = (id, severity, nodeList, title, detail) => {
|
|
25
|
+
if (nodeList.length)
|
|
26
|
+
found.push({
|
|
27
|
+
id,
|
|
28
|
+
severity,
|
|
29
|
+
title: title(nodeList.length),
|
|
30
|
+
detail,
|
|
31
|
+
nodes: nodeList.map((n) => n.id),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
add(
|
|
36
|
+
'orphan-components',
|
|
37
|
+
'warn',
|
|
38
|
+
nodes.filter((n) => n.type === 'component' && isLocal(n) && unreferenced(n)),
|
|
39
|
+
(n) => `${n} component${n === 1 ? '' : 's'} nothing renders`,
|
|
40
|
+
'No page, layout, component or markdown file references these. Likely dead code.',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
add(
|
|
44
|
+
'unused-composables',
|
|
45
|
+
'warn',
|
|
46
|
+
nodes.filter((n) => n.type === 'composable' && isLocal(n) && unreferenced(n)),
|
|
47
|
+
(n) => `${n} composable${n === 1 ? '' : 's'} nothing calls`,
|
|
48
|
+
'Defined under composables/ but never called. The file may still be imported for its types, so check before deleting it.',
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
add(
|
|
52
|
+
'missing-handlers',
|
|
53
|
+
'warn',
|
|
54
|
+
nodes.filter((n) => n.type === 'server' && n.meta.inferred),
|
|
55
|
+
(n) => `${n} endpoint${n === 1 ? '' : 's'} with no handler file`,
|
|
56
|
+
'Something fetches these paths but no matching file exists under server/. Either the path is wrong, or the route is served from elsewhere.',
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
add(
|
|
60
|
+
'dead-server-routes',
|
|
61
|
+
'info',
|
|
62
|
+
nodes.filter((n) => n.type === 'server' && !n.meta.inferred && unreferenced(n)),
|
|
63
|
+
(n) => `${n} server route${n === 1 ? '' : 's'} with no fetch call`,
|
|
64
|
+
'No $fetch or useFetch in this app targets these. Normal for webhooks, cron targets, public APIs, and redirects reached by an href — this is not a dead-code claim.',
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
add(
|
|
68
|
+
'empty-collections',
|
|
69
|
+
'warn',
|
|
70
|
+
nodes.filter((n) => n.type === 'collection' && n.file && !n.meta.files && !n.meta.remote),
|
|
71
|
+
(n) => `${n} content collection${n === 1 ? '' : 's'} with no files`,
|
|
72
|
+
'Declared in content.config.ts but its source glob matched nothing.',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
add(
|
|
76
|
+
'static-routes',
|
|
77
|
+
'info',
|
|
78
|
+
nodes.filter(
|
|
79
|
+
(n) => n.type === 'route' && !(outgoing.get(n.id) ?? []).some((e) => DATA_KINDS.has(e.kind)),
|
|
80
|
+
),
|
|
81
|
+
(n) => `${n} route${n === 1 ? '' : 's'} read no data`,
|
|
82
|
+
'These pages query no collection, call no endpoint and read no config — fully static.',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
for (const cycle of renderCycles(nodes, edges)) {
|
|
86
|
+
found.push({
|
|
87
|
+
id: `render-cycle:${cycle.join('>')}`,
|
|
88
|
+
severity: 'warn',
|
|
89
|
+
title: 'Render cycle',
|
|
90
|
+
detail: cycle.map((id) => id.split(':').pop()).join(' → '),
|
|
91
|
+
nodes: cycle,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const order = { warn: 0, info: 1 }
|
|
96
|
+
return found.sort(
|
|
97
|
+
(a, b) => order[a.severity] - order[b.severity] || b.nodes.length - a.nodes.length,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const DATA_KINDS = new Set(['queries', 'fetches', 'reads'])
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Cycles in the render graph, via depth-first search for a back edge to a node
|
|
105
|
+
* still on the current path. A component that transitively renders itself is
|
|
106
|
+
* either intentional recursion or an infinite loop; either way it's worth
|
|
107
|
+
* knowing about.
|
|
108
|
+
*/
|
|
109
|
+
function renderCycles(nodes, edges) {
|
|
110
|
+
const out = new Map()
|
|
111
|
+
for (const e of edges) {
|
|
112
|
+
if (e.kind !== 'renders') continue
|
|
113
|
+
if (!out.has(e.from)) out.set(e.from, [])
|
|
114
|
+
out.get(e.from).push(e.to)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const WHITE = 0
|
|
118
|
+
const GREY = 1
|
|
119
|
+
const BLACK = 2
|
|
120
|
+
const colour = new Map(nodes.map((n) => [n.id, WHITE]))
|
|
121
|
+
const path = []
|
|
122
|
+
const seen = new Set()
|
|
123
|
+
const cycles = []
|
|
124
|
+
|
|
125
|
+
const visit = (id) => {
|
|
126
|
+
colour.set(id, GREY)
|
|
127
|
+
path.push(id)
|
|
128
|
+
for (const next of out.get(id) ?? []) {
|
|
129
|
+
if (colour.get(next) === GREY) {
|
|
130
|
+
const cycle = path.slice(path.indexOf(next))
|
|
131
|
+
// Same cycle found from different entry points is one cycle.
|
|
132
|
+
const key = [...cycle].sort().join('|')
|
|
133
|
+
if (!seen.has(key)) {
|
|
134
|
+
seen.add(key)
|
|
135
|
+
cycles.push(cycle)
|
|
136
|
+
}
|
|
137
|
+
} else if (colour.get(next) === WHITE) {
|
|
138
|
+
visit(next)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
path.pop()
|
|
142
|
+
colour.set(id, BLACK)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const n of nodes) if (colour.get(n.id) === WHITE) visit(n.id)
|
|
146
|
+
return cycles
|
|
147
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { computeFindings } from './findings.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The graph builder every adapter writes into, plus the finalize pipeline that
|
|
5
|
+
* turns a freshly-scanned graph into the serialized report shape. Nothing here
|
|
6
|
+
* knows about any framework: an adapter produces framework-specific nodes and
|
|
7
|
+
* edges in the canonical vocabulary (see schema.mjs), and this file does the
|
|
8
|
+
* universal post-processing — prune, group, findings, stats — that every
|
|
9
|
+
* adapter's output goes through identically.
|
|
10
|
+
*/
|
|
11
|
+
export class Graph {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.nodes = new Map()
|
|
14
|
+
this.edges = new Map()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
node(id, data) {
|
|
18
|
+
if (!this.nodes.has(id)) this.nodes.set(id, { id, ...data, meta: data.meta ?? {} })
|
|
19
|
+
return this.nodes.get(id)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
has(id) {
|
|
23
|
+
return this.nodes.has(id)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
edge(from, to, kind) {
|
|
27
|
+
if (from === to) return
|
|
28
|
+
const key = `${from}|${to}|${kind}`
|
|
29
|
+
if (!this.edges.has(key)) this.edges.set(key, { from, to, kind })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
serialize() {
|
|
33
|
+
return { nodes: [...this.nodes.values()], edges: [...this.edges.values()] }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Turn an adapter's result — `{ project, graph, warnings }` — into the report
|
|
39
|
+
* shape the CLI, `--json`, and the HTML renderer all consume. The order here is
|
|
40
|
+
* load-bearing: prune before grouping so a dropped node never seeds a group,
|
|
41
|
+
* and findings/stats run on the serialized form so they see exactly what ships.
|
|
42
|
+
*/
|
|
43
|
+
export function finalize({ project, graph, warnings }) {
|
|
44
|
+
prune(graph)
|
|
45
|
+
assignGroups(graph)
|
|
46
|
+
const { nodes, edges } = graph.serialize()
|
|
47
|
+
return {
|
|
48
|
+
project: publicProject(project),
|
|
49
|
+
warnings,
|
|
50
|
+
nodes,
|
|
51
|
+
edges,
|
|
52
|
+
findings: computeFindings(nodes, edges),
|
|
53
|
+
stats: statsOf(nodes),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An adapter carries internals (absolute paths, raw config source) that have no
|
|
59
|
+
* business in a report someone may share. Only these fields ship — this is the
|
|
60
|
+
* canonical project shape every adapter is expected to fill.
|
|
61
|
+
*/
|
|
62
|
+
function publicProject(project) {
|
|
63
|
+
return {
|
|
64
|
+
name: project.name,
|
|
65
|
+
description: project.description,
|
|
66
|
+
root: project.root,
|
|
67
|
+
modules: project.modules,
|
|
68
|
+
stack: project.stack,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function statsOf(nodes) {
|
|
73
|
+
const local = nodes.filter((n) => n.meta.loc)
|
|
74
|
+
return {
|
|
75
|
+
files: local.length,
|
|
76
|
+
loc: local.reduce((sum, n) => sum + n.meta.loc, 0),
|
|
77
|
+
contentFiles: nodes
|
|
78
|
+
.filter((n) => n.type === 'collection')
|
|
79
|
+
.reduce((sum, n) => sum + (n.meta.files ?? 0), 0),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every node gets an owner: the package it came from, or the directory it
|
|
85
|
+
* lives in. Directories already encode how the app is organised
|
|
86
|
+
* (components/hub, server/api/sync), so this is grouping the author wrote
|
|
87
|
+
* rather than grouping we invented.
|
|
88
|
+
*/
|
|
89
|
+
function assignGroups(g) {
|
|
90
|
+
for (const node of g.nodes.values()) {
|
|
91
|
+
node.meta.group =
|
|
92
|
+
node.meta.pkg ?? SYNTHETIC_GROUP[node.type] ?? (node.file ? dirGroup(node.file) : node.type)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Types whose home is conceptual rather than a directory. Collections are
|
|
98
|
+
* declared in content.config.ts, so grouping them by file would file them
|
|
99
|
+
* under the repo root alongside nothing else.
|
|
100
|
+
*/
|
|
101
|
+
const SYNTHETIC_GROUP = {
|
|
102
|
+
collection: 'content',
|
|
103
|
+
config: 'config',
|
|
104
|
+
external: 'external services',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Directory of a file, minus dynamic segments, capped in depth — otherwise
|
|
109
|
+
* server/api/games/debate/[slug] becomes its own group of one and the grouping
|
|
110
|
+
* says nothing.
|
|
111
|
+
*/
|
|
112
|
+
function dirGroup(file, depth = 3) {
|
|
113
|
+
const parts = file
|
|
114
|
+
.split('/')
|
|
115
|
+
.slice(0, -1)
|
|
116
|
+
.filter((p) => !p.startsWith('['))
|
|
117
|
+
return parts.slice(0, depth).join('/') || 'root'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Drop unconnected library nodes. A dependency exports far more than any one
|
|
122
|
+
* app uses (Rig ships ~30 composables), and listing the unused ones describes
|
|
123
|
+
* the dependency rather than this app. Unconnected *local* nodes stay — a
|
|
124
|
+
* component nothing renders is a finding, not noise.
|
|
125
|
+
*/
|
|
126
|
+
function prune(g) {
|
|
127
|
+
const touched = new Set()
|
|
128
|
+
for (const e of g.edges.values()) {
|
|
129
|
+
touched.add(e.from)
|
|
130
|
+
touched.add(e.to)
|
|
131
|
+
}
|
|
132
|
+
for (const [id, node] of g.nodes) {
|
|
133
|
+
if (touched.has(id)) continue
|
|
134
|
+
const fromLibrary = node.meta.origin === 'library'
|
|
135
|
+
if (
|
|
136
|
+
node.type === 'lib-component' ||
|
|
137
|
+
node.type === 'external' ||
|
|
138
|
+
(node.type === 'composable' && fromLibrary)
|
|
139
|
+
) {
|
|
140
|
+
g.nodes.delete(id)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// An edge to a pruned or never-created node would render as a dangling line.
|
|
145
|
+
for (const [key, e] of g.edges) {
|
|
146
|
+
if (!g.nodes.has(e.from) || !g.nodes.has(e.to)) g.edges.delete(key)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One layout engine for every view. It takes a projection (nodes, edges, and a
|
|
3
|
+
* rule for which column a node belongs to) and produces coordinates.
|
|
4
|
+
*
|
|
5
|
+
* Columns come from the view. Vertical order is barycenter: repeatedly place
|
|
6
|
+
* each node near the average position of its neighbours, which pulls connected
|
|
7
|
+
* things onto the same rows and takes most of the crossings out.
|
|
8
|
+
*
|
|
9
|
+
* Pure: no DOM. The browser client and the Node-side SVG renderer both import
|
|
10
|
+
* it, so the interactive diagram and a static export can never lay out
|
|
11
|
+
* differently.
|
|
12
|
+
*/
|
|
13
|
+
import { adjacency, groupKey } from './views.mjs'
|
|
14
|
+
|
|
15
|
+
export const NODE_W = 190
|
|
16
|
+
export const NODE_H = 26
|
|
17
|
+
const ROW_GAP = 8
|
|
18
|
+
const ROW_H = NODE_H + ROW_GAP
|
|
19
|
+
const COL_GAP = 78
|
|
20
|
+
const GROUP_H = 20
|
|
21
|
+
const TOP = 44
|
|
22
|
+
const BARY_PASSES = 6
|
|
23
|
+
|
|
24
|
+
export function computeLayout(proj, state) {
|
|
25
|
+
const cols = new Map()
|
|
26
|
+
for (const n of proj.nodes) {
|
|
27
|
+
const c = proj.col(n) ?? 0
|
|
28
|
+
if (!cols.has(c)) cols.set(c, [])
|
|
29
|
+
cols.get(c).push(n)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const active = [...cols.keys()].sort((a, b) => a - b)
|
|
33
|
+
const { out, inc } = adjacency(proj.edges)
|
|
34
|
+
|
|
35
|
+
const order = new Map()
|
|
36
|
+
const reindex = () => {
|
|
37
|
+
for (const list of cols.values()) list.forEach((n, i) => order.set(n.id, i))
|
|
38
|
+
}
|
|
39
|
+
for (const c of active) cols.get(c).sort(byGroupThenLabel)
|
|
40
|
+
reindex()
|
|
41
|
+
|
|
42
|
+
for (let pass = 0; pass < BARY_PASSES; pass++) {
|
|
43
|
+
// Alternate which side we average against, so ordering information
|
|
44
|
+
// propagates both down and back up the columns.
|
|
45
|
+
const backward = pass % 2 === 1
|
|
46
|
+
for (const c of backward ? [...active].reverse() : active) {
|
|
47
|
+
const neighbours = backward ? out : inc
|
|
48
|
+
const list = cols.get(c)
|
|
49
|
+
const bary = new Map()
|
|
50
|
+
for (const n of list) {
|
|
51
|
+
const near = (neighbours.get(n.id) ?? [])
|
|
52
|
+
.map((id) => order.get(id))
|
|
53
|
+
.filter((v) => v !== undefined)
|
|
54
|
+
bary.set(
|
|
55
|
+
n.id,
|
|
56
|
+
near.length ? near.reduce((a, b) => a + b, 0) / near.length : order.get(n.id),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
list.sort(state.group ? byGroupBarycenter(bary, groupMeans(list, bary)) : byBarycenter(bary))
|
|
60
|
+
reindex()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Drop empty columns so a filtered or shallow view has no dead space.
|
|
65
|
+
const xs = new Map()
|
|
66
|
+
let x = 0
|
|
67
|
+
for (const c of active) {
|
|
68
|
+
xs.set(c, x)
|
|
69
|
+
x += NODE_W + COL_GAP
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pos = new Map()
|
|
73
|
+
const headers = []
|
|
74
|
+
let bottom = TOP
|
|
75
|
+
for (const c of active) {
|
|
76
|
+
let y = TOP
|
|
77
|
+
let current = null
|
|
78
|
+
for (const n of cols.get(c)) {
|
|
79
|
+
const key = groupKey(n)
|
|
80
|
+
if (state.group && key !== current) {
|
|
81
|
+
headers.push({ x: xs.get(c), y: y + GROUP_H - 7, text: key })
|
|
82
|
+
y += GROUP_H
|
|
83
|
+
current = key
|
|
84
|
+
}
|
|
85
|
+
pos.set(n.id, { x: xs.get(c), y })
|
|
86
|
+
y += ROW_H
|
|
87
|
+
}
|
|
88
|
+
bottom = Math.max(bottom, y)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { cols, active, xs, pos, headers, width: Math.max(x - COL_GAP, NODE_W), height: bottom }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const byGroupThenLabel = (a, b) =>
|
|
95
|
+
groupKey(a).localeCompare(groupKey(b)) || a.label.localeCompare(b.label)
|
|
96
|
+
const byBarycenter = (bary) => (a, b) =>
|
|
97
|
+
bary.get(a.id) - bary.get(b.id) || a.label.localeCompare(b.label)
|
|
98
|
+
|
|
99
|
+
/** Average barycenter of each group, so a group can be placed as one block. */
|
|
100
|
+
function groupMeans(list, bary) {
|
|
101
|
+
const acc = new Map()
|
|
102
|
+
for (const n of list) {
|
|
103
|
+
const key = groupKey(n)
|
|
104
|
+
if (!acc.has(key)) acc.set(key, [])
|
|
105
|
+
acc.get(key).push(bary.get(n.id))
|
|
106
|
+
}
|
|
107
|
+
return new Map([...acc].map(([key, vs]) => [key, vs.reduce((a, b) => a + b, 0) / vs.length]))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* With grouping on, a group is ordered as a block — placed at its members'
|
|
112
|
+
* average position — and members are ordered within it. Without this the
|
|
113
|
+
* barycenter pass would interleave groups and the headers would be a lie.
|
|
114
|
+
*/
|
|
115
|
+
function byGroupBarycenter(bary, means) {
|
|
116
|
+
return (a, b) => {
|
|
117
|
+
const ga = groupKey(a)
|
|
118
|
+
const gb = groupKey(b)
|
|
119
|
+
if (ga !== gb) return means.get(ga) - means.get(gb) || ga.localeCompare(gb)
|
|
120
|
+
return bary.get(a.id) - bary.get(b.id) || a.label.localeCompare(b.label)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The cubic-bezier path for one edge between two laid-out nodes. Backwards or
|
|
126
|
+
* same-column edges bow out to the right rather than cutting back through the
|
|
127
|
+
* boxes between them. Shared by the interactive client and the SVG export.
|
|
128
|
+
*/
|
|
129
|
+
export function edgePath(e, layout) {
|
|
130
|
+
const a = layout.pos.get(e.from)
|
|
131
|
+
const b = layout.pos.get(e.to)
|
|
132
|
+
const y1 = a.y + NODE_H / 2
|
|
133
|
+
const y2 = b.y + NODE_H / 2
|
|
134
|
+
if (b.x <= a.x) {
|
|
135
|
+
const bulge = a.x + NODE_W + 26
|
|
136
|
+
return `M${a.x + NODE_W},${y1} C${bulge},${y1} ${bulge},${y2} ${b.x + NODE_W},${y2}`
|
|
137
|
+
}
|
|
138
|
+
const x1 = a.x + NODE_W
|
|
139
|
+
const mid = (x1 + b.x) / 2
|
|
140
|
+
return `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${b.x},${y2}`
|
|
141
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Assembles the single-file HTML report. The client lives as ordinary files
|
|
5
|
+
* rather than template strings, and is inlined here — the output has to work
|
|
6
|
+
* over file:// with no server and no network.
|
|
7
|
+
*
|
|
8
|
+
* `views.mjs` and `layout.mjs` are real ESM modules, imported unchanged by the
|
|
9
|
+
* Node-side SVG renderer so the interactive diagram and a static export share
|
|
10
|
+
* one layout engine. For the browser they're inlined into one classic script:
|
|
11
|
+
* their `import`/`export` keywords are stripped and they run in a shared scope,
|
|
12
|
+
* which is exactly the environment they were written for.
|
|
13
|
+
*/
|
|
14
|
+
const read = (name) => readFileSync(new URL(`./${name}`, import.meta.url), 'utf8')
|
|
15
|
+
|
|
16
|
+
// Turn a shared ESM module into a fragment safe to concatenate into the one
|
|
17
|
+
// classic <script>: drop imports (the dependency is inlined just above) and the
|
|
18
|
+
// `export` keyword (the declaration stays, now a plain top-level binding).
|
|
19
|
+
const inlineModule = (name) => {
|
|
20
|
+
const stripped = read(name)
|
|
21
|
+
.replace(/^\s*import[^\n]*\n/gm, '')
|
|
22
|
+
.replace(/^export\s+(?=(?:const|function|class|let|var)\b)/gm, '')
|
|
23
|
+
// The strip only handles single-line imports and `export <decl>`. A surviving
|
|
24
|
+
// module keyword (a multi-line import, an `export {…}` re-export, `export
|
|
25
|
+
// default`) would silently produce a broken browser bundle — fail loudly.
|
|
26
|
+
const leftover = stripped.match(/^\s*(?:import|export)\b/m)
|
|
27
|
+
if (leftover) {
|
|
28
|
+
throw new Error(`inlineModule(${name}): unstripped module statement "${leftover[0].trim()}"`)
|
|
29
|
+
}
|
|
30
|
+
return stripped
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Order matters: views defines TYPES/adjacency, layout imports them, then the
|
|
34
|
+
// browser-only scripts (overview, app) drive the page off those globals.
|
|
35
|
+
const CLIENT = [
|
|
36
|
+
inlineModule('views.mjs'),
|
|
37
|
+
inlineModule('layout.mjs'),
|
|
38
|
+
read('client/overview.js'),
|
|
39
|
+
read('client/app.js'),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export function renderHtml({ project, nodes, edges, warnings, findings, stats }) {
|
|
43
|
+
const payload = JSON.stringify({ project, nodes, edges, warnings, findings, stats })
|
|
44
|
+
// Keep the JSON from terminating the <script> that carries it.
|
|
45
|
+
.replace(/</g, '\\u003c')
|
|
46
|
+
|
|
47
|
+
return `<!doctype html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8">
|
|
51
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
52
|
+
<title>${escapeHtml(project.name)} — app graph</title>
|
|
53
|
+
<style>${read('client/styles.css')}</style>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<header id="bar">
|
|
57
|
+
<div id="title">
|
|
58
|
+
<strong>${escapeHtml(project.name)}</strong>
|
|
59
|
+
<span id="subtitle">${escapeHtml(project.description || (project.stack?.nuxt ? 'Nuxt application' : 'Vue application'))}</span>
|
|
60
|
+
</div>
|
|
61
|
+
<div id="views" role="tablist"></div>
|
|
62
|
+
<div id="tools">
|
|
63
|
+
<label id="group-label"><input type="checkbox" id="group" checked> Group</label>
|
|
64
|
+
<input id="search" type="search" placeholder="Search…" autocomplete="off" spellcheck="false">
|
|
65
|
+
<button id="reset" type="button">Reset</button>
|
|
66
|
+
</div>
|
|
67
|
+
</header>
|
|
68
|
+
<div id="chips-row">
|
|
69
|
+
<div id="chips"></div>
|
|
70
|
+
<div id="focus-note" hidden>Isolated: <span></span><button id="focus-clear" type="button" title="Clear (Esc)">✕</button></div>
|
|
71
|
+
</div>
|
|
72
|
+
<main>
|
|
73
|
+
<div id="canvas">
|
|
74
|
+
<svg id="svg"><g id="viewport"><g id="links"></g><g id="cols"></g><g id="nodes"></g></g></svg>
|
|
75
|
+
<div id="warnings"></div>
|
|
76
|
+
<div id="hint"></div>
|
|
77
|
+
</div>
|
|
78
|
+
<aside id="panel"><div id="panel-body" class="overview"></div></aside>
|
|
79
|
+
</main>
|
|
80
|
+
<script id="data" type="application/json">${payload}</script>
|
|
81
|
+
<script>${CLIENT.join('\n')}</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeHtml(s) {
|
|
88
|
+
return String(s).replace(
|
|
89
|
+
/[&<>"']/g,
|
|
90
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical graph vocabulary — the contract between adapters and the core.
|
|
3
|
+
*
|
|
4
|
+
* An adapter's whole job is to read a framework's conventions and emit nodes
|
|
5
|
+
* and edges in *these* kinds. The core (findings, layout, render, client) is
|
|
6
|
+
* written against this vocabulary and nothing else, so a new framework becomes
|
|
7
|
+
* a new adapter rather than a change to everything downstream.
|
|
8
|
+
*
|
|
9
|
+
* The kinds below were derived from Nuxt but named for what they are in any web
|
|
10
|
+
* app, so other adapters map onto them: a SvelteKit `+page.svelte` is a `route`,
|
|
11
|
+
* a `.svelte` file a `component`, a `load` function or store a `composable`, a
|
|
12
|
+
* `+server.ts` an `endpoint` (`server`), and so on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Bumped when the shape of a serialized graph changes in a breaking way. */
|
|
16
|
+
export const SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
/** The canonical node kinds and their human labels. */
|
|
19
|
+
export const NODE_KINDS = {
|
|
20
|
+
entry: { label: 'Entry' },
|
|
21
|
+
layout: { label: 'Layout' },
|
|
22
|
+
middleware: { label: 'Middleware' },
|
|
23
|
+
plugin: { label: 'Plugin' },
|
|
24
|
+
route: { label: 'Route' },
|
|
25
|
+
component: { label: 'Component' },
|
|
26
|
+
'lib-component': { label: 'Library component' },
|
|
27
|
+
composable: { label: 'Composable' },
|
|
28
|
+
store: { label: 'Store' },
|
|
29
|
+
collection: { label: 'Content collection' },
|
|
30
|
+
server: { label: 'Server route' },
|
|
31
|
+
external: { label: 'External service' },
|
|
32
|
+
config: { label: 'Config' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The canonical edge kinds. (The verb each reads as lives in the client's EDGE_VERB.) */
|
|
36
|
+
export const EDGE_KINDS = {
|
|
37
|
+
renders: 'renders',
|
|
38
|
+
wraps: 'wraps',
|
|
39
|
+
guards: 'guards',
|
|
40
|
+
uses: 'uses',
|
|
41
|
+
queries: 'queries',
|
|
42
|
+
fetches: 'fetches',
|
|
43
|
+
reads: 'reads',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Assert an adapter produced a well-formed graph before the core touches it.
|
|
48
|
+
* Cheap structural checks only — every claim here is something a broken adapter
|
|
49
|
+
* would get wrong, and catching it at the seam beats a confusing render later.
|
|
50
|
+
* Returns the list of problems ([] when the graph conforms).
|
|
51
|
+
*/
|
|
52
|
+
export function validateGraph({ nodes, edges }) {
|
|
53
|
+
const problems = []
|
|
54
|
+
const ids = new Set()
|
|
55
|
+
for (const n of nodes ?? []) {
|
|
56
|
+
if (!n.id) problems.push(`node without an id: ${JSON.stringify(n)}`)
|
|
57
|
+
else if (ids.has(n.id)) problems.push(`duplicate node id: ${n.id}`)
|
|
58
|
+
else ids.add(n.id)
|
|
59
|
+
if (!NODE_KINDS[n.type]) problems.push(`unknown node kind "${n.type}" on ${n.id}`)
|
|
60
|
+
}
|
|
61
|
+
for (const e of edges ?? []) {
|
|
62
|
+
if (!EDGE_KINDS[e.kind]) problems.push(`unknown edge kind "${e.kind}" (${e.from} → ${e.to})`)
|
|
63
|
+
if (!ids.has(e.from)) problems.push(`edge from missing node: ${e.from}`)
|
|
64
|
+
if (!ids.has(e.to)) problems.push(`edge to missing node: ${e.to}`)
|
|
65
|
+
}
|
|
66
|
+
return problems
|
|
67
|
+
}
|