@setzkasten-cms/astro-admin 1.4.2 → 1.5.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 (166) hide show
  1. package/dist/api-routes/_auth-guard.d.ts +27 -3
  2. package/dist/api-routes/_auth-guard.js +5 -2
  3. package/dist/api-routes/_dev-session-secret.d.ts +8 -0
  4. package/dist/api-routes/_dev-session-secret.js +8 -0
  5. package/dist/api-routes/_github-token.js +1 -1
  6. package/dist/api-routes/_role-resolver.js +6 -3
  7. package/dist/api-routes/_session-secret.d.ts +19 -0
  8. package/dist/api-routes/_session-secret.js +7 -0
  9. package/dist/api-routes/_session-signing.d.ts +45 -0
  10. package/dist/api-routes/_session-signing.js +8 -0
  11. package/dist/api-routes/_webhook-dispatcher.js +4 -4
  12. package/dist/api-routes/asset-proxy.js +1 -1
  13. package/dist/api-routes/auth-callback.js +12 -5
  14. package/dist/api-routes/auth-logout.d.ts +4 -4
  15. package/dist/api-routes/auth-logout.js +8 -2
  16. package/dist/api-routes/auth-session.d.ts +6 -0
  17. package/dist/api-routes/auth-session.js +19 -19
  18. package/dist/api-routes/auth-setzkasten-login.js +14 -7
  19. package/dist/api-routes/catalog-add.js +59 -17
  20. package/dist/api-routes/catalog-export.js +14 -4
  21. package/dist/api-routes/config.d.ts +10 -3
  22. package/dist/api-routes/config.js +26 -4
  23. package/dist/api-routes/deploy-hook.js +8 -8
  24. package/dist/api-routes/editors.d.ts +1 -1
  25. package/dist/api-routes/editors.js +5 -2
  26. package/dist/api-routes/github-proxy.js +30 -8
  27. package/dist/api-routes/global-config.js +6 -3
  28. package/dist/api-routes/history-rollback.js +31 -14
  29. package/dist/api-routes/history-version.js +8 -6
  30. package/dist/api-routes/history.js +5 -2
  31. package/dist/api-routes/icons-local.js +1 -1
  32. package/dist/api-routes/init-add-section.js +150 -48
  33. package/dist/api-routes/init-apply.js +56 -42
  34. package/dist/api-routes/init-migrate.js +43 -36
  35. package/dist/api-routes/init-scan-page.d.ts +1 -1
  36. package/dist/api-routes/init-scan-page.js +59 -13
  37. package/dist/api-routes/init-scan.js +22 -7
  38. package/dist/api-routes/migrate-to-multi.js +5 -2
  39. package/dist/api-routes/pages.js +15 -4
  40. package/dist/api-routes/section-add.js +68 -16
  41. package/dist/api-routes/section-commit-pending.js +70 -22
  42. package/dist/api-routes/section-delete.js +49 -14
  43. package/dist/api-routes/section-duplicate.js +65 -16
  44. package/dist/api-routes/section-prepare-copy.js +15 -2
  45. package/dist/api-routes/section-prepare.js +25 -4
  46. package/dist/api-routes/setup-github-app-bounce.js +15 -1
  47. package/dist/api-routes/setup-github-app-branches.js +9 -6
  48. package/dist/api-routes/setup-github-app-callback.js +24 -1
  49. package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
  50. package/dist/api-routes/setup-github-app-credentials.js +43 -0
  51. package/dist/api-routes/setup-github-app-installed.js +22 -1
  52. package/dist/api-routes/setup-github-app-repos.js +5 -2
  53. package/dist/api-routes/setup-github-app.d.ts +4 -0
  54. package/dist/api-routes/setup-github-app.js +19 -2
  55. package/dist/api-routes/updater-register.js +7 -1
  56. package/dist/api-routes/webhooks-status.js +5 -2
  57. package/dist/api-routes/webhooks-test.js +9 -8
  58. package/dist/api-routes/webhooks.js +12 -14
  59. package/dist/api-routes/websites-add.js +5 -2
  60. package/dist/api-routes/websites-remove.js +5 -2
  61. package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
  62. package/dist/{chunk-RHJONMLK.js → chunk-CDXCYYQR.js} +222 -5
  63. package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
  64. package/dist/chunk-KENFINT4.js +76 -0
  65. package/dist/chunk-ONP6BRZO.js +47 -0
  66. package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
  67. package/dist/chunk-QVCW6EF3.js +26 -0
  68. package/dist/{chunk-K22A4ZBS.js → chunk-UHI6323G.js} +293 -174
  69. package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
  70. package/package.json +12 -6
  71. package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
  72. package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
  73. package/src/api-routes/__tests__/add-section-helpers.test.ts +91 -97
  74. package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
  75. package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
  76. package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
  77. package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
  78. package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
  79. package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
  80. package/src/api-routes/__tests__/github-cache.test.ts +1 -1
  81. package/src/api-routes/__tests__/github-token.test.ts +1 -1
  82. package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
  83. package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
  84. package/src/api-routes/__tests__/history.test.ts +9 -6
  85. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
  86. package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
  87. package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
  88. package/src/api-routes/__tests__/pages.test.ts +7 -2
  89. package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
  90. package/src/api-routes/__tests__/route-registry.test.ts +11 -18
  91. package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
  92. package/src/api-routes/__tests__/section-management.test.ts +28 -28
  93. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
  94. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
  95. package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
  96. package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
  97. package/src/api-routes/__tests__/updater-register.test.ts +230 -0
  98. package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
  99. package/src/api-routes/__tests__/webhooks.test.ts +19 -7
  100. package/src/api-routes/__tests__/websites-add.test.ts +2 -1
  101. package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
  102. package/src/api-routes/_auth-guard.ts +47 -15
  103. package/src/api-routes/_commit-trailers.ts +3 -2
  104. package/src/api-routes/_dev-session-secret.ts +79 -0
  105. package/src/api-routes/_github-token.ts +1 -1
  106. package/src/api-routes/_pages-meta-store.ts +2 -2
  107. package/src/api-routes/_role-resolver.ts +7 -5
  108. package/src/api-routes/_session-secret.ts +46 -0
  109. package/src/api-routes/_session-signing.ts +135 -0
  110. package/src/api-routes/_vercel-origin.ts +2 -6
  111. package/src/api-routes/_webhook-dispatcher.ts +12 -16
  112. package/src/api-routes/_website-resolver.ts +3 -10
  113. package/src/api-routes/auth-callback.ts +9 -5
  114. package/src/api-routes/auth-login.ts +5 -3
  115. package/src/api-routes/auth-logout.ts +18 -1
  116. package/src/api-routes/auth-session.ts +13 -21
  117. package/src/api-routes/auth-setzkasten-login.ts +12 -9
  118. package/src/api-routes/catalog-add.ts +89 -31
  119. package/src/api-routes/catalog-export.ts +30 -10
  120. package/src/api-routes/config.ts +39 -6
  121. package/src/api-routes/deploy-hook.ts +13 -11
  122. package/src/api-routes/editors.ts +33 -22
  123. package/src/api-routes/github-proxy.ts +25 -11
  124. package/src/api-routes/global-config.ts +103 -18
  125. package/src/api-routes/history-rollback.ts +41 -14
  126. package/src/api-routes/history-version.ts +5 -6
  127. package/src/api-routes/history.ts +3 -3
  128. package/src/api-routes/icons-local.ts +2 -2
  129. package/src/api-routes/init-add-section.ts +218 -88
  130. package/src/api-routes/init-apply.ts +71 -56
  131. package/src/api-routes/init-migrate.ts +54 -48
  132. package/src/api-routes/init-scan-page.ts +77 -30
  133. package/src/api-routes/init-scan.ts +19 -11
  134. package/src/api-routes/pages.ts +16 -11
  135. package/src/api-routes/section-add.ts +98 -27
  136. package/src/api-routes/section-commit-pending.ts +87 -34
  137. package/src/api-routes/section-delete.ts +76 -27
  138. package/src/api-routes/section-duplicate.ts +95 -28
  139. package/src/api-routes/section-management.ts +3 -7
  140. package/src/api-routes/section-prepare-copy.ts +29 -8
  141. package/src/api-routes/section-prepare.ts +38 -10
  142. package/src/api-routes/setup-github-app-bounce.ts +7 -1
  143. package/src/api-routes/setup-github-app-branches.ts +6 -7
  144. package/src/api-routes/setup-github-app-callback.ts +18 -1
  145. package/src/api-routes/setup-github-app-credentials.ts +55 -0
  146. package/src/api-routes/setup-github-app-installed.ts +12 -1
  147. package/src/api-routes/setup-github-app-repos.ts +2 -3
  148. package/src/api-routes/setup-github-app.ts +14 -5
  149. package/src/api-routes/updater-check.ts +6 -4
  150. package/src/api-routes/updater-register.ts +34 -20
  151. package/src/api-routes/updater-transfer.ts +8 -6
  152. package/src/api-routes/updater-unbind.ts +14 -10
  153. package/src/api-routes/webhooks-test.ts +9 -11
  154. package/src/api-routes/webhooks.ts +15 -19
  155. package/src/init/__tests__/page-level.test.ts +279 -105
  156. package/src/init/__tests__/page-list-coverage.test.ts +70 -70
  157. package/src/init/__tests__/patcher-child-component.test.ts +126 -0
  158. package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
  159. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
  160. package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
  161. package/src/init/__tests__/section-pipeline.test.ts +102 -16
  162. package/src/init/astro-config-patcher.ts +4 -18
  163. package/src/init/astro-detector.ts +2 -7
  164. package/src/init/astro-section-analyzer-v2.ts +475 -193
  165. package/src/init/field-label-enricher.ts +6 -6
  166. package/src/init/template-patcher-v2.ts +490 -56
@@ -1,9 +1,9 @@
1
+ import { type CommitInfo, parseCoAuthorTrailers } from '@setzkasten-cms/core'
1
2
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfigForRequest } from './_storage-config'
3
- import { resolveGitHubTokenForRequest } from './_github-token'
4
3
  import { requireAdmin } from './_auth-guard'
5
- import { parseCoAuthorTrailers, type CommitInfo } from '@setzkasten-cms/core'
6
4
  import { cachedFetch } from './_github-cache'
5
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
+ import { resolveStorageConfigForRequest } from './_storage-config'
7
7
 
8
8
  /**
9
9
  * GET /api/setzkasten/history?path=<contentPath>&before=<sha>
@@ -1,11 +1,11 @@
1
- import type { APIRoute } from 'astro'
2
1
  import {
3
2
  LOCAL_ICONS_DISCOVERY_PATHS,
4
3
  resolveLocalIconsPaths,
5
4
  sanitizeSvg,
6
5
  } from '@setzkasten-cms/core'
7
- import { resolveStorageConfigForRequest, prefixPath } from './_storage-config'
6
+ import type { APIRoute } from 'astro'
8
7
  import { resolveGitHubTokenForRequest } from './_github-token'
8
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
9
9
 
10
10
  /**
11
11
  * GET /api/setzkasten/icons/local
@@ -1,11 +1,18 @@
1
- import type { APIRoute } from 'astro'
1
+ import { isSafeKey } from '@setzkasten-cms/core'
2
2
  import type { InferredSection } from '@setzkasten-cms/core/init'
3
3
  import { addSectionToConfig } from '@setzkasten-cms/core/init'
4
- import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
5
- import { patchTemplateForFields, stripTemplateFallbacks } from '../init/template-patcher-v2'
4
+ import type { APIRoute } from 'astro'
6
5
  import type { RepeatedGroup } from '../init/analyzer-types'
6
+ import {
7
+ detectChildImports,
8
+ patchChildComponentForFieldPrefix,
9
+ patchTemplateForFields,
10
+ stripTemplateFallbacks,
11
+ } from '../init/template-patcher-v2'
12
+ import { requireAdmin } from './_auth-guard'
7
13
  import { withTrailers } from './_commit-trailers'
8
14
  import { resolveGitHubTokenForRequest } from './_github-token'
15
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
9
16
 
10
17
  /**
11
18
  * POST /api/setzkasten/init/add-section
@@ -17,10 +24,8 @@ import { resolveGitHubTokenForRequest } from './_github-token'
17
24
  * Body: { owner, repo, branch?, projectRoot, section, pageKey, contentPath? }
18
25
  */
19
26
  export const POST: APIRoute = async ({ request, cookies }) => {
20
- const session = cookies.get('setzkasten_session')?.value
21
- if (!session) {
22
- return new Response('Unauthorized', { status: 401 })
23
- }
27
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
28
+ if (denied) return denied
24
29
 
25
30
  const tokenResult = await resolveGitHubTokenForRequest(request)
26
31
  if (!tokenResult.ok) {
@@ -29,7 +34,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
29
34
  const githubToken = tokenResult.value
30
35
 
31
36
  try {
32
- const body = await request.json() as {
37
+ const body = (await request.json()) as {
33
38
  owner?: string
34
39
  repo?: string
35
40
  branch?: string
@@ -42,7 +47,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
42
47
 
43
48
  const storage = await resolveStorageConfigForRequest(request, body)
44
49
  if (!storage) {
45
- return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
50
+ return Response.json(
51
+ {
52
+ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.',
53
+ },
54
+ { status: 400 },
55
+ )
46
56
  }
47
57
  const { owner, repo, branch, projectPrefix } = storage
48
58
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
@@ -52,6 +62,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
52
62
  if (!section || !pageKey) {
53
63
  return Response.json({ error: 'section and pageKey are required' }, { status: 400 })
54
64
  }
65
+ if (!isSafeKey(pageKey)) {
66
+ return Response.json({ error: 'invalid pageKey' }, { status: 400 })
67
+ }
68
+ if (!isSafeKey(section.key)) {
69
+ return Response.json({ error: 'invalid section key' }, { status: 400 })
70
+ }
55
71
 
56
72
  const headers = {
57
73
  Authorization: `Bearer ${githubToken}`,
@@ -67,7 +83,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
67
83
  const existingConfig = await fetchFileContent(owner, repo, branch, configPath, githubToken)
68
84
 
69
85
  if (existingConfig) {
70
- const updatedConfig = addSectionToConfig(existingConfig, section.key, section, section.allFields)
86
+ const updatedConfig = addSectionToConfig(
87
+ existingConfig,
88
+ section.key,
89
+ section,
90
+ section.allFields,
91
+ )
71
92
  if (updatedConfig) {
72
93
  filesToCommit.push({ path: configPath, content: updatedConfig })
73
94
  }
@@ -75,13 +96,21 @@ export const POST: APIRoute = async ({ request, cookies }) => {
75
96
 
76
97
  // 2. Generate content JSON — merge new fields into existing data (if any)
77
98
  const sectionJsonPath = `${contentPath}/_sections/${section.key}.json`
78
- const existingSectionJson = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
99
+ const existingSectionJson = await fetchFileContent(
100
+ owner,
101
+ repo,
102
+ branch,
103
+ sectionJsonPath,
104
+ githubToken,
105
+ )
79
106
 
80
107
  let sectionData: Record<string, unknown> = {}
81
108
  if (existingSectionJson) {
82
109
  try {
83
110
  sectionData = JSON.parse(existingSectionJson)
84
- } catch { /* start fresh if invalid */ }
111
+ } catch {
112
+ /* start fresh if invalid */
113
+ }
85
114
  }
86
115
 
87
116
  // Only add values for fields that don't already exist
@@ -94,7 +123,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
94
123
  }
95
124
 
96
125
  // Reorder JSON keys to match template order (allFields if available, else fields)
97
- const orderedKeys = (section.allFields ?? section.fields).map(f => f.key)
126
+ const orderedKeys = (section.allFields ?? section.fields).map((f) => f.key)
98
127
  const orderedData: Record<string, unknown> = {}
99
128
  for (const key of orderedKeys) {
100
129
  if (key in sectionData) {
@@ -116,7 +145,13 @@ export const POST: APIRoute = async ({ request, cookies }) => {
116
145
  // 3. Update page config (add section to the end)
117
146
  const configKey = '_' + pageKey.replace(/\//g, '_')
118
147
  const pageConfigPath = `${contentPath}/pages/${configKey}.json`
119
- const existingPageConfig = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
148
+ const existingPageConfig = await fetchFileContent(
149
+ owner,
150
+ repo,
151
+ branch,
152
+ pageConfigPath,
153
+ githubToken,
154
+ )
120
155
 
121
156
  let pageConfig: { sections: Array<{ key: string; enabled: boolean }> }
122
157
  if (existingPageConfig) {
@@ -154,9 +189,13 @@ export const POST: APIRoute = async ({ request, cookies }) => {
154
189
  if (pageSource) resolvedPagePath = indexPath
155
190
  }
156
191
  if (pageSource) {
157
- const repeatedGroups: RepeatedGroup[] = (section as any)._analyzerResult?.repeatedGroups ?? []
192
+ const repeatedGroups: RepeatedGroup[] =
193
+ (section as any)._analyzerResult?.repeatedGroups ?? []
158
194
  let patchedSource = await patchTemplateForFields(
159
- pageSource, section.key, section.allFields ?? section.fields, repeatedGroups,
195
+ pageSource,
196
+ section.key,
197
+ section.allFields ?? section.fields,
198
+ repeatedGroups,
160
199
  { mode: 'page' },
161
200
  )
162
201
 
@@ -175,14 +214,19 @@ export const POST: APIRoute = async ({ request, cookies }) => {
175
214
  // analyzer's text-only defaultValue.
176
215
  const existingIsPlain = typeof existing === 'string' && !/<[a-z]/.test(existing)
177
216
  const newIsHtml = typeof value === 'string' && /<[a-z]/.test(value as string)
178
- if (!(key in sectionData) || existing === '' || existing === null || (existingIsPlain && newIsHtml)) {
217
+ if (
218
+ !(key in sectionData) ||
219
+ existing === '' ||
220
+ existing === null ||
221
+ (existingIsPlain && newIsHtml)
222
+ ) {
179
223
  sectionData[key] = value
180
224
  jsonUpdated = true
181
225
  }
182
226
  }
183
227
  // Update the content JSON entry if we added values
184
228
  if (jsonUpdated) {
185
- const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
229
+ const jsonIdx = filesToCommit.findIndex((f) => f.path === sectionJsonPath)
186
230
  if (jsonIdx !== -1) {
187
231
  filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
188
232
  }
@@ -192,18 +236,16 @@ export const POST: APIRoute = async ({ request, cookies }) => {
192
236
  if (patchedSource !== pageSource) {
193
237
  filesToCommit.push({ path: fullPagePath, content: patchedSource })
194
238
 
195
- // Create an SSR clone under sk-preview/ so the live preview iframe works.
196
- // The patched source already uses getSection() which is draft-aware.
197
- // The clone lives one directory deeper (sk-preview/<page> vs <page>),
198
- // so all relative imports need one extra "../" level.
199
- // Use resolvedPagePath (not body.pagePath) to get correct clone depth for
200
- // directory routes (docs/index.astro → sk-preview/docs/index.astro).
201
- const previewCopySource = patchedSource
202
- .replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, '')
203
- .replace(/(from\s+')(\.\.\/)/g, '$1../$2')
204
- .replace(/(from\s+")(\.\.\/)/g, '$1../$2')
239
+ // Create a thin SSR wrapper under sk-preview/ so the live preview iframe works.
240
+ // The wrapper imports the production page as a component — no content duplication.
241
+ // In SSR context (prerender=false), getSection() runs at request time (draft-aware).
242
+ // In static context (/impressum), the same component runs at build time.
205
243
  // resolvedPagePath: e.g. "src/pages/docs/index.astro" (for directory routes)
206
244
  const relativePage = resolvedPagePath.replace(/^src\/pages\//, '') // "docs/index.astro"
245
+ // Import depth: sk-preview/impressum.astro → '../impressum.astro'
246
+ // sk-preview/docs/index.astro → '../../docs/index.astro'
247
+ const importDepth = '../'.repeat(relativePage.split('/').length)
248
+ const previewCopySource = `---\nexport const prerender = false;\nimport Page from '${importDepth}${relativePage}';\n---\n<Page />\n`
207
249
  const previewCopyPath = prefixPath(`src/pages/sk-preview/${relativePage}`, projectPrefix)
208
250
  filesToCommit.push({ path: previewCopyPath, content: previewCopySource })
209
251
  }
@@ -211,10 +253,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
211
253
  // Update content JSON with any _classN fields from patcher
212
254
  const fields = section.allFields ?? section.fields
213
255
  for (const g of repeatedGroups) {
214
- const topField = fields.find(f => f.key === g.fieldKey)
256
+ const topField = fields.find((f) => f.key === g.fieldKey)
215
257
  if (!topField || !Array.isArray(topField.defaultValue)) continue
216
- sectionData[g.fieldKey] = (topField.defaultValue as unknown[]).filter(item => item != null)
217
- const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
258
+ sectionData[g.fieldKey] = (topField.defaultValue as unknown[]).filter(
259
+ (item) => item != null,
260
+ )
261
+ const jsonIdx = filesToCommit.findIndex((f) => f.path === sectionJsonPath)
218
262
  if (jsonIdx !== -1) {
219
263
  filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
220
264
  }
@@ -222,10 +266,22 @@ export const POST: APIRoute = async ({ request, cookies }) => {
222
266
  }
223
267
  // No patchPageFile / SECTION_COMPONENTS for page-level sections
224
268
  } else if (section.componentPath) {
225
- const componentSource = await fetchFileContent(owner, repo, branch, section.componentPath, githubToken)
269
+ const componentSource = await fetchFileContent(
270
+ owner,
271
+ repo,
272
+ branch,
273
+ section.componentPath,
274
+ githubToken,
275
+ )
226
276
  if (componentSource) {
227
- const repeatedGroups: RepeatedGroup[] = (section as any)._analyzerResult?.repeatedGroups ?? []
228
- const patchedSource = await patchTemplateForFields(componentSource, section.key, section.allFields ?? section.fields, repeatedGroups)
277
+ const repeatedGroups: RepeatedGroup[] =
278
+ (section as any)._analyzerResult?.repeatedGroups ?? []
279
+ const patchedSource = await patchTemplateForFields(
280
+ componentSource,
281
+ section.key,
282
+ section.allFields ?? section.fields,
283
+ repeatedGroups,
284
+ )
229
285
  if (patchedSource !== componentSource) {
230
286
  filesToCommit.push({ path: section.componentPath, content: patchedSource })
231
287
  }
@@ -235,19 +291,45 @@ export const POST: APIRoute = async ({ request, cookies }) => {
235
291
  // Re-read the now-mutated fields and update the content JSON entry.
236
292
  const fields = section.allFields ?? section.fields
237
293
  for (const g of repeatedGroups) {
238
- const topField = fields.find(f => f.key === g.fieldKey)
294
+ const topField = fields.find((f) => f.key === g.fieldKey)
239
295
  if (!topField || !Array.isArray(topField.defaultValue)) continue
240
- const items = (topField.defaultValue as Array<Record<string, unknown>>).filter(item => item != null)
296
+ const items = (topField.defaultValue as Array<Record<string, unknown>>).filter(
297
+ (item) => item != null,
298
+ )
241
299
 
242
300
  // Update sectionData with the enriched items array
243
301
  sectionData[g.fieldKey] = items
244
302
 
245
303
  // Rebuild and replace the content JSON in filesToCommit
246
- const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
304
+ const jsonIdx = filesToCommit.findIndex((f) => f.path === sectionJsonPath)
247
305
  if (jsonIdx !== -1) {
248
306
  filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
249
307
  }
250
308
  }
309
+
310
+ // 4b. Patch child components (e.g. PricingCard) used by repeated-component groups.
311
+ // detectChildImports finds any import whose component name matches a field's
312
+ // options.sourceComponent, then patchChildComponentForFieldPrefix injects
313
+ // fieldPrefix prop + data-sk-field bindings into that component file.
314
+ const allFields = section.allFields ?? section.fields
315
+ const childPatches = detectChildImports(patchedSource, allFields as any)
316
+ for (const child of childPatches) {
317
+ const sectionDir = section.componentPath.replace(/\/[^/]+$/, '')
318
+ const resolvedChildPath = resolveRelativePath(sectionDir, child.importPath)
319
+ if (!resolvedChildPath) continue
320
+ const childSource = await fetchFileContent(
321
+ owner,
322
+ repo,
323
+ branch,
324
+ resolvedChildPath,
325
+ githubToken,
326
+ )
327
+ if (!childSource) continue
328
+ const patchedChild = patchChildComponentForFieldPrefix(childSource, child.innerFields)
329
+ if (patchedChild !== childSource) {
330
+ filesToCommit.push({ path: resolvedChildPath, content: patchedChild })
331
+ }
332
+ }
251
333
  }
252
334
 
253
335
  // 5. Patch page file — add import and registry entry for new section
@@ -255,7 +337,13 @@ export const POST: APIRoute = async ({ request, cookies }) => {
255
337
  const fullPagePath = prefixPath(body.pagePath, projectPrefix)
256
338
  const pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
257
339
  if (pageSource) {
258
- const patched = patchPageFile(pageSource, section.key, section.componentName, section.componentPath, body.pagePath)
340
+ const patched = patchPageFile(
341
+ pageSource,
342
+ section.key,
343
+ section.componentName,
344
+ section.componentPath,
345
+ body.pagePath,
346
+ )
259
347
  if (patched && patched !== pageSource) {
260
348
  filesToCommit.push({ path: fullPagePath, content: patched })
261
349
  }
@@ -266,7 +354,13 @@ export const POST: APIRoute = async ({ request, cookies }) => {
266
354
  const previewPath = prefixPath(`${pageDir}/sk-preview/[...page].astro`, projectPrefix)
267
355
  const previewSource = await fetchFileContent(owner, repo, branch, previewPath, githubToken)
268
356
  if (previewSource) {
269
- const patchedPreview = patchPageFile(previewSource, section.key, section.componentName, section.componentPath, `${pageDir}/sk-preview/[...page].astro`)
357
+ const patchedPreview = patchPageFile(
358
+ previewSource,
359
+ section.key,
360
+ section.componentName,
361
+ section.componentPath,
362
+ `${pageDir}/sk-preview/[...page].astro`,
363
+ )
270
364
  if (patchedPreview && patchedPreview !== previewSource) {
271
365
  filesToCommit.push({ path: previewPath, content: patchedPreview })
272
366
  }
@@ -284,9 +378,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
284
378
  repo,
285
379
  branch,
286
380
  filesToCommit,
287
- withTrailers(existingSectionJson
288
- ? `content: update ${section.key} section — add new fields`
289
- : `content: add ${section.key} section to Setzkasten`),
381
+ withTrailers(
382
+ existingSectionJson
383
+ ? `content: update ${section.key} section add new fields`
384
+ : `content: add ${section.key} section to Setzkasten`,
385
+ ),
290
386
  headers,
291
387
  )
292
388
 
@@ -316,15 +412,24 @@ export const POST: APIRoute = async ({ request, cookies }) => {
316
412
 
317
413
  function getDefaultValue(fieldType: string): unknown {
318
414
  switch (fieldType) {
319
- case 'text': return ''
320
- case 'number': return 0
321
- case 'boolean': return false
322
- case 'image': return { path: '', alt: '' }
323
- case 'array': return []
324
- case 'color': return '#000000'
325
- case 'date': return ''
326
- case 'icon': return ''
327
- default: return ''
415
+ case 'text':
416
+ return ''
417
+ case 'number':
418
+ return 0
419
+ case 'boolean':
420
+ return false
421
+ case 'image':
422
+ return { path: '', alt: '' }
423
+ case 'array':
424
+ return []
425
+ case 'color':
426
+ return '#000000'
427
+ case 'date':
428
+ return ''
429
+ case 'icon':
430
+ return ''
431
+ default:
432
+ return ''
328
433
  }
329
434
  }
330
435
 
@@ -380,8 +485,12 @@ export function patchPageFile(
380
485
  // Find the last entry and add after it
381
486
  const lastEntryMatch = registryContent.match(/.*\w+Section,?\s*$/m)
382
487
  if (lastEntryMatch && lastEntryMatch.index !== undefined) {
383
- const insertPos = registryMatch.index! + registryMatch[0].indexOf(registryContent) + lastEntryMatch.index + lastEntryMatch[0].length
384
- const newEntry = `\n [normalize('${sectionKey}')]: ${componentName},`
488
+ const insertPos =
489
+ registryMatch.index! +
490
+ registryMatch[0].indexOf(registryContent) +
491
+ lastEntryMatch.index +
492
+ lastEntryMatch[0].length
493
+ const newEntry = `\n '${sectionKey}': ${componentName},`
385
494
  patched = patched.slice(0, insertPos) + newEntry + patched.slice(insertPos)
386
495
  }
387
496
  }
@@ -403,7 +512,11 @@ export function calculateRelativePath(fromDir: string, toPath: string): string {
403
512
 
404
513
  // Find common prefix length
405
514
  let common = 0
406
- while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
515
+ while (
516
+ common < fromParts.length &&
517
+ common < toParts.length &&
518
+ fromParts[common] === toParts[common]
519
+ ) {
407
520
  common++
408
521
  }
409
522
 
@@ -414,6 +527,28 @@ export function calculateRelativePath(fromDir: string, toPath: string): string {
414
527
  return '../'.repeat(ups) + remaining
415
528
  }
416
529
 
530
+ /**
531
+ * Resolve a relative import path against a base directory to get the repo-root-relative path.
532
+ * e.g. resolveRelativePath("src/components/sections", "../components/PricingCard.astro")
533
+ * → "src/components/PricingCard.astro"
534
+ */
535
+ function resolveRelativePath(baseDir: string, relativePath: string): string | null {
536
+ if (relativePath.startsWith('/')) return relativePath.replace(/^\//, '')
537
+ const parts = [...baseDir.split('/').filter(Boolean)]
538
+ for (const segment of relativePath.split('/')) {
539
+ if (segment === '.') continue
540
+ if (segment === '..') {
541
+ parts.pop()
542
+ continue
543
+ }
544
+ parts.push(segment)
545
+ }
546
+ const resolved = parts.join('/')
547
+ // Must stay within src/ or project root — reject anything that escapes
548
+ if (resolved.startsWith('../') || resolved.startsWith('/')) return null
549
+ return resolved
550
+ }
551
+
417
552
  async function fetchFileContent(
418
553
  owner: string,
419
554
  repo: string,
@@ -433,7 +568,7 @@ async function fetchFileContent(
433
568
  },
434
569
  )
435
570
  if (!response.ok) return null
436
- const data = await response.json() as { content: string; encoding: string }
571
+ const data = (await response.json()) as { content: string; encoding: string }
437
572
  return data.encoding === 'base64'
438
573
  ? Buffer.from(data.content, 'base64').toString('utf-8')
439
574
  : data.content
@@ -457,7 +592,7 @@ async function batchCommit(
457
592
  { headers },
458
593
  )
459
594
  if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
460
- const refData = await refRes.json() as { object: { sha: string } }
595
+ const refData = (await refRes.json()) as { object: { sha: string } }
461
596
  const headSha = refData.object.sha
462
597
 
463
598
  // 2. Get base tree
@@ -466,43 +601,38 @@ async function batchCommit(
466
601
  { headers },
467
602
  )
468
603
  if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
469
- const commitData = await commitRes.json() as { tree: { sha: string } }
604
+ const commitData = (await commitRes.json()) as { tree: { sha: string } }
470
605
 
471
606
  // 3. Create new tree
472
- const treeRes = await fetch(
473
- `https://api.github.com/repos/${owner}/${repo}/git/trees`,
474
- {
475
- method: 'POST',
476
- headers,
477
- body: JSON.stringify({
478
- base_tree: commitData.tree.sha,
479
- tree: files.map((f) => ({
480
- path: f.path,
481
- mode: '100644',
482
- type: 'blob',
483
- content: f.content,
484
- })),
485
- }),
486
- },
487
- )
607
+ const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
608
+ method: 'POST',
609
+ headers,
610
+ body: JSON.stringify({
611
+ base_tree: commitData.tree.sha,
612
+ tree: files.map((f) => ({
613
+ path: f.path,
614
+ mode: '100644',
615
+ type: 'blob',
616
+ content: f.content,
617
+ })),
618
+ }),
619
+ })
488
620
  if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
489
- const treeData = await treeRes.json() as { sha: string }
621
+ const treeData = (await treeRes.json()) as { sha: string }
490
622
 
491
623
  // 4. Create commit
492
- const newCommitRes = await fetch(
493
- `https://api.github.com/repos/${owner}/${repo}/git/commits`,
494
- {
495
- method: 'POST',
496
- headers,
497
- body: JSON.stringify({
498
- tree: treeData.sha,
499
- parents: [headSha],
500
- message,
501
- }),
502
- },
503
- )
504
- if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
505
- const newCommitData = await newCommitRes.json() as { sha: string }
624
+ const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
625
+ method: 'POST',
626
+ headers,
627
+ body: JSON.stringify({
628
+ tree: treeData.sha,
629
+ parents: [headSha],
630
+ message,
631
+ }),
632
+ })
633
+ if (!newCommitRes.ok)
634
+ return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
635
+ const newCommitData = (await newCommitRes.json()) as { sha: string }
506
636
 
507
637
  // 5. Update ref
508
638
  const updateRes = await fetch(