@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,541 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Graph } from '../../core/graph.mjs'
|
|
5
|
+
import { normalizeTag, parseMdcComponents, parseScript, parseSfc } from '../../core/sfc.mjs'
|
|
6
|
+
import { sliceBalanced, topLevelKeys } from '../../core/syntax.mjs'
|
|
7
|
+
import {
|
|
8
|
+
annotateChurn,
|
|
9
|
+
countLoc,
|
|
10
|
+
eachFile,
|
|
11
|
+
isTest,
|
|
12
|
+
linkTags,
|
|
13
|
+
readEach,
|
|
14
|
+
rel,
|
|
15
|
+
routeFromFile,
|
|
16
|
+
shortenPkgPath,
|
|
17
|
+
walk,
|
|
18
|
+
} from '../shared.mjs'
|
|
19
|
+
import { detect, loadProject, readGeneratedComponents, readGeneratedImports } from './project.mjs'
|
|
20
|
+
|
|
21
|
+
// Composables from these packages are framework plumbing (useRoute, useHead,
|
|
22
|
+
// useAsyncData). Their data-access is already drawn as typed edges, so drawing
|
|
23
|
+
// them as nodes too would bury the app's own code in noise.
|
|
24
|
+
const FRAMEWORK = /^(nuxt|vue|vue-router|h3|nitropack|@nuxt\/|@unhead|@vue\/|#)/
|
|
25
|
+
|
|
26
|
+
/** This adapter handles any directory with a nuxt.config. */
|
|
27
|
+
export const name = 'nuxt'
|
|
28
|
+
export { detect }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read a Nuxt app into a canonical graph. Returns the raw graph plus its
|
|
32
|
+
* project metadata and warnings; the shared finalize pipeline (prune, group,
|
|
33
|
+
* findings, stats) runs in core, identically for every adapter.
|
|
34
|
+
*/
|
|
35
|
+
export async function scan(dir) {
|
|
36
|
+
const project = loadProject(dir)
|
|
37
|
+
const g = new Graph()
|
|
38
|
+
const warnings = []
|
|
39
|
+
|
|
40
|
+
const generatedComponents = readGeneratedComponents(project)
|
|
41
|
+
const generatedImports = readGeneratedImports(project)
|
|
42
|
+
if (!generatedComponents) {
|
|
43
|
+
warnings.push(
|
|
44
|
+
'No .nuxt/components.d.ts found — falling back to scanning components/. ' +
|
|
45
|
+
'Run `nuxt prepare` for exact auto-import names and library components.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Independent filesystem walks — overlap their I/O. Only resolveComposables
|
|
50
|
+
// pushes warnings, and all node insertion happens after, so order is stable.
|
|
51
|
+
const [componentsByName, composablesByName] = await Promise.all([
|
|
52
|
+
resolveComponents(project, generatedComponents),
|
|
53
|
+
resolveComposables(project, generatedImports, warnings),
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
// Nodes for things that exist on disk, before any edges are drawn.
|
|
57
|
+
for (const [name, info] of componentsByName) {
|
|
58
|
+
if (info.origin === 'local') {
|
|
59
|
+
g.node(`component:${name}`, {
|
|
60
|
+
type: 'component',
|
|
61
|
+
label: name,
|
|
62
|
+
file: rel(info.file, project.root),
|
|
63
|
+
meta: { origin: 'local' },
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const [name, info] of composablesByName) {
|
|
68
|
+
g.node(`composable:${name}`, {
|
|
69
|
+
type: 'composable',
|
|
70
|
+
label: name,
|
|
71
|
+
file: rel(info.file, project.root),
|
|
72
|
+
meta: { origin: info.origin, pkg: info.pkg, loc: info.loc },
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const collections = await scanContentConfig(project, g)
|
|
77
|
+
await scanLayouts(project, g, componentsByName, composablesByName, warnings)
|
|
78
|
+
// Before pages, so a page's definePageMeta middleware can link to real nodes.
|
|
79
|
+
await scanMiddlewareAndPlugins(project, g, composablesByName, warnings)
|
|
80
|
+
await scanPages(project, g, componentsByName, composablesByName, warnings)
|
|
81
|
+
await scanComponentBodies(project, g, componentsByName, composablesByName, warnings)
|
|
82
|
+
await scanComposableBodies(project, g, composablesByName, warnings)
|
|
83
|
+
await scanServer(project, g, warnings)
|
|
84
|
+
await scanContentFiles(project, g, collections, componentsByName, warnings)
|
|
85
|
+
|
|
86
|
+
await annotateChurn(g, project.root)
|
|
87
|
+
|
|
88
|
+
return { project, graph: g, warnings }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Collapse a route's dynamic segments to `*` so the two ways of writing the
|
|
93
|
+
* same endpoint land on one node: a caller's template literal
|
|
94
|
+
* (`/api/debate/${slug}/vote`) and its handler file
|
|
95
|
+
* (server/api/debate/[slug]/vote.post.ts → `/api/debate/:slug/vote`). Without
|
|
96
|
+
* this the caller invents a phantom endpoint and the real handler looks
|
|
97
|
+
* uncalled — two wrong facts from one mismatch.
|
|
98
|
+
*/
|
|
99
|
+
const canonicalRoute = (path) =>
|
|
100
|
+
path
|
|
101
|
+
.replace(/\$\{[^}]*\}/g, '*')
|
|
102
|
+
.replace(/:[A-Za-z_]\w*/g, '*')
|
|
103
|
+
.replace(/\*\*/g, '*')
|
|
104
|
+
.replace(/\/{2,}/g, '/')
|
|
105
|
+
.replace(/(.)\/$/, '$1')
|
|
106
|
+
|
|
107
|
+
const serverId = (path) => `server:${canonicalRoute(path)}`
|
|
108
|
+
|
|
109
|
+
/** What @nuxt/content accepts as a source file across page and data collections. */
|
|
110
|
+
const CONTENT_EXTS = ['.md', '.json', '.yml', '.yaml', '.csv']
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Nuxt's fallback auto-import name for a component path: directory segments
|
|
114
|
+
* PascalCased and concatenated, but a segment is absorbed when the next already
|
|
115
|
+
* starts with it — account/AccountAvatar → AccountAvatar, not AccountAccountAvatar
|
|
116
|
+
* (the false-orphan bug real repos surfaced). Only used without .nuxt.
|
|
117
|
+
*/
|
|
118
|
+
export function componentName(segments, pathPrefix) {
|
|
119
|
+
const parts = (pathPrefix ? segments : [segments.at(-1)]).map(normalizeTag)
|
|
120
|
+
const out = []
|
|
121
|
+
for (const seg of parts) {
|
|
122
|
+
if (out.length && seg.toLowerCase().startsWith(out.at(-1).toLowerCase()))
|
|
123
|
+
out[out.length - 1] = seg
|
|
124
|
+
else out.push(seg)
|
|
125
|
+
}
|
|
126
|
+
return out.join('')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Every root to look for app code in: the app itself, then each layer it
|
|
131
|
+
* extends. Main is first, so a node it defines wins — a layer only fills in what
|
|
132
|
+
* the app doesn't. For an app with no `extends`, this is just the app.
|
|
133
|
+
*/
|
|
134
|
+
const sourcesOf = (project) => project.sources ?? [{ root: project.root, srcDir: project.srcDir }]
|
|
135
|
+
|
|
136
|
+
async function resolveComponents(project, generated) {
|
|
137
|
+
const map = new Map()
|
|
138
|
+
if (generated) {
|
|
139
|
+
for (const [name, info] of generated) map.set(name, info)
|
|
140
|
+
return map
|
|
141
|
+
}
|
|
142
|
+
// Fallback: mirror Nuxt's own naming rules over components/, across the app
|
|
143
|
+
// and its layers. Main is scanned first and wins on any name collision.
|
|
144
|
+
for (const src of sourcesOf(project)) {
|
|
145
|
+
const dir = join(src.srcDir, 'components')
|
|
146
|
+
for (const file of await walk(dir, ['.vue'], dir)) {
|
|
147
|
+
if (isTest(file)) continue
|
|
148
|
+
const name = componentName(file.replace(/\.vue$/, '').split('/'), project.pathPrefix)
|
|
149
|
+
if (!map.has(name)) map.set(name, { file: join(dir, file), origin: 'local', pkg: null })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return map
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resolveComposables(project, generated, warnings) {
|
|
156
|
+
const map = new Map()
|
|
157
|
+
for (const src of sourcesOf(project)) {
|
|
158
|
+
const dir = join(src.srcDir, 'composables')
|
|
159
|
+
await eachFile(dir, ['.ts', '.js', '.mts'], warnings, (file, source) => {
|
|
160
|
+
const full = join(dir, file)
|
|
161
|
+
const exported = [
|
|
162
|
+
...source.matchAll(/export\s+(?:const|function|async function)\s+(use[A-Z]\w*)/g),
|
|
163
|
+
]
|
|
164
|
+
const names = exported.length
|
|
165
|
+
? exported.map((m) => m[1])
|
|
166
|
+
: [basename(file).replace(/\.\w+$/, '')]
|
|
167
|
+
for (const name of names)
|
|
168
|
+
if (!map.has(name))
|
|
169
|
+
map.set(name, { file: full, origin: 'local', pkg: null, loc: countLoc(source) })
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (generated) {
|
|
173
|
+
for (const [name, info] of generated) {
|
|
174
|
+
if (map.has(name) || !name.startsWith('use')) continue
|
|
175
|
+
if (info.origin === 'library' && info.pkg && !FRAMEWORK.test(info.pkg)) map.set(name, info)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return map
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Wire one parsed script's data-access into typed edges out of `fromId`. */
|
|
182
|
+
function linkScript(g, fromId, parsed, composablesByName) {
|
|
183
|
+
for (const name of parsed.collections) {
|
|
184
|
+
g.node(`collection:${name}`, { type: 'collection', label: name, meta: {} })
|
|
185
|
+
g.edge(fromId, `collection:${name}`, 'queries')
|
|
186
|
+
}
|
|
187
|
+
for (const url of parsed.endpoints) {
|
|
188
|
+
const id = serverId(url)
|
|
189
|
+
g.node(id, { type: 'server', label: url, meta: { inferred: true } })
|
|
190
|
+
g.edge(fromId, id, 'fetches')
|
|
191
|
+
}
|
|
192
|
+
for (const host of parsed.externals) {
|
|
193
|
+
g.node(`external:${host}`, { type: 'external', label: host, meta: {} })
|
|
194
|
+
g.edge(fromId, `external:${host}`, 'fetches')
|
|
195
|
+
}
|
|
196
|
+
for (const name of parsed.calls) {
|
|
197
|
+
if (composablesByName.has(name)) g.edge(fromId, `composable:${name}`, 'uses')
|
|
198
|
+
}
|
|
199
|
+
if (parsed.usesAppConfig) {
|
|
200
|
+
g.node('config:appConfig', { type: 'config', label: 'appConfig', meta: {} })
|
|
201
|
+
g.edge(fromId, 'config:appConfig', 'reads')
|
|
202
|
+
}
|
|
203
|
+
if (parsed.usesRuntimeConfig) {
|
|
204
|
+
g.node('config:runtimeConfig', { type: 'config', label: 'runtimeConfig', meta: {} })
|
|
205
|
+
g.edge(fromId, 'config:runtimeConfig', 'reads')
|
|
206
|
+
}
|
|
207
|
+
if (parsed.usesState) {
|
|
208
|
+
g.node('config:useState', { type: 'config', label: 'useState (shared)', meta: {} })
|
|
209
|
+
g.edge(fromId, 'config:useState', 'reads')
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function scanPages(project, g, componentsByName, composablesByName, warnings) {
|
|
214
|
+
const sources = sourcesOf(project)
|
|
215
|
+
for (let s = 0; s < sources.length; s++) {
|
|
216
|
+
const isLayer = s > 0
|
|
217
|
+
const dir = join(sources[s].srcDir, 'pages')
|
|
218
|
+
await eachFile(dir, ['.vue'], warnings, (file, source) => {
|
|
219
|
+
const full = join(dir, file)
|
|
220
|
+
const route = routeFromFile(file)
|
|
221
|
+
const id = `route:${route}`
|
|
222
|
+
// A route the app defines overrides a layer's.
|
|
223
|
+
if (isLayer && g.has(id)) return
|
|
224
|
+
const parsed = parseSfc(source, full)
|
|
225
|
+
|
|
226
|
+
g.node(id, {
|
|
227
|
+
type: 'route',
|
|
228
|
+
label: route,
|
|
229
|
+
file: rel(full, project.root),
|
|
230
|
+
meta: {
|
|
231
|
+
dynamic: /[[\]]/.test(file),
|
|
232
|
+
props: parsed.props,
|
|
233
|
+
layout: parsed.meta.layout ?? 'default',
|
|
234
|
+
loc: countLoc(source),
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
linkTags(g, id, parsed.tags, componentsByName)
|
|
238
|
+
linkScript(g, id, parsed, composablesByName)
|
|
239
|
+
|
|
240
|
+
const layout = parsed.meta.layout ?? 'default'
|
|
241
|
+
if (layout !== 'false' && g.has(`layout:${layout}`)) g.edge(`layout:${layout}`, id, 'wraps')
|
|
242
|
+
|
|
243
|
+
for (const name of parsed.meta.middleware ?? []) {
|
|
244
|
+
if (g.has(`middleware:${name}`)) g.edge(`middleware:${name}`, id, 'guards')
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// app.vue is the true entry point; without pages/ it's the whole app. The
|
|
250
|
+
// app's own wins; failing that, the nearest layer that defines one.
|
|
251
|
+
for (const src of sourcesOf(project)) {
|
|
252
|
+
const appFile = join(src.srcDir, 'app.vue')
|
|
253
|
+
const app = await readFile(appFile, 'utf8').catch(() => null)
|
|
254
|
+
if (!app) continue
|
|
255
|
+
try {
|
|
256
|
+
const parsed = parseSfc(app, appFile)
|
|
257
|
+
g.node('entry:app', {
|
|
258
|
+
type: 'entry',
|
|
259
|
+
label: 'app.vue',
|
|
260
|
+
file: rel(appFile, project.root),
|
|
261
|
+
meta: { loc: countLoc(app) },
|
|
262
|
+
})
|
|
263
|
+
linkTags(g, 'entry:app', parsed.tags, componentsByName)
|
|
264
|
+
linkScript(g, 'entry:app', parsed, composablesByName)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
warnings.push(`skipped app.vue: ${err.message}`)
|
|
267
|
+
}
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function scanLayouts(project, g, componentsByName, composablesByName, warnings) {
|
|
273
|
+
const sources = sourcesOf(project)
|
|
274
|
+
for (let s = 0; s < sources.length; s++) {
|
|
275
|
+
const isLayer = s > 0
|
|
276
|
+
const dir = join(sources[s].srcDir, 'layouts')
|
|
277
|
+
await eachFile(dir, ['.vue'], warnings, (file, source) => {
|
|
278
|
+
const full = join(dir, file)
|
|
279
|
+
const name = file.replace(/\.vue$/, '')
|
|
280
|
+
const id = `layout:${name}`
|
|
281
|
+
if (isLayer && g.has(id)) return
|
|
282
|
+
const parsed = parseSfc(source, full)
|
|
283
|
+
g.node(id, {
|
|
284
|
+
type: 'layout',
|
|
285
|
+
label: name,
|
|
286
|
+
file: rel(full, project.root),
|
|
287
|
+
meta: { loc: countLoc(source) },
|
|
288
|
+
})
|
|
289
|
+
linkTags(g, id, parsed.tags, componentsByName)
|
|
290
|
+
linkScript(g, id, parsed, composablesByName)
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Composables are where a well-structured app keeps its data access, so
|
|
297
|
+
* skipping their bodies leaves every endpoint they call looking uncalled.
|
|
298
|
+
*
|
|
299
|
+
* Edges attach to every composable a file exports. Files almost always export
|
|
300
|
+
* one, and attributing a file's fetch to all of its exports is a smaller error
|
|
301
|
+
* than dropping the edge entirely.
|
|
302
|
+
*/
|
|
303
|
+
async function scanComposableBodies(project, g, composablesByName, warnings) {
|
|
304
|
+
const byFile = new Map()
|
|
305
|
+
for (const [name, info] of composablesByName) {
|
|
306
|
+
if (info.origin !== 'local') continue
|
|
307
|
+
if (!byFile.has(info.file)) byFile.set(info.file, [])
|
|
308
|
+
byFile.get(info.file).push(name)
|
|
309
|
+
}
|
|
310
|
+
await readEach(
|
|
311
|
+
[...byFile.keys()],
|
|
312
|
+
warnings,
|
|
313
|
+
(file, source) => {
|
|
314
|
+
const parsed = parseScript(source)
|
|
315
|
+
for (const name of byFile.get(file))
|
|
316
|
+
linkScript(g, `composable:${name}`, parsed, composablesByName)
|
|
317
|
+
},
|
|
318
|
+
(file) => rel(file, project.root),
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function scanComponentBodies(project, g, componentsByName, composablesByName, warnings) {
|
|
323
|
+
const locals = [...componentsByName].filter(([, info]) => info.origin === 'local')
|
|
324
|
+
await readEach(
|
|
325
|
+
locals.map(([, info]) => info.file),
|
|
326
|
+
warnings,
|
|
327
|
+
(file, source, i) => {
|
|
328
|
+
const [name] = locals[i]
|
|
329
|
+
const id = `component:${name}`
|
|
330
|
+
const parsed = parseSfc(source, file)
|
|
331
|
+
const node = g.nodes.get(id)
|
|
332
|
+
if (node) node.meta = { ...node.meta, props: parsed.props, loc: countLoc(source) }
|
|
333
|
+
linkTags(g, id, parsed.tags, componentsByName)
|
|
334
|
+
linkScript(g, id, parsed, composablesByName)
|
|
335
|
+
},
|
|
336
|
+
(file) => rel(file, project.root),
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function scanServer(project, g, warnings) {
|
|
341
|
+
const sources = sourcesOf(project)
|
|
342
|
+
for (let s = 0; s < sources.length; s++) {
|
|
343
|
+
const isLayer = s > 0
|
|
344
|
+
for (const base of ['api', 'routes']) {
|
|
345
|
+
const dir = join(sources[s].root, 'server', base)
|
|
346
|
+
await eachFile(dir, ['.ts', '.js', '.mts'], warnings, (file, source) => {
|
|
347
|
+
const full = join(dir, file)
|
|
348
|
+
const { method, path } = serverRoute(file, base)
|
|
349
|
+
const id = serverId(path)
|
|
350
|
+
// A handler the app already provides overrides a layer's. Only a real
|
|
351
|
+
// (non-inferred) handler blocks; an inferred node still wants filling in.
|
|
352
|
+
if (isLayer && g.has(id) && !g.nodes.get(id).meta.inferred) return
|
|
353
|
+
|
|
354
|
+
// A caller may have created this node already from a fetch string. Either
|
|
355
|
+
// way the handler file is the better source of truth for every field.
|
|
356
|
+
const node = g.node(id, { type: 'server', meta: {} })
|
|
357
|
+
node.label = path
|
|
358
|
+
node.file = rel(full, project.root)
|
|
359
|
+
node.meta = { ...node.meta, method, loc: countLoc(source) }
|
|
360
|
+
delete node.meta.inferred
|
|
361
|
+
|
|
362
|
+
const parsed = parseScript(source)
|
|
363
|
+
for (const host of parsed.externals) {
|
|
364
|
+
g.node(`external:${host}`, { type: 'external', label: host, meta: {} })
|
|
365
|
+
g.edge(id, `external:${host}`, 'fetches')
|
|
366
|
+
}
|
|
367
|
+
for (const name of parsed.collections) {
|
|
368
|
+
g.node(`collection:${name}`, { type: 'collection', label: name, meta: {} })
|
|
369
|
+
g.edge(id, `collection:${name}`, 'queries')
|
|
370
|
+
}
|
|
371
|
+
if (parsed.usesRuntimeConfig) {
|
|
372
|
+
g.node('config:runtimeConfig', { type: 'config', label: 'runtimeConfig', meta: {} })
|
|
373
|
+
g.edge(id, 'config:runtimeConfig', 'reads')
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function scanMiddlewareAndPlugins(project, g, composablesByName, warnings) {
|
|
381
|
+
for (const src of sourcesOf(project)) {
|
|
382
|
+
const specs = [
|
|
383
|
+
{ dir: join(src.srcDir, 'middleware'), type: 'middleware' },
|
|
384
|
+
{ dir: join(src.srcDir, 'plugins'), type: 'plugin' },
|
|
385
|
+
{ dir: join(src.root, 'server', 'middleware'), type: 'middleware' },
|
|
386
|
+
]
|
|
387
|
+
for (const { dir, type } of specs) {
|
|
388
|
+
await eachFile(dir, ['.ts', '.js', '.mts', '.vue'], warnings, (file, source) => {
|
|
389
|
+
const full = join(dir, file)
|
|
390
|
+
const name = file.replace(/\.(ts|js|mts|vue)$/, '')
|
|
391
|
+
const server = full.includes('/server/')
|
|
392
|
+
// g.node is first-wins, so the app's file beats a layer's of the same name.
|
|
393
|
+
const id = `${type}:${server ? 'server-' : ''}${name}`
|
|
394
|
+
g.node(id, {
|
|
395
|
+
type,
|
|
396
|
+
label: server ? `${name} (server)` : name,
|
|
397
|
+
file: rel(full, project.root),
|
|
398
|
+
meta: {},
|
|
399
|
+
})
|
|
400
|
+
// Middleware and plugins call composables, fetch endpoints and read
|
|
401
|
+
// config too — skipping their bodies made a composable used only from
|
|
402
|
+
// an auth middleware or an analytics plugin look uncalled.
|
|
403
|
+
const parsed = full.endsWith('.vue') ? parseSfc(source, full) : parseScript(source)
|
|
404
|
+
linkScript(g, id, parsed, composablesByName)
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Content collections are declared, not discovered — read them from config. */
|
|
411
|
+
async function scanContentConfig(project, g) {
|
|
412
|
+
const found = new Map()
|
|
413
|
+
for (const src of sourcesOf(project)) {
|
|
414
|
+
const raw = await readFile(join(src.root, 'content.config.ts'), 'utf8').catch(() => null)
|
|
415
|
+
if (!raw) continue
|
|
416
|
+
for (const m of raw.matchAll(/(\w+)\s*:\s*defineCollection\s*\(/g)) {
|
|
417
|
+
const name = m[1]
|
|
418
|
+
if (found.has(name)) continue // the app's collection wins over a layer's
|
|
419
|
+
const block = sliceBalanced(raw, m.index + m[0].length - 1, '(', ')')
|
|
420
|
+
const source = block.match(
|
|
421
|
+
/source\s*:\s*(?:['"]([^'"]+)['"]|\{[^}]*include\s*:\s*['"]([^'"]+)['"])/,
|
|
422
|
+
)
|
|
423
|
+
const info = {
|
|
424
|
+
type: block.match(/type\s*:\s*['"](\w+)['"]/)?.[1] ?? 'page',
|
|
425
|
+
source: source ? (source[1] ?? source[2]) : null,
|
|
426
|
+
// "empty" is only honest when the source is a local glob we can actually
|
|
427
|
+
// count. A `repository:` (fetched from git), or a source we can't resolve
|
|
428
|
+
// to a glob — a variable, an array of sources — has no countable local
|
|
429
|
+
// files, so it must not be flagged empty. Real on nuxt.com's docs, whose
|
|
430
|
+
// collections point at `source: [docsV3Source, …]` github repositories.
|
|
431
|
+
remote: /\brepository\s*:/.test(block) || (/\bsource\s*:/.test(block) && !source),
|
|
432
|
+
fields: schemaFields(block),
|
|
433
|
+
}
|
|
434
|
+
found.set(name, info)
|
|
435
|
+
g.node(`collection:${name}`, {
|
|
436
|
+
type: 'collection',
|
|
437
|
+
label: name,
|
|
438
|
+
file: 'content.config.ts',
|
|
439
|
+
meta: info,
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return found
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function schemaFields(block) {
|
|
447
|
+
const start = block.indexOf('z.object(')
|
|
448
|
+
if (start === -1) return []
|
|
449
|
+
return topLevelKeys(sliceBalanced(block, block.indexOf('{', start), '{', '}'))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Markdown drives two edges worth drawing: which collection a file belongs to,
|
|
454
|
+
* and which components its MDC body renders.
|
|
455
|
+
*/
|
|
456
|
+
async function scanContentFiles(project, g, collections, componentsByName, warnings) {
|
|
457
|
+
const counts = new Map()
|
|
458
|
+
// A collection's source glob collapses to a fixed path prefix; precompute the
|
|
459
|
+
// [name, prefix] list once instead of rebuilding it and re-running the regex
|
|
460
|
+
// for every content file (an app can have thousands). Kept in collection
|
|
461
|
+
// order, so `.find` still selects the same collection a per-file scan would.
|
|
462
|
+
const prefixes = [...collections].map(([name, info]) => [
|
|
463
|
+
name,
|
|
464
|
+
info.source ? info.source.replace(/\*\*?.*$/, '') : null,
|
|
465
|
+
])
|
|
466
|
+
|
|
467
|
+
// `type: 'data'` collections are JSON or YAML, not markdown. Walking only .md
|
|
468
|
+
// reports those collections as empty when they hold hundreds of files.
|
|
469
|
+
for (const src of sourcesOf(project)) {
|
|
470
|
+
const dir = join(src.root, 'content')
|
|
471
|
+
// @nuxt/content ignores files and directories prefixed with `_` — they're
|
|
472
|
+
// partials, not published pages. Counting them would inflate a collection's
|
|
473
|
+
// file total and draw render edges from content that never renders.
|
|
474
|
+
const files = (await walk(dir, CONTENT_EXTS, dir)).filter(
|
|
475
|
+
(f) => !f.split('/').some((seg) => seg.startsWith('_')),
|
|
476
|
+
)
|
|
477
|
+
if (!files.length) continue
|
|
478
|
+
|
|
479
|
+
// Only markdown renders components, so only markdown needs reading. Fan the
|
|
480
|
+
// reads out, but process files below in walk order so node insertion — and
|
|
481
|
+
// therefore the serialized output — stays identical to a serial scan.
|
|
482
|
+
const md = files.filter((f) => f.endsWith('.md'))
|
|
483
|
+
const mdSources = await Promise.all(
|
|
484
|
+
md.map((f) => readFile(join(dir, f), 'utf8').catch(() => null)),
|
|
485
|
+
)
|
|
486
|
+
const sourceOf = new Map(md.map((f, i) => [f, mdSources[i]]))
|
|
487
|
+
|
|
488
|
+
for (const file of files) {
|
|
489
|
+
const collection =
|
|
490
|
+
prefixes.find(([, prefix]) => prefix != null && file.startsWith(prefix))?.[0] ??
|
|
491
|
+
file.split('/')[0]
|
|
492
|
+
|
|
493
|
+
counts.set(collection, (counts.get(collection) ?? 0) + 1)
|
|
494
|
+
const id = `collection:${collection}`
|
|
495
|
+
if (!g.has(id)) g.node(id, { type: 'collection', label: collection, meta: {} })
|
|
496
|
+
|
|
497
|
+
if (!file.endsWith('.md')) continue
|
|
498
|
+
const raw = sourceOf.get(file)
|
|
499
|
+
if (raw == null) {
|
|
500
|
+
warnings.push(`could not read ${file}`)
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
for (const tag of parseMdcComponents(raw)) {
|
|
505
|
+
const name = normalizeTag(tag)
|
|
506
|
+
const info = componentsByName.get(name)
|
|
507
|
+
if (!info) continue
|
|
508
|
+
if (info.origin === 'library') {
|
|
509
|
+
g.node(`component:${name}`, {
|
|
510
|
+
type: 'lib-component',
|
|
511
|
+
label: name,
|
|
512
|
+
file: shortenPkgPath(info.file),
|
|
513
|
+
meta: { origin: 'library', pkg: info.pkg },
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
g.edge(id, `component:${name}`, 'renders')
|
|
517
|
+
}
|
|
518
|
+
} catch (err) {
|
|
519
|
+
warnings.push(`skipped ${file}: ${err.message}`)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const [name, count] of counts) {
|
|
525
|
+
const node = g.nodes.get(`collection:${name}`)
|
|
526
|
+
if (node) node.meta = { ...node.meta, files: count }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function serverRoute(file, base) {
|
|
531
|
+
const withoutExt = file.replace(/\.(ts|js|mts)$/, '')
|
|
532
|
+
const methodMatch = withoutExt.match(/\.(get|post|put|patch|delete|head|options)$/)
|
|
533
|
+
const method = methodMatch ? methodMatch[1].toUpperCase() : 'ALL'
|
|
534
|
+
const clean = withoutExt
|
|
535
|
+
.replace(/\.(get|post|put|patch|delete|head|options)$/, '')
|
|
536
|
+
.replace(/\/index$/, '')
|
|
537
|
+
.split('/')
|
|
538
|
+
.map((s) => (s.startsWith('[...') ? `**` : s.startsWith('[') ? `:${s.slice(1, -1)}` : s))
|
|
539
|
+
.join('/')
|
|
540
|
+
return { method, path: `/${base === 'api' ? 'api/' : ''}${clean}` }
|
|
541
|
+
}
|