@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.
Files changed (49) hide show
  1. package/LICENSE +37 -0
  2. package/package.json +70 -0
  3. package/src/admin-page.astro +148 -0
  4. package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
  5. package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
  6. package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
  7. package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
  8. package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
  9. package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
  10. package/src/api-routes/__tests__/section-management.test.ts +284 -0
  11. package/src/api-routes/_storage-config.ts +54 -0
  12. package/src/api-routes/asset-proxy.ts +76 -0
  13. package/src/api-routes/auth-callback.ts +105 -0
  14. package/src/api-routes/auth-login.ts +87 -0
  15. package/src/api-routes/auth-logout.ts +9 -0
  16. package/src/api-routes/auth-session.ts +36 -0
  17. package/src/api-routes/catalog-add.ts +151 -0
  18. package/src/api-routes/catalog-export.ts +86 -0
  19. package/src/api-routes/catalog-helpers.ts +83 -0
  20. package/src/api-routes/catalog-list.ts +12 -0
  21. package/src/api-routes/config.ts +30 -0
  22. package/src/api-routes/deploy-hook.ts +69 -0
  23. package/src/api-routes/github-proxy.ts +111 -0
  24. package/src/api-routes/init-add-section.ts +511 -0
  25. package/src/api-routes/init-apply.ts +270 -0
  26. package/src/api-routes/init-migrate.ts +262 -0
  27. package/src/api-routes/init-scan-page.ts +336 -0
  28. package/src/api-routes/init-scan.ts +162 -0
  29. package/src/api-routes/pages.ts +17 -0
  30. package/src/api-routes/section-add.ts +189 -0
  31. package/src/api-routes/section-commit-pending.ts +147 -0
  32. package/src/api-routes/section-delete.ts +141 -0
  33. package/src/api-routes/section-duplicate.ts +144 -0
  34. package/src/api-routes/section-management.ts +95 -0
  35. package/src/api-routes/section-prepare-copy.ts +93 -0
  36. package/src/api-routes/section-prepare.ts +121 -0
  37. package/src/env.d.ts +7 -0
  38. package/src/init/__tests__/page-level.test.ts +1033 -0
  39. package/src/init/__tests__/page-list-coverage.test.ts +474 -0
  40. package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
  41. package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
  42. package/src/init/__tests__/section-pipeline.test.ts +393 -0
  43. package/src/init/analyzer-types.ts +92 -0
  44. package/src/init/astro-config-patcher.ts +98 -0
  45. package/src/init/astro-detector.ts +207 -0
  46. package/src/init/astro-section-analyzer-v2.ts +1663 -0
  47. package/src/init/field-label-enricher.ts +72 -0
  48. package/src/init/template-patcher-v2.ts +1957 -0
  49. 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
+ }