@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,189 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfig, prefixPath } from './_storage-config'
3
+ import { generateAddKey, addToPageConfig } from './section-management'
4
+
5
+ /**
6
+ * POST /api/setzkasten/sections/add
7
+ *
8
+ * Adds a new section instance to a page:
9
+ * - Creates content JSON with default values from schema (_sections/{key}.json)
10
+ * - Appends new entry to the page config (pages/_{pageKey}.json)
11
+ * - Key auto-generated from type: hero → hero (or hero--2 if taken)
12
+ *
13
+ * Body: { pageKey, sectionType, sectionKey? (override), sourcePage? (content seed from another page's section), owner?, repo?, branch?, contentPath? }
14
+ *
15
+ * sourcePage: if provided, uses the existing content JSON of that section as seed instead of schema defaults.
16
+ * The source section key is derived from sectionType (or sectionKey if provided) on the sourcePage.
17
+ */
18
+ export const POST: APIRoute = async ({ request, cookies }) => {
19
+ const session = cookies.get('setzkasten_session')?.value
20
+ if (!session) return new Response('Unauthorized', { status: 401 })
21
+
22
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
23
+ if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
24
+
25
+ try {
26
+ const body = await request.json() as {
27
+ pageKey: string
28
+ sectionType: string
29
+ sectionKey?: string
30
+ sourcePage?: string
31
+ owner?: string
32
+ repo?: string
33
+ branch?: string
34
+ contentPath?: string
35
+ }
36
+
37
+ const storage = resolveStorageConfig(body)
38
+ if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
39
+ const { owner, repo, branch, projectPrefix } = storage
40
+
41
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
42
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
43
+ const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
44
+ const { pageKey, sectionType, sourcePage } = body
45
+
46
+ if (!pageKey || !sectionType) {
47
+ return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
48
+ }
49
+
50
+ const headers = {
51
+ Authorization: `Bearer ${githubToken}`,
52
+ Accept: 'application/vnd.github+json',
53
+ 'X-GitHub-Api-Version': '2022-11-28',
54
+ 'Content-Type': 'application/json',
55
+ }
56
+
57
+ // 1. Read page config
58
+ // Content paths are relative to repo root — do NOT apply projectPrefix
59
+ const configKey = '_' + pageKey.replace(/\//g, '_')
60
+ const pageConfigPath = `${contentPath}/pages/${configKey}.json`
61
+ const pageConfigRaw = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
62
+ if (!pageConfigRaw) return Response.json({ error: 'Page config not found' }, { status: 404 })
63
+
64
+ const pageConfig = JSON.parse(pageConfigRaw)
65
+ const existingKeys: string[] = (pageConfig.sections ?? []).map((s: { key: string }) => s.key)
66
+
67
+ // 2. Determine key
68
+ const newKey = body.sectionKey ?? generateAddKey(existingKeys, sectionType)
69
+ if (existingKeys.includes(newKey)) {
70
+ return Response.json({ error: `Key "${newKey}" already exists on this page` }, { status: 409 })
71
+ }
72
+
73
+ // 3. Determine initial content: sourcePage seed → schema defaults → empty
74
+ let defaultContent: Record<string, unknown> = {}
75
+ if (sourcePage) {
76
+ // Try to read content from sourcePage's section JSON as seed
77
+ // Key on sourcePage is either sectionType (first instance) or the explicit sectionKey
78
+ const sourceKey = sectionType
79
+ const sourceJsonPath = `${contentPath}/_sections/${sourceKey}.json`
80
+ const sourceContent = await fetchFileContent(owner, repo, branch, sourceJsonPath, githubToken)
81
+ if (sourceContent) {
82
+ try { defaultContent = JSON.parse(sourceContent) } catch { /* fallback to schema */ }
83
+ }
84
+ }
85
+ if (Object.keys(defaultContent).length === 0) {
86
+ const sectionDef = findSectionDef(fullConfig, sectionType)
87
+ defaultContent = sectionDef ? buildDefaultContent(sectionDef.fields ?? {}) : {}
88
+ }
89
+
90
+ // 4. Build commit
91
+ const updatedConfig = addToPageConfig(pageConfig, newKey, sectionType)
92
+ const sectionJsonPath = `${contentPath}/_sections/${newKey}.json`
93
+
94
+ const commitResult = await batchCommit(
95
+ owner, repo, branch,
96
+ [
97
+ { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
98
+ { path: sectionJsonPath, content: JSON.stringify(defaultContent, null, 2) },
99
+ ],
100
+ `content: add ${sectionType} section "${newKey}" to ${pageKey}`,
101
+ headers,
102
+ )
103
+
104
+ if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
105
+
106
+ return Response.json({ success: true, newKey, commitSha: commitResult.sha })
107
+ } catch (error) {
108
+ console.error('[setzkasten] section-add error:', error)
109
+ return Response.json(
110
+ { error: error instanceof Error ? error.message : 'Add section failed' },
111
+ { status: 500 },
112
+ )
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Helpers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function findSectionDef(fullConfig: any, sectionType: string): any {
121
+ if (!fullConfig?.products) return null
122
+ for (const product of Object.values(fullConfig.products) as any[]) {
123
+ if (product?.sections?.[sectionType]) return product.sections[sectionType]
124
+ }
125
+ return null
126
+ }
127
+
128
+ /** Builds a default content object from a field record by extracting defaultValues. */
129
+ function buildDefaultContent(fields: Record<string, any>): Record<string, unknown> {
130
+ const result: Record<string, unknown> = {}
131
+ for (const [key, field] of Object.entries(fields)) {
132
+ if (field.defaultValue !== undefined) {
133
+ result[key] = field.defaultValue
134
+ }
135
+ }
136
+ return result
137
+ }
138
+
139
+ async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
140
+ try {
141
+ const res = await fetch(
142
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
143
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
144
+ )
145
+ if (!res.ok) return null
146
+ const data = await res.json() as { content: string; encoding: string }
147
+ return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
148
+ } catch { return null }
149
+ }
150
+
151
+ async function batchCommit(
152
+ owner: string, repo: string, branch: string,
153
+ files: Array<{ path: string; content: string }>,
154
+ message: string,
155
+ headers: Record<string, string>,
156
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
157
+ try {
158
+ const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
159
+ if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
160
+ const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
161
+
162
+ const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
163
+ if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
164
+ const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
165
+
166
+ const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
167
+ method: 'POST', headers,
168
+ body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
169
+ })
170
+ if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
171
+ const { sha: treeSha } = await treeRes.json() as { sha: string }
172
+
173
+ const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
174
+ method: 'POST', headers,
175
+ body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
176
+ })
177
+ if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
178
+ const { sha: newSha } = await newCommitRes.json() as { sha: string }
179
+
180
+ const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
181
+ method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
182
+ })
183
+ if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
184
+
185
+ return { ok: true, sha: newSha }
186
+ } catch (error) {
187
+ return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
188
+ }
189
+ }
@@ -0,0 +1,147 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { resolveStorageConfig } from './_storage-config'
5
+
6
+ /**
7
+ * POST /api/setzkasten/sections/commit-pending
8
+ *
9
+ * Batch-commits all pending (optimistically added) sections in ONE GitHub commit.
10
+ * This triggers exactly one Vercel build regardless of how many sections were added.
11
+ *
12
+ * Body: {
13
+ * pageKey: string,
14
+ * pageConfig: object, // full updated page config (already includes pending entries)
15
+ * sections: Array<{ key: string, content: Record<string, unknown> }>,
16
+ * owner?, repo?, branch?, contentPath?
17
+ * }
18
+ */
19
+ export const POST: APIRoute = async ({ request, cookies }) => {
20
+ const session = cookies.get('setzkasten_session')?.value
21
+ if (!session) return new Response('Unauthorized', { status: 401 })
22
+
23
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
+ if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
25
+
26
+ try {
27
+ const body = await request.json() as {
28
+ pageKey: string
29
+ pageConfig: Record<string, unknown>
30
+ sections: Array<{ key: string; content: Record<string, unknown> }>
31
+ edits?: Array<{ key: string; content: Record<string, unknown> }>
32
+ owner?: string
33
+ repo?: string
34
+ branch?: string
35
+ contentPath?: string
36
+ }
37
+
38
+ const storage = resolveStorageConfig(body)
39
+ if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
40
+ const { owner, repo, branch } = storage
41
+
42
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
43
+ const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
44
+ const { pageKey, pageConfig, sections, edits = [] } = body
45
+
46
+ if (!pageKey || !pageConfig || !Array.isArray(sections)) {
47
+ return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
48
+ }
49
+
50
+ const headers = {
51
+ Authorization: `Bearer ${githubToken}`,
52
+ Accept: 'application/vnd.github+json',
53
+ 'X-GitHub-Api-Version': '2022-11-28',
54
+ 'Content-Type': 'application/json',
55
+ }
56
+
57
+ // Build all files for the batch commit
58
+ const configKey = '_' + pageKey.replace(/\//g, '_')
59
+ const pageConfigPath = `${contentPath}/pages/${configKey}.json`
60
+
61
+ const files: Array<{ path: string; content: string }> = [
62
+ { path: pageConfigPath, content: JSON.stringify(pageConfig, null, 2) },
63
+ ...sections.map(s => ({
64
+ path: `${contentPath}/_sections/${s.key}.json`,
65
+ content: JSON.stringify(s.content, null, 2),
66
+ })),
67
+ ...edits.map(s => ({
68
+ path: `${contentPath}/_sections/${s.key}.json`,
69
+ content: JSON.stringify(s.content, null, 2),
70
+ })),
71
+ ]
72
+
73
+ const parts: string[] = []
74
+ if (sections.length > 0) {
75
+ const keys = sections.map(s => s.key).join(', ')
76
+ parts.push(`add ${sections.length} section${sections.length > 1 ? 's' : ''} (${keys})`)
77
+ }
78
+ if (edits.length > 0) {
79
+ const keys = edits.map(s => s.key).join(', ')
80
+ parts.push(`update ${edits.length} section${edits.length > 1 ? 's' : ''} (${keys})`)
81
+ }
82
+ const commitMessage = `content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`
83
+ const commitResult = await batchCommit(owner, repo, branch, files, commitMessage, headers)
84
+
85
+ if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
86
+
87
+ // Mirror committed files to local filesystem so the dev-server preview stays in sync.
88
+ // Without this, the virtual content module (loaded at startup) would show stale data
89
+ // until the user does a `git pull` + server restart.
90
+ const repoRoot: string | undefined = serverConfig?.repoRoot
91
+ if (repoRoot) {
92
+ await Promise.all(
93
+ files.map(f => writeFile(join(repoRoot, f.path), f.content, 'utf-8').catch(() => {
94
+ // Non-fatal: local sync is best-effort (e.g. read-only FS in some CI envs)
95
+ })),
96
+ )
97
+ }
98
+
99
+ return Response.json({ success: true, commitSha: commitResult.sha })
100
+ } catch (error) {
101
+ console.error('[setzkasten] section-commit-pending error:', error)
102
+ return Response.json(
103
+ { error: error instanceof Error ? error.message : 'Commit failed' },
104
+ { status: 500 },
105
+ )
106
+ }
107
+ }
108
+
109
+ async function batchCommit(
110
+ owner: string, repo: string, branch: string,
111
+ files: Array<{ path: string; content: string }>,
112
+ message: string,
113
+ headers: Record<string, string>,
114
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
115
+ try {
116
+ const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
117
+ if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
118
+ const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
119
+
120
+ const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
121
+ if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
122
+ const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
123
+
124
+ const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
125
+ method: 'POST', headers,
126
+ body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
127
+ })
128
+ if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
129
+ const { sha: treeSha } = await treeRes.json() as { sha: string }
130
+
131
+ const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
132
+ method: 'POST', headers,
133
+ body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
134
+ })
135
+ if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
136
+ const { sha: newSha } = await newCommitRes.json() as { sha: string }
137
+
138
+ const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
139
+ method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
140
+ })
141
+ if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
142
+
143
+ return { ok: true, sha: newSha }
144
+ } catch (error) {
145
+ return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
146
+ }
147
+ }
@@ -0,0 +1,141 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfig, prefixPath } from './_storage-config'
3
+ import { removeFromPageConfig } from './section-management'
4
+
5
+ /**
6
+ * DELETE /api/setzkasten/sections
7
+ *
8
+ * Removes a section from a page:
9
+ * - Deletes the content JSON (_sections/{key}.json)
10
+ * - Removes the entry from the page config (pages/_{pageKey}.json)
11
+ * All changes committed as a single batch.
12
+ *
13
+ * Body: { pageKey, sectionKey, owner?, repo?, branch?, contentPath? }
14
+ */
15
+ export const DELETE: APIRoute = async ({ request, cookies }) => {
16
+ const session = cookies.get('setzkasten_session')?.value
17
+ if (!session) return new Response('Unauthorized', { status: 401 })
18
+
19
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
20
+ if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
21
+
22
+ try {
23
+ const body = await request.json() as {
24
+ pageKey: string
25
+ sectionKey: string
26
+ owner?: string
27
+ repo?: string
28
+ branch?: string
29
+ contentPath?: string
30
+ }
31
+
32
+ const storage = resolveStorageConfig(body)
33
+ if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
34
+ const { owner, repo, branch, projectPrefix } = storage
35
+
36
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
37
+ const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
38
+ const { pageKey, sectionKey } = body
39
+
40
+ if (!pageKey || !sectionKey) {
41
+ return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
42
+ }
43
+
44
+ const headers = {
45
+ Authorization: `Bearer ${githubToken}`,
46
+ Accept: 'application/vnd.github+json',
47
+ 'X-GitHub-Api-Version': '2022-11-28',
48
+ 'Content-Type': 'application/json',
49
+ }
50
+
51
+ // 1. Read page config
52
+ // Content paths are relative to repo root — do NOT apply projectPrefix
53
+ const configKey = '_' + pageKey.replace(/\//g, '_')
54
+ const pageConfigPath = `${contentPath}/pages/${configKey}.json`
55
+ const pageConfigRaw = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
56
+ if (!pageConfigRaw) return Response.json({ error: 'Page config not found' }, { status: 404 })
57
+
58
+ const pageConfig = JSON.parse(pageConfigRaw)
59
+ const updatedConfig = removeFromPageConfig(pageConfig, sectionKey)
60
+
61
+ // 2. Build commit: update page config + delete section JSON
62
+ const sectionJsonPath = `${contentPath}/_sections/${sectionKey}.json`
63
+
64
+ const commitResult = await batchCommitWithDeletions(
65
+ owner, repo, branch,
66
+ [{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) }],
67
+ [sectionJsonPath],
68
+ `content: remove ${sectionKey} section from ${pageKey}`,
69
+ headers,
70
+ )
71
+
72
+ if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
73
+
74
+ return Response.json({ success: true, commitSha: commitResult.sha })
75
+ } catch (error) {
76
+ console.error('[setzkasten] section-delete error:', error)
77
+ return Response.json(
78
+ { error: error instanceof Error ? error.message : 'Delete failed' },
79
+ { status: 500 },
80
+ )
81
+ }
82
+ }
83
+
84
+ async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
85
+ try {
86
+ const res = await fetch(
87
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
88
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
89
+ )
90
+ if (!res.ok) return null
91
+ const data = await res.json() as { content: string; encoding: string }
92
+ return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
93
+ } catch { return null }
94
+ }
95
+
96
+ async function batchCommitWithDeletions(
97
+ owner: string, repo: string, branch: string,
98
+ upserts: Array<{ path: string; content: string }>,
99
+ deletions: string[],
100
+ message: string,
101
+ headers: Record<string, string>,
102
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
103
+ try {
104
+ const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
105
+ if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
106
+ const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
107
+
108
+ const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
109
+ if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
110
+ const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
111
+
112
+ const tree = [
113
+ ...upserts.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })),
114
+ ...deletions.map(path => ({ path, mode: '100644', type: 'blob', sha: null })),
115
+ ]
116
+
117
+ const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
118
+ method: 'POST', headers,
119
+ body: JSON.stringify({ base_tree: baseSha, tree }),
120
+ })
121
+ if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
122
+ const { sha: treeSha } = await treeRes.json() as { sha: string }
123
+
124
+ const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
125
+ method: 'POST', headers,
126
+ body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
127
+ })
128
+ if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
129
+ const { sha: newSha } = await newCommitRes.json() as { sha: string }
130
+
131
+ const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
132
+ method: 'PATCH', headers,
133
+ body: JSON.stringify({ sha: newSha }),
134
+ })
135
+ if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
136
+
137
+ return { ok: true, sha: newSha }
138
+ } catch (error) {
139
+ return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
140
+ }
141
+ }
@@ -0,0 +1,144 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfig, prefixPath } from './_storage-config'
3
+ import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
+
5
+ /**
6
+ * POST /api/setzkasten/sections/duplicate
7
+ *
8
+ * Duplicates a section within a page:
9
+ * - Copies the content JSON to a new key (_sections/{newKey}.json)
10
+ * - Inserts new entry after the original in the page config
11
+ * - New key is auto-generated: hero → hero--copy → hero--copy2 …
12
+ *
13
+ * Body: { pageKey, sectionKey, owner?, repo?, branch?, contentPath? }
14
+ */
15
+ export const POST: APIRoute = async ({ request, cookies }) => {
16
+ const session = cookies.get('setzkasten_session')?.value
17
+ if (!session) return new Response('Unauthorized', { status: 401 })
18
+
19
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
20
+ if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
21
+
22
+ try {
23
+ const body = await request.json() as {
24
+ pageKey: string
25
+ sectionKey: string
26
+ owner?: string
27
+ repo?: string
28
+ branch?: string
29
+ contentPath?: string
30
+ }
31
+
32
+ const storage = resolveStorageConfig(body)
33
+ if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
34
+ const { owner, repo, branch, projectPrefix } = storage
35
+
36
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
37
+ const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
38
+ const { pageKey, sectionKey } = body
39
+
40
+ if (!pageKey || !sectionKey) {
41
+ return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
42
+ }
43
+
44
+ const headers = {
45
+ Authorization: `Bearer ${githubToken}`,
46
+ Accept: 'application/vnd.github+json',
47
+ 'X-GitHub-Api-Version': '2022-11-28',
48
+ 'Content-Type': 'application/json',
49
+ }
50
+
51
+ // 1. Read page config
52
+ // Content paths are relative to repo root — do NOT apply projectPrefix
53
+ const configKey = '_' + pageKey.replace(/\//g, '_')
54
+ const pageConfigPath = `${contentPath}/pages/${configKey}.json`
55
+ const pageConfigRaw = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
56
+ if (!pageConfigRaw) return Response.json({ error: 'Page config not found' }, { status: 404 })
57
+
58
+ const pageConfig = JSON.parse(pageConfigRaw)
59
+
60
+ // 2. Generate unique duplicate key
61
+ const existingKeys: string[] = (pageConfig.sections ?? []).map((s: { key: string }) => s.key)
62
+ const newKey = generateDuplicateKey(existingKeys, sectionKey)
63
+
64
+ // 3. Read original section content
65
+ const originalJsonPath = `${contentPath}/_sections/${sectionKey}.json`
66
+ const originalContent = await fetchFileContent(owner, repo, branch, originalJsonPath, githubToken)
67
+
68
+ // 4. Build commit
69
+ const updatedConfig = duplicateInPageConfig(pageConfig, sectionKey, newKey)
70
+ const filesToCommit: Array<{ path: string; content: string }> = [
71
+ { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
72
+ ]
73
+
74
+ if (originalContent) {
75
+ const copyPath = `${contentPath}/_sections/${newKey}.json`
76
+ filesToCommit.push({ path: copyPath, content: originalContent })
77
+ }
78
+
79
+ const commitResult = await batchCommit(owner, repo, branch, filesToCommit,
80
+ `content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`, headers)
81
+
82
+ if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
83
+
84
+ return Response.json({ success: true, newKey, commitSha: commitResult.sha })
85
+ } catch (error) {
86
+ console.error('[setzkasten] section-duplicate error:', error)
87
+ return Response.json(
88
+ { error: error instanceof Error ? error.message : 'Duplicate failed' },
89
+ { status: 500 },
90
+ )
91
+ }
92
+ }
93
+
94
+ async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
95
+ try {
96
+ const res = await fetch(
97
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
98
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
99
+ )
100
+ if (!res.ok) return null
101
+ const data = await res.json() as { content: string; encoding: string }
102
+ return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
103
+ } catch { return null }
104
+ }
105
+
106
+ async function batchCommit(
107
+ owner: string, repo: string, branch: string,
108
+ files: Array<{ path: string; content: string }>,
109
+ message: string,
110
+ headers: Record<string, string>,
111
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
112
+ try {
113
+ const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
114
+ if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
115
+ const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
116
+
117
+ const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
118
+ if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
119
+ const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
120
+
121
+ const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
122
+ method: 'POST', headers,
123
+ body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
124
+ })
125
+ if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
126
+ const { sha: treeSha } = await treeRes.json() as { sha: string }
127
+
128
+ const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
129
+ method: 'POST', headers,
130
+ body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
131
+ })
132
+ if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
133
+ const { sha: newSha } = await newCommitRes.json() as { sha: string }
134
+
135
+ const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
136
+ method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
137
+ })
138
+ if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
139
+
140
+ return { ok: true, sha: newSha }
141
+ } catch (error) {
142
+ return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
143
+ }
144
+ }