@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
package/src/core/sfc.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { parse } from '@vue/compiler-sfc'
|
|
2
|
+
|
|
3
|
+
import { sliceBalanced, topLevelKeys } from './syntax.mjs'
|
|
4
|
+
|
|
5
|
+
const ELEMENT = 1
|
|
6
|
+
|
|
7
|
+
/** kebab-case / PascalCase tags both resolve to one auto-import name. */
|
|
8
|
+
export function normalizeTag(tag) {
|
|
9
|
+
return tag
|
|
10
|
+
.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
|
|
11
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a Vue SFC into the two things the graph cares about: which components
|
|
16
|
+
* its template renders, and what its script reaches out to for data.
|
|
17
|
+
*/
|
|
18
|
+
export function parseSfc(source, file) {
|
|
19
|
+
const { descriptor } = parse(source, { filename: file })
|
|
20
|
+
const script = [descriptor.script?.content, descriptor.scriptSetup?.content]
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join('\n')
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
tags: descriptor.template?.ast ? collectTags(descriptor.template.ast) : new Set(),
|
|
26
|
+
...parseScript(script),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function collectTags(ast, found = new Set()) {
|
|
31
|
+
for (const node of ast.children ?? []) {
|
|
32
|
+
if (node.type === ELEMENT && node.tag) found.add(node.tag)
|
|
33
|
+
// Recurse through branches too — v-if/v-for wrap children in their own nodes.
|
|
34
|
+
collectTags(node, found)
|
|
35
|
+
for (const branch of node.branches ?? []) collectTags(branch, found)
|
|
36
|
+
}
|
|
37
|
+
return found
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pull data-access out of a <script> body by pattern. This is deliberately
|
|
42
|
+
* lexical rather than a full type-aware trace: it catches the conventional
|
|
43
|
+
* Nuxt call shapes, which is what a reader of the diagram is looking for.
|
|
44
|
+
*/
|
|
45
|
+
export function parseScript(script) {
|
|
46
|
+
const strip = script.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
|
47
|
+
|
|
48
|
+
const collections = new Set()
|
|
49
|
+
for (const [, name] of strip.matchAll(
|
|
50
|
+
/queryCollection(?:Navigation|ItemSurroundings)?\s*\(\s*['"]([^'"]+)['"]/g,
|
|
51
|
+
)) {
|
|
52
|
+
collections.add(name)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const endpoints = new Set()
|
|
56
|
+
const externals = new Set()
|
|
57
|
+
for (const [, url] of strip.matchAll(
|
|
58
|
+
/(?:\$fetch|useFetch|useLazyFetch)\s*(?:<[^>]*>)?\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
59
|
+
)) {
|
|
60
|
+
if (/^https?:\/\//.test(url)) externals.add(hostOf(url))
|
|
61
|
+
else endpoints.add(url.split('?')[0])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const calls = new Set()
|
|
65
|
+
for (const [, name] of strip.matchAll(/\b(use[A-Z]\w*)\s*\(/g)) calls.add(name)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
collections,
|
|
69
|
+
endpoints,
|
|
70
|
+
externals,
|
|
71
|
+
calls,
|
|
72
|
+
meta: parsePageMeta(strip),
|
|
73
|
+
usesAppConfig: /\buseAppConfig\s*\(/.test(strip),
|
|
74
|
+
usesRuntimeConfig: /\buseRuntimeConfig\s*\(/.test(strip),
|
|
75
|
+
usesState: /\buseState\s*\(/.test(strip),
|
|
76
|
+
props: parseProps(strip),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Props from either the type form `defineProps<{…}>()` or the runtime form `defineProps({…})`. */
|
|
81
|
+
function parseProps(script) {
|
|
82
|
+
const props = []
|
|
83
|
+
for (const m of script.matchAll(/defineProps\s*[<(]/g)) {
|
|
84
|
+
const start = script.indexOf('{', m.index)
|
|
85
|
+
if (start === -1) continue
|
|
86
|
+
props.push(...topLevelKeys(sliceBalanced(script, start, '{', '}')))
|
|
87
|
+
}
|
|
88
|
+
return props
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parsePageMeta(script) {
|
|
92
|
+
const block = script.match(/definePageMeta\s*\(\s*\{([\s\S]*?)\}\s*\)/)?.[1]
|
|
93
|
+
if (!block) return { middleware: [] }
|
|
94
|
+
// middleware takes either a single name or an array of them.
|
|
95
|
+
const mw = block.match(/middleware\s*:\s*(\[[^\]]*\]|['"][\w-]+['"])/)?.[1]
|
|
96
|
+
return {
|
|
97
|
+
layout: block.match(/layout\s*:\s*['"]([^'"]+)['"]/)?.[1],
|
|
98
|
+
middleware: mw ? [...mw.matchAll(/['"]([\w-]+)['"]/g)].map((m) => m[1]) : [],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hostOf(url) {
|
|
103
|
+
try {
|
|
104
|
+
return new URL(url).host
|
|
105
|
+
} catch {
|
|
106
|
+
return url
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* MDC lets markdown render Vue components. Those edges are invisible to a
|
|
112
|
+
* template scan but are real render paths — miss them and a component rendered
|
|
113
|
+
* only from content looks like an orphan.
|
|
114
|
+
*
|
|
115
|
+
* Three syntaxes, all in use: `::block`, `:inline{}`, and ordinary tag syntax
|
|
116
|
+
* (`<ProductCardWrapper />`). MDC accepts a component name in either case in
|
|
117
|
+
* every form — `::quick-answer` and `::QuickAnswer` both resolve — so the block
|
|
118
|
+
* and inline patterns match both, and `normalizeTag` folds them to one name.
|
|
119
|
+
* The tag patterns are shaped so plain HTML — div, br, img — can never match: a
|
|
120
|
+
* component tag is either PascalCase or contains a dash.
|
|
121
|
+
*/
|
|
122
|
+
export function parseMdcComponents(markdown) {
|
|
123
|
+
const body = markdown
|
|
124
|
+
.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
125
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
126
|
+
.replace(/`[^`\n]*`/g, '')
|
|
127
|
+
|
|
128
|
+
const found = new Set()
|
|
129
|
+
for (const [, name] of body.matchAll(/^\s*::+([A-Za-z][\w-]*)/gm)) found.add(name)
|
|
130
|
+
for (const [, name] of body.matchAll(/(?<!:):([A-Za-z][\w-]*)\{/g)) found.add(name)
|
|
131
|
+
for (const [, name] of body.matchAll(/<([A-Z][A-Za-z0-9]*)[\s/>]/g)) found.add(name)
|
|
132
|
+
for (const [, name] of body.matchAll(/<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g)) found.add(name)
|
|
133
|
+
return found
|
|
134
|
+
}
|
package/src/core/svg.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a scanned graph to a static SVG, server-side, with no browser.
|
|
3
|
+
*
|
|
4
|
+
* It reuses the exact projection and layout the interactive report uses
|
|
5
|
+
* (`views.mjs` + `layout.mjs`), so an embedded SVG and the live diagram can
|
|
6
|
+
* never disagree about structure. The point is living docs: a graph that embeds
|
|
7
|
+
* in a README or a docs page that can't run JavaScript, regenerated in CI.
|
|
8
|
+
*/
|
|
9
|
+
import { computeLayout, edgePath, NODE_H, NODE_W } from './layout.mjs'
|
|
10
|
+
import { TYPES, VIEWS } from './views.mjs'
|
|
11
|
+
|
|
12
|
+
// Concrete colours for a standalone file. The interactive report reads these
|
|
13
|
+
// from CSS custom properties in styles.css; these mirror its light theme.
|
|
14
|
+
const PALETTE = {
|
|
15
|
+
entry: '#8b7cc8',
|
|
16
|
+
route: '#2f6fd0',
|
|
17
|
+
component: '#2e9e73',
|
|
18
|
+
lib: '#9a9a90',
|
|
19
|
+
composable: '#d08a2f',
|
|
20
|
+
store: '#7b61c9',
|
|
21
|
+
collection: '#c05a8a',
|
|
22
|
+
server: '#c0563f',
|
|
23
|
+
external: '#7a7a72',
|
|
24
|
+
config: '#4b8fa8',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const colorOf = (n) => PALETTE[TYPES[n.type]?.color] ?? '#888888'
|
|
28
|
+
const truncate = (s, max) => (s.length > max ? `${s.slice(0, max - 1)}…` : s)
|
|
29
|
+
const esc = (s) =>
|
|
30
|
+
String(s).replace(
|
|
31
|
+
/[&<>"]/g,
|
|
32
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const MARGIN = 24
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Serialize `graph` (the shape `scan()` returns) into an SVG string for one
|
|
39
|
+
* view. `view` is any of the report's views; `group` buckets by owner exactly
|
|
40
|
+
* as the interactive Group toggle does.
|
|
41
|
+
*/
|
|
42
|
+
export function renderSvg(graph, { view = 'layers', group = true } = {}) {
|
|
43
|
+
const v = VIEWS[view]
|
|
44
|
+
if (!v) throw new Error(`Unknown view "${view}" — one of: ${Object.keys(VIEWS).join(', ')}`)
|
|
45
|
+
|
|
46
|
+
const state = { view, group, types: new Set(Object.keys(TYPES)), focus: null }
|
|
47
|
+
const proj = v.project(graph, state)
|
|
48
|
+
const grouping = group && v.grouping !== false
|
|
49
|
+
const layout = computeLayout(proj, { ...state, group: grouping })
|
|
50
|
+
const byId = new Map(proj.nodes.map((n) => [n.id, n]))
|
|
51
|
+
|
|
52
|
+
const width = Math.ceil(layout.width + MARGIN * 2)
|
|
53
|
+
const height = Math.ceil(layout.height + MARGIN * 2)
|
|
54
|
+
const parts = []
|
|
55
|
+
|
|
56
|
+
for (const e of proj.edges) {
|
|
57
|
+
const stroke = colorOf(byId.get(e.to))
|
|
58
|
+
const weight = e.weight > 1 ? Math.min(6, 1 + Math.log2(e.weight)).toFixed(2) : 1
|
|
59
|
+
parts.push(
|
|
60
|
+
`<path d="${edgePath(e, layout)}" fill="none" stroke="${stroke}" stroke-opacity="0.35" stroke-width="${weight}"/>`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const c of layout.active) {
|
|
65
|
+
const x = layout.xs.get(c)
|
|
66
|
+
const name = proj.title(c) ?? `Column ${c}`
|
|
67
|
+
parts.push(
|
|
68
|
+
`<text x="${x}" y="16" class="col">${esc(name)} (${layout.cols.get(c).length})</text>`,
|
|
69
|
+
)
|
|
70
|
+
parts.push(`<line x1="${x}" y1="26" x2="${x + NODE_W}" y2="26" class="rule"/>`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const h of layout.headers) {
|
|
74
|
+
parts.push(`<text x="${h.x + 2}" y="${h.y}" class="grp">${esc(h.text)}</text>`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const n of proj.nodes) {
|
|
78
|
+
const { x, y } = layout.pos.get(n.id)
|
|
79
|
+
parts.push(
|
|
80
|
+
`<g transform="translate(${x},${y})">` +
|
|
81
|
+
`<rect width="${NODE_W}" height="${NODE_H}" rx="4" fill="#ffffff" stroke="#d8d8d8"/>` +
|
|
82
|
+
`<rect width="3" height="${NODE_H}" rx="1.5" fill="${colorOf(n)}"/>` +
|
|
83
|
+
`<text x="10" y="${NODE_H / 2 + 4}" class="lbl">${esc(truncate(n.label, 24))}</text>` +
|
|
84
|
+
`</g>`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="ui-sans-serif, system-ui, -apple-system, sans-serif">
|
|
89
|
+
<style>
|
|
90
|
+
text { fill: #1c1c1c }
|
|
91
|
+
.col { font-size: 11px; font-weight: 600; fill: #555555 }
|
|
92
|
+
.grp { font-size: 10px; fill: #8a8a8a }
|
|
93
|
+
.lbl { font-size: 12px }
|
|
94
|
+
.rule { stroke: #e4e4e2 }
|
|
95
|
+
</style>
|
|
96
|
+
<rect width="${width}" height="${height}" fill="#fbfbfa"/>
|
|
97
|
+
<g transform="translate(${MARGIN},${MARGIN})">
|
|
98
|
+
${parts.join('\n')}
|
|
99
|
+
</g>
|
|
100
|
+
</svg>
|
|
101
|
+
`
|
|
102
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small brace-aware scanners shared by the SFC and config parsers.
|
|
3
|
+
*
|
|
4
|
+
* These exist because line-oriented regexes quietly get the wrong answer on
|
|
5
|
+
* code that happens to be formatted on one line — `{ title?: string; size?:
|
|
6
|
+
* number }` reported one prop instead of two. Counting delimiters costs a few
|
|
7
|
+
* lines and stops formatting from changing the result.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The balanced `open`…`close` span starting at `start`, inclusive. */
|
|
11
|
+
export function sliceBalanced(str, start, open, close) {
|
|
12
|
+
let depth = 0
|
|
13
|
+
for (let i = start; i < str.length; i++) {
|
|
14
|
+
if (str[i] === open) depth++
|
|
15
|
+
else if (str[i] === close && --depth === 0) return str.slice(start, i + 1)
|
|
16
|
+
}
|
|
17
|
+
return str.slice(start)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Only these nest. Angle brackets are deliberately excluded: `=>` in a function
|
|
21
|
+
// type would otherwise read as a closing bracket and unbalance everything.
|
|
22
|
+
const OPEN = '{(['
|
|
23
|
+
const CLOSE = '})]'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keys declared at the top level of an object-ish body — `{ a: X; b?: Y }` →
|
|
27
|
+
* ['a', 'b']. Nested keys are skipped, so `{ a: { b: X } }` yields only 'a'.
|
|
28
|
+
* Works the same whether the source is one line or twenty.
|
|
29
|
+
*/
|
|
30
|
+
export function topLevelKeys(body) {
|
|
31
|
+
const keys = []
|
|
32
|
+
let depth = 0
|
|
33
|
+
let token = ''
|
|
34
|
+
|
|
35
|
+
for (const char of body) {
|
|
36
|
+
if (OPEN.includes(char)) {
|
|
37
|
+
depth++
|
|
38
|
+
token = ''
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (CLOSE.includes(char)) {
|
|
42
|
+
depth--
|
|
43
|
+
token = ''
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (depth !== 1) continue
|
|
47
|
+
|
|
48
|
+
if (/[\w$]/.test(char)) {
|
|
49
|
+
token += char
|
|
50
|
+
} else if (char === ':' || char === '?') {
|
|
51
|
+
// A name immediately followed by `:` or `?:` is a key declaration.
|
|
52
|
+
if (token) keys.push(token)
|
|
53
|
+
token = ''
|
|
54
|
+
} else {
|
|
55
|
+
token = ''
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return keys
|
|
59
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A view is a projection of the one underlying graph: which nodes it keeps,
|
|
3
|
+
* which edges it keeps, and what a column means. Everything downstream —
|
|
4
|
+
* layout, drawing, the detail panel — is shared, so adding a view means
|
|
5
|
+
* answering those three questions and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const TYPES = {
|
|
9
|
+
entry: { col: 0, label: 'Entry', color: 'entry' },
|
|
10
|
+
layout: { col: 0, label: 'Layout', color: 'entry' },
|
|
11
|
+
middleware: { col: 0, label: 'Middleware', color: 'entry' },
|
|
12
|
+
plugin: { col: 0, label: 'Plugin', color: 'entry' },
|
|
13
|
+
route: { col: 1, label: 'Route', color: 'route' },
|
|
14
|
+
component: { col: 2, label: 'Component', color: 'component' },
|
|
15
|
+
'lib-component': { col: 3, label: 'Library component', color: 'lib' },
|
|
16
|
+
composable: { col: 4, label: 'Composable', color: 'composable' },
|
|
17
|
+
store: { col: 4, label: 'Store', color: 'store' },
|
|
18
|
+
collection: { col: 5, label: 'Content collection', color: 'collection' },
|
|
19
|
+
server: { col: 5, label: 'Server route', color: 'server' },
|
|
20
|
+
external: { col: 5, label: 'External service', color: 'external' },
|
|
21
|
+
config: { col: 5, label: 'Config', color: 'config' },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LAYER_TITLES = [
|
|
25
|
+
'Entry & layouts',
|
|
26
|
+
'Routes',
|
|
27
|
+
'Components',
|
|
28
|
+
'Library',
|
|
29
|
+
'Composables',
|
|
30
|
+
'Data & services',
|
|
31
|
+
]
|
|
32
|
+
export const EDGE_VERB = {
|
|
33
|
+
renders: 'renders',
|
|
34
|
+
uses: 'uses',
|
|
35
|
+
queries: 'queries',
|
|
36
|
+
fetches: 'fetches',
|
|
37
|
+
reads: 'reads',
|
|
38
|
+
wraps: 'wraps',
|
|
39
|
+
guards: 'guards',
|
|
40
|
+
depends: 'depends on',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const STRUCTURE_KINDS = new Set(['renders', 'wraps'])
|
|
44
|
+
const SOURCE_TYPES = new Set(['collection', 'server', 'external', 'config'])
|
|
45
|
+
|
|
46
|
+
export const groupKey = (n) => n.meta.group ?? n.type
|
|
47
|
+
|
|
48
|
+
export function adjacency(edges) {
|
|
49
|
+
const out = new Map()
|
|
50
|
+
const inc = new Map()
|
|
51
|
+
for (const e of edges) {
|
|
52
|
+
if (!out.has(e.from)) out.set(e.from, [])
|
|
53
|
+
out.get(e.from).push(e.to)
|
|
54
|
+
if (!inc.has(e.to)) inc.set(e.to, [])
|
|
55
|
+
inc.get(e.to).push(e.from)
|
|
56
|
+
}
|
|
57
|
+
return { out, inc }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Depth of each node as its longest path from a root. Longest rather than
|
|
62
|
+
* shortest so a component used at depth 1 and depth 4 sits after everything
|
|
63
|
+
* that renders it, which is what keeps edges pointing forwards. Iteration is
|
|
64
|
+
* bounded because a render cycle would otherwise never settle.
|
|
65
|
+
*/
|
|
66
|
+
function longestPath(nodes, edges, maxDepth = 24) {
|
|
67
|
+
const depth = new Map(nodes.map((n) => [n.id, 0]))
|
|
68
|
+
for (let pass = 0; pass < nodes.length; pass++) {
|
|
69
|
+
let changed = false
|
|
70
|
+
for (const e of edges) {
|
|
71
|
+
if (!depth.has(e.from) || !depth.has(e.to)) continue
|
|
72
|
+
const next = Math.min(depth.get(e.from) + 1, maxDepth)
|
|
73
|
+
if (next > depth.get(e.to)) {
|
|
74
|
+
depth.set(e.to, next)
|
|
75
|
+
changed = true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!changed) break
|
|
79
|
+
}
|
|
80
|
+
return depth
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function subgraph(G, keep, edgeFilter = () => true) {
|
|
84
|
+
const nodes = G.nodes.filter((n) => keep(n))
|
|
85
|
+
const ids = new Set(nodes.map((n) => n.id))
|
|
86
|
+
const edges = G.edges.filter((e) => ids.has(e.from) && ids.has(e.to) && edgeFilter(e))
|
|
87
|
+
return { nodes, edges }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const VIEWS = {
|
|
91
|
+
layers: {
|
|
92
|
+
name: 'Layers',
|
|
93
|
+
hint: 'Every entity, arranged by kind. The whole app at once.',
|
|
94
|
+
project(G, state) {
|
|
95
|
+
const { nodes, edges } = subgraph(G, (n) => state.types.has(n.type))
|
|
96
|
+
return { nodes, edges, col: (n) => TYPES[n.type].col, title: (c) => LAYER_TITLES[c] }
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
render: {
|
|
101
|
+
name: 'Render tree',
|
|
102
|
+
hint: 'What renders what, from entry points down. Depth is nesting depth.',
|
|
103
|
+
project(G, state) {
|
|
104
|
+
const keep = new Set(['entry', 'layout', 'route', 'component', 'lib-component'])
|
|
105
|
+
// Markdown renders components too (MDC). Drop the collections that do and
|
|
106
|
+
// those components lose their only parent, then masquerade as entry
|
|
107
|
+
// points — so content collections that render something belong here.
|
|
108
|
+
const renderers = new Set()
|
|
109
|
+
for (const e of G.edges) {
|
|
110
|
+
if (STRUCTURE_KINDS.has(e.kind)) renderers.add(e.from)
|
|
111
|
+
}
|
|
112
|
+
const { nodes, edges } = subgraph(
|
|
113
|
+
G,
|
|
114
|
+
(n) =>
|
|
115
|
+
state.types.has(n.type) &&
|
|
116
|
+
(keep.has(n.type) || (n.type === 'collection' && renderers.has(n.id))),
|
|
117
|
+
(e) => STRUCTURE_KINDS.has(e.kind),
|
|
118
|
+
)
|
|
119
|
+
const depth = longestPath(nodes, edges)
|
|
120
|
+
return {
|
|
121
|
+
nodes,
|
|
122
|
+
edges,
|
|
123
|
+
col: (n) => depth.get(n.id),
|
|
124
|
+
title: (c) => (c === 0 ? 'Entry points' : `Nested depth ${c}`),
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
data: {
|
|
130
|
+
name: 'Data flow',
|
|
131
|
+
hint: 'Every edge reversed, so data reads left to right: origin → who reads it → where it surfaces.',
|
|
132
|
+
project(G, state) {
|
|
133
|
+
// Keep only what can actually reach a data source, walking backwards from
|
|
134
|
+
// each source. A purely presentational component has no place here.
|
|
135
|
+
const { inc } = adjacency(G.edges)
|
|
136
|
+
const keep = new Set(G.nodes.filter((n) => SOURCE_TYPES.has(n.type)).map((n) => n.id))
|
|
137
|
+
const stack = [...keep]
|
|
138
|
+
while (stack.length) {
|
|
139
|
+
for (const from of inc.get(stack.pop()) ?? []) {
|
|
140
|
+
if (!keep.has(from)) {
|
|
141
|
+
keep.add(from)
|
|
142
|
+
stack.push(from)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const picked = subgraph(G, (n) => keep.has(n.id) && state.types.has(n.type))
|
|
147
|
+
// Reversed: the dependency graph points consumer → source, but data
|
|
148
|
+
// travels the other way.
|
|
149
|
+
const edges = picked.edges.map((e) => ({
|
|
150
|
+
from: e.to,
|
|
151
|
+
to: e.from,
|
|
152
|
+
kind: e.kind,
|
|
153
|
+
reversed: true,
|
|
154
|
+
}))
|
|
155
|
+
const depth = longestPath(picked.nodes, edges)
|
|
156
|
+
return {
|
|
157
|
+
nodes: picked.nodes,
|
|
158
|
+
edges,
|
|
159
|
+
col: (n) => depth.get(n.id),
|
|
160
|
+
title: (c) =>
|
|
161
|
+
['Data origin', 'Read directly by', 'Surfaced by'][c] ?? `Surfaced by · ${c - 1}`,
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
modules: {
|
|
167
|
+
name: 'Modules',
|
|
168
|
+
hint: 'Folders and packages as single blocks. Line weight is how many references cross between them.',
|
|
169
|
+
// The module is the group, so grouping again would just repeat every label.
|
|
170
|
+
grouping: false,
|
|
171
|
+
project(G, state) {
|
|
172
|
+
const groups = new Map()
|
|
173
|
+
for (const n of G.nodes) {
|
|
174
|
+
if (!state.types.has(n.type)) continue
|
|
175
|
+
const k = groupKey(n)
|
|
176
|
+
if (!groups.has(k)) groups.set(k, [])
|
|
177
|
+
groups.get(k).push(n)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const nodes = []
|
|
181
|
+
const moduleOf = new Map()
|
|
182
|
+
for (const [key, members] of groups) {
|
|
183
|
+
const id = `module:${key}`
|
|
184
|
+
for (const m of members) moduleOf.set(m.id, id)
|
|
185
|
+
const kinds = tally(members.map((m) => m.type))
|
|
186
|
+
nodes.push({
|
|
187
|
+
id,
|
|
188
|
+
type: kinds[0][0],
|
|
189
|
+
label: key,
|
|
190
|
+
file: null,
|
|
191
|
+
meta: {
|
|
192
|
+
module: true,
|
|
193
|
+
group: key,
|
|
194
|
+
members: members.length,
|
|
195
|
+
kinds: kinds.map(([t, n]) => `${n} ${TYPES[t].label.toLowerCase()}`),
|
|
196
|
+
names: members.map((m) => m.label).sort(),
|
|
197
|
+
// A module's column is where its members mostly live, so the
|
|
198
|
+
// aggregate still reads left-to-right like the layer view.
|
|
199
|
+
col: Math.round(mean(members.map((m) => TYPES[m.type].col))),
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const agg = new Map()
|
|
205
|
+
for (const e of G.edges) {
|
|
206
|
+
const from = moduleOf.get(e.from)
|
|
207
|
+
const to = moduleOf.get(e.to)
|
|
208
|
+
if (!from || !to || from === to) continue
|
|
209
|
+
const key = `${from}|${to}`
|
|
210
|
+
if (!agg.has(key)) agg.set(key, { from, to, kind: 'depends', weight: 0 })
|
|
211
|
+
agg.get(key).weight++
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
nodes,
|
|
216
|
+
edges: [...agg.values()],
|
|
217
|
+
col: (n) => n.meta.col,
|
|
218
|
+
title: (c) => LAYER_TITLES[c],
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
sitemap: {
|
|
224
|
+
name: 'Sitemap',
|
|
225
|
+
hint: 'Every page and everything it pulls in, laid out from the routes. Whatever a page never reaches — dead code — drops away.',
|
|
226
|
+
project(G, state) {
|
|
227
|
+
// Walk forward from the routes; keep only what a page can actually reach.
|
|
228
|
+
const { out } = adjacency(G.edges)
|
|
229
|
+
const keep = new Set(G.nodes.filter((n) => n.type === 'route').map((n) => n.id))
|
|
230
|
+
const stack = [...keep]
|
|
231
|
+
while (stack.length) {
|
|
232
|
+
for (const to of out.get(stack.pop()) ?? []) {
|
|
233
|
+
if (!keep.has(to)) {
|
|
234
|
+
keep.add(to)
|
|
235
|
+
stack.push(to)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Drop wraps/guards so routes are the true left-most spine, not layouts.
|
|
240
|
+
const { nodes, edges } = subgraph(
|
|
241
|
+
G,
|
|
242
|
+
(n) => keep.has(n.id) && state.types.has(n.type),
|
|
243
|
+
(e) => e.kind !== 'wraps' && e.kind !== 'guards',
|
|
244
|
+
)
|
|
245
|
+
const depth = longestPath(nodes, edges)
|
|
246
|
+
return {
|
|
247
|
+
nodes,
|
|
248
|
+
edges,
|
|
249
|
+
col: (n) => depth.get(n.id),
|
|
250
|
+
title: (c) => (c === 0 ? 'Pages' : c === 1 ? 'Renders & uses' : `Reached · ${c}`),
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
cleanup: {
|
|
256
|
+
name: 'Cleanup',
|
|
257
|
+
hint: 'Only what the findings flag — dead components, uncalled composables, empty collections, render cycles — gathered in one place.',
|
|
258
|
+
// Each flagged node stands on its own; grouping would just repeat kinds.
|
|
259
|
+
grouping: false,
|
|
260
|
+
project(G, state) {
|
|
261
|
+
// Only warnings — genuine remove-candidates. Info findings (a static
|
|
262
|
+
// route, a webhook with no caller) are normal, not cleanup.
|
|
263
|
+
const flagged = new Set(
|
|
264
|
+
(G.findings ?? []).filter((f) => f.severity === 'warn').flatMap((f) => f.nodes),
|
|
265
|
+
)
|
|
266
|
+
const { nodes, edges } = subgraph(G, (n) => flagged.has(n.id) && state.types.has(n.type))
|
|
267
|
+
return { nodes, edges, col: (n) => TYPES[n.type].col, title: (c) => LAYER_TITLES[c] }
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function tally(items) {
|
|
273
|
+
const counts = new Map()
|
|
274
|
+
for (const it of items) counts.set(it, (counts.get(it) ?? 0) + 1)
|
|
275
|
+
return [...counts].sort((a, b) => b[1] - a[1])
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Isolate one node: keep only what it reaches and what reaches it. This is the
|
|
282
|
+
* "who consumes what" question asked of a single entity.
|
|
283
|
+
*/
|
|
284
|
+
export function applyFocus(proj, focusId) {
|
|
285
|
+
if (!focusId || !proj.nodes.some((n) => n.id === focusId)) return proj
|
|
286
|
+
const { out, inc } = adjacency(proj.edges)
|
|
287
|
+
const keep = new Set([focusId])
|
|
288
|
+
for (const dir of [out, inc]) {
|
|
289
|
+
const stack = [focusId]
|
|
290
|
+
while (stack.length) {
|
|
291
|
+
for (const next of dir.get(stack.pop()) ?? []) {
|
|
292
|
+
if (!keep.has(next)) {
|
|
293
|
+
keep.add(next)
|
|
294
|
+
stack.push(next)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
...proj,
|
|
301
|
+
nodes: proj.nodes.filter((n) => keep.has(n.id)),
|
|
302
|
+
edges: proj.edges.filter((e) => keep.has(e.from) && keep.has(e.to)),
|
|
303
|
+
}
|
|
304
|
+
}
|