@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
@@ -17,7 +17,7 @@
17
17
  * and accepts arbitrary HTML children (including `<p>`).
18
18
  */
19
19
 
20
- import { describe, it, expect } from 'vitest'
20
+ import { describe, expect, it } from 'vitest'
21
21
  import { patchTemplateForFields } from '../../init/template-patcher-v2'
22
22
  import type { PatchField } from '../../init/template-patcher-v2'
23
23
 
@@ -53,15 +53,21 @@ describe('patchMixedContentField — phrasing wrapper conversion', () => {
53
53
  ]
54
54
 
55
55
  it('converts a <p> mixed-content wrapper to <div> so RTE-injected <p> children remain valid', async () => {
56
- const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], { mode: 'page' })
56
+ const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], {
57
+ mode: 'page',
58
+ })
57
59
 
58
60
  // The patched description binding must NOT live on a <p> element —
59
61
  // otherwise an RTE-edited value of "<p>…</p>" produces nested <p>'s.
60
62
  expect(patched).not.toMatch(/<p[^>]*data-sk-field="_page_impressum\.description"/)
61
63
 
62
64
  // Instead, the wrapper should be a <div> that retains the original classes.
63
- expect(patched).toMatch(/<div[^>]*class="text-sk-slate leading-relaxed"[^>]*data-sk-field="_page_impressum\.description"/)
64
- expect(patched).toMatch(/data-sk-field="_page_impressum\.description"[^>]*set:html=\{skData\?\.description/)
65
+ expect(patched).toMatch(
66
+ /<div[^>]*class="text-sk-slate leading-relaxed"[^>]*data-sk-field="_page_impressum\.description"/,
67
+ )
68
+ expect(patched).toMatch(
69
+ /data-sk-field="_page_impressum\.description"[^>]*set:html=\{skData\?\.description/,
70
+ )
65
71
 
66
72
  // The closing tag must match (no </p> for this binding's wrapper).
67
73
  // A simple sanity check: the <div ... data-sk-field="…description"> must be followed by </div>.
@@ -77,12 +83,16 @@ describe('patchMixedContentField — phrasing wrapper conversion', () => {
77
83
  // Headings rarely host RTE content but must remain semantically intact.
78
84
  // patchTextField (not patchMixedContentField) handles plain text headings,
79
85
  // so the <h2> should keep its tag name.
80
- const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], { mode: 'page' })
86
+ const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], {
87
+ mode: 'page',
88
+ })
81
89
  expect(patched).toMatch(/<h2[^>]*data-sk-field="_page_impressum\.heading2"/)
82
90
  })
83
91
 
84
92
  it('keeps the original <p> classes on the converted <div> wrapper', async () => {
85
- const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], { mode: 'page' })
93
+ const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], {
94
+ mode: 'page',
95
+ })
86
96
  // The Tailwind classes from the original <p class="text-sk-slate leading-relaxed">
87
97
  // must be preserved on the new <div>.
88
98
  expect(patched).toContain('text-sk-slate leading-relaxed')
@@ -9,7 +9,7 @@
9
9
  * 5. removeOldVarDeclarations causes TDZ (alias placed before skData declaration)
10
10
  */
11
11
 
12
- import { describe, it, expect } from 'vitest'
12
+ import { describe, expect, it } from 'vitest'
13
13
  import { patchTemplateForFields } from '../../init/template-patcher-v2'
14
14
  import type { PatchField } from '../../init/template-patcher-v2'
15
15
 
@@ -60,14 +60,22 @@ import BaseLayout from '../layouts/BaseLayout.astro';
60
60
  `
61
61
 
62
62
  const fields: PatchField[] = [
63
- { key: 'items', type: 'array', defaultValue: [
64
- { name: '@setzkasten-cms/core', desc: 'Schema, Feldtypen, Validation' },
65
- { name: '@setzkasten-cms/ui', desc: 'React-UI: Provider, FormStore' },
66
- ]},
67
- { key: 'items2', type: 'array', defaultValue: [
68
- { title: 'Dependency Inversion', desc: 'UI haengt nur von Ports ab' },
69
- { title: 'Schema-First', desc: 'Alles leitet sich vom TypeScript-Schema ab' },
70
- ]},
63
+ {
64
+ key: 'items',
65
+ type: 'array',
66
+ defaultValue: [
67
+ { name: '@setzkasten-cms/core', desc: 'Schema, Feldtypen, Validation' },
68
+ { name: '@setzkasten-cms/ui', desc: 'React-UI: Provider, FormStore' },
69
+ ],
70
+ },
71
+ {
72
+ key: 'items2',
73
+ type: 'array',
74
+ defaultValue: [
75
+ { title: 'Dependency Inversion', desc: 'UI haengt nur von Ports ab' },
76
+ { title: 'Schema-First', desc: 'Alles leitet sich vom TypeScript-Schema ab' },
77
+ ],
78
+ },
71
79
  ]
72
80
 
73
81
  it('should reference skData?.items for the first array', async () => {
@@ -111,9 +119,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
111
119
  </BaseLayout>
112
120
  `
113
121
 
114
- const fields: PatchField[] = [
115
- { key: 'heading', type: 'text', defaultValue: 'Titel' },
116
- ]
122
+ const fields: PatchField[] = [{ key: 'heading', type: 'text', defaultValue: 'Titel' }]
117
123
 
118
124
  it('should add id="section-_page_test" to the <section> element', async () => {
119
125
  const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
@@ -147,9 +153,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
147
153
  </BaseLayout>
148
154
  `
149
155
 
150
- const fields: PatchField[] = [
151
- { key: 'heading', type: 'text', defaultValue: 'Architektur' },
152
- ]
156
+ const fields: PatchField[] = [{ key: 'heading', type: 'text', defaultValue: 'Architektur' }]
153
157
 
154
158
  it('should add data-sk-field to the <h1> element', async () => {
155
159
  const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
@@ -203,7 +207,7 @@ const fields = ['Alpha', 'Beta', 'Gamma'];
203
207
  const fmMatch = patched.match(/^---\n([\s\S]*?)\n---/)
204
208
  const frontmatter = fmMatch ? fmMatch[1]! : patched
205
209
 
206
- const lines = frontmatter.split('\n').map(l => l.trim())
210
+ const lines = frontmatter.split('\n').map((l) => l.trim())
207
211
  expect(lines).not.toContain(';')
208
212
  })
209
213
  })
@@ -239,7 +243,9 @@ const fields = ['Alpha', 'Beta', 'Gamma'];
239
243
  ]
240
244
 
241
245
  it('should place the fields alias AFTER the skData declaration', async () => {
242
- const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], { mode: 'page' })
246
+ const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], {
247
+ mode: 'page',
248
+ })
243
249
 
244
250
  const fmMatch = patched.match(/^---\n([\s\S]*?)\n---/)
245
251
  const frontmatter = fmMatch ? fmMatch[1]! : patched
@@ -253,17 +259,21 @@ const fields = ['Alpha', 'Beta', 'Gamma'];
253
259
  })
254
260
 
255
261
  it('should not contain a stray semicolon line in frontmatter', async () => {
256
- const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], { mode: 'page' })
262
+ const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], {
263
+ mode: 'page',
264
+ })
257
265
 
258
266
  const fmMatch = patched.match(/^---\n([\s\S]*?)\n---/)
259
267
  const frontmatter = fmMatch ? fmMatch[1]! : patched
260
268
 
261
- const lines = frontmatter.split('\n').map(l => l.trim())
269
+ const lines = frontmatter.split('\n').map((l) => l.trim())
262
270
  expect(lines).not.toContain(';')
263
271
  })
264
272
 
265
273
  it('should preserve fields.length in the template', async () => {
266
- const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], { mode: 'page' })
274
+ const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], {
275
+ mode: 'page',
276
+ })
267
277
 
268
278
  // Extract template (after closing ---)
269
279
  const templatePart = patched.split('\n---\n').slice(1).join('\n---\n')
@@ -12,9 +12,9 @@
12
12
  * - Stale frontmatter var cleanup
13
13
  */
14
14
 
15
- import { describe, it, expect, beforeAll } from 'vitest'
16
15
  import { readFileSync, readdirSync } from 'fs'
17
- import { join, basename } from 'path'
16
+ import { basename, join } from 'path'
17
+ import { beforeAll, describe, expect, it } from 'vitest'
18
18
  import { analyzeAstroSection } from '../../init/astro-section-analyzer-v2'
19
19
  import { patchTemplateForFields } from '../../init/template-patcher-v2'
20
20
 
@@ -23,7 +23,9 @@ import { patchTemplateForFields } from '../../init/template-patcher-v2'
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
25
  const SECTIONS_DIR = join(import.meta.dirname!, '..', '..', '..', '..', '..', 'test-sections')
26
- const files = readdirSync(SECTIONS_DIR).filter(f => f.endsWith('.astro')).sort()
26
+ const files = readdirSync(SECTIONS_DIR)
27
+ .filter((f) => f.endsWith('.astro'))
28
+ .sort()
27
29
 
28
30
  // ---------------------------------------------------------------------------
29
31
  // Helpers (ported from test-sections-runner.mts)
@@ -40,12 +42,16 @@ function extractClassValues(src: string): string[] {
40
42
  function checkCssIntegrity(
41
43
  source: string,
42
44
  patched: string,
43
- groups: Array<{ tag: string; instances: Array<{ start: number; end: number }>; fields?: Array<{ key: string; defaultValues?: unknown[] }> }>,
45
+ groups: Array<{
46
+ tag: string
47
+ instances: Array<{ start: number; end: number }>
48
+ fields?: Array<{ key: string; defaultValues?: unknown[] }>
49
+ }>,
44
50
  ): { ok: boolean; lost: string[] } {
45
51
  const lost: string[] = []
46
52
  const dynamicClassValues = new Set<string>()
47
53
  for (const g of groups) {
48
- for (const f of (g.fields ?? [])) {
54
+ for (const f of g.fields ?? []) {
49
55
  if (f.key.startsWith('_class') && f.defaultValues) {
50
56
  for (const dv of f.defaultValues) {
51
57
  if (typeof dv === 'string') dynamicClassValues.add(dv)
@@ -102,6 +108,17 @@ function checkSkFieldBindings(
102
108
  }
103
109
  continue
104
110
  }
111
+ // Array fields from repeated component instances use fieldPrefix= instead of data-sk-field
112
+ // (Astro components don't forward unknown props to the DOM).
113
+ if (field.type === 'array' && (field as any).options?.sourceComponent) {
114
+ const fieldPrefixBinding = `${sectionKey}.${field.key}.\${_i}\``
115
+ if (patched.includes(fieldPrefixBinding)) {
116
+ found.push(`${sectionKey}.${field.key}.*`)
117
+ } else {
118
+ missing.push(`${sectionKey}.${field.key}`)
119
+ }
120
+ continue
121
+ }
105
122
  if (!fieldNeedsSkBinding(field)) {
106
123
  skipped.push(`${sectionKey}.${field.key} (${field.type})`)
107
124
  continue
@@ -122,9 +139,18 @@ function checkSkFieldBindings(
122
139
 
123
140
  for (const g of groups) {
124
141
  for (const innerField of g.fields) {
125
- if (innerField.type === 'array') { skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (array)`); continue }
126
- if (!fieldNeedsSkBinding(innerField)) { skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (${innerField.type})`); continue }
127
- if (!patched.includes(`item.${innerField.key}`)) { skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (not replaced)`); continue }
142
+ if (innerField.type === 'array') {
143
+ skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (array)`)
144
+ continue
145
+ }
146
+ if (!fieldNeedsSkBinding(innerField)) {
147
+ skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (${innerField.type})`)
148
+ continue
149
+ }
150
+ if (!patched.includes(`item.${innerField.key}`)) {
151
+ skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (not replaced)`)
152
+ continue
153
+ }
128
154
  const innerBinding = `${sectionKey}.${g.fieldKey}.\${_i}.${innerField.key}`
129
155
  if (patched.includes(innerBinding)) {
130
156
  found.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key}`)
@@ -162,7 +188,13 @@ describe('Section Pipeline', () => {
162
188
  const getSectionMatch = isPageMode ? source.match(/getSection\(['"]([^'"]+)['"]\)/) : null
163
189
  sectionKey = getSectionMatch?.[1] ?? filenameKey
164
190
  const options = isPageMode ? { mode: 'page' as const } : undefined
165
- section = await analyzeAstroSection(source, sectionKey, name, `src/components/sections/${file}`, options)
191
+ section = await analyzeAstroSection(
192
+ source,
193
+ sectionKey,
194
+ name,
195
+ `src/components/sections/${file}`,
196
+ options,
197
+ )
166
198
  groups = (section as any)._analyzerResult?.repeatedGroups ?? []
167
199
  patched = await patchTemplateForFields(source, sectionKey, section.fields, groups, options)
168
200
  })
@@ -206,10 +238,13 @@ describe('Section Pipeline', () => {
206
238
  }
207
239
  }
208
240
  }
209
- const duplicateArrays = section.fields.filter((f: any) =>
210
- f.type === 'array' && innerArraySources.has(f.key),
241
+ const duplicateArrays = section.fields.filter(
242
+ (f: any) => f.type === 'array' && innerArraySources.has(f.key),
211
243
  )
212
- expect(duplicateArrays, `Duplicate arrays: ${duplicateArrays.map((f: any) => f.key).join(', ')}`).toHaveLength(0)
244
+ expect(
245
+ duplicateArrays,
246
+ `Duplicate arrays: ${duplicateArrays.map((f: any) => f.key).join(', ')}`,
247
+ ).toHaveLength(0)
213
248
  })
214
249
 
215
250
  it('should remove consumed frontmatter vars', () => {
@@ -256,7 +291,10 @@ describe('Section Pipeline', () => {
256
291
 
257
292
  for (const g of groups) {
258
293
  const arrayField = section.fields.find((f: any) => f.key === g.fieldKey)
259
- if (!arrayField) { issues.push(`No top-level field for group ${g.fieldKey}`); continue }
294
+ if (!arrayField) {
295
+ issues.push(`No top-level field for group ${g.fieldKey}`)
296
+ continue
297
+ }
260
298
 
261
299
  const items = arrayField.defaultValue as Array<Record<string, unknown>> | undefined
262
300
  if (!Array.isArray(items) || items.length === 0) {
@@ -283,7 +321,7 @@ describe('Section Pipeline', () => {
283
321
  }
284
322
  for (const refKey of referencedKeys) {
285
323
  if (['map', 'length', 'filter', 'forEach', 'join', 'slice'].includes(refKey)) continue
286
- const hasInAnyItem = items.some(item => refKey in item)
324
+ const hasInAnyItem = items.some((item) => refKey in item)
287
325
  if (!hasInAnyItem) {
288
326
  issues.push(`{item.${refKey}} used in template but missing in content JSON`)
289
327
  }
@@ -324,7 +362,9 @@ describe('Section Pipeline', () => {
324
362
  const origCount = originalClassCounts.get(val) ?? 0
325
363
  if (keys.length > origCount) {
326
364
  const label = instIdx === 0 ? 'template' : `instance ${instIdx}`
327
- issues.push(`${g.fieldKey} ${label}: "${val.slice(0, 40)}" assigned to [${keys.join(', ')}] but appears ${origCount}×`)
365
+ issues.push(
366
+ `${g.fieldKey} ${label}: "${val.slice(0, 40)}" assigned to [${keys.join(', ')}] but appears ${origCount}×`,
367
+ )
328
368
  }
329
369
  }
330
370
  }
@@ -369,7 +409,9 @@ describe('Section Pipeline', () => {
369
409
  const actualTag = lastPart.replace(/:\d+$/, '') || g.tag
370
410
 
371
411
  if (actualTag !== expectedTag) {
372
- issues.push(`${cf.key} instance ${instIdx}: expected <${expectedTag}> but on <${actualTag}>`)
412
+ issues.push(
413
+ `${cf.key} instance ${instIdx}: expected <${expectedTag}> but on <${actualTag}>`,
414
+ )
373
415
  }
374
416
  }
375
417
  }
@@ -390,6 +432,50 @@ describe('Section Pipeline', () => {
390
432
  const bindings = checkSkFieldBindings(patched, sectionKey, section.fields, groups)
391
433
  expect(bindings.missing, `Missing bindings: ${bindings.missing.join(', ')}`).toHaveLength(0)
392
434
  })
435
+
436
+ it('should collapse repeated component instances into .map()', () => {
437
+ const componentArrayFields = section.fields.filter(
438
+ (f: any) => f.type === 'array' && f.options?.sourceComponent,
439
+ )
440
+ if (componentArrayFields.length === 0) return
441
+ for (const field of componentArrayFields) {
442
+ const compName = (field as any).options.sourceComponent as string
443
+ const tagRegex = new RegExp(`<${compName}[\\s/>]`, 'g')
444
+ const originalCount = (source.match(tagRegex) || []).length
445
+ const patchedCount = (patched.match(tagRegex) || []).length
446
+ expect(
447
+ patchedCount,
448
+ `<${compName}> count should be reduced (was ${originalCount}, still ${patchedCount})`,
449
+ ).toBeLessThan(originalCount)
450
+ expect(patched, `Expected .map( after collapsing <${compName}>`).toContain('.map(')
451
+ }
452
+ })
453
+
454
+ it('should remove stale frontmatter vars from repeated component props', () => {
455
+ const componentArrayFields = section.fields.filter(
456
+ (f: any) =>
457
+ f.type === 'array' && f.options?.sourceComponent && Array.isArray(f.defaultValue),
458
+ )
459
+ if (componentArrayFields.length === 0) return
460
+ const fmEnd = patched.indexOf('---', patched.indexOf('---') + 3)
461
+ const patchedFm = fmEnd > 0 ? patched.slice(0, fmEnd) : ''
462
+ const staleVars: string[] = []
463
+ for (const field of componentArrayFields) {
464
+ const items = (field as any).defaultValue as Array<Record<string, unknown>>
465
+ for (const innerField of ((field as any).options?.arrayItem?.fields ?? []) as Array<{
466
+ key: string
467
+ }>) {
468
+ // Only string-array props might have been declared as frontmatter vars
469
+ const allVarLike = items.every((item) => Array.isArray(item[innerField.key]))
470
+ if (!allVarLike) continue
471
+ const varNames = items.map((_, i) => `${innerField.key}${i === 0 ? '' : i + 1}`)
472
+ for (const v of varNames) {
473
+ if (patchedFm.includes(`const ${v}`)) staleVars.push(v)
474
+ }
475
+ }
476
+ }
477
+ expect(staleVars, `Stale frontmatter vars: ${staleVars.join(', ')}`).toHaveLength(0)
478
+ })
393
479
  })
394
480
  }
395
481
  })
@@ -21,11 +21,7 @@ export function patchAstroConfig(source: string): string | null {
21
21
  // Find the last import statement
22
22
  const lastImportIndex = findLastImportEnd(result)
23
23
  if (lastImportIndex >= 0) {
24
- result =
25
- result.slice(0, lastImportIndex) +
26
- '\n' +
27
- importLine +
28
- result.slice(lastImportIndex)
24
+ result = result.slice(0, lastImportIndex) + '\n' + importLine + result.slice(lastImportIndex)
29
25
  } else {
30
26
  // No imports found — add at the very top
31
27
  result = importLine + '\n' + result
@@ -67,19 +63,11 @@ function addToIntegrations(source: string): string {
67
63
 
68
64
  // Check if array is empty
69
65
  if (after.startsWith(']')) {
70
- return (
71
- source.slice(0, insertPos) +
72
- 'setzkasten()' +
73
- source.slice(insertPos)
74
- )
66
+ return source.slice(0, insertPos) + 'setzkasten()' + source.slice(insertPos)
75
67
  }
76
68
 
77
69
  // Array has items — prepend
78
- return (
79
- source.slice(0, insertPos) +
80
- '\n setzkasten(),\n ' +
81
- source.slice(insertPos)
82
- )
70
+ return source.slice(0, insertPos) + '\n setzkasten(),\n ' + source.slice(insertPos)
83
71
  }
84
72
 
85
73
  // Case 2: No integrations array — add it to defineConfig
@@ -87,9 +75,7 @@ function addToIntegrations(source: string): string {
87
75
  if (defineConfigMatch && defineConfigMatch.index !== undefined) {
88
76
  const insertPos = defineConfigMatch.index + defineConfigMatch[0].length
89
77
  return (
90
- source.slice(0, insertPos) +
91
- '\n integrations: [setzkasten()],\n' +
92
- source.slice(insertPos)
78
+ source.slice(0, insertPos) + '\n integrations: [setzkasten()],\n' + source.slice(insertPos)
93
79
  )
94
80
  }
95
81
 
@@ -47,9 +47,7 @@ export function findAstroPages(files: RepoFile[], projectRoot: string): AstroPag
47
47
 
48
48
  const name = relativePath.replace(/\.astro$/, '')
49
49
  const isIndex = name === 'index' || name.endsWith('/index')
50
- const pageKey = isIndex
51
- ? name === 'index' ? 'index' : name.replace(/\/index$/, '')
52
- : name
50
+ const pageKey = isIndex ? (name === 'index' ? 'index' : name.replace(/\/index$/, '')) : name
53
51
 
54
52
  pages.push({
55
53
  filePath: file.path,
@@ -170,10 +168,7 @@ function resolveImportPath(
170
168
  // Handle alias imports like @/components/... or ~/components/...
171
169
  if (importPath.startsWith('@/') || importPath.startsWith('~/')) {
172
170
  const aliasPath = importPath.replace(/^[@~]\//, '')
173
- const candidates = [
174
- `${projectRoot}/src/${aliasPath}`,
175
- `${projectRoot}/src/${aliasPath}.astro`,
176
- ]
171
+ const candidates = [`${projectRoot}/src/${aliasPath}`, `${projectRoot}/src/${aliasPath}.astro`]
177
172
  for (const candidate of candidates) {
178
173
  if (allFiles.some((f) => f.path === candidate)) return candidate
179
174
  }