@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
@@ -38,7 +38,11 @@ const normalizeWs = (s: string) => s.replace(/\s+/g, ' ').trim()
38
38
  * Find text in a string where the needle may have collapsed whitespace
39
39
  * but the haystack preserves original whitespace (incl. newlines).
40
40
  */
41
- function findNormalizedText(haystack: string, needle: string, startFrom: number): { start: number; end: number } | null {
41
+ function findNormalizedText(
42
+ haystack: string,
43
+ needle: string,
44
+ startFrom: number,
45
+ ): { start: number; end: number } | null {
42
46
  // Try exact match first
43
47
  const exactIdx = haystack.indexOf(needle, startFrom)
44
48
  if (exactIdx !== -1) return { start: exactIdx, end: exactIdx + needle.length }
@@ -49,7 +53,8 @@ function findNormalizedText(haystack: string, needle: string, startFrom: number)
49
53
  const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+')
50
54
  const regex = new RegExp(escaped)
51
55
  const match = regex.exec(haystack.slice(startFrom))
52
- if (match) return { start: startFrom + match.index, end: startFrom + match.index + match[0].length }
56
+ if (match)
57
+ return { start: startFrom + match.index, end: startFrom + match.index + match[0].length }
53
58
 
54
59
  return null
55
60
  }
@@ -128,7 +133,10 @@ export async function patchTemplateForFields(
128
133
  // Adjust positions in-place on the original groups so mutations
129
134
  // (like _class fields from makeDynamicClasses) are visible to the caller.
130
135
  for (const g of repeatedGroups) {
131
- for (const inst of g.instances) { inst.start += shift; inst.end += shift }
136
+ for (const inst of g.instances) {
137
+ inst.start += shift
138
+ inst.end += shift
139
+ }
132
140
  for (const f of g.fields) {
133
141
  for (let i = 0; i < f.positions.length; i++) {
134
142
  const p = f.positions[i]
@@ -143,7 +151,13 @@ export async function patchTemplateForFields(
143
151
  }
144
152
 
145
153
  // Patch expressions first (old vars still present → patcher detects them)
146
- const patched = await patchTemplateForFields(modifiedSource, sectionKey, fields, repeatedGroups, options)
154
+ const patched = await patchTemplateForFields(
155
+ modifiedSource,
156
+ sectionKey,
157
+ fields,
158
+ repeatedGroups,
159
+ options,
160
+ )
147
161
 
148
162
  // Now remove old variable declarations from the patched result
149
163
  return removeOldVarDeclarations(patched, fields, repeatedGroups, varName)
@@ -183,8 +197,8 @@ export async function patchTemplateForFields(
183
197
  // Collect elements with mixed content (text + inline child elements)
184
198
  // These are rich text fields where the whole element is one CMS field.
185
199
  if (isElement(node) && node.children && node.children.length > 1) {
186
- const hasText = node.children.some(c => c.type === 'text' && c.value?.trim())
187
- const hasChild = node.children.some(c => isElement(c) || c.type === 'expression')
200
+ const hasText = node.children.some((c) => c.type === 'text' && c.value?.trim())
201
+ const hasChild = node.children.some((c) => isElement(c) || c.type === 'expression')
188
202
  if (hasText && hasChild) {
189
203
  const fullText = normalizeWs(getElementTextContent(node))
190
204
  if (fullText) mixedElements.push({ node, normalizedText: fullText })
@@ -249,13 +263,18 @@ export async function patchTemplateForFields(
249
263
  // built the defaultValue array before patching. Without this sync, the
250
264
  // content JSON (built from field.defaultValue) would be missing _classN values.
251
265
  for (const g of repeatedGroups) {
252
- const topField = fields.find(f => f.key === g.fieldKey)
266
+ const topField = fields.find((f) => f.key === g.fieldKey)
253
267
  if (!topField || !Array.isArray(topField.defaultValue)) continue
254
268
  const items = topField.defaultValue as Array<Record<string, unknown>>
255
269
  for (const innerField of g.fields) {
256
270
  for (let ii = 0; ii < items.length && ii < innerField.defaultValues.length; ii++) {
257
271
  const val = innerField.defaultValues[ii]
258
- if (val != null && items[ii] && typeof items[ii] === 'object' && !(innerField.key in items[ii]!)) {
272
+ if (
273
+ val != null &&
274
+ items[ii] &&
275
+ typeof items[ii] === 'object' &&
276
+ !(innerField.key in items[ii]!)
277
+ ) {
259
278
  items[ii]![innerField.key] = val
260
279
  }
261
280
  }
@@ -279,6 +298,10 @@ export async function patchTemplateForFields(
279
298
  // No .map() expression found — try static <ul>/<li> conversion
280
299
  patchStaticListField(source, sectionKey, field, varName, ast, edits)
281
300
  }
301
+ if (edits.length === editsBefore) {
302
+ // Still nothing — try collapsing repeated component instances
303
+ patchRepeatedComponentInstances(source, sectionKey, field, varName, ast, edits)
304
+ }
282
305
  continue
283
306
  }
284
307
 
@@ -328,7 +351,7 @@ export async function patchTemplateForFields(
328
351
  // Treat the whole element as a rich text field with set:html for CMS rendering.
329
352
  if (edits.length === editsBefore) {
330
353
  const dv = field.defaultValue as string
331
- const mixedMatch = mixedElements.find(m => m.normalizedText === normalizeWs(dv))
354
+ const mixedMatch = mixedElements.find((m) => m.normalizedText === normalizeWs(dv))
332
355
  if (mixedMatch) {
333
356
  patchMixedContentField(source, sectionKey, field, varName, mixedMatch.node, edits)
334
357
  }
@@ -368,7 +391,10 @@ export function convertToSetHtml(source: string): string {
368
391
  const ch = result[i]!
369
392
  if (ch === '{') braceDepth++
370
393
  else if (ch === '}') braceDepth--
371
- else if (ch === '>' && braceDepth === 0) { tagEnd = i; break }
394
+ else if (ch === '>' && braceDepth === 0) {
395
+ tagEnd = i
396
+ break
397
+ }
372
398
  }
373
399
  if (tagEnd === -1) continue
374
400
 
@@ -470,7 +496,10 @@ function convertAstPositions(node: AstNode, b2c: (offset: number) => number): vo
470
496
  * AST expression positions can extend beyond the actual braces, so we search
471
497
  * the source directly using the child text content as anchor.
472
498
  */
473
- function findExpressionBounds(source: string, node: AstNode): { start: number; end: number } | null {
499
+ function findExpressionBounds(
500
+ source: string,
501
+ node: AstNode,
502
+ ): { start: number; end: number } | null {
474
503
  const approxStart = node.position?.start?.offset
475
504
  if (approxStart == null) return null
476
505
 
@@ -506,7 +535,10 @@ function findExpressionBounds(source: string, node: AstNode): { start: number; e
506
535
  if (ch === inStr && source[i - 1] !== '\\') inStr = null
507
536
  continue
508
537
  }
509
- if (ch === "'" || ch === '"' || ch === '`') { inStr = ch; continue }
538
+ if (ch === "'" || ch === '"' || ch === '`') {
539
+ inStr = ch
540
+ continue
541
+ }
510
542
  if (ch === '{') depth++
511
543
  if (ch === '}') {
512
544
  depth--
@@ -556,7 +588,10 @@ function findTextBounds(source: string, node: AstNode): { start: number; end: nu
556
588
  function getElementTextContent(node: AstNode): string {
557
589
  if (node.type === 'text') return node.value ?? ''
558
590
  if (node.type === 'expression') {
559
- return (node.children ?? []).filter(c => c.type === 'text').map(c => c.value ?? '').join('')
591
+ return (node.children ?? [])
592
+ .filter((c) => c.type === 'text')
593
+ .map((c) => c.value ?? '')
594
+ .join('')
560
595
  }
561
596
  return (node.children ?? []).map(getElementTextContent).join('')
562
597
  }
@@ -595,7 +630,7 @@ function patchMixedContentField(
595
630
  const bindingKey = `${sectionKey}.${field.key}`
596
631
 
597
632
  // Skip if already has data-sk-field
598
- if (element.attributes?.some(a => a.name === 'data-sk-field')) return
633
+ if (element.attributes?.some((a) => a.name === 'data-sk-field')) return
599
634
 
600
635
  // Find the element's opening tag in source
601
636
  const startOffset = element.position?.start?.offset
@@ -683,14 +718,15 @@ function patchIconOrImageField(
683
718
  if (targetParent) return // already found
684
719
  if (!parent || !isElement(parent)) return
685
720
  // Skip if parent already has data-sk-field
686
- if (parent.attributes?.some(a => a.name === 'data-sk-field')) return
721
+ if (parent.attributes?.some((a) => a.name === 'data-sk-field')) return
687
722
 
688
723
  if (field.type === 'icon') {
689
724
  // Match component named *Icon* or element with icon/name attr matching default
690
725
  const isIconComp = isElement(node) && /icon/i.test(node.name ?? '')
691
726
  const hasIconAttr = node.attributes?.some(
692
- a => (a.name === 'icon' || a.name === 'name' || a.name === 'data-icon') &&
693
- (a.value === defaultStr || a.value.includes(field.key))
727
+ (a) =>
728
+ (a.name === 'icon' || a.name === 'name' || a.name === 'data-icon') &&
729
+ (a.value === defaultStr || a.value.includes(field.key)),
694
730
  )
695
731
  if (isIconComp || hasIconAttr) {
696
732
  targetParent = parent
@@ -700,7 +736,7 @@ function patchIconOrImageField(
700
736
  const tag = node.name ?? ''
701
737
  const isImgEl = /^(img|Image|picture)$/.test(tag)
702
738
  const hasSrcAttr = node.attributes?.some(
703
- a => a.name === 'src' && (a.value === defaultStr || a.value.includes(field.key))
739
+ (a) => a.name === 'src' && (a.value === defaultStr || a.value.includes(field.key)),
704
740
  )
705
741
  if (isImgEl || hasSrcAttr) {
706
742
  targetParent = parent
@@ -906,15 +942,20 @@ function patchArrayField(
906
942
  // Match by looking for the field's first item value inside each expression's source.
907
943
  let mapExpr = mapExpressions[0]!
908
944
 
909
- if (mapExpressions.length > 1 && Array.isArray(field.defaultValue) && field.defaultValue.length > 0) {
945
+ if (
946
+ mapExpressions.length > 1 &&
947
+ Array.isArray(field.defaultValue) &&
948
+ field.defaultValue.length > 0
949
+ ) {
910
950
  const firstItem = field.defaultValue[0]
911
- const searchStr = typeof firstItem === 'string'
912
- ? firstItem.slice(0, 30)
913
- : typeof firstItem === 'object' && firstItem !== null
914
- ? Object.values(firstItem as Record<string, unknown>)
915
- .find((v): v is string => typeof v === 'string' && v.length >= 3)
916
- ?.slice(0, 30) ?? ''
917
- : ''
951
+ const searchStr =
952
+ typeof firstItem === 'string'
953
+ ? firstItem.slice(0, 30)
954
+ : typeof firstItem === 'object' && firstItem !== null
955
+ ? (Object.values(firstItem as Record<string, unknown>)
956
+ .find((v): v is string => typeof v === 'string' && v.length >= 3)
957
+ ?.slice(0, 30) ?? '')
958
+ : ''
918
959
 
919
960
  if (searchStr.length >= 3) {
920
961
  for (const expr of mapExpressions) {
@@ -979,7 +1020,7 @@ function patchArrayField(
979
1020
  })
980
1021
  } else {
981
1022
  // Try variable reference: varName.map( → (cmsVar?.field ?? []).map(
982
- const varMapRegex = new RegExp(`(\\w+)\\.map\\s*\\(`)
1023
+ const varMapRegex = /(\w+)\.map\s*\(/
983
1024
  const varMapMatch = exprSource.match(varMapRegex)
984
1025
  if (varMapMatch) {
985
1026
  const varRef = varMapMatch[1]!
@@ -1038,15 +1079,19 @@ function patchStaticListField(
1038
1079
  walkAst(node, (n) => {
1039
1080
  if (hasMap) return
1040
1081
  if (n.type === 'expression') {
1041
- const code = (n.children ?? []).map(c => c.value ?? '').join('')
1082
+ const code = (n.children ?? []).map((c) => c.value ?? '').join('')
1042
1083
  if (/\.map\s*\(/.test(code)) hasMap = true
1043
1084
  }
1044
1085
  })
1045
1086
  if (hasMap) return
1046
1087
  // Compare <li> text content against field items.
1047
1088
  // items[i] may contain HTML (if formatting: true) — strip tags for comparison.
1048
- const stripHtml = (s: string) => s.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
1049
- const liChildren = (node.children ?? []).filter(c => c.type === 'element' && c.name === 'li')
1089
+ const stripHtml = (s: string) =>
1090
+ s
1091
+ .replace(/<[^>]+>/g, '')
1092
+ .replace(/\s+/g, ' ')
1093
+ .trim()
1094
+ const liChildren = (node.children ?? []).filter((c) => c.type === 'element' && c.name === 'li')
1050
1095
  if (liChildren.length !== items.length) return
1051
1096
  const allMatch = liChildren.every((li, i) => {
1052
1097
  const text = getElementTextContent(li).replace(/\s+/g, ' ').trim()
@@ -1059,7 +1104,7 @@ function patchStaticListField(
1059
1104
 
1060
1105
  const ulNode = matchedUl as AstNode
1061
1106
  const liChildren = (ulNode.children ?? []).filter(
1062
- c => c.type === 'element' && c.name === 'li',
1107
+ (c) => c.type === 'element' && c.name === 'li',
1063
1108
  ) as AstNode[]
1064
1109
  if (liChildren.length === 0) return
1065
1110
 
@@ -1097,15 +1142,16 @@ function patchStaticListField(
1097
1142
 
1098
1143
  // Build the fallback array literal.
1099
1144
  // HTML items (formatting: true) are wrapped in backtick strings to avoid escaping issues.
1100
- const hasHtmlItems = items.some(s => /<[a-z]/.test(s))
1145
+ const hasHtmlItems = items.some((s) => /<[a-z]/.test(s))
1101
1146
  const fallbackItems = hasHtmlItems
1102
- ? items.map(s => `\`${s.replace(/`/g, '\\`').replace(/\$/g, '\\$')}\``).join(', ')
1103
- : items.map(s => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`).join(', ')
1147
+ ? items.map((s) => `\`${s.replace(/`/g, '\\`').replace(/\$/g, '\\$')}\``).join(', ')
1148
+ : items.map((s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`).join(', ')
1104
1149
 
1105
1150
  // Determine indentation of the first <li> for formatting
1106
1151
  const textBeforeFirstLi = source.slice(0, rangeStart)
1107
1152
  const lastNewline = textBeforeFirstLi.lastIndexOf('\n')
1108
- const indent = lastNewline >= 0 ? textBeforeFirstLi.slice(lastNewline + 1).match(/^[ \t]*/)?.[0] ?? '' : ''
1153
+ const indent =
1154
+ lastNewline >= 0 ? (textBeforeFirstLi.slice(lastNewline + 1).match(/^[ \t]*/)?.[0] ?? '') : ''
1109
1155
 
1110
1156
  const replacement =
1111
1157
  `{(${varName}?.${field.key} ?? [${fallbackItems}]).map((item, _i) => (\n` +
@@ -1347,7 +1393,7 @@ function collectDynamicClassEdits(
1347
1393
  function sharedClassCount(instValue: string): number {
1348
1394
  const instParts = new Set(instValue.split(/\s+/).filter(Boolean))
1349
1395
  if (tmplParts.size === 0 && instParts.size === 0) return 1 // both empty = match
1350
- return [...tmplParts].filter(p => instParts.has(p)).length
1396
+ return [...tmplParts].filter((p) => instParts.has(p)).length
1351
1397
  }
1352
1398
 
1353
1399
  // 1. Exact path — only accept if at least 1 CSS class is shared
@@ -1361,7 +1407,11 @@ function collectDynamicClassEdits(
1361
1407
  }
1362
1408
 
1363
1409
  // 2. Same tag-path (without indices) — pick the one with most shared classes
1364
- const stripIdx = (p: string) => p.split('/').map(s => s.replace(/:\d+$/, '')).join('/')
1410
+ const stripIdx = (p: string) =>
1411
+ p
1412
+ .split('/')
1413
+ .map((s) => s.replace(/:\d+$/, ''))
1414
+ .join('/')
1365
1415
  const tmplTagPath = stripIdx(tmplAttr.path)
1366
1416
  let bestIdx = -1
1367
1417
  let bestShared = 0
@@ -1369,7 +1419,10 @@ function collectDynamicClassEdits(
1369
1419
  if (claimed.has(i)) continue
1370
1420
  if (stripIdx(instAttrs[i]!.path) !== tmplTagPath) continue
1371
1421
  const shared = sharedClassCount(instAttrs[i]!.value)
1372
- if (shared > bestShared) { bestShared = shared; bestIdx = i }
1422
+ if (shared > bestShared) {
1423
+ bestShared = shared
1424
+ bestIdx = i
1425
+ }
1373
1426
  }
1374
1427
  if (bestIdx >= 0 && bestShared > 0) {
1375
1428
  claimed.add(bestIdx)
@@ -1399,7 +1452,7 @@ function collectDynamicClassEdits(
1399
1452
  // adjustments can put the class attr offset slightly after the text offset.
1400
1453
  const fieldTag = field.tag
1401
1454
  let bestAttrIdx = -1
1402
- let bestDist = Infinity
1455
+ let bestDist = Number.POSITIVE_INFINITY
1403
1456
  for (let ai = 0; ai < tmplAttrs.length; ai++) {
1404
1457
  // Extract leaf tag from path: "div:0/div:0/span:1" → "span"
1405
1458
  const pathParts = tmplAttrs[ai]!.path.split('/')
@@ -1473,6 +1526,156 @@ function collectDynamicClassEdits(
1473
1526
  }
1474
1527
  }
1475
1528
 
1529
+ /**
1530
+ * Collapses N repeated instances of the same Astro component into a single
1531
+ * `.map()` expression over a CMS array field.
1532
+ *
1533
+ * Example: 2× <PricingCard name="Free" features={freeFeatures} />
1534
+ * → {(skData?.pricingcards ?? [...]).map((item, _i) => <PricingCard name={item.name} ... />)}
1535
+ *
1536
+ * Only triggered when field.options.sourceComponent is set (by the analyzer).
1537
+ */
1538
+ function patchRepeatedComponentInstances(
1539
+ source: string,
1540
+ sectionKey: string,
1541
+ field: PatchField,
1542
+ varName: string,
1543
+ ast: AstNode,
1544
+ edits: Edit[],
1545
+ ): void {
1546
+ const compName = (field as any).options?.sourceComponent as string | undefined
1547
+ if (!compName) return
1548
+
1549
+ // Collect all component instances from the AST
1550
+ const instanceNodes: AstNode[] = []
1551
+ walkAst(ast, (node) => {
1552
+ if (node.type === 'component' && node.name === compName) {
1553
+ instanceNodes.push(node)
1554
+ }
1555
+ })
1556
+ if (instanceNodes.length < 2) return
1557
+
1558
+ // Resolve source ranges: [start, end) for each instance
1559
+ // The AST gives us approximate positions; we scan forward/backward to find exact tag bounds.
1560
+ const ranges: Array<{ start: number; end: number; attrs: AstAttr[] }> = []
1561
+ for (const node of instanceNodes) {
1562
+ const approxStart = node.position?.start?.offset
1563
+ if (approxStart == null) return
1564
+ const searchFrom = Math.max(0, approxStart - 2)
1565
+ const tagStart = source.indexOf(`<${compName}`, searchFrom)
1566
+ if (tagStart === -1 || tagStart > approxStart + 2) return
1567
+
1568
+ // Find the end: either a self-closing /> or explicit </CompName>
1569
+ const tagEnd = findComponentTagEnd(source, tagStart, compName)
1570
+ if (tagEnd === -1) return
1571
+ ranges.push({ start: tagStart, end: tagEnd, attrs: node.attributes ?? [] })
1572
+ }
1573
+ if (ranges.length < 2) return
1574
+
1575
+ // Build the default value JSON (inline fallback in the .map() call)
1576
+ const defaultItems = Array.isArray(field.defaultValue) ? field.defaultValue : []
1577
+ const defaultJson = JSON.stringify(defaultItems)
1578
+
1579
+ // Build the mapped component tag from the first instance's attributes.
1580
+ // CMS-managed props are replaced with item.propName; structural props are kept as-is.
1581
+ const innerFieldKeys = new Set<string>(
1582
+ ((field as any).options?.arrayItem?.fields ?? []).map((f: { key: string }) => f.key),
1583
+ )
1584
+ const skipPropRegex = /^(class|className|id|style|type|role|aria-|data-)$/
1585
+ const firstAttrs = ranges[0]!.attrs
1586
+ const propLines: string[] = []
1587
+ for (const attr of firstAttrs) {
1588
+ const name = attr.name
1589
+ if (skipPropRegex.test(name) || name.startsWith('aria-') || name.startsWith('data-')) {
1590
+ // Keep structural attributes verbatim
1591
+ const raw = attr.kind === 'quoted' ? `${name}="${attr.value}"` : `${name}={${attr.value}}`
1592
+ propLines.push(raw)
1593
+ continue
1594
+ }
1595
+ if (innerFieldKeys.has(name)) {
1596
+ propLines.push(`${name}={item.${name}}`)
1597
+ } else {
1598
+ // Unknown prop — keep original value
1599
+ const raw = attr.kind === 'quoted' ? `${name}="${attr.value}"` : `${name}={${attr.value}}`
1600
+ propLines.push(raw)
1601
+ }
1602
+ }
1603
+ // fieldPrefix enables child component to add its own data-sk-field bindings.
1604
+ // data-sk-field on component calls is not forwarded to the DOM by Astro.
1605
+ propLines.push(`fieldPrefix={\`${sectionKey}.${field.key}.\${_i}\`}`)
1606
+
1607
+ const propsStr = propLines.map((p) => ` ${p}`).join('\n')
1608
+ const mapExpr =
1609
+ `{(${varName}?.${field.key} ?? ${defaultJson}).map((item, _i) => (\n` +
1610
+ ` <${compName}\n${propsStr}\n />\n` +
1611
+ `))}`
1612
+
1613
+ // Replace first instance with the .map() expression; delete the rest.
1614
+ // Also consume any leading whitespace before subsequent instances so we don't
1615
+ // leave blank lines.
1616
+ edits.push({
1617
+ offset: ranges[0]!.start,
1618
+ deleteCount: ranges[0]!.end - ranges[0]!.start,
1619
+ insert: mapExpr,
1620
+ })
1621
+ for (let i = 1; i < ranges.length; i++) {
1622
+ const r = ranges[i]!
1623
+ // Walk back to consume leading whitespace/newline
1624
+ let deleteStart = r.start
1625
+ while (deleteStart > 0 && /[ \t]/.test(source[deleteStart - 1]!)) deleteStart--
1626
+ if (deleteStart > 0 && source[deleteStart - 1] === '\n') deleteStart--
1627
+ edits.push({ offset: deleteStart, deleteCount: r.end - deleteStart, insert: '' })
1628
+ }
1629
+ }
1630
+
1631
+ /**
1632
+ * Find the end offset of a component tag starting at `tagStart`.
1633
+ * Returns the offset AFTER the last character (`>` of `/>` or `</Name>`).
1634
+ * Returns -1 if the end cannot be determined.
1635
+ */
1636
+ function findComponentTagEnd(source: string, tagStart: number, compName: string): number {
1637
+ let i = tagStart + 1 // skip '<'
1638
+ let inQuote: string | null = null
1639
+ let inExpr = 0
1640
+ while (i < source.length) {
1641
+ const ch = source[i]!
1642
+ if (inQuote) {
1643
+ if (ch === inQuote && source[i - 1] !== '\\') inQuote = null
1644
+ i++
1645
+ continue
1646
+ }
1647
+ if (ch === '{') {
1648
+ inExpr++
1649
+ i++
1650
+ continue
1651
+ }
1652
+ if (ch === '}' && inExpr > 0) {
1653
+ inExpr--
1654
+ i++
1655
+ continue
1656
+ }
1657
+ if (inExpr > 0) {
1658
+ i++
1659
+ continue
1660
+ }
1661
+ if (ch === '"' || ch === "'") {
1662
+ inQuote = ch
1663
+ i++
1664
+ continue
1665
+ }
1666
+ if (ch === '/' && source[i + 1] === '>') return i + 2 // self-closing />
1667
+ if (ch === '>') {
1668
+ // Check for explicit closing tag </CompName>
1669
+ const closing = `</${compName}>`
1670
+ const closingIdx = source.indexOf(closing, i + 1)
1671
+ if (closingIdx !== -1) return closingIdx + closing.length
1672
+ return i + 1
1673
+ }
1674
+ i++
1675
+ }
1676
+ return -1
1677
+ }
1678
+
1476
1679
  function patchRepeatedGroups(
1477
1680
  source: string,
1478
1681
  sectionKey: string,
@@ -1600,7 +1803,8 @@ function patchRepeatedGroups(
1600
1803
  if (typeof defaultVal === 'string' && defaultVal.length >= 1) {
1601
1804
  const found = findNormalizedText(elementSrc, defaultVal, 0)
1602
1805
  if (found) {
1603
- elementSrc = elementSrc.slice(0, found.start) + `{item.${field.key}}` + elementSrc.slice(found.end)
1806
+ elementSrc =
1807
+ elementSrc.slice(0, found.start) + `{item.${field.key}}` + elementSrc.slice(found.end)
1604
1808
  }
1605
1809
  }
1606
1810
 
@@ -1621,7 +1825,10 @@ function patchRepeatedGroups(
1621
1825
  // Apply inner edits in reverse order
1622
1826
  innerEdits.sort((a, b) => b.offset - a.offset)
1623
1827
  for (const edit of innerEdits) {
1624
- templateSrc = templateSrc.slice(0, edit.offset) + edit.insert + templateSrc.slice(edit.offset + edit.deleteCount)
1828
+ templateSrc =
1829
+ templateSrc.slice(0, edit.offset) +
1830
+ edit.insert +
1831
+ templateSrc.slice(edit.offset + edit.deleteCount)
1625
1832
  }
1626
1833
 
1627
1834
  // Add data-sk-field to the template element's opening tag (container binding)
@@ -1650,9 +1857,13 @@ function patchRepeatedGroups(
1650
1857
  const inst = instances[i]!
1651
1858
  // Also remove surrounding whitespace/newlines
1652
1859
  let deleteStart = inst.start
1653
- let deleteEnd = inst.end
1860
+ const deleteEnd = inst.end
1654
1861
  // Extend backwards to eat preceding whitespace
1655
- while (deleteStart > 0 && /\s/.test(source[deleteStart - 1]!) && source[deleteStart - 1] !== '\n') {
1862
+ while (
1863
+ deleteStart > 0 &&
1864
+ /\s/.test(source[deleteStart - 1]!) &&
1865
+ source[deleteStart - 1] !== '\n'
1866
+ ) {
1656
1867
  deleteStart--
1657
1868
  }
1658
1869
  // Eat the preceding newline too
@@ -1699,11 +1910,23 @@ function addInnerFieldBindings(
1699
1910
  let inE = 0
1700
1911
  for (let i = tagStart; i < idx; i++) {
1701
1912
  const ch = templateSrc[i]!
1702
- if (inQ) { if (ch === inQ && templateSrc[i - 1] !== '\\') inQ = null; continue }
1703
- if (ch === '{') { inE++; continue }
1704
- if (ch === '}' && inE > 0) { inE--; continue }
1913
+ if (inQ) {
1914
+ if (ch === inQ && templateSrc[i - 1] !== '\\') inQ = null
1915
+ continue
1916
+ }
1917
+ if (ch === '{') {
1918
+ inE++
1919
+ continue
1920
+ }
1921
+ if (ch === '}' && inE > 0) {
1922
+ inE--
1923
+ continue
1924
+ }
1705
1925
  if (inE > 0) continue
1706
- if (ch === '"' || ch === "'") { inQ = ch; continue }
1926
+ if (ch === '"' || ch === "'") {
1927
+ inQ = ch
1928
+ continue
1929
+ }
1707
1930
  if (ch === '>') tagEnd = i
1708
1931
  }
1709
1932
  if (tagEnd === -1) continue
@@ -1807,7 +2030,9 @@ function extractVarName(frontmatter: string): string | null {
1807
2030
  }
1808
2031
 
1809
2032
  /** Type guard for element-like nodes (element, component, custom-element). */
1810
- function isElement(node: AstNode): node is AstNode & { attributes: AstAttr[]; children: AstNode[] } {
2033
+ function isElement(
2034
+ node: AstNode,
2035
+ ): node is AstNode & { attributes: AstAttr[]; children: AstNode[] } {
1811
2036
  return (
1812
2037
  (node.type === 'element' || node.type === 'component' || node.type === 'custom-element') &&
1813
2038
  Array.isArray(node.attributes)
@@ -1815,7 +2040,11 @@ function isElement(node: AstNode): node is AstNode & { attributes: AstAttr[]; ch
1815
2040
  }
1816
2041
 
1817
2042
  /** Recursive AST walk with parent tracking. */
1818
- function walkAst(node: AstNode, callback: (node: AstNode, parent: AstNode | null) => void, parent: AstNode | null = null): void {
2043
+ function walkAst(
2044
+ node: AstNode,
2045
+ callback: (node: AstNode, parent: AstNode | null) => void,
2046
+ parent: AstNode | null = null,
2047
+ ): void {
1819
2048
  callback(node, parent)
1820
2049
  if (node.children) {
1821
2050
  for (const child of node.children) {
@@ -1840,7 +2069,12 @@ function findFirstChildElement(node: AstNode): (AstNode & { attributes: AstAttr[
1840
2069
  * Inserts before the closing `>` of the opening tag.
1841
2070
  * Uses the element's tag name to locate it reliably in the source.
1842
2071
  */
1843
- function addAttributeToElement(source: string, element: AstNode, attrStr: string, edits: Edit[]): void {
2072
+ function addAttributeToElement(
2073
+ source: string,
2074
+ element: AstNode,
2075
+ attrStr: string,
2076
+ edits: Edit[],
2077
+ ): void {
1844
2078
  const approxStart = element.position?.start?.offset
1845
2079
  if (approxStart == null) return
1846
2080
 
@@ -1876,10 +2110,19 @@ function findOpeningTagEnd(source: string, startOffset: number): number {
1876
2110
  if (ch === inQuote && source[i - 1] !== '\\') inQuote = null
1877
2111
  continue
1878
2112
  }
1879
- if (ch === '{') { inExpr++; continue }
1880
- if (ch === '}' && inExpr > 0) { inExpr--; continue }
2113
+ if (ch === '{') {
2114
+ inExpr++
2115
+ continue
2116
+ }
2117
+ if (ch === '}' && inExpr > 0) {
2118
+ inExpr--
2119
+ continue
2120
+ }
1881
2121
  if (inExpr > 0) continue
1882
- if (ch === '"' || ch === "'") { inQuote = ch; continue }
2122
+ if (ch === '"' || ch === "'") {
2123
+ inQuote = ch
2124
+ continue
2125
+ }
1883
2126
  if (ch === '>') return i
1884
2127
  }
1885
2128
  return -1
@@ -1894,7 +2137,8 @@ function applyEdits(source: string, edits: Edit[]): string {
1894
2137
 
1895
2138
  let result = source
1896
2139
  for (const edit of sorted) {
1897
- result = result.slice(0, edit.offset) + edit.insert + result.slice(edit.offset + edit.deleteCount)
2140
+ result =
2141
+ result.slice(0, edit.offset) + edit.insert + result.slice(edit.offset + edit.deleteCount)
1898
2142
  }
1899
2143
  return result
1900
2144
  }
@@ -1904,7 +2148,12 @@ function applyEdits(source: string, edits: Edit[]): string {
1904
2148
  * Called AFTER expression patching so that variable references have already
1905
2149
  * been replaced with CMS expressions.
1906
2150
  */
1907
- function removeOldVarDeclarations(source: string, fields: PatchField[], repeatedGroups?: RepeatedGroup[], cmsVarName = 'skData'): string {
2151
+ function removeOldVarDeclarations(
2152
+ source: string,
2153
+ fields: PatchField[],
2154
+ repeatedGroups?: RepeatedGroup[],
2155
+ cmsVarName = 'skData',
2156
+ ): string {
1908
2157
  const fmStart = source.indexOf('---')
1909
2158
  if (fmStart === -1) return source
1910
2159
  const fmEnd = source.indexOf('---', fmStart + 3)
@@ -1925,6 +2174,19 @@ function removeOldVarDeclarations(source: string, fields: PatchField[], repeated
1925
2174
  }
1926
2175
  }
1927
2176
 
2177
+ // Remove frontmatter array variables that were passed as props to repeated components
2178
+ // and are no longer referenced in the template after patching.
2179
+ const templatePart0 = source.slice(fmEnd + 3)
2180
+ const fmVarRegex2 = /(?:const|let)\s+(\w+)\s*=\s*\[/g
2181
+ let m: RegExpExecArray | null
2182
+ while ((m = fmVarRegex2.exec(frontmatter)) !== null) {
2183
+ const vName = m[1]!
2184
+ if (fieldKeys.has(vName)) continue
2185
+ // If no longer referenced in the template, it was consumed by component patching
2186
+ const stillUsed = new RegExp(`\\b${vName}\\b`).test(templatePart0)
2187
+ if (!stillUsed) fieldKeys.add(vName)
2188
+ }
2189
+
1928
2190
  // Collect all ranges to remove (relative to frontmatter string)
1929
2191
  const removals: Array<{ start: number; end: number }> = []
1930
2192
  // Aliases that must be appended AFTER the cmsVarName (= skData) declaration to avoid TDZ
@@ -1962,7 +2224,10 @@ function removeOldVarDeclarations(source: string, fields: PatchField[], repeated
1962
2224
  if (ch === inStr && rhs[i - 1] !== '\\') inStr = null
1963
2225
  continue
1964
2226
  }
1965
- if (ch === "'" || ch === '"' || ch === '`') { inStr = ch; continue }
2227
+ if (ch === "'" || ch === '"' || ch === '`') {
2228
+ inStr = ch
2229
+ continue
2230
+ }
1966
2231
  if (ch === open) depth++
1967
2232
  if (ch === close) depth--
1968
2233
  }
@@ -2021,6 +2286,175 @@ function removeOldVarDeclarations(source: string, fields: PatchField[], repeated
2021
2286
  //
2022
2287
  // Returns the cleaned source and a map of fieldKey → extracted value.
2023
2288
  // The caller decides which values to write to JSON:
2289
+ // ---------------------------------------------------------------------------
2290
+ // Child-component fieldPrefix patching
2291
+ // ---------------------------------------------------------------------------
2292
+
2293
+ /**
2294
+ * Patches a child Astro component (e.g. PricingCard.astro) to accept a
2295
+ * `fieldPrefix` prop and expose `data-sk-field` bindings on its elements,
2296
+ * enabling the inline editor to identify individual fields.
2297
+ *
2298
+ * Transforms:
2299
+ * <p>{name}</p> → <p data-sk-field={fieldPrefix ? `${fieldPrefix}.name` : undefined}>{name}</p>
2300
+ * href={ctaHref} → data-sk-field={fieldPrefix ? `${fieldPrefix}.ctaHref` : undefined}
2301
+ * features.map() → features.map((feature, _fi) => ... data-sk-field per item
2302
+ *
2303
+ * Idempotent: no-op if fieldPrefix is already present.
2304
+ */
2305
+ export function patchChildComponentForFieldPrefix(
2306
+ source: string,
2307
+ innerFields: Array<{ key: string; type: string }>,
2308
+ ): string {
2309
+ // Idempotency guard — already patched
2310
+ if (source.includes('fieldPrefix?: string') || source.includes('fieldPrefix?:string'))
2311
+ return source
2312
+
2313
+ const fmStart = source.indexOf('---')
2314
+ const fmEnd = source.indexOf('---', fmStart + 3)
2315
+ if (fmStart === -1 || fmEnd === -1) return source
2316
+
2317
+ let result = source
2318
+
2319
+ // 1. Add fieldPrefix to Props interface
2320
+ // Find the closing `}` of the interface block
2321
+ const interfaceMatch = result.match(/export\s+interface\s+Props\s*\{([\s\S]*?)\}/)
2322
+ if (interfaceMatch) {
2323
+ const ifaceEnd = result.indexOf(interfaceMatch[0]) + interfaceMatch[0].length
2324
+ const lastPropLine = interfaceMatch[0].slice(0, -1).trimEnd() // remove trailing `}`
2325
+ result = result.slice(0, ifaceEnd - 1) + '\n fieldPrefix?: string;\n}' + result.slice(ifaceEnd)
2326
+ } else {
2327
+ // No interface — insert before closing ---
2328
+ const closeFm = result.indexOf('---', result.indexOf('---') + 3)
2329
+ result =
2330
+ result.slice(0, closeFm) +
2331
+ 'interface Props { fieldPrefix?: string }\n' +
2332
+ result.slice(closeFm)
2333
+ }
2334
+
2335
+ // 2. Add fieldPrefix to destructuring
2336
+ // Match: const { ..., highlight = false } = Astro.props
2337
+ result = result.replace(/(\bconst\s*\{[^}]*)(}\s*=\s*Astro\.props)/, (_m, before, after) => {
2338
+ // Avoid adding twice
2339
+ if (before.includes('fieldPrefix')) return _m
2340
+ const trimmed = before.trimEnd()
2341
+ const sep = trimmed.endsWith(',') ? ' ' : ',\n '
2342
+ return `${trimmed}${sep}fieldPrefix${after}`
2343
+ })
2344
+
2345
+ // 3. Patch template elements — work on the template part only
2346
+ const closingFm = result.indexOf('---', result.indexOf('---') + 3)
2347
+ const frontmatterPart = result.slice(0, closingFm + 3)
2348
+ let templatePart = result.slice(closingFm + 3)
2349
+
2350
+ const scalarFields = innerFields.filter((f) => f.type !== 'array')
2351
+ const arrayFields = innerFields.filter((f) => f.type === 'array')
2352
+
2353
+ // 3a. Scalar fields: add data-sk-field to the element containing {propName}
2354
+ for (const field of scalarFields) {
2355
+ const propExpr = `{${field.key}}`
2356
+ // href attribute: <a href={ctaHref} ...
2357
+ if (field.key.toLowerCase().includes('href') || field.key.toLowerCase().includes('link')) {
2358
+ templatePart = templatePart.replace(
2359
+ new RegExp(`href=\\{${field.key}\\}`, 'g'),
2360
+ `href={${field.key}} data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`,
2361
+ )
2362
+ continue
2363
+ }
2364
+
2365
+ // Inline text in element: find <tag ...>{propName}</tag> or <tag ...>{propName}
2366
+ // Add data-sk-field to the opening tag of the nearest wrapping element
2367
+ const tagWithExprRegex = new RegExp(
2368
+ `(<(?!/)(?:p|span|h[1-6]|div|li|td|th|dt|dd|label|button|a)\\b[^>]*?)(\\/?>)([^<]*\\{${field.key}\\})`,
2369
+ 'g',
2370
+ )
2371
+ templatePart = templatePart.replace(tagWithExprRegex, (_m, tagOpen, tagClose, rest) => {
2372
+ if (tagOpen.includes('data-sk-field')) return _m
2373
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`
2374
+ return `${tagOpen}${attr}${tagClose}${rest}`
2375
+ })
2376
+ }
2377
+
2378
+ // 3b. Array fields: add index param + data-sk-field on innermost element
2379
+ for (const field of arrayFields) {
2380
+ // Match: features.map((feature) => or features.map((feature, fi) =>
2381
+ const mapParamRegex = new RegExp(`(${field.key}\\.map\\s*\\(\\s*\\(\\s*)(\\w+)(\\s*\\))`, 'g')
2382
+ // Add _fi index if missing, and annotate the ul/ol containing the map
2383
+ templatePart = templatePart.replace(
2384
+ new RegExp(`(<(?:ul|ol)[^>]*?)(\\/?>)([\\s\\S]*?${field.key}\\.map)`, 'g'),
2385
+ (_m, ulOpen, ulClose, rest) => {
2386
+ if (ulOpen.includes('data-sk-field')) return _m
2387
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`
2388
+ return `${ulOpen}${attr}${ulClose}${rest}`
2389
+ },
2390
+ )
2391
+
2392
+ // Add _fi index param to .map() callback if not already indexed
2393
+ templatePart = templatePart.replace(mapParamRegex, (_m, before, param, close) => {
2394
+ if (close.trim().startsWith(',')) return _m // already has index
2395
+ return `${before}${param}, _fi${close}`
2396
+ })
2397
+
2398
+ // Add data-sk-field on the innermost span/li that renders the item variable
2399
+ // Pattern: <span ...>{itemVar}</span> or <li ...>{itemVar}</li>
2400
+ // We search within .map() callback blocks
2401
+ const mapBlockRegex = new RegExp(
2402
+ `(${field.key}\\.map\\s*\\([^)]*\\)\\s*=>\\s*\\([\\s\\S]*?)(<(?:span|li|td)\\b)([^>]*?>)([^<]*\\{\\w+\\})`,
2403
+ 'g',
2404
+ )
2405
+ templatePart = templatePart.replace(
2406
+ mapBlockRegex,
2407
+ (_m, mapHead, tagOpen, tagClose, content) => {
2408
+ if (tagClose.includes('data-sk-field')) return _m
2409
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}.\${_fi}\` : undefined}`
2410
+ return `${mapHead}${tagOpen}${tagClose.slice(0, -1)}${attr}>${content}`
2411
+ },
2412
+ )
2413
+ }
2414
+
2415
+ return frontmatterPart + templatePart
2416
+ }
2417
+
2418
+ /**
2419
+ * Scans the section source for import statements matching any field's
2420
+ * `options.sourceComponent`, and returns metadata for child-component patching.
2421
+ */
2422
+ export function detectChildImports(
2423
+ source: string,
2424
+ fields: PatchField[],
2425
+ ): Array<{
2426
+ compName: string
2427
+ importPath: string
2428
+ innerFields: Array<{ key: string; type: string }>
2429
+ }> {
2430
+ const result: Array<{
2431
+ compName: string
2432
+ importPath: string
2433
+ innerFields: Array<{ key: string; type: string }>
2434
+ }> = []
2435
+
2436
+ for (const field of fields) {
2437
+ const compName = (field as any).options?.sourceComponent as string | undefined
2438
+ if (!compName) continue
2439
+
2440
+ const innerFields: Array<{ key: string; type: string }> = (
2441
+ (field as any).options?.arrayItem?.fields ?? []
2442
+ ).map((f: { key: string; type: string }) => ({
2443
+ key: f.key,
2444
+ type: f.type,
2445
+ }))
2446
+
2447
+ // Find: import CompName from '...'
2448
+ const importRegex = new RegExp(`import\\s+${compName}\\s+from\\s+['"]([^'"]+)['"]`)
2449
+ const m = source.match(importRegex)
2450
+ if (!m) continue
2451
+
2452
+ result.push({ compName, importPath: m[1]!, innerFields })
2453
+ }
2454
+
2455
+ return result
2456
+ }
2457
+
2024
2458
  // - If the JSON already has a value for that field → just strip (JSON wins)
2025
2459
  // - If the JSON has no value → use the extracted fallback
2026
2460
  // ---------------------------------------------------------------------------