@setzkasten-cms/astro-admin 0.6.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/LICENSE +37 -0
- package/package.json +70 -0
- package/src/admin-page.astro +148 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
- package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
- package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
- package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
- package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
- package/src/api-routes/__tests__/section-management.test.ts +284 -0
- package/src/api-routes/_storage-config.ts +54 -0
- package/src/api-routes/asset-proxy.ts +76 -0
- package/src/api-routes/auth-callback.ts +105 -0
- package/src/api-routes/auth-login.ts +87 -0
- package/src/api-routes/auth-logout.ts +9 -0
- package/src/api-routes/auth-session.ts +36 -0
- package/src/api-routes/catalog-add.ts +151 -0
- package/src/api-routes/catalog-export.ts +86 -0
- package/src/api-routes/catalog-helpers.ts +83 -0
- package/src/api-routes/catalog-list.ts +12 -0
- package/src/api-routes/config.ts +30 -0
- package/src/api-routes/deploy-hook.ts +69 -0
- package/src/api-routes/github-proxy.ts +111 -0
- package/src/api-routes/init-add-section.ts +511 -0
- package/src/api-routes/init-apply.ts +270 -0
- package/src/api-routes/init-migrate.ts +262 -0
- package/src/api-routes/init-scan-page.ts +336 -0
- package/src/api-routes/init-scan.ts +162 -0
- package/src/api-routes/pages.ts +17 -0
- package/src/api-routes/section-add.ts +189 -0
- package/src/api-routes/section-commit-pending.ts +147 -0
- package/src/api-routes/section-delete.ts +141 -0
- package/src/api-routes/section-duplicate.ts +144 -0
- package/src/api-routes/section-management.ts +95 -0
- package/src/api-routes/section-prepare-copy.ts +93 -0
- package/src/api-routes/section-prepare.ts +121 -0
- package/src/env.d.ts +7 -0
- package/src/init/__tests__/page-level.test.ts +1033 -0
- package/src/init/__tests__/page-list-coverage.test.ts +474 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
- package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
- package/src/init/__tests__/section-pipeline.test.ts +393 -0
- package/src/init/analyzer-types.ts +92 -0
- package/src/init/astro-config-patcher.ts +98 -0
- package/src/init/astro-detector.ts +207 -0
- package/src/init/astro-section-analyzer-v2.ts +1663 -0
- package/src/init/field-label-enricher.ts +72 -0
- package/src/init/template-patcher-v2.ts +1957 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import type { SetzKastenConfig } from '@setzkasten-cms/core'
|
|
3
|
+
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
4
|
+
import { extractSectionImports, extractLayoutImport } from '../init/astro-detector'
|
|
5
|
+
import { analyzeAstroSection } from '../init/astro-section-analyzer-v2'
|
|
6
|
+
import type { RepoFile } from '@setzkasten-cms/core/init'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/setzkasten/init/scan-page
|
|
10
|
+
*
|
|
11
|
+
* Scans a single page's Astro template for section imports and identifies:
|
|
12
|
+
* 1. Unmanaged sections — not yet in the Setzkasten schema
|
|
13
|
+
* 2. Missing fields on managed sections — template fields not in the schema
|
|
14
|
+
*
|
|
15
|
+
* Body: { owner, repo, branch?, pagePath, projectRoot? }
|
|
16
|
+
* Returns: { unmanagedSections: InferredSection[], managedUpdates: ManagedUpdate[] }
|
|
17
|
+
*/
|
|
18
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
19
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
20
|
+
if (!session) {
|
|
21
|
+
return new Response('Unauthorized', { status: 401 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
25
|
+
if (!githubToken) {
|
|
26
|
+
return new Response('GitHub token not configured', { status: 500 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const body = await request.json() as {
|
|
31
|
+
owner?: string
|
|
32
|
+
repo?: string
|
|
33
|
+
branch?: string
|
|
34
|
+
pagePath: string
|
|
35
|
+
projectRoot?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const storage = resolveStorageConfig(body)
|
|
39
|
+
if (!storage) {
|
|
40
|
+
return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
|
|
41
|
+
}
|
|
42
|
+
const { owner, repo, branch, projectPrefix } = storage
|
|
43
|
+
const { pagePath } = body
|
|
44
|
+
|
|
45
|
+
if (!pagePath) {
|
|
46
|
+
return Response.json({ error: 'pagePath is required' }, { status: 400 })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Get current schema to know which sections are already managed + their fields
|
|
50
|
+
const config = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as SetzKastenConfig | undefined
|
|
51
|
+
const managedSections = new Map<string, Set<string>>() // key → field keys
|
|
52
|
+
if (config) {
|
|
53
|
+
for (const product of Object.values(config.products)) {
|
|
54
|
+
for (const [key, section] of Object.entries(product.sections)) {
|
|
55
|
+
managedSections.set(key, new Set(Object.keys(section.fields)))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fetch repo tree to resolve imports
|
|
61
|
+
const treeResponse = await githubFetch(
|
|
62
|
+
`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
|
|
63
|
+
githubToken,
|
|
64
|
+
)
|
|
65
|
+
if (!treeResponse.ok) {
|
|
66
|
+
return Response.json(
|
|
67
|
+
{ error: `Failed to fetch repo tree: ${treeResponse.status}` },
|
|
68
|
+
{ status: treeResponse.status },
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const treeData = await treeResponse.json() as {
|
|
73
|
+
tree: Array<{ path: string; type: string }>
|
|
74
|
+
}
|
|
75
|
+
const files: RepoFile[] = treeData.tree.map((item) => ({
|
|
76
|
+
path: item.path,
|
|
77
|
+
type: item.type as 'blob' | 'tree',
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
// Fetch page source (prefix with monorepo path)
|
|
81
|
+
// Try both pagePath.astro and pagePath/index.astro for directory-based routes
|
|
82
|
+
let fullPagePath = prefixPath(pagePath, projectPrefix)
|
|
83
|
+
let pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
|
|
84
|
+
if (!pageSource && pagePath.endsWith('.astro')) {
|
|
85
|
+
// Try directory index: src/pages/docs.astro → src/pages/docs/index.astro
|
|
86
|
+
const indexPath = pagePath.replace(/\.astro$/, '/index.astro')
|
|
87
|
+
fullPagePath = prefixPath(indexPath, projectPrefix)
|
|
88
|
+
pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
|
|
89
|
+
}
|
|
90
|
+
if (!pageSource) {
|
|
91
|
+
return Response.json({ error: `Could not read page source: ${fullPagePath}` }, { status: 404 })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Extract section imports
|
|
95
|
+
const imports = extractSectionImports(pageSource, fullPagePath, files, projectPrefix)
|
|
96
|
+
|
|
97
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
98
|
+
const contentPath = serverConfig?.storage?.contentPath || 'content'
|
|
99
|
+
|
|
100
|
+
const unmanagedSections: Array<Awaited<ReturnType<typeof analyzeAstroSection>>> = []
|
|
101
|
+
const managedUpdates: Array<{
|
|
102
|
+
key: string
|
|
103
|
+
componentName: string
|
|
104
|
+
componentPath: string
|
|
105
|
+
missingFields: Array<Awaited<ReturnType<typeof analyzeAstroSection>>['fields'][number]>
|
|
106
|
+
allFields: Array<Awaited<ReturnType<typeof analyzeAstroSection>>['fields'][number]>
|
|
107
|
+
}> = []
|
|
108
|
+
|
|
109
|
+
for (const imp of imports) {
|
|
110
|
+
if (!imp.resolvedPath) continue
|
|
111
|
+
|
|
112
|
+
if (!managedSections.has(imp.sectionKey)) {
|
|
113
|
+
// Unmanaged section — full analysis
|
|
114
|
+
const sectionSource = await fetchFileContent(owner, repo, branch, imp.resolvedPath, githubToken)
|
|
115
|
+
if (!sectionSource) continue
|
|
116
|
+
|
|
117
|
+
const section = await analyzeAstroSection(
|
|
118
|
+
sectionSource,
|
|
119
|
+
imp.sectionKey,
|
|
120
|
+
imp.componentName,
|
|
121
|
+
imp.resolvedPath,
|
|
122
|
+
)
|
|
123
|
+
unmanagedSections.push(section)
|
|
124
|
+
} else {
|
|
125
|
+
// Managed section — check for missing fields
|
|
126
|
+
const existingFieldKeys = managedSections.get(imp.sectionKey)!
|
|
127
|
+
const sectionSource = await fetchFileContent(owner, repo, branch, imp.resolvedPath, githubToken)
|
|
128
|
+
if (!sectionSource) continue
|
|
129
|
+
|
|
130
|
+
const inferred = await analyzeAstroSection(
|
|
131
|
+
sectionSource,
|
|
132
|
+
imp.sectionKey,
|
|
133
|
+
imp.componentName,
|
|
134
|
+
imp.resolvedPath,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Load existing content to filter out fields whose value already exists
|
|
138
|
+
// (e.g. template has ctaText="GitHub öffnen" but schema has buttonText with same value)
|
|
139
|
+
const sectionJsonPath = `${contentPath}/_sections/${imp.sectionKey}.json`
|
|
140
|
+
const sectionJson = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
|
|
141
|
+
const existingValues = new Set<string>()
|
|
142
|
+
if (sectionJson) {
|
|
143
|
+
try {
|
|
144
|
+
const data = JSON.parse(sectionJson) as Record<string, unknown>
|
|
145
|
+
for (const val of Object.values(data)) {
|
|
146
|
+
if (typeof val === 'string' && val.length >= 2) existingValues.add(val)
|
|
147
|
+
}
|
|
148
|
+
} catch { /* ignore parse errors */ }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Find fields that aren't in the schema AND whose value isn't already stored
|
|
152
|
+
const missingFields = inferred.fields.filter(f => {
|
|
153
|
+
if (existingFieldKeys.has(f.key)) return false
|
|
154
|
+
if (typeof f.defaultValue === 'string' && existingValues.has(f.defaultValue)) return false
|
|
155
|
+
return true
|
|
156
|
+
})
|
|
157
|
+
if (missingFields.length > 0) {
|
|
158
|
+
managedUpdates.push({
|
|
159
|
+
key: imp.sectionKey,
|
|
160
|
+
componentName: imp.componentName,
|
|
161
|
+
componentPath: imp.resolvedPath,
|
|
162
|
+
missingFields,
|
|
163
|
+
allFields: inferred.fields,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Always analyze page-level inline content (in addition to imported sections).
|
|
170
|
+
// A page can have imported sections AND inline content (headings, text, arrays).
|
|
171
|
+
{
|
|
172
|
+
const pageKeyNorm = pagePath
|
|
173
|
+
.replace(/^src\/pages\//, '')
|
|
174
|
+
.replace(/\/(index)?\.astro$/, '')
|
|
175
|
+
.replace(/\.astro$/, '') || 'index'
|
|
176
|
+
const sectionKey = '_page_' + pageKeyNorm.replace(/\//g, '_')
|
|
177
|
+
|
|
178
|
+
const pageSection = await analyzeAstroSection(
|
|
179
|
+
pageSource,
|
|
180
|
+
sectionKey,
|
|
181
|
+
pageKeyNorm.replace(/\//g, '_') + 'Page',
|
|
182
|
+
fullPagePath,
|
|
183
|
+
{ mode: 'page' },
|
|
184
|
+
)
|
|
185
|
+
;(pageSection as any).isPageLevel = true
|
|
186
|
+
|
|
187
|
+
if (!managedSections.has(sectionKey)) {
|
|
188
|
+
// Not yet in config — offer adoption (even if getSection already present)
|
|
189
|
+
if (pageSection.fields.length > 0) {
|
|
190
|
+
unmanagedSections.push(pageSection)
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
// Already in config — check for new unbound fields
|
|
194
|
+
const existingFieldKeys = managedSections.get(sectionKey)!
|
|
195
|
+
const sectionJsonPath = `${contentPath}/_sections/${sectionKey}.json`
|
|
196
|
+
const sectionJson = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
|
|
197
|
+
const existingValues = new Set<string>()
|
|
198
|
+
if (sectionJson) {
|
|
199
|
+
try {
|
|
200
|
+
const data = JSON.parse(sectionJson) as Record<string, unknown>
|
|
201
|
+
for (const val of Object.values(data)) {
|
|
202
|
+
if (typeof val === 'string' && val.length >= 2) existingValues.add(val)
|
|
203
|
+
}
|
|
204
|
+
} catch { /* ignore */ }
|
|
205
|
+
}
|
|
206
|
+
const missingFields = pageSection.fields.filter(f => {
|
|
207
|
+
if (existingFieldKeys.has(f.key)) return false
|
|
208
|
+
if (typeof f.defaultValue === 'string' && existingValues.has(f.defaultValue)) return false
|
|
209
|
+
return true
|
|
210
|
+
})
|
|
211
|
+
if (missingFields.length > 0) {
|
|
212
|
+
managedUpdates.push({
|
|
213
|
+
key: sectionKey,
|
|
214
|
+
componentName: pageKeyNorm.replace(/\//g, '_') + 'Page',
|
|
215
|
+
componentPath: fullPagePath,
|
|
216
|
+
missingFields,
|
|
217
|
+
allFields: pageSection.fields,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Analyze layout file for header/footer content (global sections).
|
|
224
|
+
// Only done once per layout — if _layout_header or _layout_footer are
|
|
225
|
+
// already managed, they are skipped.
|
|
226
|
+
{
|
|
227
|
+
const layoutImport = extractLayoutImport(pageSource, fullPagePath, files, projectPrefix)
|
|
228
|
+
if (layoutImport?.resolvedPath) {
|
|
229
|
+
const layoutSource = await fetchFileContent(owner, repo, branch, layoutImport.resolvedPath, githubToken)
|
|
230
|
+
if (layoutSource) {
|
|
231
|
+
for (const region of extractLayoutRegions(layoutSource)) {
|
|
232
|
+
const sectionKey = `_layout_${region.name}`
|
|
233
|
+
if (managedSections.has(sectionKey)) continue
|
|
234
|
+
|
|
235
|
+
// Wrap the region in a minimal Astro file for the analyzer
|
|
236
|
+
const regionSource = `---\n---\n\n${region.html}`
|
|
237
|
+
const section = await analyzeAstroSection(
|
|
238
|
+
regionSource, sectionKey,
|
|
239
|
+
region.name, layoutImport.resolvedPath,
|
|
240
|
+
{ mode: 'page' },
|
|
241
|
+
)
|
|
242
|
+
;(section as any).isPageLevel = true
|
|
243
|
+
;(section as any).isLayoutRegion = true
|
|
244
|
+
;(section as any).layoutPath = layoutImport.resolvedPath
|
|
245
|
+
;(section as any).regionTag = region.tag
|
|
246
|
+
if (section.fields.length > 0) {
|
|
247
|
+
unmanagedSections.push(section)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return Response.json({ unmanagedSections, managedUpdates })
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error('[setzkasten] scan-page error:', error)
|
|
257
|
+
return Response.json(
|
|
258
|
+
{ error: error instanceof Error ? error.message : 'Scan failed' },
|
|
259
|
+
{ status: 500 },
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function githubFetch(url: string, token: string): Promise<Response> {
|
|
265
|
+
return fetch(url, {
|
|
266
|
+
headers: {
|
|
267
|
+
Authorization: `Bearer ${token}`,
|
|
268
|
+
Accept: 'application/vnd.github+json',
|
|
269
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface LayoutRegion {
|
|
275
|
+
/** Region name: 'header' or 'footer' */
|
|
276
|
+
name: string
|
|
277
|
+
/** HTML tag used to identify this region */
|
|
278
|
+
tag: string
|
|
279
|
+
/** Extracted HTML content */
|
|
280
|
+
html: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Extract header and footer regions from a layout file's template.
|
|
285
|
+
* Looks for top-level <header>...</header> and <footer>...</footer> blocks.
|
|
286
|
+
*/
|
|
287
|
+
export function extractLayoutRegions(layoutSource: string): LayoutRegion[] {
|
|
288
|
+
// Strip frontmatter to get template only
|
|
289
|
+
let template = layoutSource
|
|
290
|
+
if (layoutSource.startsWith('---')) {
|
|
291
|
+
const endIdx = layoutSource.indexOf('\n---', 3)
|
|
292
|
+
if (endIdx !== -1) {
|
|
293
|
+
template = layoutSource.slice(endIdx + 4)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const regions: LayoutRegion[] = []
|
|
298
|
+
|
|
299
|
+
for (const tag of ['header', 'footer']) {
|
|
300
|
+
const openTag = new RegExp(`<${tag}(\\s[^>]*)?>`, 'i')
|
|
301
|
+
const openMatch = openTag.exec(template)
|
|
302
|
+
if (!openMatch) continue
|
|
303
|
+
|
|
304
|
+
// Find matching closing tag (simple approach — works for non-nested same-tag)
|
|
305
|
+
const closeTag = `</${tag}>`
|
|
306
|
+
const closeIdx = template.indexOf(closeTag, openMatch.index)
|
|
307
|
+
if (closeIdx === -1) continue
|
|
308
|
+
|
|
309
|
+
const html = template.slice(openMatch.index, closeIdx + closeTag.length)
|
|
310
|
+
regions.push({ name: tag, tag, html })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return regions
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function fetchFileContent(
|
|
317
|
+
owner: string,
|
|
318
|
+
repo: string,
|
|
319
|
+
branch: string,
|
|
320
|
+
path: string,
|
|
321
|
+
token: string,
|
|
322
|
+
): Promise<string | null> {
|
|
323
|
+
try {
|
|
324
|
+
const response = await githubFetch(
|
|
325
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
326
|
+
token,
|
|
327
|
+
)
|
|
328
|
+
if (!response.ok) return null
|
|
329
|
+
const data = await response.json() as { content: string; encoding: string }
|
|
330
|
+
return data.encoding === 'base64'
|
|
331
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
332
|
+
: data.content
|
|
333
|
+
} catch {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { analyzeProject, type RepoFile } from '@setzkasten-cms/core/init'
|
|
3
|
+
import { findAstroPages, extractSectionImports } from '../init/astro-detector'
|
|
4
|
+
import { analyzeAstroSection } from '../init/astro-section-analyzer-v2'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/setzkasten/init/scan
|
|
8
|
+
*
|
|
9
|
+
* Scans a GitHub repo and returns project analysis + detected sections.
|
|
10
|
+
* Body: { owner: string, repo: string, branch?: string }
|
|
11
|
+
*/
|
|
12
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
13
|
+
// Verify session
|
|
14
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
15
|
+
if (!session) {
|
|
16
|
+
return new Response('Unauthorized', { status: 401 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
20
|
+
if (!githubToken) {
|
|
21
|
+
return new Response('GitHub token not configured', { status: 500 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const body = await request.json() as { owner: string; repo: string; branch?: string }
|
|
26
|
+
const { owner, repo, branch = 'main' } = body
|
|
27
|
+
|
|
28
|
+
if (!owner || !repo) {
|
|
29
|
+
return Response.json({ error: 'owner and repo are required' }, { status: 400 })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 1. Fetch repo tree (recursive)
|
|
33
|
+
const treeResponse = await githubFetch(
|
|
34
|
+
`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
|
|
35
|
+
githubToken,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if (!treeResponse.ok) {
|
|
39
|
+
return Response.json(
|
|
40
|
+
{ error: `Failed to fetch repo tree: ${treeResponse.status}` },
|
|
41
|
+
{ status: treeResponse.status },
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const treeData = await treeResponse.json() as {
|
|
46
|
+
tree: Array<{ path: string; type: string; sha: string }>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const files: RepoFile[] = treeData.tree.map((item) => ({
|
|
50
|
+
path: item.path,
|
|
51
|
+
type: item.type as 'blob' | 'tree',
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
// 2. Analyze project structure
|
|
55
|
+
const analysis = analyzeProject(files)
|
|
56
|
+
|
|
57
|
+
// 3. Find Astro project root
|
|
58
|
+
const astroRoot = analysis.projectRoots.find((r) => r.framework === 'astro')
|
|
59
|
+
if (!astroRoot) {
|
|
60
|
+
return Response.json({
|
|
61
|
+
analysis,
|
|
62
|
+
pages: [],
|
|
63
|
+
sections: [],
|
|
64
|
+
astroConfigPath: null,
|
|
65
|
+
message: 'Kein Astro-Projekt gefunden',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 4. Find pages
|
|
70
|
+
const pages = findAstroPages(files, astroRoot.path)
|
|
71
|
+
|
|
72
|
+
// 5. For each page, extract section imports and analyze sections
|
|
73
|
+
const allSections = new Map<string, Awaited<ReturnType<typeof analyzeAstroSection>>>()
|
|
74
|
+
const pageSections: Record<string, string[]> = {}
|
|
75
|
+
|
|
76
|
+
for (const page of pages) {
|
|
77
|
+
// Fetch page source
|
|
78
|
+
const pageSource = await fetchFileContent(owner, repo, branch, page.filePath, githubToken)
|
|
79
|
+
if (!pageSource) continue
|
|
80
|
+
|
|
81
|
+
const imports = extractSectionImports(pageSource, page.filePath, files, astroRoot.path)
|
|
82
|
+
pageSections[page.pageKey] = imports.map((i) => i.sectionKey)
|
|
83
|
+
|
|
84
|
+
// Analyze each section (skip duplicates)
|
|
85
|
+
for (const imp of imports) {
|
|
86
|
+
if (allSections.has(imp.sectionKey)) continue
|
|
87
|
+
if (!imp.resolvedPath) continue
|
|
88
|
+
|
|
89
|
+
const sectionSource = await fetchFileContent(owner, repo, branch, imp.resolvedPath, githubToken)
|
|
90
|
+
if (!sectionSource) continue
|
|
91
|
+
|
|
92
|
+
const section = await analyzeAstroSection(
|
|
93
|
+
sectionSource,
|
|
94
|
+
imp.sectionKey,
|
|
95
|
+
imp.componentName,
|
|
96
|
+
imp.resolvedPath,
|
|
97
|
+
)
|
|
98
|
+
allSections.set(imp.sectionKey, section)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 6. Find astro.config path
|
|
103
|
+
const astroConfigCandidates = ['astro.config.mjs', 'astro.config.ts', 'astro.config.js']
|
|
104
|
+
let astroConfigPath: string | null = null
|
|
105
|
+
for (const candidate of astroConfigCandidates) {
|
|
106
|
+
const path = astroRoot.path ? `${astroRoot.path}/${candidate}` : candidate
|
|
107
|
+
if (files.some((f) => f.path === path)) {
|
|
108
|
+
astroConfigPath = path
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return Response.json({
|
|
114
|
+
analysis,
|
|
115
|
+
projectRoot: astroRoot.path,
|
|
116
|
+
pages,
|
|
117
|
+
pageSections,
|
|
118
|
+
sections: Object.fromEntries(allSections),
|
|
119
|
+
astroConfigPath,
|
|
120
|
+
})
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error('[setzkasten] Init scan error:', error)
|
|
123
|
+
return Response.json(
|
|
124
|
+
{ error: error instanceof Error ? error.message : 'Scan failed' },
|
|
125
|
+
{ status: 500 },
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function githubFetch(url: string, token: string): Promise<Response> {
|
|
131
|
+
return fetch(url, {
|
|
132
|
+
headers: {
|
|
133
|
+
Authorization: `Bearer ${token}`,
|
|
134
|
+
Accept: 'application/vnd.github+json',
|
|
135
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fetchFileContent(
|
|
141
|
+
owner: string,
|
|
142
|
+
repo: string,
|
|
143
|
+
branch: string,
|
|
144
|
+
path: string,
|
|
145
|
+
token: string,
|
|
146
|
+
): Promise<string | null> {
|
|
147
|
+
try {
|
|
148
|
+
const response = await githubFetch(
|
|
149
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
150
|
+
token,
|
|
151
|
+
)
|
|
152
|
+
if (!response.ok) return null
|
|
153
|
+
|
|
154
|
+
const data = await response.json() as { content: string; encoding: string }
|
|
155
|
+
if (data.encoding === 'base64') {
|
|
156
|
+
return Buffer.from(data.content, 'base64').toString('utf-8')
|
|
157
|
+
}
|
|
158
|
+
return data.content
|
|
159
|
+
} catch {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the list of pages detected at build time.
|
|
5
|
+
* The pages are scanned from src/pages/ by the integration hook
|
|
6
|
+
* and injected into globalThis.__SETZKASTEN_PAGES__.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/setzkasten/pages
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async () => {
|
|
11
|
+
const pages = (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ ?? []
|
|
12
|
+
|
|
13
|
+
return new Response(JSON.stringify({ pages }), {
|
|
14
|
+
status: 200,
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
})
|
|
17
|
+
}
|