@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,511 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import type { InferredSection } from '@setzkasten-cms/core/init'
|
|
3
|
+
import { addSectionToConfig } from '@setzkasten-cms/core/init'
|
|
4
|
+
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
5
|
+
import { patchTemplateForFields, stripTemplateFallbacks } from '../init/template-patcher-v2'
|
|
6
|
+
import type { RepeatedGroup } from '../init/analyzer-types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/setzkasten/init/add-section
|
|
10
|
+
*
|
|
11
|
+
* Adds a single section to an already-initialized Setzkasten project.
|
|
12
|
+
* Updates setzkasten.config.ts, creates the content JSON, and updates the page config.
|
|
13
|
+
* All changes are committed as a single batch via Git Trees API.
|
|
14
|
+
*
|
|
15
|
+
* Body: { owner, repo, branch?, projectRoot, section, pageKey, contentPath? }
|
|
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
|
+
projectRoot?: string
|
|
34
|
+
section: InferredSection & { allFields?: InferredSection['fields']; isPageLevel?: boolean }
|
|
35
|
+
pageKey: string
|
|
36
|
+
pagePath?: string
|
|
37
|
+
contentPath?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const storage = resolveStorageConfig(body)
|
|
41
|
+
if (!storage) {
|
|
42
|
+
return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
|
|
43
|
+
}
|
|
44
|
+
const { owner, repo, branch, projectPrefix } = storage
|
|
45
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
46
|
+
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
47
|
+
const { section, pageKey } = body
|
|
48
|
+
|
|
49
|
+
if (!section || !pageKey) {
|
|
50
|
+
return Response.json({ error: 'section and pageKey are required' }, { status: 400 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const headers = {
|
|
54
|
+
Authorization: `Bearer ${githubToken}`,
|
|
55
|
+
Accept: 'application/vnd.github+json',
|
|
56
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const filesToCommit: Array<{ path: string; content: string }> = []
|
|
61
|
+
|
|
62
|
+
// 1. Read and update setzkasten.config.ts
|
|
63
|
+
const configPath = prefixPath('setzkasten.config.ts', projectPrefix)
|
|
64
|
+
const existingConfig = await fetchFileContent(owner, repo, branch, configPath, githubToken)
|
|
65
|
+
|
|
66
|
+
if (existingConfig) {
|
|
67
|
+
const updatedConfig = addSectionToConfig(existingConfig, section.key, section, section.allFields)
|
|
68
|
+
if (updatedConfig) {
|
|
69
|
+
filesToCommit.push({ path: configPath, content: updatedConfig })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Generate content JSON — merge new fields into existing data (if any)
|
|
74
|
+
const sectionJsonPath = `${contentPath}/_sections/${section.key}.json`
|
|
75
|
+
const existingSectionJson = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
|
|
76
|
+
|
|
77
|
+
let sectionData: Record<string, unknown> = {}
|
|
78
|
+
if (existingSectionJson) {
|
|
79
|
+
try {
|
|
80
|
+
sectionData = JSON.parse(existingSectionJson)
|
|
81
|
+
} catch { /* start fresh if invalid */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Only add values for fields that don't already exist
|
|
85
|
+
for (const field of section.fields) {
|
|
86
|
+
if (!(field.key in sectionData)) {
|
|
87
|
+
sectionData[field.key] = field.defaultValue ?? getDefaultValue(field.type)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Reorder JSON keys to match template order (allFields if available, else fields)
|
|
92
|
+
const orderedKeys = (section.allFields ?? section.fields).map(f => f.key)
|
|
93
|
+
const orderedData: Record<string, unknown> = {}
|
|
94
|
+
for (const key of orderedKeys) {
|
|
95
|
+
if (key in sectionData) {
|
|
96
|
+
orderedData[key] = sectionData[key]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Append any remaining keys not in the ordered list
|
|
100
|
+
for (const key of Object.keys(sectionData)) {
|
|
101
|
+
if (!(key in orderedData)) {
|
|
102
|
+
orderedData[key] = sectionData[key]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
filesToCommit.push({
|
|
107
|
+
path: sectionJsonPath,
|
|
108
|
+
content: JSON.stringify(orderedData, null, 2),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// 3. Update page config (add section to the end)
|
|
112
|
+
const configKey = '_' + pageKey.replace(/\//g, '_')
|
|
113
|
+
const pageConfigPath = `${contentPath}/pages/${configKey}.json`
|
|
114
|
+
const existingPageConfig = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
|
|
115
|
+
|
|
116
|
+
let pageConfig: { sections: Array<{ key: string; enabled: boolean }> }
|
|
117
|
+
if (existingPageConfig) {
|
|
118
|
+
pageConfig = JSON.parse(existingPageConfig)
|
|
119
|
+
// Only add if not already present (normalize keys to handle kebab vs camelCase)
|
|
120
|
+
const normalizeKey = (k: string) => k.replace(/[-_]/g, '').toLowerCase()
|
|
121
|
+
const normalizedNewKey = normalizeKey(section.key)
|
|
122
|
+
if (!pageConfig.sections.some((s) => normalizeKey(s.key) === normalizedNewKey)) {
|
|
123
|
+
pageConfig.sections.push({ key: section.key, enabled: true })
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
pageConfig = {
|
|
127
|
+
sections: [{ key: section.key, enabled: true }],
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
filesToCommit.push({
|
|
131
|
+
path: pageConfigPath,
|
|
132
|
+
content: JSON.stringify(pageConfig, null, 2),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// 4. Patch template — replace hardcoded content with CMS variables + data-sk-field
|
|
136
|
+
if (section.isPageLevel && body.pagePath) {
|
|
137
|
+
// Page-level: patch the PAGE file directly (not a separate component)
|
|
138
|
+
// Directory routes: UI sends src/pages/docs.astro but actual file is docs/index.astro.
|
|
139
|
+
// resolvedPagePath tracks the ACTUAL file found so the sk-preview clone is placed
|
|
140
|
+
// at the correct path (sk-preview/docs/index.astro, not sk-preview/docs.astro).
|
|
141
|
+
// This matters for the "../" depth adjustment in buildPreviewClone.
|
|
142
|
+
let fullPagePath = prefixPath(body.pagePath, projectPrefix)
|
|
143
|
+
let pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
|
|
144
|
+
let resolvedPagePath = body.pagePath
|
|
145
|
+
if (!pageSource && body.pagePath.endsWith('.astro')) {
|
|
146
|
+
const indexPath = body.pagePath.replace(/\.astro$/, '/index.astro')
|
|
147
|
+
fullPagePath = prefixPath(indexPath, projectPrefix)
|
|
148
|
+
pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
|
|
149
|
+
if (pageSource) resolvedPagePath = indexPath
|
|
150
|
+
}
|
|
151
|
+
if (pageSource) {
|
|
152
|
+
const repeatedGroups: RepeatedGroup[] = (section as any)._analyzerResult?.repeatedGroups ?? []
|
|
153
|
+
let patchedSource = await patchTemplateForFields(
|
|
154
|
+
pageSource, section.key, section.allFields ?? section.fields, repeatedGroups,
|
|
155
|
+
{ mode: 'page' },
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// Strip ?? 'fallback' expressions from script-patched templates.
|
|
159
|
+
// Values go into the content JSON (JSON wins if already set).
|
|
160
|
+
const { source: strippedSource, fallbacks } = stripTemplateFallbacks(patchedSource)
|
|
161
|
+
if (strippedSource !== patchedSource) {
|
|
162
|
+
patchedSource = strippedSource
|
|
163
|
+
// Merge extracted fallback values — only write to JSON where not already set
|
|
164
|
+
let jsonUpdated = false
|
|
165
|
+
for (const [key, value] of Object.entries(fallbacks)) {
|
|
166
|
+
const existing = sectionData[key]
|
|
167
|
+
// HTML extracted from template wins over a plain-text defaultValue that was
|
|
168
|
+
// written by buildSectionData. This preserves links (<a href="mailto:">),
|
|
169
|
+
// inline code etc. that are present in the template fallback but not in the
|
|
170
|
+
// analyzer's text-only defaultValue.
|
|
171
|
+
const existingIsPlain = typeof existing === 'string' && !/<[a-z]/.test(existing)
|
|
172
|
+
const newIsHtml = typeof value === 'string' && /<[a-z]/.test(value as string)
|
|
173
|
+
if (!(key in sectionData) || existing === '' || existing === null || (existingIsPlain && newIsHtml)) {
|
|
174
|
+
sectionData[key] = value
|
|
175
|
+
jsonUpdated = true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Update the content JSON entry if we added values
|
|
179
|
+
if (jsonUpdated) {
|
|
180
|
+
const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
|
|
181
|
+
if (jsonIdx !== -1) {
|
|
182
|
+
filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (patchedSource !== pageSource) {
|
|
188
|
+
filesToCommit.push({ path: fullPagePath, content: patchedSource })
|
|
189
|
+
|
|
190
|
+
// Create an SSR clone under sk-preview/ so the live preview iframe works.
|
|
191
|
+
// The patched source already uses getSection() which is draft-aware.
|
|
192
|
+
// The clone lives one directory deeper (sk-preview/<page> vs <page>),
|
|
193
|
+
// so all relative imports need one extra "../" level.
|
|
194
|
+
// Use resolvedPagePath (not body.pagePath) to get correct clone depth for
|
|
195
|
+
// directory routes (docs/index.astro → sk-preview/docs/index.astro).
|
|
196
|
+
const previewCopySource = patchedSource
|
|
197
|
+
.replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, '')
|
|
198
|
+
.replace(/(from\s+')(\.\.\/)/g, '$1../$2')
|
|
199
|
+
.replace(/(from\s+")(\.\.\/)/g, '$1../$2')
|
|
200
|
+
// resolvedPagePath: e.g. "src/pages/docs/index.astro" (for directory routes)
|
|
201
|
+
const relativePage = resolvedPagePath.replace(/^src\/pages\//, '') // "docs/index.astro"
|
|
202
|
+
const previewCopyPath = prefixPath(`src/pages/sk-preview/${relativePage}`, projectPrefix)
|
|
203
|
+
filesToCommit.push({ path: previewCopyPath, content: previewCopySource })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Update content JSON with any _classN fields from patcher
|
|
207
|
+
const fields = section.allFields ?? section.fields
|
|
208
|
+
for (const g of repeatedGroups) {
|
|
209
|
+
const topField = fields.find(f => f.key === g.fieldKey)
|
|
210
|
+
if (!topField || !Array.isArray(topField.defaultValue)) continue
|
|
211
|
+
sectionData[g.fieldKey] = topField.defaultValue
|
|
212
|
+
const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
|
|
213
|
+
if (jsonIdx !== -1) {
|
|
214
|
+
filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// No patchPageFile / SECTION_COMPONENTS for page-level sections
|
|
219
|
+
} else if (section.componentPath) {
|
|
220
|
+
const componentSource = await fetchFileContent(owner, repo, branch, section.componentPath, githubToken)
|
|
221
|
+
if (componentSource) {
|
|
222
|
+
const repeatedGroups: RepeatedGroup[] = (section as any)._analyzerResult?.repeatedGroups ?? []
|
|
223
|
+
const patchedSource = await patchTemplateForFields(componentSource, section.key, section.allFields ?? section.fields, repeatedGroups)
|
|
224
|
+
if (patchedSource !== componentSource) {
|
|
225
|
+
filesToCommit.push({ path: section.componentPath, content: patchedSource })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Patcher may have added _classN fields to repeatedGroups (dynamic CSS classes).
|
|
229
|
+
// These were not in the content JSON yet (built before patcher ran).
|
|
230
|
+
// Re-read the now-mutated fields and update the content JSON entry.
|
|
231
|
+
const fields = section.allFields ?? section.fields
|
|
232
|
+
for (const g of repeatedGroups) {
|
|
233
|
+
const topField = fields.find(f => f.key === g.fieldKey)
|
|
234
|
+
if (!topField || !Array.isArray(topField.defaultValue)) continue
|
|
235
|
+
const items = topField.defaultValue as Array<Record<string, unknown>>
|
|
236
|
+
|
|
237
|
+
// Update sectionData with the enriched items array
|
|
238
|
+
sectionData[g.fieldKey] = items
|
|
239
|
+
|
|
240
|
+
// Rebuild and replace the content JSON in filesToCommit
|
|
241
|
+
const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
|
|
242
|
+
if (jsonIdx !== -1) {
|
|
243
|
+
filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 5. Patch page file — add import and registry entry for new section
|
|
249
|
+
if (body.pagePath && section.componentName) {
|
|
250
|
+
const fullPagePath = prefixPath(body.pagePath, projectPrefix)
|
|
251
|
+
const pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
|
|
252
|
+
if (pageSource) {
|
|
253
|
+
const patched = patchPageFile(pageSource, section.key, section.componentName, section.componentPath, body.pagePath)
|
|
254
|
+
if (patched && patched !== pageSource) {
|
|
255
|
+
filesToCommit.push({ path: fullPagePath, content: patched })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 5b. Also patch the SSR preview route (sk-preview/[...page].astro)
|
|
260
|
+
const pageDir = body.pagePath.replace(/\/[^/]+$/, '') // e.g. "src/pages"
|
|
261
|
+
const previewPath = prefixPath(`${pageDir}/sk-preview/[...page].astro`, projectPrefix)
|
|
262
|
+
const previewSource = await fetchFileContent(owner, repo, branch, previewPath, githubToken)
|
|
263
|
+
if (previewSource) {
|
|
264
|
+
const patchedPreview = patchPageFile(previewSource, section.key, section.componentName, section.componentPath, `${pageDir}/sk-preview/[...page].astro`)
|
|
265
|
+
if (patchedPreview && patchedPreview !== previewSource) {
|
|
266
|
+
filesToCommit.push({ path: previewPath, content: patchedPreview })
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (filesToCommit.length === 0) {
|
|
273
|
+
return Response.json({ error: 'Nothing to commit' }, { status: 400 })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 6. Batch commit via Git Trees API
|
|
277
|
+
const commitResult = await batchCommit(
|
|
278
|
+
owner,
|
|
279
|
+
repo,
|
|
280
|
+
branch,
|
|
281
|
+
filesToCommit,
|
|
282
|
+
existingSectionJson
|
|
283
|
+
? `content: update ${section.key} section — add new fields`
|
|
284
|
+
: `content: add ${section.key} section to Setzkasten`,
|
|
285
|
+
headers,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if (!commitResult.ok) {
|
|
289
|
+
return Response.json({ error: commitResult.error }, { status: 500 })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Response.json({
|
|
293
|
+
success: true,
|
|
294
|
+
commitSha: commitResult.sha,
|
|
295
|
+
filesWritten: filesToCommit.map((f) => f.path),
|
|
296
|
+
})
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error('[setzkasten] add-section error:', error)
|
|
299
|
+
return Response.json(
|
|
300
|
+
{ error: error instanceof Error ? error.message : 'Add section failed' },
|
|
301
|
+
{ status: 500 },
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function getDefaultValue(fieldType: string): unknown {
|
|
307
|
+
switch (fieldType) {
|
|
308
|
+
case 'text': return ''
|
|
309
|
+
case 'number': return 0
|
|
310
|
+
case 'boolean': return false
|
|
311
|
+
case 'image': return { path: '', alt: '' }
|
|
312
|
+
case 'array': return []
|
|
313
|
+
case 'color': return '#000000'
|
|
314
|
+
case 'date': return ''
|
|
315
|
+
case 'icon': return ''
|
|
316
|
+
default: return ''
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Add import statement and SECTION_COMPONENTS registry entry to a page file.
|
|
322
|
+
* Returns the patched source, or null if no changes needed.
|
|
323
|
+
*/
|
|
324
|
+
export function patchPageFile(
|
|
325
|
+
source: string,
|
|
326
|
+
sectionKey: string,
|
|
327
|
+
componentName: string,
|
|
328
|
+
componentPath: string,
|
|
329
|
+
pagePath: string,
|
|
330
|
+
): string | null {
|
|
331
|
+
// Normalize for comparison: strip dashes/underscores and lowercase
|
|
332
|
+
const normalize = (k: string) => k.replace(/[-_]/g, '').toLowerCase()
|
|
333
|
+
|
|
334
|
+
// Check if component is already imported
|
|
335
|
+
if (source.includes(`import ${componentName}`) || source.includes(`'${componentName}'`)) {
|
|
336
|
+
// Already imported — check if also in registry
|
|
337
|
+
if (source.includes(normalize(sectionKey))) return null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Calculate relative import path from page to component
|
|
341
|
+
const pageDir = pagePath.replace(/\/[^/]+$/, '') // e.g. "src/pages"
|
|
342
|
+
const relPath = calculateRelativePath(pageDir, componentPath)
|
|
343
|
+
|
|
344
|
+
let patched = source
|
|
345
|
+
|
|
346
|
+
// 1. Add import after last section import (before setzkasten:content import)
|
|
347
|
+
const lastImportMatch = patched.match(/import\s+\w+\s+from\s+['"][^'"]*Section\.astro['"];?\s*\n/)
|
|
348
|
+
if (lastImportMatch) {
|
|
349
|
+
// Find the LAST such import
|
|
350
|
+
let lastIdx = 0
|
|
351
|
+
const importRegex = /import\s+\w+\s+from\s+['"][^'"]*Section\.astro['"];?\s*\n/g
|
|
352
|
+
let m: RegExpExecArray | null
|
|
353
|
+
while ((m = importRegex.exec(patched)) !== null) {
|
|
354
|
+
lastIdx = m.index + m[0].length
|
|
355
|
+
}
|
|
356
|
+
const importLine = `import ${componentName} from '${relPath}';\n`
|
|
357
|
+
if (!patched.includes(`import ${componentName}`)) {
|
|
358
|
+
patched = patched.slice(0, lastIdx) + importLine + patched.slice(lastIdx)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 2. Add to SECTION_COMPONENTS registry
|
|
363
|
+
const registryPattern = /SECTION_COMPONENTS[^{]*\{([^}]*)\}/s
|
|
364
|
+
const registryMatch = patched.match(registryPattern)
|
|
365
|
+
if (registryMatch) {
|
|
366
|
+
const registryContent = registryMatch[1]!
|
|
367
|
+
// Check if key (normalized) is already in registry
|
|
368
|
+
if (!registryContent.includes(normalize(sectionKey))) {
|
|
369
|
+
// Find the last entry and add after it
|
|
370
|
+
const lastEntryMatch = registryContent.match(/.*\w+Section,?\s*$/m)
|
|
371
|
+
if (lastEntryMatch && lastEntryMatch.index !== undefined) {
|
|
372
|
+
const insertPos = registryMatch.index! + registryMatch[0].indexOf(registryContent) + lastEntryMatch.index + lastEntryMatch[0].length
|
|
373
|
+
const newEntry = `\n [normalize('${sectionKey}')]: ${componentName},`
|
|
374
|
+
patched = patched.slice(0, insertPos) + newEntry + patched.slice(insertPos)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 3. Remove hardcoded <ComponentName /> from template (now rendered via loop)
|
|
380
|
+
const hardcodedPattern = new RegExp(`\\s*<${componentName}\\s*/?>\\s*\\n?`, 'g')
|
|
381
|
+
patched = patched.replace(hardcodedPattern, '\n')
|
|
382
|
+
|
|
383
|
+
return patched !== source ? patched : null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Calculate a relative import path from a page directory to a component file.
|
|
388
|
+
*/
|
|
389
|
+
export function calculateRelativePath(fromDir: string, toPath: string): string {
|
|
390
|
+
const fromParts = fromDir.split('/')
|
|
391
|
+
const toParts = toPath.split('/')
|
|
392
|
+
|
|
393
|
+
// Find common prefix length
|
|
394
|
+
let common = 0
|
|
395
|
+
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
|
|
396
|
+
common++
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const ups = fromParts.length - common
|
|
400
|
+
const remaining = toParts.slice(common).join('/')
|
|
401
|
+
|
|
402
|
+
if (ups === 0) return './' + remaining
|
|
403
|
+
return '../'.repeat(ups) + remaining
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function fetchFileContent(
|
|
407
|
+
owner: string,
|
|
408
|
+
repo: string,
|
|
409
|
+
branch: string,
|
|
410
|
+
path: string,
|
|
411
|
+
token: string,
|
|
412
|
+
): Promise<string | null> {
|
|
413
|
+
try {
|
|
414
|
+
const response = await fetch(
|
|
415
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
416
|
+
{
|
|
417
|
+
headers: {
|
|
418
|
+
Authorization: `Bearer ${token}`,
|
|
419
|
+
Accept: 'application/vnd.github+json',
|
|
420
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
)
|
|
424
|
+
if (!response.ok) return null
|
|
425
|
+
const data = await response.json() as { content: string; encoding: string }
|
|
426
|
+
return data.encoding === 'base64'
|
|
427
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
428
|
+
: data.content
|
|
429
|
+
} catch {
|
|
430
|
+
return null
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function batchCommit(
|
|
435
|
+
owner: string,
|
|
436
|
+
repo: string,
|
|
437
|
+
branch: string,
|
|
438
|
+
files: Array<{ path: string; content: string }>,
|
|
439
|
+
message: string,
|
|
440
|
+
headers: Record<string, string>,
|
|
441
|
+
): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
|
|
442
|
+
try {
|
|
443
|
+
// 1. Get HEAD ref
|
|
444
|
+
const refRes = await fetch(
|
|
445
|
+
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
|
|
446
|
+
{ headers },
|
|
447
|
+
)
|
|
448
|
+
if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
|
|
449
|
+
const refData = await refRes.json() as { object: { sha: string } }
|
|
450
|
+
const headSha = refData.object.sha
|
|
451
|
+
|
|
452
|
+
// 2. Get base tree
|
|
453
|
+
const commitRes = await fetch(
|
|
454
|
+
`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
|
|
455
|
+
{ headers },
|
|
456
|
+
)
|
|
457
|
+
if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
|
|
458
|
+
const commitData = await commitRes.json() as { tree: { sha: string } }
|
|
459
|
+
|
|
460
|
+
// 3. Create new tree
|
|
461
|
+
const treeRes = await fetch(
|
|
462
|
+
`https://api.github.com/repos/${owner}/${repo}/git/trees`,
|
|
463
|
+
{
|
|
464
|
+
method: 'POST',
|
|
465
|
+
headers,
|
|
466
|
+
body: JSON.stringify({
|
|
467
|
+
base_tree: commitData.tree.sha,
|
|
468
|
+
tree: files.map((f) => ({
|
|
469
|
+
path: f.path,
|
|
470
|
+
mode: '100644',
|
|
471
|
+
type: 'blob',
|
|
472
|
+
content: f.content,
|
|
473
|
+
})),
|
|
474
|
+
}),
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
|
|
478
|
+
const treeData = await treeRes.json() as { sha: string }
|
|
479
|
+
|
|
480
|
+
// 4. Create commit
|
|
481
|
+
const newCommitRes = await fetch(
|
|
482
|
+
`https://api.github.com/repos/${owner}/${repo}/git/commits`,
|
|
483
|
+
{
|
|
484
|
+
method: 'POST',
|
|
485
|
+
headers,
|
|
486
|
+
body: JSON.stringify({
|
|
487
|
+
tree: treeData.sha,
|
|
488
|
+
parents: [headSha],
|
|
489
|
+
message,
|
|
490
|
+
}),
|
|
491
|
+
},
|
|
492
|
+
)
|
|
493
|
+
if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
|
|
494
|
+
const newCommitData = await newCommitRes.json() as { sha: string }
|
|
495
|
+
|
|
496
|
+
// 5. Update ref
|
|
497
|
+
const updateRes = await fetch(
|
|
498
|
+
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
|
|
499
|
+
{
|
|
500
|
+
method: 'PATCH',
|
|
501
|
+
headers,
|
|
502
|
+
body: JSON.stringify({ sha: newCommitData.sha }),
|
|
503
|
+
},
|
|
504
|
+
)
|
|
505
|
+
if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
|
|
506
|
+
|
|
507
|
+
return { ok: true, sha: newCommitData.sha }
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
|
|
510
|
+
}
|
|
511
|
+
}
|