@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,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
|
+
}
|