@pyreon/mcp 0.5.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/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +13376 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +3392 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +1 -0
- package/package.json +55 -0
- package/src/api-reference.ts +447 -0
- package/src/index.ts +245 -0
- package/src/project-scanner.ts +215 -0
- package/src/tests/api-reference.test.ts +14 -0
- package/src/tests/project-scanner.test.ts +294 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project scanner — extracts route, component, and island information from source files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs"
|
|
6
|
+
import * as path from "node:path"
|
|
7
|
+
|
|
8
|
+
export interface RouteInfo {
|
|
9
|
+
path: string
|
|
10
|
+
name?: string | undefined
|
|
11
|
+
component?: string | undefined
|
|
12
|
+
hasLoader: boolean
|
|
13
|
+
hasGuard: boolean
|
|
14
|
+
params: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ComponentInfo {
|
|
18
|
+
name: string
|
|
19
|
+
file: string
|
|
20
|
+
hasSignals: boolean
|
|
21
|
+
signalNames: string[]
|
|
22
|
+
props: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IslandInfo {
|
|
26
|
+
name: string
|
|
27
|
+
file: string
|
|
28
|
+
hydrate: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProjectContext {
|
|
32
|
+
framework: "pyreon"
|
|
33
|
+
version: string
|
|
34
|
+
generatedAt: string
|
|
35
|
+
routes: RouteInfo[]
|
|
36
|
+
components: ComponentInfo[]
|
|
37
|
+
islands: IslandInfo[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function generateContext(cwd: string): ProjectContext {
|
|
41
|
+
const files = collectSourceFiles(cwd)
|
|
42
|
+
const version = readVersion(cwd)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
framework: "pyreon",
|
|
46
|
+
version,
|
|
47
|
+
generatedAt: new Date().toISOString(),
|
|
48
|
+
routes: extractRoutes(files, cwd),
|
|
49
|
+
components: extractComponents(files, cwd),
|
|
50
|
+
islands: extractIslands(files, cwd),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectSourceFiles(cwd: string): string[] {
|
|
55
|
+
const results: string[] = []
|
|
56
|
+
const extensions = new Set([".tsx", ".jsx", ".ts", ".js"])
|
|
57
|
+
const ignoreDirs = new Set(["node_modules", "dist", "lib", ".pyreon", ".git", "build"])
|
|
58
|
+
|
|
59
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: simple recursive walker
|
|
60
|
+
function walk(dir: string): void {
|
|
61
|
+
let entries: fs.Dirent[]
|
|
62
|
+
try {
|
|
63
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
64
|
+
} catch {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) continue
|
|
69
|
+
if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue
|
|
70
|
+
const fullPath = path.join(dir, entry.name)
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
walk(fullPath)
|
|
73
|
+
} else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
|
|
74
|
+
results.push(fullPath)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
walk(cwd)
|
|
80
|
+
return results
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractRoutes(files: string[], _cwd: string): RouteInfo[] {
|
|
84
|
+
const routes: RouteInfo[] = []
|
|
85
|
+
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
let code: string
|
|
88
|
+
try {
|
|
89
|
+
code = fs.readFileSync(file, "utf-8")
|
|
90
|
+
} catch {
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const routeArrayRe =
|
|
95
|
+
/(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g
|
|
96
|
+
let match: RegExpExecArray | null
|
|
97
|
+
for (match = routeArrayRe.exec(code); match; match = routeArrayRe.exec(code)) {
|
|
98
|
+
const block = match[1] ?? ""
|
|
99
|
+
const routeObjRe = /path\s*:\s*["']([^"']+)["']/g
|
|
100
|
+
let routeMatch: RegExpExecArray | null
|
|
101
|
+
for (routeMatch = routeObjRe.exec(block); routeMatch; routeMatch = routeObjRe.exec(block)) {
|
|
102
|
+
const routePath = routeMatch[1] ?? ""
|
|
103
|
+
const surroundingStart = Math.max(0, routeMatch.index - 50)
|
|
104
|
+
const surroundingEnd = Math.min(block.length, routeMatch.index + 200)
|
|
105
|
+
const surrounding = block.slice(surroundingStart, surroundingEnd)
|
|
106
|
+
|
|
107
|
+
routes.push({
|
|
108
|
+
path: routePath,
|
|
109
|
+
name: surrounding.match(/name\s*:\s*["']([^"']+)["']/)?.[1],
|
|
110
|
+
hasLoader: /loader\s*:/.test(surrounding),
|
|
111
|
+
hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
|
|
112
|
+
params: extractParams(routePath),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return routes
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractComponents(files: string[], cwd: string): ComponentInfo[] {
|
|
122
|
+
const components: ComponentInfo[] = []
|
|
123
|
+
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
let code: string
|
|
126
|
+
try {
|
|
127
|
+
code = fs.readFileSync(file, "utf-8")
|
|
128
|
+
} catch {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const componentRe =
|
|
133
|
+
/(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g
|
|
134
|
+
let match: RegExpExecArray | null
|
|
135
|
+
|
|
136
|
+
for (match = componentRe.exec(code); match; match = componentRe.exec(code)) {
|
|
137
|
+
const name = match[1] ?? "Unknown"
|
|
138
|
+
const propsStr = match[2] ?? ""
|
|
139
|
+
const props = propsStr
|
|
140
|
+
.split(/[,;]/)
|
|
141
|
+
.map((p) => p.trim().replace(/[{}]/g, "").trim().split(":")[0]?.split("=")[0]?.trim() ?? "")
|
|
142
|
+
.filter((p) => p && p !== "props")
|
|
143
|
+
|
|
144
|
+
const bodyStart = match.index + match[0].length
|
|
145
|
+
const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2000))
|
|
146
|
+
const signalNames: string[] = []
|
|
147
|
+
const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g
|
|
148
|
+
let sigMatch: RegExpExecArray | null
|
|
149
|
+
for (sigMatch = signalRe.exec(body); sigMatch; sigMatch = signalRe.exec(body)) {
|
|
150
|
+
if (sigMatch[1]) signalNames.push(sigMatch[1])
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
components.push({
|
|
154
|
+
name,
|
|
155
|
+
file: path.relative(cwd, file),
|
|
156
|
+
hasSignals: signalNames.length > 0,
|
|
157
|
+
signalNames,
|
|
158
|
+
props,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return components
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractIslands(files: string[], cwd: string): IslandInfo[] {
|
|
167
|
+
const islands: IslandInfo[] = []
|
|
168
|
+
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
let code: string
|
|
171
|
+
try {
|
|
172
|
+
code = fs.readFileSync(file, "utf-8")
|
|
173
|
+
} catch {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const islandRe =
|
|
178
|
+
/island\s*\(\s*\(\)\s*=>\s*import\(.+?\)\s*,\s*\{[^}]*name\s*:\s*["']([^"']+)["'][^}]*?(?:hydrate\s*:\s*["']([^"']+)["'])?[^}]*\}/g
|
|
179
|
+
let match: RegExpExecArray | null
|
|
180
|
+
for (match = islandRe.exec(code); match; match = islandRe.exec(code)) {
|
|
181
|
+
if (match[1]) {
|
|
182
|
+
islands.push({
|
|
183
|
+
name: match[1],
|
|
184
|
+
file: path.relative(cwd, file),
|
|
185
|
+
hydrate: match[2] ?? "load",
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return islands
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractParams(routePath: string): string[] {
|
|
195
|
+
const params: string[] = []
|
|
196
|
+
const paramRe = /:(\w+)\??/g
|
|
197
|
+
let match: RegExpExecArray | null
|
|
198
|
+
for (match = paramRe.exec(routePath); match; match = paramRe.exec(routePath)) {
|
|
199
|
+
if (match[1]) params.push(match[1])
|
|
200
|
+
}
|
|
201
|
+
return params
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readVersion(cwd: string): string {
|
|
205
|
+
try {
|
|
206
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"))
|
|
207
|
+
const deps: Record<string, unknown> = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
208
|
+
for (const [name, ver] of Object.entries(deps)) {
|
|
209
|
+
if (name.startsWith("@pyreon/") && typeof ver === "string") return ver.replace(/^[\^~]/, "")
|
|
210
|
+
}
|
|
211
|
+
return (pkg.version as string) || "unknown"
|
|
212
|
+
} catch {
|
|
213
|
+
return "unknown"
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { API_REFERENCE } from "../api-reference"
|
|
2
|
+
|
|
3
|
+
describe("api-reference", () => {
|
|
4
|
+
it("has entries", () => {
|
|
5
|
+
expect(Object.keys(API_REFERENCE).length).toBeGreaterThan(0)
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it("entries have required fields", () => {
|
|
9
|
+
for (const [key, entry] of Object.entries(API_REFERENCE)) {
|
|
10
|
+
expect(entry.signature, `${key} missing signature`).toBeTruthy()
|
|
11
|
+
expect(entry.example, `${key} missing example`).toBeTruthy()
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as os from "node:os"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
import { generateContext } from "../project-scanner"
|
|
5
|
+
|
|
6
|
+
function makeTmpDir(): string {
|
|
7
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "pyreon-scanner-"))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function writeFile(dir: string, relPath: string, content: string): void {
|
|
11
|
+
const full = path.join(dir, relPath)
|
|
12
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
13
|
+
fs.writeFileSync(full, content, "utf-8")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("generateContext", () => {
|
|
17
|
+
let tmpDir: string
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = makeTmpDir()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("returns correct framework and version from @pyreon dependency", () => {
|
|
28
|
+
writeFile(
|
|
29
|
+
tmpDir,
|
|
30
|
+
"package.json",
|
|
31
|
+
JSON.stringify({ dependencies: { "@pyreon/core": "^1.2.3" } }),
|
|
32
|
+
)
|
|
33
|
+
const ctx = generateContext(tmpDir)
|
|
34
|
+
expect(ctx.framework).toBe("pyreon")
|
|
35
|
+
expect(ctx.version).toBe("1.2.3")
|
|
36
|
+
expect(ctx.generatedAt).toBeTruthy()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("falls back to package version when no @pyreon dep", () => {
|
|
40
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "0.5.0" }))
|
|
41
|
+
const ctx = generateContext(tmpDir)
|
|
42
|
+
expect(ctx.version).toBe("0.5.0")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("returns unknown version when no package.json", () => {
|
|
46
|
+
const ctx = generateContext(tmpDir)
|
|
47
|
+
expect(ctx.version).toBe("unknown")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("extracts routes from createRouter calls", () => {
|
|
51
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
52
|
+
writeFile(
|
|
53
|
+
tmpDir,
|
|
54
|
+
"src/router.ts",
|
|
55
|
+
`
|
|
56
|
+
import { createRouter } from "@pyreon/router"
|
|
57
|
+
const router = createRouter([
|
|
58
|
+
{ path: "/", component: Home },
|
|
59
|
+
{ path: "/about", component: About },
|
|
60
|
+
])
|
|
61
|
+
`,
|
|
62
|
+
)
|
|
63
|
+
const ctx = generateContext(tmpDir)
|
|
64
|
+
expect(ctx.routes).toHaveLength(2)
|
|
65
|
+
expect(ctx.routes[0]?.path).toBe("/")
|
|
66
|
+
expect(ctx.routes[1]?.path).toBe("/about")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("extracts routes from routes variable", () => {
|
|
70
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
71
|
+
writeFile(
|
|
72
|
+
tmpDir,
|
|
73
|
+
"src/routes.ts",
|
|
74
|
+
`
|
|
75
|
+
const routes = [
|
|
76
|
+
{ path: "/home", component: Home },
|
|
77
|
+
]
|
|
78
|
+
`,
|
|
79
|
+
)
|
|
80
|
+
const ctx = generateContext(tmpDir)
|
|
81
|
+
expect(ctx.routes).toHaveLength(1)
|
|
82
|
+
expect(ctx.routes[0]?.path).toBe("/home")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("extracts route params", () => {
|
|
86
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
87
|
+
writeFile(
|
|
88
|
+
tmpDir,
|
|
89
|
+
"src/router.ts",
|
|
90
|
+
`
|
|
91
|
+
const routes = [
|
|
92
|
+
{ path: "/users/:id/posts/:postId" },
|
|
93
|
+
]
|
|
94
|
+
`,
|
|
95
|
+
)
|
|
96
|
+
const ctx = generateContext(tmpDir)
|
|
97
|
+
expect(ctx.routes[0]?.params).toEqual(["id", "postId"])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("extracts optional route params", () => {
|
|
101
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
102
|
+
writeFile(
|
|
103
|
+
tmpDir,
|
|
104
|
+
"src/router.ts",
|
|
105
|
+
`
|
|
106
|
+
const routes = [
|
|
107
|
+
{ path: "/search/:query?" },
|
|
108
|
+
]
|
|
109
|
+
`,
|
|
110
|
+
)
|
|
111
|
+
const ctx = generateContext(tmpDir)
|
|
112
|
+
expect(ctx.routes[0]?.params).toEqual(["query"])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("detects loaders and guards", () => {
|
|
116
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
117
|
+
writeFile(
|
|
118
|
+
tmpDir,
|
|
119
|
+
"src/router.ts",
|
|
120
|
+
`
|
|
121
|
+
const routes = [
|
|
122
|
+
{ path: "/dash", loader: fetchData, beforeEnter: authGuard },
|
|
123
|
+
]
|
|
124
|
+
`,
|
|
125
|
+
)
|
|
126
|
+
const ctx = generateContext(tmpDir)
|
|
127
|
+
expect(ctx.routes[0]?.hasLoader).toBe(true)
|
|
128
|
+
expect(ctx.routes[0]?.hasGuard).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("detects beforeLeave guard", () => {
|
|
132
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
133
|
+
writeFile(
|
|
134
|
+
tmpDir,
|
|
135
|
+
"src/router.ts",
|
|
136
|
+
`
|
|
137
|
+
const routes = [
|
|
138
|
+
{ path: "/form", beforeLeave: confirmLeave },
|
|
139
|
+
]
|
|
140
|
+
`,
|
|
141
|
+
)
|
|
142
|
+
const ctx = generateContext(tmpDir)
|
|
143
|
+
expect(ctx.routes[0]?.hasGuard).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("extracts route names", () => {
|
|
147
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
148
|
+
writeFile(
|
|
149
|
+
tmpDir,
|
|
150
|
+
"src/router.ts",
|
|
151
|
+
`
|
|
152
|
+
const routes = [
|
|
153
|
+
{ path: "/profile", name: "profile", component: Profile },
|
|
154
|
+
]
|
|
155
|
+
`,
|
|
156
|
+
)
|
|
157
|
+
const ctx = generateContext(tmpDir)
|
|
158
|
+
expect(ctx.routes[0]?.name).toBe("profile")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("extracts component names with props", () => {
|
|
162
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
163
|
+
writeFile(
|
|
164
|
+
tmpDir,
|
|
165
|
+
"src/Button.tsx",
|
|
166
|
+
`
|
|
167
|
+
export function Button(props: { label: string; disabled: boolean }) {
|
|
168
|
+
return <button>{props.label}</button>
|
|
169
|
+
}
|
|
170
|
+
`,
|
|
171
|
+
)
|
|
172
|
+
const ctx = generateContext(tmpDir)
|
|
173
|
+
expect(ctx.components).toHaveLength(1)
|
|
174
|
+
expect(ctx.components[0]?.name).toBe("Button")
|
|
175
|
+
expect(ctx.components[0]?.file).toBe("src/Button.tsx")
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("detects signals in components", () => {
|
|
179
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
180
|
+
writeFile(
|
|
181
|
+
tmpDir,
|
|
182
|
+
"src/Counter.tsx",
|
|
183
|
+
`
|
|
184
|
+
function Counter() {
|
|
185
|
+
const count = signal<number>(0)
|
|
186
|
+
const name = signal<string>("hello")
|
|
187
|
+
return <div>{count()}</div>
|
|
188
|
+
}
|
|
189
|
+
`,
|
|
190
|
+
)
|
|
191
|
+
const ctx = generateContext(tmpDir)
|
|
192
|
+
expect(ctx.components[0]?.hasSignals).toBe(true)
|
|
193
|
+
expect(ctx.components[0]?.signalNames).toEqual(["count", "name"])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("reports no signals when none present", () => {
|
|
197
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
198
|
+
writeFile(
|
|
199
|
+
tmpDir,
|
|
200
|
+
"src/Static.tsx",
|
|
201
|
+
`
|
|
202
|
+
function Static() {
|
|
203
|
+
return <div>hello</div>
|
|
204
|
+
}
|
|
205
|
+
`,
|
|
206
|
+
)
|
|
207
|
+
const ctx = generateContext(tmpDir)
|
|
208
|
+
expect(ctx.components[0]?.hasSignals).toBe(false)
|
|
209
|
+
expect(ctx.components[0]?.signalNames).toEqual([])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("extracts island declarations with hydrate strategy", () => {
|
|
213
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
214
|
+
writeFile(
|
|
215
|
+
tmpDir,
|
|
216
|
+
"src/islands.ts",
|
|
217
|
+
`
|
|
218
|
+
const Counter = island(() => import("./Counter"), { name: "Counter", hydrate: "visible" })
|
|
219
|
+
const Nav = island(() => import("./Nav"), { name: "Nav", hydrate: "idle" })
|
|
220
|
+
`,
|
|
221
|
+
)
|
|
222
|
+
const ctx = generateContext(tmpDir)
|
|
223
|
+
expect(ctx.islands).toHaveLength(2)
|
|
224
|
+
expect(ctx.islands[0]?.name).toBe("Counter")
|
|
225
|
+
expect(ctx.islands[1]?.name).toBe("Nav")
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it("defaults hydrate to load when not specified", () => {
|
|
229
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
230
|
+
writeFile(
|
|
231
|
+
tmpDir,
|
|
232
|
+
"src/islands.ts",
|
|
233
|
+
`
|
|
234
|
+
const Widget = island(() => import("./Widget"), { name: "Widget" })
|
|
235
|
+
`,
|
|
236
|
+
)
|
|
237
|
+
const ctx = generateContext(tmpDir)
|
|
238
|
+
expect(ctx.islands[0]?.hydrate).toBe("load")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("returns empty arrays for empty project", () => {
|
|
242
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
243
|
+
const ctx = generateContext(tmpDir)
|
|
244
|
+
expect(ctx.routes).toEqual([])
|
|
245
|
+
expect(ctx.components).toEqual([])
|
|
246
|
+
expect(ctx.islands).toEqual([])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("skips node_modules and dist directories", () => {
|
|
250
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
251
|
+
writeFile(
|
|
252
|
+
tmpDir,
|
|
253
|
+
"node_modules/@pyreon/core/src/index.ts",
|
|
254
|
+
`function Internal() { return null }`,
|
|
255
|
+
)
|
|
256
|
+
writeFile(tmpDir, "dist/bundle.ts", `function Bundled() { return null }`)
|
|
257
|
+
writeFile(tmpDir, "src/App.tsx", `function App() { return <div /> }`)
|
|
258
|
+
const ctx = generateContext(tmpDir)
|
|
259
|
+
expect(ctx.components).toHaveLength(1)
|
|
260
|
+
expect(ctx.components[0]?.name).toBe("App")
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it("handles unreadable directories gracefully", () => {
|
|
264
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
265
|
+
writeFile(tmpDir, "src/App.tsx", `function App() { return <div /> }`)
|
|
266
|
+
const badDir = path.join(tmpDir, "restricted")
|
|
267
|
+
fs.mkdirSync(badDir)
|
|
268
|
+
fs.chmodSync(badDir, 0o000)
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const ctx = generateContext(tmpDir)
|
|
272
|
+
expect(ctx.components).toHaveLength(1)
|
|
273
|
+
expect(ctx.components[0]?.name).toBe("App")
|
|
274
|
+
} finally {
|
|
275
|
+
fs.chmodSync(badDir, 0o755)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it("extracts const arrow function components", () => {
|
|
280
|
+
writeFile(tmpDir, "package.json", JSON.stringify({ version: "1.0.0" }))
|
|
281
|
+
writeFile(
|
|
282
|
+
tmpDir,
|
|
283
|
+
"src/Card.tsx",
|
|
284
|
+
`
|
|
285
|
+
export const Card = (props: CardProps) => {
|
|
286
|
+
return <div>{props.title}</div>
|
|
287
|
+
}
|
|
288
|
+
`,
|
|
289
|
+
)
|
|
290
|
+
const ctx = generateContext(tmpDir)
|
|
291
|
+
expect(ctx.components).toHaveLength(1)
|
|
292
|
+
expect(ctx.components[0]?.name).toBe("Card")
|
|
293
|
+
})
|
|
294
|
+
})
|