@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
@@ -14,9 +14,9 @@
14
14
  * 6. Regression: real docs pages have no hardcoded <li> text after fix
15
15
  */
16
16
 
17
- import { describe, it, expect, beforeAll } from 'vitest'
18
17
  import { readFileSync } from 'node:fs'
19
18
  import { join, resolve } from 'node:path'
19
+ import { beforeAll, describe, expect, it } from 'vitest'
20
20
  import { analyzeAstroSection } from '../astro-section-analyzer-v2'
21
21
  import { patchTemplateForFields } from '../template-patcher-v2'
22
22
 
@@ -30,7 +30,8 @@ const SECTIONS_DIR = resolve(import.meta.dirname!, '..', '..', '..', '..', '..',
30
30
  function findHardcodedListItems(source: string): RegExpMatchArray[] {
31
31
  // Match <span> that has direct text (no set:html, no {expr}), inside a <li>
32
32
  // We look for: <span> followed by a letter (not { or <), then text, then </span>
33
- const pattern = /<li[\s\S]{0,300}?<span(?![^>]*set:html)(?![^>]*data-sk-field)[^>]*>([A-Za-zÄÖÜäöüß][^<{]*)<\/span>/g
33
+ const pattern =
34
+ /<li[\s\S]{0,300}?<span(?![^>]*set:html)(?![^>]*data-sk-field)[^>]*>([A-Za-zÄÖÜäöüß][^<{]*)<\/span>/g
34
35
  return [...source.matchAll(pattern)]
35
36
  }
36
37
 
@@ -58,15 +59,17 @@ describe('PageWithHardcodedList — analyzer detects list items', () => {
58
59
  })
59
60
 
60
61
  it('should detect the h2 headings as text fields', () => {
61
- const keys = section.fields.map(f => f.key)
62
+ const keys = section.fields.map((f) => f.key)
62
63
  // The page has "Page Builder", "Section-Editor", "Collections" as <h2>
63
- const hasHeadings = keys.some(k => k.includes('heading') || k.includes('pageBuild') || k.includes('section'))
64
+ const hasHeadings = keys.some(
65
+ (k) => k.includes('heading') || k.includes('pageBuild') || k.includes('section'),
66
+ )
64
67
  expect(hasHeadings, 'Should detect h2 headings').toBe(true)
65
68
  })
66
69
 
67
70
  it('should detect list item content as array fields', () => {
68
71
  // List items should appear either as array fields or individual text fields
69
- const arrayFields = section.fields.filter(f => f.type === 'array')
72
+ const arrayFields = section.fields.filter((f) => f.type === 'array')
70
73
  const allFields = section.fields
71
74
 
72
75
  // Either the list items are grouped as arrays, or they appear as individual text fields
@@ -76,20 +79,19 @@ describe('PageWithHardcodedList — analyzer detects list items', () => {
76
79
  })
77
80
 
78
81
  it('should find list item default values', () => {
79
- const allDefaultValues = section.fields
80
- .flatMap(f => {
81
- if (f.type === 'array' && Array.isArray(f.defaultValue)) return f.defaultValue as string[]
82
- return typeof f.defaultValue === 'string' ? [f.defaultValue] : []
83
- })
84
-
85
- const hasListContent = allDefaultValues.some(v =>
86
- typeof v === 'string' && (
87
- v.includes('adoptieren') ||
88
- v.includes('Textfelder') ||
89
- v.includes('Eintraege')
90
- ),
82
+ const allDefaultValues = section.fields.flatMap((f) => {
83
+ if (f.type === 'array' && Array.isArray(f.defaultValue)) return f.defaultValue as string[]
84
+ return typeof f.defaultValue === 'string' ? [f.defaultValue] : []
85
+ })
86
+
87
+ const hasListContent = allDefaultValues.some(
88
+ (v) =>
89
+ typeof v === 'string' &&
90
+ (v.includes('adoptieren') || v.includes('Textfelder') || v.includes('Eintraege')),
91
+ )
92
+ expect(hasListContent, 'List item text should appear in detected field default values').toBe(
93
+ true,
91
94
  )
92
- expect(hasListContent, 'List item text should appear in detected field default values').toBe(true)
93
95
  })
94
96
  })
95
97
 
@@ -109,20 +111,16 @@ describe('PageWithHardcodedList — patcher eliminates hardcoded list items', ()
109
111
  { mode: 'page' },
110
112
  )
111
113
  const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
112
- patched = await patchTemplateForFields(
113
- source,
114
- '_page_docs_admin',
115
- section.fields,
116
- groups,
117
- { mode: 'page' },
118
- )
114
+ patched = await patchTemplateForFields(source, '_page_docs_admin', section.fields, groups, {
115
+ mode: 'page',
116
+ })
119
117
  })
120
118
 
121
119
  it('should not contain hardcoded <li> text after patching', () => {
122
120
  const hardcoded = findHardcodedListItems(patched)
123
121
  expect(
124
122
  hardcoded,
125
- `Hardcoded list items found after patching:\n${hardcoded.map(m => ` "${m[1]}"`).join('\n')}`,
123
+ `Hardcoded list items found after patching:\n${hardcoded.map((m) => ` "${m[1]}"`).join('\n')}`,
126
124
  ).toHaveLength(0)
127
125
  })
128
126
 
@@ -160,21 +158,21 @@ describe('PagePartiallyAdoptedWithList — patcher fixes remaining static list',
160
158
  { mode: 'page' },
161
159
  )
162
160
  const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
163
- patched = await patchTemplateForFields(
164
- source,
165
- '_page_docs_partial',
166
- section.fields,
167
- groups,
168
- { mode: 'page' },
169
- )
161
+ patched = await patchTemplateForFields(source, '_page_docs_partial', section.fields, groups, {
162
+ mode: 'page',
163
+ })
170
164
  })
171
165
 
172
166
  it('should detect the static list as an array field', async () => {
173
167
  const source = readFileSync(join(SECTIONS_DIR, 'PagePartiallyAdoptedWithList.astro'), 'utf-8')
174
168
  const section = await analyzeAstroSection(
175
- source, '_page_docs_partial', 'partialPage', 'src/pages/docs/partial.astro', { mode: 'page' },
169
+ source,
170
+ '_page_docs_partial',
171
+ 'partialPage',
172
+ 'src/pages/docs/partial.astro',
173
+ { mode: 'page' },
176
174
  )
177
- const arrayFields = section.fields.filter(f => f.type === 'array')
175
+ const arrayFields = section.fields.filter((f) => f.type === 'array')
178
176
  expect(arrayFields.length, 'Should detect at least one array field').toBeGreaterThanOrEqual(1)
179
177
  })
180
178
 
@@ -182,7 +180,7 @@ describe('PagePartiallyAdoptedWithList — patcher fixes remaining static list',
182
180
  const hardcoded = findHardcodedListItems(patched)
183
181
  expect(
184
182
  hardcoded,
185
- `Hardcoded list items remain after patching partially-adopted page:\n${hardcoded.map(m => ` "${m[1]}"`).join('\n')}`,
183
+ `Hardcoded list items remain after patching partially-adopted page:\n${hardcoded.map((m) => ` "${m[1]}"`).join('\n')}`,
186
184
  ).toHaveLength(0)
187
185
  })
188
186
 
@@ -207,7 +205,7 @@ describe('PageWithListItemsAdopted — already clean (regression baseline)', ()
207
205
  const hardcoded = findHardcodedListItems(source)
208
206
  expect(
209
207
  hardcoded,
210
- `Adopted fixture should have no hardcoded list items, found:\n${hardcoded.map(m => ` "${m[1]}"`).join('\n')}`,
208
+ `Adopted fixture should have no hardcoded list items, found:\n${hardcoded.map((m) => ` "${m[1]}"`).join('\n')}`,
211
209
  ).toHaveLength(0)
212
210
  })
213
211
 
@@ -246,21 +244,21 @@ describe('PageWithFormattedList — analyzer detects formatting in list items',
246
244
  })
247
245
 
248
246
  it('should detect the list as an array field', () => {
249
- const arrayFields = section.fields.filter(f => f.type === 'array')
247
+ const arrayFields = section.fields.filter((f) => f.type === 'array')
250
248
  expect(arrayFields.length).toBeGreaterThanOrEqual(1)
251
249
  })
252
250
 
253
251
  it('should set formatting: true on the array item type', () => {
254
- const arrayField = section.fields.find(f => f.type === 'array')
252
+ const arrayField = section.fields.find((f) => f.type === 'array')
255
253
  expect(arrayField).toBeDefined()
256
254
  expect((arrayField?.options as any)?.arrayItem?.formatting).toBe(true)
257
255
  })
258
256
 
259
257
  it('should store innerHTML (with <strong>) as default values', () => {
260
- const arrayField = section.fields.find(f => f.type === 'array')
258
+ const arrayField = section.fields.find((f) => f.type === 'array')
261
259
  const items = arrayField?.defaultValue as string[] | undefined
262
260
  expect(Array.isArray(items)).toBe(true)
263
- const hasStrong = (items ?? []).some(v => typeof v === 'string' && v.includes('<strong'))
261
+ const hasStrong = (items ?? []).some((v) => typeof v === 'string' && v.includes('<strong'))
264
262
  expect(hasStrong, 'Default values should contain <strong> HTML').toBe(true)
265
263
  })
266
264
  })
@@ -278,13 +276,9 @@ describe('PageWithFormattedList — patcher replaces content span with set:html'
278
276
  { mode: 'page' },
279
277
  )
280
278
  const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
281
- patched = await patchTemplateForFields(
282
- source,
283
- '_page_formatted',
284
- section.fields,
285
- groups,
286
- { mode: 'page' },
287
- )
279
+ patched = await patchTemplateForFields(source, '_page_formatted', section.fields, groups, {
280
+ mode: 'page',
281
+ })
288
282
  })
289
283
 
290
284
  it('should inject set:html={item} on the content span', () => {
@@ -298,7 +292,10 @@ describe('PageWithFormattedList — patcher replaces content span with set:html'
298
292
  const withoutTemplateLiterals = patched.replace(/`[^`]*`/g, '""')
299
293
  const withoutExpressions = withoutTemplateLiterals.replace(/\{[^}]*\}/g, '{}')
300
294
  const strongInMarkup = withoutExpressions.match(/<strong[^>]*>[^<{]+<\/strong>/g) ?? []
301
- expect(strongInMarkup, `Hardcoded <strong> in template: ${strongInMarkup.join(', ')}`).toHaveLength(0)
295
+ expect(
296
+ strongInMarkup,
297
+ `Hardcoded <strong> in template: ${strongInMarkup.join(', ')}`,
298
+ ).toHaveLength(0)
302
299
  })
303
300
 
304
301
  it('should add data-sk-field to list items', () => {
@@ -334,23 +331,26 @@ describe('PageWithCalloutDiv — callout div with inline link detected as descri
334
331
  // The callout div must get the next available key: description4.
335
332
  // Bug: descCount started at 0, so key was 'description' → collision → skipped.
336
333
  // Fix: descCount is initialized from the count of already-used description* keys.
337
- const calloutField = section.fields.find(f => f.key === 'description4')
338
- expect(calloutField, 'description4 field must be detected from callout div (not dropped due to key collision)').toBeDefined()
334
+ const calloutField = section.fields.find((f) => f.key === 'description4')
335
+ expect(
336
+ calloutField,
337
+ 'description4 field must be detected from callout div (not dropped due to key collision)',
338
+ ).toBeDefined()
339
339
  })
340
340
 
341
341
  it('sets formatting: true on the callout field (contains inline <a>)', () => {
342
- const calloutField = section.fields.find(f => f.key === 'description4')
342
+ const calloutField = section.fields.find((f) => f.key === 'description4')
343
343
  expect((calloutField?.options as any)?.formatting).toBe(true)
344
344
  })
345
345
 
346
346
  it('extracts the text content as defaultValue', () => {
347
- const calloutField = section.fields.find(f => f.key === 'description4')
347
+ const calloutField = section.fields.find((f) => f.key === 'description4')
348
348
  expect(typeof calloutField?.defaultValue).toBe('string')
349
- expect((calloutField?.defaultValue as string)).toMatch(/kommerzielle Lizenzen/)
349
+ expect(calloutField?.defaultValue as string).toMatch(/kommerzielle Lizenzen/)
350
350
  })
351
351
 
352
352
  it('still detects heading from data-sk-field (Section 0)', () => {
353
- const headingField = section.fields.find(f => f.key === 'heading')
353
+ const headingField = section.fields.find((f) => f.key === 'heading')
354
354
  expect(headingField, 'heading must be detected via Section 0').toBeDefined()
355
355
  expect(headingField?.defaultValue).toBe('Lizenz')
356
356
  })
@@ -381,41 +381,41 @@ describe('PageWithAdoptedArrays — analyzer detects arrays from ?? [...] fallba
381
381
  })
382
382
 
383
383
  it('detects items as type array (not text)', () => {
384
- const itemsField = section.fields.find(f => f.key === 'items')
384
+ const itemsField = section.fields.find((f) => f.key === 'items')
385
385
  expect(itemsField, 'items field must exist').toBeDefined()
386
386
  expect(itemsField?.type).toBe('array')
387
387
  })
388
388
 
389
389
  it('extracts fallback items as defaultValue', () => {
390
- const itemsField = section.fields.find(f => f.key === 'items')
390
+ const itemsField = section.fields.find((f) => f.key === 'items')
391
391
  expect(Array.isArray(itemsField?.defaultValue)).toBe(true)
392
392
  expect(itemsField?.defaultValue).toEqual(['Eintrag A', 'Eintrag B', 'Eintrag C'])
393
393
  })
394
394
 
395
395
  it('sets no formatting on plain-text array items', () => {
396
- const itemsField = section.fields.find(f => f.key === 'items')
396
+ const itemsField = section.fields.find((f) => f.key === 'items')
397
397
  expect((itemsField?.options as any)?.arrayItem?.formatting).toBeFalsy()
398
398
  })
399
399
 
400
400
  it('detects items2 as type array', () => {
401
- const items2Field = section.fields.find(f => f.key === 'items2')
401
+ const items2Field = section.fields.find((f) => f.key === 'items2')
402
402
  expect(items2Field?.type).toBe('array')
403
403
  })
404
404
 
405
405
  it('sets formatting: true on items2 because items contain HTML', () => {
406
- const items2Field = section.fields.find(f => f.key === 'items2')
406
+ const items2Field = section.fields.find((f) => f.key === 'items2')
407
407
  expect((items2Field?.options as any)?.arrayItem?.formatting).toBe(true)
408
408
  })
409
409
 
410
410
  it('extracts HTML items as defaultValue for items2', () => {
411
- const items2Field = section.fields.find(f => f.key === 'items2')
411
+ const items2Field = section.fields.find((f) => f.key === 'items2')
412
412
  const items = items2Field?.defaultValue as string[] | undefined
413
413
  expect(Array.isArray(items)).toBe(true)
414
- expect(items?.some(v => v.includes('<strong'))).toBe(true)
414
+ expect(items?.some((v) => v.includes('<strong'))).toBe(true)
415
415
  })
416
416
 
417
417
  it('detects heading with fallback value', () => {
418
- const headingField = section.fields.find(f => f.key === 'heading')
418
+ const headingField = section.fields.find((f) => f.key === 'heading')
419
419
  expect(headingField?.type).toBe('text')
420
420
  expect(headingField?.defaultValue).toBe('Lizenz')
421
421
  })
@@ -445,30 +445,30 @@ describe('PageWithHtmlFallback — Section 0 sets formatting:true for HTML fallb
445
445
  })
446
446
 
447
447
  it('detects heading without formatting (plain text fallback)', () => {
448
- const f = section.fields.find(field => field.key === 'heading')
448
+ const f = section.fields.find((field) => field.key === 'heading')
449
449
  expect(f, 'heading must be detected').toBeDefined()
450
450
  expect((f?.options as any)?.formatting).toBeFalsy()
451
451
  })
452
452
 
453
453
  it('detects description with formatting:true (set:html attr with <code> in fallback)', () => {
454
- const f = section.fields.find(field => field.key === 'description')
454
+ const f = section.fields.find((field) => field.key === 'description')
455
455
  expect(f, 'description must be detected').toBeDefined()
456
456
  expect((f?.options as any)?.formatting).toBe(true)
457
457
  })
458
458
 
459
459
  it('description defaultValue contains the HTML with <code>', () => {
460
- const f = section.fields.find(field => field.key === 'description')
461
- expect((f?.defaultValue as string)).toMatch(/<code>/)
460
+ const f = section.fields.find((field) => field.key === 'description')
461
+ expect(f?.defaultValue as string).toMatch(/<code>/)
462
462
  })
463
463
 
464
464
  it('detects note with formatting:true (expression fallback with <strong>)', () => {
465
- const f = section.fields.find(field => field.key === 'note')
465
+ const f = section.fields.find((field) => field.key === 'note')
466
466
  expect(f, 'note must be detected').toBeDefined()
467
467
  expect((f?.options as any)?.formatting).toBe(true)
468
468
  })
469
469
 
470
470
  it('note defaultValue contains the HTML with <strong>', () => {
471
- const f = section.fields.find(field => field.key === 'note')
472
- expect((f?.defaultValue as string)).toMatch(/<strong>/)
471
+ const f = section.fields.find((field) => field.key === 'note')
472
+ expect(f?.defaultValue as string).toMatch(/<strong>/)
473
473
  })
474
474
  })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tests for patchChildComponentForFieldPrefix:
3
+ * Injects fieldPrefix prop + data-sk-field bindings into child components
4
+ * used by repeated-component sections (e.g. PricingCard).
5
+ */
6
+
7
+ import { readFileSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { describe, expect, it } from 'vitest'
10
+ import { detectChildImports, patchChildComponentForFieldPrefix } from '../template-patcher-v2'
11
+
12
+ const CHILD_DIR = join(
13
+ import.meta.dirname!,
14
+ '..',
15
+ '..',
16
+ '..',
17
+ '..',
18
+ '..',
19
+ 'test-sections',
20
+ 'child-components',
21
+ )
22
+
23
+ function readFixture(name: string): string {
24
+ return readFileSync(join(CHILD_DIR, name), 'utf-8')
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Inner fields for PricingCard (mirrors what the analyzer produces)
29
+ // ---------------------------------------------------------------------------
30
+ const PRICING_INNER_FIELDS = [
31
+ { key: 'name', type: 'text' },
32
+ { key: 'price', type: 'text' },
33
+ { key: 'priceNote', type: 'text' },
34
+ { key: 'description', type: 'text' },
35
+ { key: 'features', type: 'array' },
36
+ { key: 'cta', type: 'text' },
37
+ { key: 'ctaHref', type: 'text' },
38
+ ]
39
+
40
+ describe('patchChildComponentForFieldPrefix', () => {
41
+ it('adds fieldPrefix to Props interface', () => {
42
+ const source = readFixture('PricingCard.astro')
43
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
44
+ expect(patched).toContain('fieldPrefix?: string')
45
+ })
46
+
47
+ it('adds fieldPrefix to destructuring', () => {
48
+ const source = readFixture('PricingCard.astro')
49
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
50
+ expect(patched).toMatch(/fieldPrefix\s*[,}]/)
51
+ })
52
+
53
+ it('adds data-sk-field for scalar text props', () => {
54
+ const source = readFixture('PricingCard.astro')
55
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
56
+ expect(patched).toContain('fieldPrefix}.name')
57
+ expect(patched).toContain('fieldPrefix}.price')
58
+ expect(patched).toContain('fieldPrefix}.description')
59
+ })
60
+
61
+ it('adds data-sk-field for href prop on <a>', () => {
62
+ const source = readFixture('PricingCard.astro')
63
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
64
+ expect(patched).toContain('fieldPrefix}.ctaHref')
65
+ expect(patched).toContain('fieldPrefix}.cta')
66
+ })
67
+
68
+ it('adds indexed data-sk-field for array prop features', () => {
69
+ const source = readFixture('PricingCard.astro')
70
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
71
+ expect(patched).toMatch(/fieldPrefix\}\.features\._fi|fieldPrefix\}\.features\.\$\{_fi\}/)
72
+ })
73
+
74
+ it('is idempotent — second pass produces no changes', () => {
75
+ const source = readFixture('PricingCard.astro')
76
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
77
+ const patchedAgain = patchChildComponentForFieldPrefix(patched, PRICING_INNER_FIELDS)
78
+ expect(patchedAgain).toBe(patched)
79
+ })
80
+
81
+ it('does not modify source when fieldPrefix already present', () => {
82
+ const source = readFixture('PricingCard.astro')
83
+ const prePatched = source.replace(
84
+ 'highlight?: boolean;',
85
+ 'highlight?: boolean;\n fieldPrefix?: string;',
86
+ )
87
+ const result = patchChildComponentForFieldPrefix(prePatched, PRICING_INNER_FIELDS)
88
+ expect(result).toContain('fieldPrefix?: string')
89
+ expect((result.match(/fieldPrefix\?\s*:\s*string/g) ?? []).length).toBe(1)
90
+ })
91
+ })
92
+
93
+ describe('detectChildImports', () => {
94
+ it('detects component imports from fields with sourceComponent', () => {
95
+ const sectionSource = `---
96
+ import PricingCard from '../components/PricingCard.astro'
97
+ interface Props { data: Record<string, any> | null }
98
+ const { data: skData } = Astro.props
99
+ ---
100
+ <section></section>`
101
+
102
+ const fields = [
103
+ {
104
+ key: 'pricingcards',
105
+ type: 'array',
106
+ defaultValue: [],
107
+ options: {
108
+ sourceComponent: 'PricingCard',
109
+ arrayItem: { type: 'object', fields: PRICING_INNER_FIELDS },
110
+ },
111
+ },
112
+ ]
113
+
114
+ const imports = detectChildImports(sectionSource, fields as any)
115
+ expect(imports).toHaveLength(1)
116
+ expect(imports[0]!.compName).toBe('PricingCard')
117
+ expect(imports[0]!.importPath).toBe('../components/PricingCard.astro')
118
+ expect(imports[0]!.innerFields).toEqual(PRICING_INNER_FIELDS)
119
+ })
120
+
121
+ it('returns empty array when no sourceComponent fields', () => {
122
+ const source = `---\nconst x = 1\n---\n<div></div>`
123
+ const fields = [{ key: 'heading', type: 'text' }]
124
+ expect(detectChildImports(source, fields as any)).toHaveLength(0)
125
+ })
126
+ })
@@ -24,8 +24,12 @@
24
24
  * still inject the getSection() call without throwing.
25
25
  */
26
26
 
27
- import { describe, it, expect } from 'vitest'
28
- import { patchTemplateForFields, stripTemplateFallbacks, convertToSetHtml } from '../../init/template-patcher-v2'
27
+ import { describe, expect, it } from 'vitest'
28
+ import {
29
+ convertToSetHtml,
30
+ patchTemplateForFields,
31
+ stripTemplateFallbacks,
32
+ } from '../../init/template-patcher-v2'
29
33
  import type { PatchField } from '../../init/template-patcher-v2'
30
34
 
31
35
  // ---------------------------------------------------------------------------
@@ -89,22 +93,30 @@ import BaseLayout from '../layouts/BaseLayout.astro';
89
93
  ]
90
94
 
91
95
  it('should produce valid output containing skData references', async () => {
92
- const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
96
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], {
97
+ mode: 'page',
98
+ })
93
99
  expect(patched).toContain('skData')
94
100
  })
95
101
 
96
102
  it('should bind the heading field containing an umlaut', async () => {
97
- const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
103
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], {
104
+ mode: 'page',
105
+ })
98
106
  expect(patched).toContain('skData?.heading')
99
107
  })
100
108
 
101
109
  it('should bind the body field containing umlauts', async () => {
102
- const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
110
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], {
111
+ mode: 'page',
112
+ })
103
113
  expect(patched).toContain('skData?.body')
104
114
  })
105
115
 
106
116
  it('should not corrupt the surrounding markup', async () => {
107
- const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
117
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], {
118
+ mode: 'page',
119
+ })
108
120
  // The <section> tag must still be intact
109
121
  expect(patched).toMatch(/<section[^>]*>/)
110
122
  // Closing tags must still be present
@@ -113,7 +125,9 @@ import BaseLayout from '../layouts/BaseLayout.astro';
113
125
  })
114
126
 
115
127
  it('should add data-sk-field to the heading element', async () => {
116
- const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
128
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], {
129
+ mode: 'page',
130
+ })
117
131
  expect(patched).toMatch(/<h1[^>]*data-sk-field/)
118
132
  })
119
133
  })
@@ -151,25 +165,33 @@ const skData = getSection('_page_about')
151
165
  ]
152
166
 
153
167
  it('should not duplicate the getSection import', async () => {
154
- const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
168
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], {
169
+ mode: 'page',
170
+ })
155
171
  const importCount = (patched.match(/import \{ getSection \}/g) ?? []).length
156
172
  expect(importCount).toBe(1)
157
173
  })
158
174
 
159
175
  it('should not duplicate the skData declaration', async () => {
160
- const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
176
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], {
177
+ mode: 'page',
178
+ })
161
179
  const declCount = (patched.match(/const skData\s*=/g) ?? []).length
162
180
  expect(declCount).toBe(1)
163
181
  })
164
182
 
165
183
  it('should preserve existing data-sk-field attributes', async () => {
166
- const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
184
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], {
185
+ mode: 'page',
186
+ })
167
187
  expect(patched).toContain('data-sk-field="_page_about.heading"')
168
188
  expect(patched).toContain('data-sk-field="_page_about.body"')
169
189
  })
170
190
 
171
191
  it('should not add a second section id', async () => {
172
- const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
192
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], {
193
+ mode: 'page',
194
+ })
173
195
  const idCount = (patched.match(/id="section-_page_about"/g) ?? []).length
174
196
  expect(idCount).toBe(1)
175
197
  })
@@ -198,13 +220,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
198
220
  // Convert all LF to CRLF
199
221
  const crlf = lf.replace(/\n/g, '\r\n')
200
222
 
201
- const fields: PatchField[] = [
202
- { key: 'heading', type: 'text', defaultValue: 'Hello World' },
203
- ]
223
+ const fields: PatchField[] = [{ key: 'heading', type: 'text', defaultValue: 'Hello World' }]
204
224
 
205
225
  it('should not throw on CRLF input', async () => {
206
226
  await expect(
207
- patchTemplateForFields(crlf, '_page_test', fields, [], { mode: 'page' })
227
+ patchTemplateForFields(crlf, '_page_test', fields, [], { mode: 'page' }),
208
228
  ).resolves.toBeDefined()
209
229
  })
210
230
 
@@ -240,13 +260,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';---
240
260
  </BaseLayout>
241
261
  `
242
262
 
243
- const fields: PatchField[] = [
244
- { key: 'heading', type: 'text', defaultValue: 'Tight Frontmatter' },
245
- ]
263
+ const fields: PatchField[] = [{ key: 'heading', type: 'text', defaultValue: 'Tight Frontmatter' }]
246
264
 
247
265
  it('should not throw', async () => {
248
266
  await expect(
249
- patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
267
+ patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' }),
250
268
  ).resolves.toBeDefined()
251
269
  })
252
270
 
@@ -285,7 +303,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
285
303
 
286
304
  it('should not throw', async () => {
287
305
  await expect(
288
- patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
306
+ patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' }),
289
307
  ).resolves.toBeDefined()
290
308
  })
291
309
 
@@ -342,7 +360,7 @@ describe('stripTemplateFallbacks', () => {
342
360
  })
343
361
 
344
362
  it('strips array fallback with backtick HTML items', () => {
345
- const source = "{(skData?.items ?? [`<strong>Label</strong> &mdash; Text.`]).map(("
363
+ const source = '{(skData?.items ?? [`<strong>Label</strong> &mdash; Text.`]).map(('
346
364
  const { source: result, fallbacks } = stripTemplateFallbacks(source)
347
365
  expect(result).toBe(`{(skData?.items ?? []).map((`)
348
366
  expect((fallbacks['items'] as string[])?.[0]).toContain('<strong>')
@@ -424,7 +442,12 @@ describe('HTML fallback wins over plain-text default in sectionData merge', () =
424
442
  const existing = result[key]
425
443
  const existingIsPlain = typeof existing === 'string' && !/<[a-z]/.test(existing)
426
444
  const newIsHtml = typeof value === 'string' && /<[a-z]/.test(value as string)
427
- if (!(key in result) || existing === '' || existing === null || (existingIsPlain && newIsHtml)) {
445
+ if (
446
+ !(key in result) ||
447
+ existing === '' ||
448
+ existing === null ||
449
+ (existingIsPlain && newIsHtml)
450
+ ) {
428
451
  result[key] = value
429
452
  }
430
453
  }
@@ -436,7 +459,8 @@ describe('HTML fallback wins over plain-text default in sectionData merge', () =
436
459
  callout: 'Für kommerzielle Lizenzen: hello@example.com — text.',
437
460
  }
438
461
  const fallbacks = {
439
- callout: 'Für kommerzielle Lizenzen: <a href="mailto:hello@example.com">hello@example.com</a> — text.',
462
+ callout:
463
+ 'Für kommerzielle Lizenzen: <a href="mailto:hello@example.com">hello@example.com</a> — text.',
440
464
  }
441
465
  const result = mergeFallbacks(sectionData, fallbacks)
442
466
  expect(result['callout']).toMatch(/href="mailto:/)