@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,270 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { generateConfigFile, type InferredSection, type ConfigGeneratorInput } from '@setzkasten-cms/core/init'
3
+ import { patchAstroConfig } from '../init/astro-config-patcher'
4
+ import { patchTemplateForFields } from '../init/template-patcher-v2'
5
+
6
+ interface ApplyRequest {
7
+ owner: string
8
+ repo: string
9
+ branch?: string
10
+ projectRoot: string
11
+ astroConfigPath: string | null
12
+ sections: InferredSection[]
13
+ pages: Array<{
14
+ pageKey: string
15
+ sectionKeys: string[]
16
+ }>
17
+ contentPath?: string
18
+ }
19
+
20
+ interface FileToCommit {
21
+ path: string
22
+ content: string
23
+ }
24
+
25
+ /**
26
+ * POST /api/setzkasten/init/apply
27
+ *
28
+ * Generates config + content files and commits them to the repo.
29
+ * Body: ApplyRequest
30
+ */
31
+ export const POST: APIRoute = async ({ request, cookies }) => {
32
+ // Verify session
33
+ const session = cookies.get('setzkasten_session')?.value
34
+ if (!session) {
35
+ return new Response('Unauthorized', { status: 401 })
36
+ }
37
+
38
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
39
+ if (!githubToken) {
40
+ return new Response('GitHub token not configured', { status: 500 })
41
+ }
42
+
43
+ try {
44
+ const body = await request.json() as ApplyRequest
45
+ const { owner, repo, branch = 'main', projectRoot, astroConfigPath, sections, pages } = body
46
+ const contentPath = body.contentPath ?? 'content'
47
+
48
+ const filesToCommit: FileToCommit[] = []
49
+
50
+ // 1. Generate setzkasten.config.ts
51
+ const configInput: ConfigGeneratorInput = {
52
+ gitRepo: `${owner}/${repo}`,
53
+ productKey: 'website',
54
+ sections,
55
+ }
56
+ const configContent = generateConfigFile(configInput)
57
+ const configPath = projectRoot ? `${projectRoot}/setzkasten.config.ts` : 'setzkasten.config.ts'
58
+ filesToCommit.push({ path: configPath, content: configContent })
59
+
60
+ // 2. Patch astro.config if needed
61
+ if (astroConfigPath) {
62
+ const astroConfigSource = await fetchFileContent(owner, repo, branch, astroConfigPath, githubToken)
63
+ if (astroConfigSource) {
64
+ const patched = patchAstroConfig(astroConfigSource)
65
+ if (patched) {
66
+ filesToCommit.push({ path: astroConfigPath, content: patched })
67
+ }
68
+ }
69
+ }
70
+
71
+ // 3. Generate content JSON for each section
72
+ for (const section of sections) {
73
+ const sectionData: Record<string, unknown> = {}
74
+ for (const field of section.fields) {
75
+ sectionData[field.key] = getDefaultValue(field.type)
76
+ }
77
+ const sectionPath = `${contentPath}/_sections/${section.key}.json`
78
+ filesToCommit.push({
79
+ path: sectionPath,
80
+ content: JSON.stringify(sectionData, null, 2),
81
+ })
82
+ }
83
+
84
+ // 4. Patch component templates — add data-sk-field + CMS variables
85
+ for (const section of sections) {
86
+ if (!section.componentPath) continue
87
+ const componentSource = await fetchFileContent(owner, repo, branch, section.componentPath, githubToken)
88
+ if (!componentSource) continue
89
+ const patched = await patchTemplateForFields(componentSource, section.key, section.fields)
90
+ if (patched !== componentSource) {
91
+ filesToCommit.push({ path: section.componentPath, content: patched })
92
+ }
93
+ }
94
+
95
+ // 5. Generate page configs (renumbered from 4)
96
+ for (const page of pages) {
97
+ const pageConfig = {
98
+ sections: page.sectionKeys.map((key, index) => ({
99
+ key,
100
+ enabled: true,
101
+ order: index,
102
+ })),
103
+ }
104
+ const configKey = '_' + page.pageKey.replace(/\//g, '_')
105
+ const pagePath = `${contentPath}/pages/${configKey}.json`
106
+ filesToCommit.push({
107
+ path: pagePath,
108
+ content: JSON.stringify(pageConfig, null, 2),
109
+ })
110
+ }
111
+
112
+ // 6. Commit all files via Git Trees API
113
+ const commitResult = await batchCommit(
114
+ owner,
115
+ repo,
116
+ branch,
117
+ filesToCommit,
118
+ 'feat: initialize Setzkasten CMS',
119
+ githubToken,
120
+ )
121
+
122
+ if (!commitResult.ok) {
123
+ return Response.json(
124
+ { error: commitResult.error },
125
+ { status: 500 },
126
+ )
127
+ }
128
+
129
+ return Response.json({
130
+ success: true,
131
+ commitSha: commitResult.sha,
132
+ filesWritten: filesToCommit.map((f) => f.path),
133
+ })
134
+ } catch (error) {
135
+ console.error('[setzkasten] Init apply error:', error)
136
+ return Response.json(
137
+ { error: error instanceof Error ? error.message : 'Apply failed' },
138
+ { status: 500 },
139
+ )
140
+ }
141
+ }
142
+
143
+ function getDefaultValue(fieldType: string): unknown {
144
+ switch (fieldType) {
145
+ case 'text': return ''
146
+ case 'number': return 0
147
+ case 'boolean': return false
148
+ case 'image': return { path: '', alt: '' }
149
+ case 'array': return []
150
+ case 'color': return '#000000'
151
+ case 'date': return ''
152
+ case 'icon': return ''
153
+ default: return ''
154
+ }
155
+ }
156
+
157
+ async function fetchFileContent(
158
+ owner: string,
159
+ repo: string,
160
+ branch: string,
161
+ path: string,
162
+ token: string,
163
+ ): Promise<string | null> {
164
+ try {
165
+ const response = await fetch(
166
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
167
+ {
168
+ headers: {
169
+ Authorization: `Bearer ${token}`,
170
+ Accept: 'application/vnd.github+json',
171
+ 'X-GitHub-Api-Version': '2022-11-28',
172
+ },
173
+ },
174
+ )
175
+ if (!response.ok) return null
176
+ const data = await response.json() as { content: string; encoding: string }
177
+ return data.encoding === 'base64'
178
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
179
+ : data.content
180
+ } catch {
181
+ return null
182
+ }
183
+ }
184
+
185
+ async function batchCommit(
186
+ owner: string,
187
+ repo: string,
188
+ branch: string,
189
+ files: FileToCommit[],
190
+ message: string,
191
+ token: string,
192
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
193
+ const headers = {
194
+ Authorization: `Bearer ${token}`,
195
+ Accept: 'application/vnd.github+json',
196
+ 'X-GitHub-Api-Version': '2022-11-28',
197
+ 'Content-Type': 'application/json',
198
+ }
199
+
200
+ try {
201
+ // 1. Get HEAD ref
202
+ const refRes = await fetch(
203
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
204
+ { headers },
205
+ )
206
+ if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
207
+ const refData = await refRes.json() as { object: { sha: string } }
208
+ const headSha = refData.object.sha
209
+
210
+ // 2. Get base tree
211
+ const commitRes = await fetch(
212
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
213
+ { headers },
214
+ )
215
+ if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
216
+ const commitData = await commitRes.json() as { tree: { sha: string } }
217
+ const baseTreeSha = commitData.tree.sha
218
+
219
+ // 3. Create new tree
220
+ const treeRes = await fetch(
221
+ `https://api.github.com/repos/${owner}/${repo}/git/trees`,
222
+ {
223
+ method: 'POST',
224
+ headers,
225
+ body: JSON.stringify({
226
+ base_tree: baseTreeSha,
227
+ tree: files.map((f) => ({
228
+ path: f.path,
229
+ mode: '100644',
230
+ type: 'blob',
231
+ content: f.content,
232
+ })),
233
+ }),
234
+ },
235
+ )
236
+ if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
237
+ const treeData = await treeRes.json() as { sha: string }
238
+
239
+ // 4. Create commit
240
+ const newCommitRes = await fetch(
241
+ `https://api.github.com/repos/${owner}/${repo}/git/commits`,
242
+ {
243
+ method: 'POST',
244
+ headers,
245
+ body: JSON.stringify({
246
+ tree: treeData.sha,
247
+ parents: [headSha],
248
+ message,
249
+ }),
250
+ },
251
+ )
252
+ if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
253
+ const newCommitData = await newCommitRes.json() as { sha: string }
254
+
255
+ // 5. Update ref
256
+ const updateRes = await fetch(
257
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
258
+ {
259
+ method: 'PATCH',
260
+ headers,
261
+ body: JSON.stringify({ sha: newCommitData.sha }),
262
+ },
263
+ )
264
+ if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
265
+
266
+ return { ok: true, sha: newCommitData.sha }
267
+ } catch (error) {
268
+ return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
269
+ }
270
+ }
@@ -0,0 +1,262 @@
1
+ import type { APIRoute } from 'astro'
2
+ import type { SetzKastenConfig } from '@setzkasten-cms/core'
3
+ import { resolveStorageConfig, prefixPath } from './_storage-config'
4
+ import { patchTemplateForFields } from '../init/template-patcher-v2'
5
+
6
+ /**
7
+ * POST /api/setzkasten/init/migrate
8
+ *
9
+ * Adds `data-sk-field` attributes to an existing Astro section component.
10
+ * Handles two cases:
11
+ * 1. Fields using `getSection()` / `Astro.props` variable patterns
12
+ * 2. Hardcoded text matching content JSON values (for adopted fields without bindings)
13
+ *
14
+ * Body: { owner, repo, branch?, sectionKey, componentPath }
15
+ * Returns: { commitSha, patchedSource, originalSource }
16
+ */
17
+ export const POST: APIRoute = async ({ request, cookies }) => {
18
+ const session = cookies.get('setzkasten_session')?.value
19
+ if (!session) {
20
+ return new Response('Unauthorized', { status: 401 })
21
+ }
22
+
23
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
+ if (!githubToken) {
25
+ return new Response('GitHub token not configured', { status: 500 })
26
+ }
27
+
28
+ try {
29
+ const body = await request.json() as {
30
+ owner?: string
31
+ repo?: string
32
+ branch?: string
33
+ sectionKey: string
34
+ componentPath?: string
35
+ }
36
+
37
+ const storage = resolveStorageConfig(body)
38
+ if (!storage) {
39
+ return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
40
+ }
41
+ const { owner, repo, branch, projectPrefix } = storage
42
+ const { sectionKey } = body
43
+
44
+ // Derive component path from section key if not provided, then prefix for monorepo
45
+ const componentPath = prefixPath(
46
+ body.componentPath || deriveComponentPath(sectionKey),
47
+ projectPrefix,
48
+ )
49
+
50
+ if (!sectionKey) {
51
+ return Response.json({ error: 'sectionKey is required' }, { status: 400 })
52
+ }
53
+
54
+ const headers = {
55
+ Authorization: `Bearer ${githubToken}`,
56
+ Accept: 'application/vnd.github+json',
57
+ 'X-GitHub-Api-Version': '2022-11-28',
58
+ 'Content-Type': 'application/json',
59
+ }
60
+
61
+ // 1. Fetch component source
62
+ const source = await fetchFileContent(owner, repo, branch, componentPath, githubToken)
63
+ if (!source) {
64
+ return Response.json({ error: 'Could not read component source' }, { status: 404 })
65
+ }
66
+
67
+ // 2. Build fields list from config + content JSON for AST-based patching
68
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
69
+ const contentPath = serverConfig?.storage?.contentPath || 'content'
70
+ const sectionJsonPath = `${contentPath}/_sections/${sectionKey}.json`
71
+ const sectionJson = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
72
+
73
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as SetzKastenConfig | undefined
74
+ const fieldDefs = getFieldDefs(fullConfig, sectionKey)
75
+
76
+ // Build fields: combine config field types with content JSON values
77
+ const fields: Array<{ key: string; type: string; defaultValue?: unknown }> = []
78
+ let contentData: Record<string, unknown> = {}
79
+ if (sectionJson) {
80
+ try { contentData = JSON.parse(sectionJson) } catch { /* ignore */ }
81
+ }
82
+
83
+ // Add fields from config definitions
84
+ for (const [key, type] of fieldDefs) {
85
+ fields.push({ key, type, defaultValue: contentData[key] })
86
+ }
87
+ // Add any content keys not in config (for CMS-bound field detection)
88
+ for (const [key, value] of Object.entries(contentData)) {
89
+ if (!fieldDefs.has(key)) {
90
+ fields.push({ key, type: 'text', defaultValue: value })
91
+ }
92
+ }
93
+
94
+ // 3. AST-based patching: adds data-sk-field + CMS variables
95
+ const patched = await patchTemplateForFields(source, sectionKey, fields)
96
+
97
+ if (patched === source) {
98
+ return Response.json({
99
+ success: true,
100
+ noChanges: true,
101
+ message: 'No patchable patterns found in template',
102
+ })
103
+ }
104
+
105
+ // 4. Commit the patched file
106
+ const commitResult = await batchCommit(
107
+ owner,
108
+ repo,
109
+ branch,
110
+ [{ path: componentPath, content: patched }],
111
+ `chore: add live-preview bindings to ${sectionKey} section`,
112
+ headers,
113
+ )
114
+
115
+ if (!commitResult.ok) {
116
+ return Response.json({ error: commitResult.error }, { status: 500 })
117
+ }
118
+
119
+ return Response.json({
120
+ success: true,
121
+ commitSha: commitResult.sha,
122
+ patchedSource: patched,
123
+ originalSource: source,
124
+ })
125
+ } catch (error) {
126
+ console.error('[setzkasten] migrate error:', error)
127
+ return Response.json(
128
+ { error: error instanceof Error ? error.message : 'Migration failed' },
129
+ { status: 500 },
130
+ )
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Get field definitions for a section from the full config.
136
+ */
137
+ function getFieldDefs(
138
+ config: SetzKastenConfig | undefined,
139
+ sectionKey: string,
140
+ ): Map<string, string> {
141
+ const defs = new Map<string, string>() // field key → field type
142
+ if (!config) return defs
143
+ for (const product of Object.values(config.products)) {
144
+ const section = product.sections[sectionKey]
145
+ if (section) {
146
+ for (const [key, field] of Object.entries(section.fields)) {
147
+ defs.set(key, (field as any).type || 'text')
148
+ }
149
+ }
150
+ }
151
+ return defs
152
+ }
153
+
154
+ function deriveComponentPath(sectionKey: string): string {
155
+ const componentName = sectionKey
156
+ .split('-')
157
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
158
+ .join('') + 'Section'
159
+ return `src/components/sections/${componentName}.astro`
160
+ }
161
+
162
+ async function fetchFileContent(
163
+ owner: string,
164
+ repo: string,
165
+ branch: string,
166
+ path: string,
167
+ token: string,
168
+ ): Promise<string | null> {
169
+ try {
170
+ const response = await fetch(
171
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
172
+ {
173
+ headers: {
174
+ Authorization: `Bearer ${token}`,
175
+ Accept: 'application/vnd.github+json',
176
+ 'X-GitHub-Api-Version': '2022-11-28',
177
+ },
178
+ },
179
+ )
180
+ if (!response.ok) return null
181
+ const data = await response.json() as { content: string; encoding: string }
182
+ return data.encoding === 'base64'
183
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
184
+ : data.content
185
+ } catch {
186
+ return null
187
+ }
188
+ }
189
+
190
+ async function batchCommit(
191
+ owner: string,
192
+ repo: string,
193
+ branch: string,
194
+ files: Array<{ path: string; content: string }>,
195
+ message: string,
196
+ headers: Record<string, string>,
197
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
198
+ try {
199
+ const refRes = await fetch(
200
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
201
+ { headers },
202
+ )
203
+ if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
204
+ const refData = await refRes.json() as { object: { sha: string } }
205
+ const headSha = refData.object.sha
206
+
207
+ const commitRes = await fetch(
208
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
209
+ { headers },
210
+ )
211
+ if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
212
+ const commitData = await commitRes.json() as { tree: { sha: string } }
213
+
214
+ const treeRes = await fetch(
215
+ `https://api.github.com/repos/${owner}/${repo}/git/trees`,
216
+ {
217
+ method: 'POST',
218
+ headers,
219
+ body: JSON.stringify({
220
+ base_tree: commitData.tree.sha,
221
+ tree: files.map((f) => ({
222
+ path: f.path,
223
+ mode: '100644',
224
+ type: 'blob',
225
+ content: f.content,
226
+ })),
227
+ }),
228
+ },
229
+ )
230
+ if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
231
+ const treeData = await treeRes.json() as { sha: string }
232
+
233
+ const newCommitRes = await fetch(
234
+ `https://api.github.com/repos/${owner}/${repo}/git/commits`,
235
+ {
236
+ method: 'POST',
237
+ headers,
238
+ body: JSON.stringify({
239
+ tree: treeData.sha,
240
+ parents: [headSha],
241
+ message,
242
+ }),
243
+ },
244
+ )
245
+ if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
246
+ const newCommitData = await newCommitRes.json() as { sha: string }
247
+
248
+ const updateRes = await fetch(
249
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
250
+ {
251
+ method: 'PATCH',
252
+ headers,
253
+ body: JSON.stringify({ sha: newCommitData.sha }),
254
+ },
255
+ )
256
+ if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
257
+
258
+ return { ok: true, sha: newCommitData.sha }
259
+ } catch (error) {
260
+ return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
261
+ }
262
+ }