@test328932/test328933 1.0.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/package.json +41 -0
- package/src/component-border.js +839 -0
- package/src/component-values.js +248 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +6 -0
- package/src/llm-explain-inject.js +218 -0
- package/src/runtime/index.d.ts +4 -0
- package/src/runtime/index.js +2 -0
- package/src/runtime/llm-explain-panel.jsx +813 -0
- package/src/runtime/llm-worker.js +7 -0
- package/src/save-file.js +46 -0
- package/src/source-compressor.js +352 -0
- package/src/source-explorer.js +129 -0
package/src/save-file.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
function handleSaveRequest(req, res, filePath) {
|
|
5
|
+
if (req.method !== 'POST') {
|
|
6
|
+
res.statusCode = 405
|
|
7
|
+
res.end('Method not allowed')
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let body = ''
|
|
12
|
+
req.on('data', chunk => { body += chunk })
|
|
13
|
+
req.on('end', () => {
|
|
14
|
+
try {
|
|
15
|
+
const items = JSON.parse(body)
|
|
16
|
+
fs.writeFileSync(filePath, JSON.stringify(items, null, 2))
|
|
17
|
+
res.setHeader('Content-Type', 'application/json')
|
|
18
|
+
res.end(JSON.stringify({ ok: true }))
|
|
19
|
+
} catch (e) {
|
|
20
|
+
res.statusCode = 400
|
|
21
|
+
res.end(JSON.stringify({ error: e.message }))
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function saveFilePlugin() {
|
|
27
|
+
let rootDir = ''
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
name: 'vite-plugin-save-file',
|
|
31
|
+
configResolved(config) {
|
|
32
|
+
rootDir = path.resolve(config.root, 'public')
|
|
33
|
+
},
|
|
34
|
+
configureServer(server) {
|
|
35
|
+
server.middlewares.use('/__save-list-items', (req, res) => {
|
|
36
|
+
if (!fs.existsSync(rootDir)) fs.mkdirSync(rootDir, { recursive: true })
|
|
37
|
+
handleSaveRequest(req, res, path.join(rootDir, 'listitems.json'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
server.middlewares.use('/__save-api-objects', (req, res) => {
|
|
41
|
+
if (!fs.existsSync(rootDir)) fs.mkdirSync(rootDir, { recursive: true })
|
|
42
|
+
handleSaveRequest(req, res, path.join(rootDir, 'apiobjects.json'))
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parse } from '@babel/parser'
|
|
4
|
+
import traverse from '@babel/traverse'
|
|
5
|
+
import generate from '@babel/generator'
|
|
6
|
+
import * as t from '@babel/types'
|
|
7
|
+
|
|
8
|
+
const traverseAst = traverse.default ?? traverse
|
|
9
|
+
const generateCode = generate.default ?? generate
|
|
10
|
+
|
|
11
|
+
const SOURCE_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js'])
|
|
12
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'public'])
|
|
13
|
+
const SKIP_PATTERNS = [/\.test\./, /\.spec\./, /\.d\.ts$/, /\.config\./]
|
|
14
|
+
|
|
15
|
+
const TAILWIND_RE = /^[a-z][\w-]*(?:\s+[a-z][\w-/[\]:]*){2,}$/
|
|
16
|
+
const MAX_STRING_LENGTH = 100
|
|
17
|
+
|
|
18
|
+
// --- File collection ---
|
|
19
|
+
|
|
20
|
+
function collectSourceFiles(dir, root = dir) {
|
|
21
|
+
const results = []
|
|
22
|
+
let entries
|
|
23
|
+
try {
|
|
24
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
25
|
+
} catch {
|
|
26
|
+
return results
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.name.startsWith('.')) continue
|
|
31
|
+
|
|
32
|
+
const abs = path.join(dir, entry.name)
|
|
33
|
+
const rel = path.relative(root, abs).split(path.sep).join('/')
|
|
34
|
+
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
37
|
+
results.push(...collectSourceFiles(abs, root))
|
|
38
|
+
}
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ext = path.extname(entry.name)
|
|
43
|
+
if (!SOURCE_EXTENSIONS.has(ext)) continue
|
|
44
|
+
if (SKIP_PATTERNS.some(p => p.test(entry.name))) continue
|
|
45
|
+
|
|
46
|
+
results.push({ abs, rel })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return results.sort((a, b) => a.rel.localeCompare(b.rel))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- AST transforms ---
|
|
53
|
+
|
|
54
|
+
function isJSXFile(filename) {
|
|
55
|
+
return /\.[jt]sx$/.test(filename)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseCode(code, filename) {
|
|
59
|
+
return parse(code, {
|
|
60
|
+
sourceType: 'module',
|
|
61
|
+
plugins: [
|
|
62
|
+
'jsx',
|
|
63
|
+
...(filename.endsWith('.ts') || filename.endsWith('.tsx') ? ['typescript'] : []),
|
|
64
|
+
'decorators-legacy',
|
|
65
|
+
'classProperties',
|
|
66
|
+
'optionalChaining',
|
|
67
|
+
'nullishCoalescingOperator',
|
|
68
|
+
],
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compressFile(code, filename) {
|
|
73
|
+
let ast
|
|
74
|
+
try {
|
|
75
|
+
ast = parseCode(code, filename)
|
|
76
|
+
} catch {
|
|
77
|
+
// If we can't parse it, return the raw code truncated
|
|
78
|
+
return `// [parse error — raw source]\n${code.slice(0, 500)}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
traverseAst(ast, {
|
|
82
|
+
// 1. Strip import paths — keep bindings only
|
|
83
|
+
ImportDeclaration(nodePath) {
|
|
84
|
+
// import { X, Y } from 'some/long/path' → import { X, Y }
|
|
85
|
+
// Keep the source for relative imports (they show project structure)
|
|
86
|
+
const src = nodePath.node.source.value
|
|
87
|
+
if (!src.startsWith('.') && !src.startsWith('/')) {
|
|
88
|
+
// External package — shorten to just the package name
|
|
89
|
+
const parts = src.split('/')
|
|
90
|
+
const pkgName = src.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
|
|
91
|
+
nodePath.node.source.value = pkgName
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// 2. Strip Tailwind className values + strip large inline style objects
|
|
96
|
+
JSXAttribute(nodePath) {
|
|
97
|
+
const attrName = nodePath.node.name.name
|
|
98
|
+
|
|
99
|
+
if (attrName === 'className') {
|
|
100
|
+
const value = nodePath.node.value
|
|
101
|
+
if (t.isStringLiteral(value) && TAILWIND_RE.test(value.value)) {
|
|
102
|
+
value.value = '[tw]'
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (t.isJSXExpressionContainer(value)) {
|
|
106
|
+
const expr = value.expression
|
|
107
|
+
if (t.isTemplateLiteral(expr)) {
|
|
108
|
+
const hasTw = expr.quasis.some(q => TAILWIND_RE.test(q.value.raw.trim()))
|
|
109
|
+
if (hasTw) {
|
|
110
|
+
nodePath.node.value = t.stringLiteral('[tw]')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (t.isCallExpression(expr)) {
|
|
114
|
+
const callee = expr.callee
|
|
115
|
+
const name = t.isIdentifier(callee) ? callee.name : null
|
|
116
|
+
if (name === 'cn' || name === 'clsx' || name === 'cva' || name === 'twMerge') {
|
|
117
|
+
nodePath.node.value = t.stringLiteral('[tw]')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (attrName === 'style') {
|
|
125
|
+
const value = nodePath.node.value
|
|
126
|
+
if (!t.isJSXExpressionContainer(value)) return
|
|
127
|
+
const expr = value.expression
|
|
128
|
+
if (t.isObjectExpression(expr) && expr.properties.length > 2) {
|
|
129
|
+
value.expression = t.identifier('styles')
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// 3. Collapse SVG elements
|
|
135
|
+
JSXElement(nodePath) {
|
|
136
|
+
const opening = nodePath.node.openingElement
|
|
137
|
+
if (t.isJSXIdentifier(opening.name) && opening.name.name === 'svg') {
|
|
138
|
+
nodePath.replaceWith(
|
|
139
|
+
t.jsxElement(
|
|
140
|
+
t.jsxOpeningElement(t.jsxIdentifier('svg'), [], true),
|
|
141
|
+
null,
|
|
142
|
+
[],
|
|
143
|
+
true,
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
nodePath.skip()
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// 4. Truncate long string literals
|
|
151
|
+
StringLiteral(nodePath) {
|
|
152
|
+
if (nodePath.node.value.length > MAX_STRING_LENGTH) {
|
|
153
|
+
if (t.isImportDeclaration(nodePath.parent)) return
|
|
154
|
+
nodePath.node.value = nodePath.node.value.slice(0, MAX_STRING_LENGTH) + '...'
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
TemplateLiteral(nodePath) {
|
|
159
|
+
for (const quasi of nodePath.node.quasis) {
|
|
160
|
+
if (quasi.value.raw.length > MAX_STRING_LENGTH) {
|
|
161
|
+
const truncated = quasi.value.raw.slice(0, MAX_STRING_LENGTH) + '...'
|
|
162
|
+
quasi.value = { raw: truncated, cooked: truncated }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// 6. Strip comments
|
|
168
|
+
enter(nodePath) {
|
|
169
|
+
const node = nodePath.node
|
|
170
|
+
if (node.leadingComments) node.leadingComments = []
|
|
171
|
+
if (node.trailingComments) node.trailingComments = []
|
|
172
|
+
if (node.innerComments) node.innerComments = []
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
// 7. Strip type annotations (keep simple ones, remove complex generics)
|
|
176
|
+
TSTypeAnnotation(nodePath) {
|
|
177
|
+
const annotation = nodePath.node.typeAnnotation
|
|
178
|
+
// Keep simple types like : string, : number, : boolean
|
|
179
|
+
if (t.isTSStringKeyword(annotation)) return
|
|
180
|
+
if (t.isTSNumberKeyword(annotation)) return
|
|
181
|
+
if (t.isTSBooleanKeyword(annotation)) return
|
|
182
|
+
// Remove complex types
|
|
183
|
+
if (t.isTSUnionType(annotation) || t.isTSIntersectionType(annotation) ||
|
|
184
|
+
t.isTSTypeLiteral(annotation) || t.isTSMappedType(annotation) ||
|
|
185
|
+
t.isTSConditionalType(annotation)) {
|
|
186
|
+
nodePath.remove()
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// Remove type-only exports and interfaces
|
|
191
|
+
TSInterfaceDeclaration(nodePath) {
|
|
192
|
+
nodePath.remove()
|
|
193
|
+
},
|
|
194
|
+
TSTypeAliasDeclaration(nodePath) {
|
|
195
|
+
nodePath.remove()
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const { code: output } = generateCode(ast, {
|
|
200
|
+
retainLines: false,
|
|
201
|
+
compact: false,
|
|
202
|
+
concise: true,
|
|
203
|
+
comments: false,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return output
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Repeated JSX siblings collapse ---
|
|
210
|
+
|
|
211
|
+
function collapseRepeatedJSX(code) {
|
|
212
|
+
// Post-process: detect consecutive identical JSX tags and collapse
|
|
213
|
+
// This is simpler as a text transform after generation
|
|
214
|
+
const lines = code.split('\n')
|
|
215
|
+
const result = []
|
|
216
|
+
let repeatTag = null
|
|
217
|
+
let repeatCount = 0
|
|
218
|
+
|
|
219
|
+
for (const line of lines) {
|
|
220
|
+
const tagMatch = line.match(/^\s*<(\w+)\s/)
|
|
221
|
+
if (tagMatch && tagMatch[1] === repeatTag) {
|
|
222
|
+
repeatCount++
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (repeatCount > 0) {
|
|
227
|
+
result.push(`{/* ×${repeatCount} more <${repeatTag}/> */}`)
|
|
228
|
+
repeatCount = 0
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
repeatTag = tagMatch ? tagMatch[1] : null
|
|
232
|
+
result.push(line)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (repeatCount > 0) {
|
|
236
|
+
result.push(`{/* ×${repeatCount} more <${repeatTag}/> */}`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result.join('\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Main build ---
|
|
243
|
+
|
|
244
|
+
function buildCompressedSource(srcDir) {
|
|
245
|
+
const files = collectSourceFiles(srcDir)
|
|
246
|
+
const parts = []
|
|
247
|
+
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
let code
|
|
250
|
+
try {
|
|
251
|
+
code = fs.readFileSync(file.abs, 'utf8')
|
|
252
|
+
} catch {
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!code.trim()) continue
|
|
257
|
+
|
|
258
|
+
const compressed = compressFile(code, file.rel)
|
|
259
|
+
const collapsed = collapseRepeatedJSX(compressed)
|
|
260
|
+
|
|
261
|
+
parts.push(`--- ${file.rel} ---\n${collapsed}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return parts.join('\n\n')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Vite plugin ---
|
|
268
|
+
|
|
269
|
+
function sendJson(res, statusCode, body) {
|
|
270
|
+
res.statusCode = statusCode
|
|
271
|
+
res.setHeader('Content-Type', 'application/json')
|
|
272
|
+
res.end(JSON.stringify(body))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export default function sourceCompressorPlugin() {
|
|
276
|
+
let srcDir = ''
|
|
277
|
+
let cached = null
|
|
278
|
+
|
|
279
|
+
function invalidate() {
|
|
280
|
+
cached = null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getCompressed() {
|
|
284
|
+
if (cached === null) {
|
|
285
|
+
cached = buildCompressedSource(srcDir)
|
|
286
|
+
}
|
|
287
|
+
return cached
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
name: 'vite-plugin-source-compressor',
|
|
292
|
+
apply: 'serve',
|
|
293
|
+
|
|
294
|
+
configResolved(config) {
|
|
295
|
+
srcDir = path.resolve(config.root, 'src')
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
configureServer(server) {
|
|
299
|
+
console.log('[source-compressor] plugin loaded, srcDir:', srcDir)
|
|
300
|
+
|
|
301
|
+
// HMR channel — Vite 6 uses server.hot, older uses server.ws
|
|
302
|
+
const hmr = server.hot ?? server.ws
|
|
303
|
+
|
|
304
|
+
const handleChange = (filePath) => {
|
|
305
|
+
if (!filePath.startsWith(srcDir)) return
|
|
306
|
+
const ext = path.extname(filePath)
|
|
307
|
+
if (!SOURCE_EXTENSIONS.has(ext)) return
|
|
308
|
+
invalidate()
|
|
309
|
+
console.log('[source-compressor] invalidated, file changed:', path.relative(srcDir, filePath))
|
|
310
|
+
|
|
311
|
+
hmr.send({
|
|
312
|
+
type: 'custom',
|
|
313
|
+
event: 'source-compressed:update',
|
|
314
|
+
data: { timestamp: Date.now() },
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
server.watcher.on('change', handleChange)
|
|
319
|
+
server.watcher.on('add', handleChange)
|
|
320
|
+
server.watcher.on('unlink', handleChange)
|
|
321
|
+
|
|
322
|
+
// Build initial cache eagerly
|
|
323
|
+
try {
|
|
324
|
+
getCompressed()
|
|
325
|
+
console.log('[source-compressor] initial build done,', cached.length, 'chars')
|
|
326
|
+
} catch (e) {
|
|
327
|
+
console.error('[source-compressor] initial build failed:', e.message)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
server.middlewares.use('/__source/compressed', (req, res) => {
|
|
331
|
+
if (req.method !== 'GET') {
|
|
332
|
+
sendJson(res, 405, { error: 'Method not allowed' })
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const compressed = getCompressed()
|
|
338
|
+
sendJson(res, 200, {
|
|
339
|
+
content: compressed,
|
|
340
|
+
charCount: compressed.length,
|
|
341
|
+
timestamp: Date.now(),
|
|
342
|
+
})
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error('[source-compressor] error:', error)
|
|
345
|
+
sendJson(res, 500, {
|
|
346
|
+
error: error instanceof Error ? error.message : 'Compression failed',
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const BLOCKED_DIRS = new Set(['node_modules', 'dist'])
|
|
5
|
+
|
|
6
|
+
function isBlockedPathSegment(segment) {
|
|
7
|
+
return BLOCKED_DIRS.has(segment) || /^\.env($|\.)/.test(segment)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toPosixPath(filePath) {
|
|
11
|
+
return filePath.split(path.sep).join('/')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function listSourceTree(rootDir, currentDir = rootDir) {
|
|
15
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
|
16
|
+
const visibleEntries = entries
|
|
17
|
+
.filter(entry => !isBlockedPathSegment(entry.name))
|
|
18
|
+
.sort((a, b) => {
|
|
19
|
+
if (a.isDirectory() && !b.isDirectory()) return -1
|
|
20
|
+
if (!a.isDirectory() && b.isDirectory()) return 1
|
|
21
|
+
return a.name.localeCompare(b.name)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return visibleEntries.map(entry => {
|
|
25
|
+
const absolutePath = path.join(currentDir, entry.name)
|
|
26
|
+
const relativePath = toPosixPath(path.relative(rootDir, absolutePath))
|
|
27
|
+
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
return {
|
|
30
|
+
name: entry.name,
|
|
31
|
+
path: relativePath,
|
|
32
|
+
type: 'dir',
|
|
33
|
+
children: listSourceTree(rootDir, absolutePath),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: entry.name,
|
|
39
|
+
path: relativePath,
|
|
40
|
+
type: 'file',
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sendJson(res, statusCode, body) {
|
|
46
|
+
res.statusCode = statusCode
|
|
47
|
+
res.setHeader('Content-Type', 'application/json')
|
|
48
|
+
res.end(JSON.stringify(body))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isSafeSourcePath(relativePath) {
|
|
52
|
+
if (!relativePath || relativePath.includes('\0')) return false
|
|
53
|
+
if (path.isAbsolute(relativePath)) return false
|
|
54
|
+
|
|
55
|
+
const normalized = path.posix.normalize(relativePath.replace(/\\/g, '/'))
|
|
56
|
+
if (normalized === '..' || normalized.startsWith('../')) return false
|
|
57
|
+
|
|
58
|
+
const segments = normalized.split('/').filter(Boolean)
|
|
59
|
+
if (segments.some(isBlockedPathSegment)) return false
|
|
60
|
+
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function sourceExplorerPlugin() {
|
|
65
|
+
return {
|
|
66
|
+
name: 'vite-plugin-source-explorer',
|
|
67
|
+
apply: 'serve',
|
|
68
|
+
configureServer(server) {
|
|
69
|
+
const sourceRoot = path.resolve(process.cwd(), 'src')
|
|
70
|
+
|
|
71
|
+
server.middlewares.use('/__source/tree', (req, res) => {
|
|
72
|
+
if (req.method !== 'GET') {
|
|
73
|
+
sendJson(res, 405, { error: 'Method not allowed' })
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const tree = listSourceTree(sourceRoot)
|
|
79
|
+
sendJson(res, 200, tree)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
sendJson(res, 500, {
|
|
82
|
+
error: error instanceof Error ? error.message : 'Failed to read source tree',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
server.middlewares.use('/__source/file', (req, res) => {
|
|
88
|
+
if (req.method !== 'GET') {
|
|
89
|
+
sendJson(res, 405, { error: 'Method not allowed' })
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const requestUrl = new URL(req.url ?? '/', 'http://localhost')
|
|
94
|
+
const requestedPath = requestUrl.searchParams.get('path') ?? ''
|
|
95
|
+
|
|
96
|
+
if (!isSafeSourcePath(requestedPath)) {
|
|
97
|
+
sendJson(res, 400, { error: 'Invalid or blocked path' })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const normalizedPath = path.posix.normalize(requestedPath.replace(/\\/g, '/'))
|
|
102
|
+
const absolutePath = path.resolve(sourceRoot, normalizedPath)
|
|
103
|
+
const safeRootPrefix = `${sourceRoot}${path.sep}`
|
|
104
|
+
if (absolutePath !== sourceRoot && !absolutePath.startsWith(safeRootPrefix)) {
|
|
105
|
+
sendJson(res, 403, { error: 'Path is outside source root' })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const stats = fs.statSync(absolutePath)
|
|
111
|
+
if (!stats.isFile()) {
|
|
112
|
+
sendJson(res, 400, { error: 'Path must point to a file' })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const content = fs.readFileSync(absolutePath, 'utf8')
|
|
117
|
+
sendJson(res, 200, {
|
|
118
|
+
path: toPosixPath(path.relative(sourceRoot, absolutePath)),
|
|
119
|
+
content,
|
|
120
|
+
})
|
|
121
|
+
} catch (error) {
|
|
122
|
+
sendJson(res, 404, {
|
|
123
|
+
error: error instanceof Error ? error.message : 'File not found',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|