@setzkasten-cms/astro-admin 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +37 -0
  2. package/package.json +70 -0
  3. package/src/admin-page.astro +148 -0
  4. package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
  5. package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
  6. package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
  7. package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
  8. package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
  9. package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
  10. package/src/api-routes/__tests__/section-management.test.ts +284 -0
  11. package/src/api-routes/_storage-config.ts +54 -0
  12. package/src/api-routes/asset-proxy.ts +76 -0
  13. package/src/api-routes/auth-callback.ts +105 -0
  14. package/src/api-routes/auth-login.ts +87 -0
  15. package/src/api-routes/auth-logout.ts +9 -0
  16. package/src/api-routes/auth-session.ts +36 -0
  17. package/src/api-routes/catalog-add.ts +151 -0
  18. package/src/api-routes/catalog-export.ts +86 -0
  19. package/src/api-routes/catalog-helpers.ts +83 -0
  20. package/src/api-routes/catalog-list.ts +12 -0
  21. package/src/api-routes/config.ts +30 -0
  22. package/src/api-routes/deploy-hook.ts +69 -0
  23. package/src/api-routes/github-proxy.ts +111 -0
  24. package/src/api-routes/init-add-section.ts +511 -0
  25. package/src/api-routes/init-apply.ts +270 -0
  26. package/src/api-routes/init-migrate.ts +262 -0
  27. package/src/api-routes/init-scan-page.ts +336 -0
  28. package/src/api-routes/init-scan.ts +162 -0
  29. package/src/api-routes/pages.ts +17 -0
  30. package/src/api-routes/section-add.ts +189 -0
  31. package/src/api-routes/section-commit-pending.ts +147 -0
  32. package/src/api-routes/section-delete.ts +141 -0
  33. package/src/api-routes/section-duplicate.ts +144 -0
  34. package/src/api-routes/section-management.ts +95 -0
  35. package/src/api-routes/section-prepare-copy.ts +93 -0
  36. package/src/api-routes/section-prepare.ts +121 -0
  37. package/src/env.d.ts +7 -0
  38. package/src/init/__tests__/page-level.test.ts +1033 -0
  39. package/src/init/__tests__/page-list-coverage.test.ts +474 -0
  40. package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
  41. package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
  42. package/src/init/__tests__/section-pipeline.test.ts +393 -0
  43. package/src/init/analyzer-types.ts +92 -0
  44. package/src/init/astro-config-patcher.ts +98 -0
  45. package/src/init/astro-detector.ts +207 -0
  46. package/src/init/astro-section-analyzer-v2.ts +1663 -0
  47. package/src/init/field-label-enricher.ts +72 -0
  48. package/src/init/template-patcher-v2.ts +1957 -0
  49. package/tsconfig.json +9 -0
@@ -0,0 +1,1957 @@
1
+ /**
2
+ * AST-based template patcher for Astro components (v2).
3
+ *
4
+ * Key difference from v1: Repeated element groups are patched using
5
+ * pre-computed RepeatedGroup data from the analyzer. No own detection needed.
6
+ *
7
+ * Uses @astrojs/compiler to parse .astro files, then applies position-based
8
+ * edits to replace hardcoded content with CMS variable references and add
9
+ * data-sk-field attributes for live-preview binding.
10
+ */
11
+
12
+ import { parse } from '@astrojs/compiler'
13
+ import type { RepeatedGroup } from './analyzer-types.js'
14
+
15
+ // AST node types from @astrojs/compiler (re-declared for clarity)
16
+ interface AstNode {
17
+ type: string
18
+ position?: { start: { offset: number; line: number; column: number }; end?: { offset: number } }
19
+ children?: AstNode[]
20
+ attributes?: AstAttr[]
21
+ name?: string
22
+ value?: string
23
+ }
24
+
25
+ interface AstAttr {
26
+ type: 'attribute'
27
+ kind: 'quoted' | 'empty' | 'expression' | 'spread' | 'shorthand' | 'template-literal'
28
+ name: string
29
+ value: string
30
+ raw?: string
31
+ position?: { start: { offset: number } }
32
+ }
33
+
34
+ /** Collapse whitespace to single spaces for comparison. */
35
+ const normalizeWs = (s: string) => s.replace(/\s+/g, ' ').trim()
36
+
37
+ /**
38
+ * Find text in a string where the needle may have collapsed whitespace
39
+ * but the haystack preserves original whitespace (incl. newlines).
40
+ */
41
+ function findNormalizedText(haystack: string, needle: string, startFrom: number): { start: number; end: number } | null {
42
+ // Try exact match first
43
+ const exactIdx = haystack.indexOf(needle, startFrom)
44
+ if (exactIdx !== -1) return { start: exactIdx, end: exactIdx + needle.length }
45
+
46
+ // Build regex: escape special chars, replace spaces with \s+
47
+ const normalized = normalizeWs(needle)
48
+ if (!normalized || normalized.length < 2) return null
49
+ const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+')
50
+ const regex = new RegExp(escaped)
51
+ const match = regex.exec(haystack.slice(startFrom))
52
+ if (match) return { start: startFrom + match.index, end: startFrom + match.index + match[0].length }
53
+
54
+ return null
55
+ }
56
+
57
+ /** A position-based string edit. Applied in reverse offset order to maintain positions. */
58
+ interface Edit {
59
+ offset: number
60
+ deleteCount: number
61
+ insert: string
62
+ }
63
+
64
+ export type PatchField = { key: string; type: string; defaultValue?: unknown }
65
+
66
+ /**
67
+ * Patch an Astro component template using AST analysis.
68
+ *
69
+ * Replaces hardcoded content with CMS variable references
70
+ * and adds data-sk-field attributes for live-preview binding.
71
+ *
72
+ * Handles:
73
+ * - CMS-bound fields ({var?.field ?? 'fallback'}) — wraps in <span data-sk-field> if missing
74
+ * - Hardcoded text → replaced with CMS expression + data-sk-field
75
+ * - Hardcoded href → replaced with CMS expression
76
+ * - Array .map() patterns → adds index parameter + data-sk-field on first element
77
+ */
78
+ export async function patchTemplateForFields(
79
+ source: string,
80
+ sectionKey: string,
81
+ fields: PatchField[],
82
+ repeatedGroups: RepeatedGroup[] = [],
83
+ options?: { mode?: 'component' | 'page' },
84
+ ): Promise<string> {
85
+ // Parse the AST
86
+ let result: Awaited<ReturnType<typeof parse>>
87
+ try {
88
+ result = await parse(source)
89
+ } catch (err) {
90
+ console.error('[setzkasten] template-patcher: parse() failed:', err)
91
+ return source // graceful fallback: return unpatched
92
+ }
93
+ const ast = result.ast as AstNode
94
+
95
+ // Convert all AST byte offsets to JS char offsets
96
+ const b2c = buildByteToCharMap(source)
97
+ convertAstPositions(ast, b2c)
98
+
99
+ // Find frontmatter and extract data variable name
100
+ const fmNode = ast.children?.find((c) => c.type === 'frontmatter')
101
+ if (!fmNode?.value) return source
102
+
103
+ let varName = extractVarName(fmNode.value)
104
+
105
+ // If no CMS variable exists yet, inject Props interface + data destructuring,
106
+ // then re-run patcher on the modified source so AST positions are correct.
107
+ // IMPORTANT: Keep old variable declarations during the recursive call so the
108
+ // patcher can detect variable expressions ({overline} etc.) and replace them.
109
+ // Remove old declarations only AFTER patching is complete.
110
+ if (!varName) {
111
+ // Use a safe variable name that won't collide with existing frontmatter vars
112
+ varName = 'skData'
113
+ const isPageMode = options?.mode === 'page'
114
+ let propsBlock: string
115
+ if (isPageMode) {
116
+ // Page-level: import getSection and call it directly
117
+ propsBlock = `\nimport { getSection } from 'setzkasten:content'\nconst ${varName} = getSection('${sectionKey}')\n`
118
+ } else {
119
+ // Component-level: use Astro.props destructuring
120
+ propsBlock = `\ninterface Props {\n data: Record<string, any> | null\n}\nconst { data: ${varName} } = Astro.props\n`
121
+ }
122
+ const fmEnd = source.indexOf('---', source.indexOf('---') + 3)
123
+ if (fmEnd === -1) return source
124
+ const modifiedSource = source.slice(0, fmEnd) + propsBlock + source.slice(fmEnd)
125
+
126
+ // Adjust repeated group positions: the Props block shifts everything after fmEnd
127
+ const shift = propsBlock.length
128
+ // Adjust positions in-place on the original groups so mutations
129
+ // (like _class fields from makeDynamicClasses) are visible to the caller.
130
+ for (const g of repeatedGroups) {
131
+ for (const inst of g.instances) { inst.start += shift; inst.end += shift }
132
+ for (const f of g.fields) {
133
+ for (let i = 0; i < f.positions.length; i++) {
134
+ const p = f.positions[i]
135
+ if (p) p.offset += shift
136
+ }
137
+ }
138
+ if (g.classAttrs) {
139
+ for (const instAttrs of g.classAttrs) {
140
+ for (const a of instAttrs) a.sourceOffset += shift
141
+ }
142
+ }
143
+ }
144
+
145
+ // Patch expressions first (old vars still present → patcher detects them)
146
+ const patched = await patchTemplateForFields(modifiedSource, sectionKey, fields, repeatedGroups, options)
147
+
148
+ // Now remove old variable declarations from the patched result
149
+ return removeOldVarDeclarations(patched, fields, repeatedGroups, varName)
150
+ }
151
+
152
+ // Collect existing data-sk-field bindings
153
+ const existingBindings = new Set<string>()
154
+ walkAst(ast, (node) => {
155
+ if (isElement(node) && node.attributes) {
156
+ for (const attr of node.attributes) {
157
+ if (attr.name === 'data-sk-field' && attr.value) {
158
+ existingBindings.add(attr.value)
159
+ }
160
+ }
161
+ }
162
+ })
163
+
164
+ // Build set of frontmatter variable names (for detecting variable expressions)
165
+ const fmVarNames = new Set<string>()
166
+ const fmVarRegex = /(?:const|let)\s+(\w+)\s*=/g
167
+ let fmMatch: RegExpExecArray | null
168
+ while ((fmMatch = fmVarRegex.exec(fmNode.value)) !== null) {
169
+ fmVarNames.add(fmMatch[1]!)
170
+ }
171
+
172
+ // Collect interesting nodes in one walk
173
+ const cmsExpressions: Array<{ node: AstNode; parent: AstNode; fieldKey: string }> = []
174
+ const varExpressions: Array<{ node: AstNode; parent: AstNode; varName: string }> = []
175
+ const textNodes: Array<{ node: AstNode; parent: AstNode }> = []
176
+ const hrefAttrs: Array<{ attr: AstAttr; element: AstNode }> = []
177
+ const mapExpressions: Array<{ node: AstNode }> = []
178
+ const mixedElements: Array<{ node: AstNode; normalizedText: string }> = []
179
+
180
+ walkAst(ast, (node, parent) => {
181
+ if (!parent) return
182
+
183
+ // Collect elements with mixed content (text + inline child elements)
184
+ // These are rich text fields where the whole element is one CMS field.
185
+ 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')
188
+ if (hasText && hasChild) {
189
+ const fullText = normalizeWs(getElementTextContent(node))
190
+ if (fullText) mixedElements.push({ node, normalizedText: fullText })
191
+ }
192
+ }
193
+
194
+ // Expression nodes: check for CMS variables, frontmatter vars, or .map() patterns
195
+ if (node.type === 'expression' && node.children) {
196
+ const exprText = node.children
197
+ .filter((c) => c.type === 'text')
198
+ .map((c) => c.value ?? '')
199
+ .join('')
200
+
201
+ // CMS variable: {var?.fieldKey ...}
202
+ const cmsMatch = exprText.match(new RegExp(`${varName}\\?\\.\\s*(\\w+)`))
203
+ if (cmsMatch) {
204
+ cmsExpressions.push({ node, parent, fieldKey: cmsMatch[1]! })
205
+ }
206
+
207
+ // Simple frontmatter variable reference: {varName} (not .map, not ?.field)
208
+ const trimmedExpr = exprText.trim()
209
+ if (fmVarNames.has(trimmedExpr) && !trimmedExpr.includes('.') && !trimmedExpr.includes('(')) {
210
+ varExpressions.push({ node, parent, varName: trimmedExpr })
211
+ }
212
+
213
+ // .map() pattern
214
+ if (exprText.includes('.map(')) {
215
+ mapExpressions.push({ node })
216
+ }
217
+ }
218
+
219
+ // Text nodes with actual content
220
+ if (node.type === 'text' && node.value?.trim()) {
221
+ textNodes.push({ node, parent })
222
+ }
223
+
224
+ // Elements with href attributes
225
+ if (isElement(node) && node.attributes) {
226
+ for (const attr of node.attributes) {
227
+ if (attr.name === 'href' && attr.kind === 'quoted' && attr.value) {
228
+ hrefAttrs.push({ attr, element: node })
229
+ }
230
+ }
231
+ }
232
+ })
233
+
234
+ const edits: Edit[] = []
235
+
236
+ // --- Ensure root element has id="section-{sectionKey}" ---
237
+ patchSectionId(source, ast, sectionKey, edits, options)
238
+
239
+ // --- Repeated element groups: collapse N× <article> into .map() ---
240
+ // Must run BEFORE per-field loop since it handles inner fields itself.
241
+ const repeatedFieldKeys = new Set<string>()
242
+ if (repeatedGroups.length > 0) {
243
+ const repeatedEdits = patchRepeatedGroups(source, sectionKey, varName, repeatedGroups)
244
+ edits.push(...repeatedEdits)
245
+ for (const g of repeatedGroups) repeatedFieldKeys.add(g.fieldKey)
246
+
247
+ // Sync _classN defaultValues back into top-level array field's defaultValue.
248
+ // The patcher adds _classN fields to group.fields, but the analyzer already
249
+ // built the defaultValue array before patching. Without this sync, the
250
+ // content JSON (built from field.defaultValue) would be missing _classN values.
251
+ for (const g of repeatedGroups) {
252
+ const topField = fields.find(f => f.key === g.fieldKey)
253
+ if (!topField || !Array.isArray(topField.defaultValue)) continue
254
+ const items = topField.defaultValue as Array<Record<string, unknown>>
255
+ for (const innerField of g.fields) {
256
+ for (let ii = 0; ii < items.length && ii < innerField.defaultValues.length; ii++) {
257
+ const val = innerField.defaultValues[ii]
258
+ if (val != null && items[ii] && typeof items[ii] === 'object' && !(innerField.key in items[ii]!)) {
259
+ items[ii]![innerField.key] = val
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ for (const field of fields) {
267
+ // Skip fields that belong to a repeated group (already handled above)
268
+ if (repeatedFieldKeys.has(field.key)) continue
269
+ const bindingKey = `${sectionKey}.${field.key}`
270
+
271
+ // Skip if already has data-sk-field
272
+ if (existingBindings.has(bindingKey)) continue
273
+
274
+ // --- Array fields: patch .map() with indexed data-sk-field ---
275
+ if (field.type === 'array' && Array.isArray(field.defaultValue)) {
276
+ const editsBefore = edits.length
277
+ patchArrayField(source, sectionKey, field, varName, mapExpressions, edits)
278
+ if (edits.length === editsBefore) {
279
+ // No .map() expression found — try static <ul>/<li> conversion
280
+ patchStaticListField(source, sectionKey, field, varName, ast, edits)
281
+ }
282
+ continue
283
+ }
284
+
285
+ // --- CMS-bound fields (already has {var?.field} but no data-sk-field) ---
286
+ const cmsExpr = cmsExpressions.find((e) => e.fieldKey === field.key)
287
+ if (cmsExpr) {
288
+ patchCmsBoundField(source, sectionKey, field, cmsExpr, edits)
289
+ continue
290
+ }
291
+
292
+ // --- Frontmatter variable expressions ({varName} → {skData?.fieldKey ?? 'default'}) ---
293
+ const varExpr = varExpressions.find((e) => e.varName === field.key)
294
+ if (varExpr) {
295
+ patchVarExpression(source, sectionKey, field, varName, varExpr, edits)
296
+ continue
297
+ }
298
+
299
+ // Fields without string defaultValue can't be matched by content
300
+ if (!field.defaultValue || typeof field.defaultValue !== 'string') continue
301
+ if (field.defaultValue.length < 2) continue
302
+
303
+ // --- Link/URL fields: replace hardcoded href ---
304
+ if (/link|url|href/i.test(field.key)) {
305
+ const hrefMatch = hrefAttrs.find((h) => h.attr.value === field.defaultValue)
306
+ if (hrefMatch) {
307
+ patchHrefField(source, sectionKey, field, varName, hrefMatch, edits)
308
+ continue
309
+ }
310
+ }
311
+
312
+ // --- Text fields: replace hardcoded text content ---
313
+ const editsBefore = edits.length
314
+ patchTextField(source, sectionKey, field, varName, textNodes, edits)
315
+
316
+ // --- Fallback: mixed-content element → rich text field (set:html) ---
317
+ // If patchTextField couldn't find a matching text node, the text might be
318
+ // spread across child elements (e.g. <p>text <strong>bold</strong> more</p>).
319
+ // Treat the whole element as a rich text field with set:html for CMS rendering.
320
+ if (edits.length === editsBefore) {
321
+ const dv = field.defaultValue as string
322
+ const mixedMatch = mixedElements.find(m => m.normalizedText === normalizeWs(dv))
323
+ if (mixedMatch) {
324
+ patchMixedContentField(source, sectionKey, field, varName, mixedMatch.node, edits)
325
+ }
326
+ }
327
+ }
328
+
329
+ const patched = applyEdits(source, edits)
330
+ return convertToSetHtml(patched)
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Post-processing: convert text bindings to set:html
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Convert data-sk-field elements with simple {expression} content to use set:html.
339
+ * This enables rich text (formatting:true) to work automatically — HTML from the
340
+ * CMS editor is rendered as HTML, not escaped text.
341
+ *
342
+ * Matches: <tag ...data-sk-field...>{skData?.field}</tag>
343
+ * Result: <tag ...data-sk-field... set:html={skData?.field}></tag>
344
+ */
345
+ function convertToSetHtml(source: string): string {
346
+ const marker = 'data-sk-field'
347
+ let result = source
348
+ let searchFrom = 0
349
+
350
+ while (true) {
351
+ const idx = result.indexOf(marker, searchFrom)
352
+ if (idx === -1) break
353
+ searchFrom = idx + marker.length
354
+
355
+ // Find the > that closes this opening tag (respecting brace depth for template literals)
356
+ let tagEnd = -1
357
+ let braceDepth = 0
358
+ for (let i = idx; i < result.length; i++) {
359
+ const ch = result[i]!
360
+ if (ch === '{') braceDepth++
361
+ else if (ch === '}') braceDepth--
362
+ else if (ch === '>' && braceDepth === 0) { tagEnd = i; break }
363
+ }
364
+ if (tagEnd === -1) continue
365
+
366
+ // Skip if already has set:html before the >
367
+ const tagSection = result.slice(idx, tagEnd)
368
+ if (tagSection.includes('set:html')) continue
369
+
370
+ // Check inner content after >: must be just {expression} with optional whitespace
371
+ const afterTag = result.slice(tagEnd + 1)
372
+ const innerMatch = afterTag.match(/^(\s*)\{([^{}]+)\}(\s*)<\/(\w+)>/)
373
+ if (!innerMatch) continue
374
+
375
+ // Only convert CMS bindings (skData?.field or item.field)
376
+ const expr = innerMatch[2]!
377
+ if (!/^(?:skData\?\.\w+|item\.\w+)$/.test(expr)) continue
378
+
379
+ const fullInnerLength = innerMatch[0]!.length
380
+ const closeTag = `</${innerMatch[4]}>`
381
+
382
+ // Insert set:html before > and replace inner content with empty
383
+ const insertion = ` set:html={${expr}}>${closeTag}`
384
+ result = result.slice(0, tagEnd) + insertion + result.slice(tagEnd + 1 + fullInnerLength)
385
+ searchFrom = tagEnd + insertion.length
386
+ }
387
+
388
+ return result
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Byte → Char offset conversion
393
+ // ---------------------------------------------------------------------------
394
+
395
+ /**
396
+ * Build a mapping function from UTF-8 byte offsets to JS string char indices.
397
+ * @astrojs/compiler (Go/WASM) returns byte offsets; JS strings use UTF-16 code units.
398
+ */
399
+ function buildByteToCharMap(source: string): (byteOffset: number) => number {
400
+ const buf = Buffer.from(source, 'utf-8')
401
+ if (buf.length === source.length) {
402
+ // All ASCII — offsets are identical
403
+ return (offset) => offset
404
+ }
405
+
406
+ // Build byte→char mapping array
407
+ const map = new Array<number>(buf.length + 1)
408
+ let byteIdx = 0
409
+ for (let charIdx = 0; charIdx < source.length; charIdx++) {
410
+ const codePoint = source.codePointAt(charIdx)!
411
+ const charByteLen = codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4
412
+ for (let b = 0; b < charByteLen; b++) {
413
+ map[byteIdx + b] = charIdx
414
+ }
415
+ byteIdx += charByteLen
416
+ // Skip surrogate pair's second code unit
417
+ if (codePoint > 0xffff) charIdx++
418
+ }
419
+ map[byteIdx] = source.length
420
+
421
+ return (offset) => {
422
+ if (offset <= 0) return 0
423
+ if (offset >= map.length) return source.length
424
+ return map[offset]!
425
+ }
426
+ }
427
+
428
+ /** Convert all AST node positions from byte offsets to char offsets in-place. */
429
+ function convertAstPositions(node: AstNode, b2c: (offset: number) => number): void {
430
+ if (node.position?.start) {
431
+ node.position.start.offset = b2c(node.position.start.offset)
432
+ }
433
+ if (node.position?.end) {
434
+ node.position.end.offset = b2c(node.position.end.offset)
435
+ }
436
+ if (node.attributes) {
437
+ for (const attr of node.attributes) {
438
+ if (attr.position?.start) {
439
+ attr.position.start.offset = b2c(attr.position.start.offset)
440
+ }
441
+ }
442
+ }
443
+ if (node.children) {
444
+ for (const child of node.children) {
445
+ convertAstPositions(child, b2c)
446
+ }
447
+ }
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Source-level position helpers
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Find the actual {…} expression bounds in the source for an expression node.
456
+ * AST expression positions can extend beyond the actual braces, so we search
457
+ * the source directly using the child text content as anchor.
458
+ */
459
+ function findExpressionBounds(source: string, node: AstNode): { start: number; end: number } | null {
460
+ const approxStart = node.position?.start?.offset
461
+ if (approxStart == null) return null
462
+
463
+ // Get the expression text from children
464
+ const exprText = (node.children ?? [])
465
+ .filter((c) => c.type === 'text')
466
+ .map((c) => c.value ?? '')
467
+ .join('')
468
+ if (!exprText) return null
469
+
470
+ // Search for the opening { near the AST position (within ±10 chars)
471
+ const searchStart = Math.max(0, approxStart - 10)
472
+ const searchEnd = Math.min(source.length, approxStart + 10)
473
+ let braceStart = -1
474
+ for (let i = searchStart; i < searchEnd; i++) {
475
+ if (source[i] === '{') {
476
+ // Verify this { leads to our expression content
477
+ const afterBrace = source.slice(i + 1, i + 1 + exprText.length + 5)
478
+ if (afterBrace.trimStart().startsWith(exprText.trimStart().slice(0, 20))) {
479
+ braceStart = i
480
+ break
481
+ }
482
+ }
483
+ }
484
+ if (braceStart === -1) return null
485
+
486
+ // Find matching closing }
487
+ let depth = 1
488
+ let inStr: string | null = null
489
+ for (let i = braceStart + 1; i < source.length; i++) {
490
+ const ch = source[i]!
491
+ if (inStr) {
492
+ if (ch === inStr && source[i - 1] !== '\\') inStr = null
493
+ continue
494
+ }
495
+ if (ch === "'" || ch === '"' || ch === '`') { inStr = ch; continue }
496
+ if (ch === '{') depth++
497
+ if (ch === '}') {
498
+ depth--
499
+ if (depth === 0) return { start: braceStart, end: i + 1 }
500
+ }
501
+ }
502
+ return null
503
+ }
504
+
505
+ /**
506
+ * Find the actual text content position in the source for a text node.
507
+ * AST text node positions can extend beyond the actual text into adjacent elements.
508
+ * We locate the text value by searching near the AST position.
509
+ */
510
+ function findTextBounds(source: string, node: AstNode): { start: number; end: number } | null {
511
+ const value = node.value
512
+ if (!value) return null
513
+ const trimmed = value.trim()
514
+ if (!trimmed) return null
515
+
516
+ const approxStart = node.position?.start?.offset
517
+ if (approxStart == null) return null
518
+
519
+ // Search for the trimmed text near the AST position
520
+ const searchStart = Math.max(0, approxStart - 20)
521
+ const idx = source.indexOf(trimmed, searchStart)
522
+ if (idx === -1 || idx > approxStart + 50) return null
523
+
524
+ // Include surrounding whitespace up to parent element boundaries
525
+ let start = idx
526
+ while (start > 0 && /\s/.test(source[start - 1]!) && source[start - 1] !== '>') {
527
+ start--
528
+ }
529
+ let end = idx + trimmed.length
530
+ while (end < source.length && /\s/.test(source[end]!) && source[end] !== '<') {
531
+ end++
532
+ }
533
+
534
+ return { start, end }
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Individual patching strategies
539
+ // ---------------------------------------------------------------------------
540
+
541
+ /** Recursively get all text content from an element (including child elements). */
542
+ function getElementTextContent(node: AstNode): string {
543
+ if (node.type === 'text') return node.value ?? ''
544
+ if (node.type === 'expression') {
545
+ return (node.children ?? []).filter(c => c.type === 'text').map(c => c.value ?? '').join('')
546
+ }
547
+ return (node.children ?? []).map(getElementTextContent).join('')
548
+ }
549
+
550
+ /**
551
+ * Patch a mixed-content element as a rich text field.
552
+ * Adds data-sk-field + set:html to the element, keeping original HTML as fallback.
553
+ * The live editor uses the RTE (contentEditable) for this field.
554
+ */
555
+ function patchMixedContentField(
556
+ source: string,
557
+ sectionKey: string,
558
+ field: PatchField,
559
+ varName: string,
560
+ element: AstNode,
561
+ edits: Edit[],
562
+ ): void {
563
+ const bindingKey = `${sectionKey}.${field.key}`
564
+
565
+ // Skip if already has data-sk-field
566
+ if (element.attributes?.some(a => a.name === 'data-sk-field')) return
567
+
568
+ // Find the element's opening tag in source
569
+ const startOffset = element.position?.start?.offset
570
+ if (startOffset == null) return
571
+ const tagName = element.name
572
+ if (!tagName) return
573
+
574
+ const searchStart = Math.max(0, startOffset - 10)
575
+ const tagPattern = `<${tagName}`
576
+ const tagIdx = source.indexOf(tagPattern, searchStart)
577
+ if (tagIdx === -1 || tagIdx > startOffset + 10) return
578
+
579
+ // Find > of opening tag
580
+ const openTagEnd = findOpeningTagEnd(source, tagIdx)
581
+ if (openTagEnd === -1) return
582
+
583
+ // Find closing tag
584
+ const endOffset = element.position?.end?.offset
585
+ if (endOffset == null) return
586
+ const closeTag = `</${tagName}>`
587
+ const closeIdx = source.lastIndexOf(closeTag, endOffset)
588
+ if (closeIdx === -1 || closeIdx <= openTagEnd) return
589
+
590
+ // Get original innerHTML for the fallback
591
+ const innerStart = openTagEnd + 1
592
+ const innerHTML = source.slice(innerStart, closeIdx).trim()
593
+ const escapedHTML = innerHTML.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
594
+
595
+ // Add data-sk-field + set:html attributes
596
+ edits.push({
597
+ offset: openTagEnd,
598
+ deleteCount: 0,
599
+ insert: ` data-sk-field="${bindingKey}" set:html={${varName}?.${field.key} ?? \`${escapedHTML}\`}`,
600
+ })
601
+
602
+ // Remove inner content (set:html replaces it at render time)
603
+ edits.push({
604
+ offset: innerStart,
605
+ deleteCount: closeIdx - innerStart,
606
+ insert: '',
607
+ })
608
+ }
609
+
610
+ function patchCmsBoundField(
611
+ source: string,
612
+ sectionKey: string,
613
+ field: PatchField,
614
+ cmsExpr: { node: AstNode; parent: AstNode },
615
+ edits: Edit[],
616
+ ): void {
617
+ const bindingKey = `${sectionKey}.${field.key}`
618
+ const parentEl = cmsExpr.parent
619
+
620
+ // Check if parent already has data-sk-field
621
+ if (isElement(parentEl) && parentEl.attributes) {
622
+ for (const attr of parentEl.attributes) {
623
+ if (attr.name === 'data-sk-field') return
624
+ }
625
+
626
+ // If parent has only this expression + whitespace as children, add attr to parent
627
+ const meaningfulChildren = (parentEl.children ?? []).filter(
628
+ (c) => !(c.type === 'text' && !c.value?.trim()),
629
+ )
630
+ if (meaningfulChildren.length === 1 && meaningfulChildren[0] === cmsExpr.node) {
631
+ addAttributeToElement(source, parentEl, `data-sk-field="${bindingKey}"`, edits)
632
+ return
633
+ }
634
+ }
635
+
636
+ // Wrap expression in <span data-sk-field="...">
637
+ // Use source-level search to find actual {…} bounds (AST positions are unreliable)
638
+ const bounds = findExpressionBounds(source, cmsExpr.node)
639
+ if (!bounds) return
640
+
641
+ const exprSource = source.slice(bounds.start, bounds.end)
642
+ edits.push({
643
+ offset: bounds.start,
644
+ deleteCount: bounds.end - bounds.start,
645
+ insert: `<span data-sk-field="${bindingKey}">${exprSource}</span>`,
646
+ })
647
+ }
648
+
649
+ function patchTextField(
650
+ source: string,
651
+ sectionKey: string,
652
+ field: PatchField,
653
+ varName: string,
654
+ textNodes: Array<{ node: AstNode; parent: AstNode }>,
655
+ edits: Edit[],
656
+ ): void {
657
+ const defaultValue = field.defaultValue as string
658
+ const bindingKey = `${sectionKey}.${field.key}`
659
+ const cmsExpr = `{${varName}?.${field.key}}`
660
+
661
+ // Find ALL text nodes matching this field's default value (whitespace-normalized comparison).
662
+ // Multiple nodes can share the same value (e.g. h1 + breadcrumb span both showing "Architektur").
663
+ // All of them should be bound to the same CMS field.
664
+ const normalizedDefault = normalizeWs(defaultValue)
665
+ const matches = textNodes.filter((t) => normalizeWs(t.node.value ?? '') === normalizedDefault)
666
+ if (matches.length === 0) return
667
+
668
+ for (const match of matches) {
669
+ const textNode = match.node
670
+ const parentEl = match.parent
671
+
672
+ // Use source-level search to find actual text bounds
673
+ const bounds = findTextBounds(source, textNode)
674
+ if (!bounds) continue
675
+ const { start, end } = bounds
676
+
677
+ // Skip if this offset is already covered by a previous edit in this batch
678
+ if (edits.some((e) => e.offset === start)) continue
679
+
680
+ // Check if text is sole meaningful content of parent element
681
+ if (isElement(parentEl)) {
682
+ const meaningfulChildren = (parentEl.children ?? []).filter(
683
+ (c) => !(c.type === 'text' && !c.value?.trim()),
684
+ )
685
+ const isSoleContent = meaningfulChildren.length === 1 && meaningfulChildren[0] === textNode
686
+
687
+ if (isSoleContent) {
688
+ // Check parent doesn't already have data-sk-field
689
+ const hasBinding = parentEl.attributes?.some((a) => a.name === 'data-sk-field')
690
+ if (!hasBinding) {
691
+ addAttributeToElement(source, parentEl, `data-sk-field="${bindingKey}"`, edits)
692
+ }
693
+ // Replace text content with CMS expression (preserve surrounding whitespace)
694
+ const originalText = source.slice(start, end)
695
+ const leadingWs = originalText.match(/^(\s*)/)?.[1] ?? ''
696
+ const trailingWs = originalText.match(/(\s*)$/)?.[1] ?? ''
697
+ edits.push({
698
+ offset: start,
699
+ deleteCount: end - start,
700
+ insert: `${leadingWs}${cmsExpr}${trailingWs}`,
701
+ })
702
+ continue
703
+ }
704
+ }
705
+
706
+ // Mixed content: wrap text in <span data-sk-field="...">
707
+ const originalText = source.slice(start, end)
708
+ const leadingWs = originalText.match(/^(\s*)/)?.[1] ?? ''
709
+ const trailingWs = originalText.match(/(\s*)$/)?.[1] ?? ''
710
+ edits.push({
711
+ offset: start,
712
+ deleteCount: end - start,
713
+ insert: `${leadingWs}<span data-sk-field="${bindingKey}">${cmsExpr}</span>${trailingWs}`,
714
+ })
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Replace a frontmatter variable expression {varName} with a CMS expression
720
+ * {skData?.fieldKey ?? 'defaultValue'} and add data-sk-field to the parent.
721
+ */
722
+ function patchVarExpression(
723
+ source: string,
724
+ sectionKey: string,
725
+ field: PatchField,
726
+ cmsVarName: string,
727
+ varExpr: { node: AstNode; parent: AstNode },
728
+ edits: Edit[],
729
+ ): void {
730
+ const bindingKey = `${sectionKey}.${field.key}`
731
+ const bounds = findExpressionBounds(source, varExpr.node)
732
+ if (!bounds) return
733
+
734
+ // Build CMS expression (no fallback — CMS is the source of truth)
735
+ const cmsExpr = `{${cmsVarName}?.${field.key}}`
736
+
737
+ const parentEl = varExpr.parent
738
+
739
+ // If parent element has only this expression as meaningful content, add data-sk-field to parent
740
+ if (isElement(parentEl)) {
741
+ const meaningfulChildren = (parentEl.children ?? []).filter(
742
+ (c) => !(c.type === 'text' && !c.value?.trim()),
743
+ )
744
+ if (meaningfulChildren.length === 1 && meaningfulChildren[0] === varExpr.node) {
745
+ const hasBinding = parentEl.attributes?.some((a) => a.name === 'data-sk-field')
746
+ if (!hasBinding) {
747
+ addAttributeToElement(source, parentEl, `data-sk-field="${bindingKey}"`, edits)
748
+ }
749
+ edits.push({ offset: bounds.start, deleteCount: bounds.end - bounds.start, insert: cmsExpr })
750
+ return
751
+ }
752
+ }
753
+
754
+ // Mixed content: wrap in <span data-sk-field>
755
+ edits.push({
756
+ offset: bounds.start,
757
+ deleteCount: bounds.end - bounds.start,
758
+ insert: `<span data-sk-field="${bindingKey}">${cmsExpr}</span>`,
759
+ })
760
+ }
761
+
762
+ function patchHrefField(
763
+ source: string,
764
+ _sectionKey: string,
765
+ field: PatchField,
766
+ varName: string,
767
+ hrefMatch: { attr: AstAttr; element: AstNode },
768
+ edits: Edit[],
769
+ ): void {
770
+ const attr = hrefMatch.attr
771
+ const approxStart = attr.position?.start?.offset
772
+ if (approxStart == null) return
773
+
774
+ const rawAttr = attr.raw ?? `"${attr.value}"`
775
+ // Find the full href="..." in the source near the attribute position
776
+ const hrefStr = `href=${rawAttr}`
777
+ const searchStart = Math.max(0, approxStart - 20)
778
+ const actualOffset = source.indexOf(hrefStr, searchStart)
779
+ if (actualOffset === -1) return
780
+
781
+ edits.push({
782
+ offset: actualOffset,
783
+ deleteCount: hrefStr.length,
784
+ insert: `href={${varName}?.${field.key}}`,
785
+ })
786
+ }
787
+
788
+ function patchArrayField(
789
+ source: string,
790
+ sectionKey: string,
791
+ field: PatchField,
792
+ varName: string,
793
+ mapExpressions: Array<{ node: AstNode }>,
794
+ edits: Edit[],
795
+ ): void {
796
+ if (mapExpressions.length === 0) return
797
+
798
+ // Find the .map() expression that belongs to this field's data.
799
+ // With multiple inline arrays (e.g. packages + design-principles), always using
800
+ // mapExpressions[0] causes overlapping edits that corrupt both arrays.
801
+ // Match by looking for the field's first item value inside each expression's source.
802
+ let mapExpr = mapExpressions[0]!
803
+
804
+ if (mapExpressions.length > 1 && Array.isArray(field.defaultValue) && field.defaultValue.length > 0) {
805
+ const firstItem = field.defaultValue[0]
806
+ const searchStr = typeof firstItem === 'string'
807
+ ? firstItem.slice(0, 30)
808
+ : typeof firstItem === 'object' && firstItem !== null
809
+ ? Object.values(firstItem as Record<string, unknown>)
810
+ .find((v): v is string => typeof v === 'string' && v.length >= 3)
811
+ ?.slice(0, 30) ?? ''
812
+ : ''
813
+
814
+ if (searchStr.length >= 3) {
815
+ for (const expr of mapExpressions) {
816
+ const exprBounds = findExpressionBounds(source, expr.node)
817
+ if (!exprBounds) continue
818
+ const exprSrc = source.slice(exprBounds.start, exprBounds.end)
819
+ if (exprSrc.includes(searchStr)) {
820
+ mapExpr = expr
821
+ break
822
+ }
823
+ }
824
+ }
825
+ }
826
+
827
+ // Find actual expression bounds in source
828
+ const bounds = findExpressionBounds(source, mapExpr.node)
829
+ if (!bounds) return
830
+
831
+ const exprSource = source.slice(bounds.start, bounds.end)
832
+
833
+ // Parse callback parameters from .map((param) => or .map((param, index) =>
834
+ const mapParamRegex = /\.map\s*\(\s*\(?\s*(\w+)(\s*,\s*(\w+))?\s*\)?/
835
+ const paramMatch = exprSource.match(mapParamRegex)
836
+ if (!paramMatch) return
837
+
838
+ const itemParam = paramMatch[1]!
839
+ let indexParam = paramMatch[3]
840
+
841
+ // Add index parameter if missing
842
+ if (!indexParam) {
843
+ indexParam = '_i'
844
+ // Find the callback signature in the source and add index param
845
+ const sigRegex = new RegExp(`\\.map\\s*\\(\\s*\\(?\\s*${itemParam}(\\s*\\)?)`)
846
+ const sigMatch = exprSource.match(sigRegex)
847
+ if (sigMatch) {
848
+ const sigOffset = bounds.start + exprSource.indexOf(sigMatch[0])
849
+ const insertPoint = sigOffset + sigMatch[0].length - (sigMatch[1]?.length ?? 0)
850
+ edits.push({
851
+ offset: insertPoint,
852
+ deleteCount: 0,
853
+ insert: `, ${indexParam}`,
854
+ })
855
+ }
856
+ }
857
+
858
+ // Replace the array source with CMS variable (empty array fallback for .map() safety)
859
+ // Case 1: Inline array literal — ['a', 'b'].map(… → (var?.field ?? []).map(…
860
+ // Case 2: Variable reference — testimonials.map(… → (var?.field ?? []).map(…
861
+ const arrayAndMapRegex = /(\[[\s\S]*?\])\s*\.map\s*\(/
862
+ const arrayMatch = exprSource.match(arrayAndMapRegex)
863
+ if (arrayMatch) {
864
+ const arrayLiteral = arrayMatch[1]!
865
+ const arrayStartInExpr = exprSource.indexOf(arrayLiteral)
866
+ const arrayEndInExpr = arrayStartInExpr + arrayLiteral.length
867
+ const arrayStart = bounds.start + arrayStartInExpr
868
+ const arrayEnd = bounds.start + arrayEndInExpr
869
+
870
+ edits.push({
871
+ offset: arrayStart,
872
+ deleteCount: arrayEnd - arrayStart,
873
+ insert: `(${varName}?.${field.key} ?? [])`,
874
+ })
875
+ } else {
876
+ // Try variable reference: varName.map( → (cmsVar?.field ?? []).map(
877
+ const varMapRegex = new RegExp(`(\\w+)\\.map\\s*\\(`)
878
+ const varMapMatch = exprSource.match(varMapRegex)
879
+ if (varMapMatch) {
880
+ const varRef = varMapMatch[1]!
881
+ const varStartInExpr = exprSource.indexOf(varMapMatch[0])
882
+ const varEndInExpr = varStartInExpr + varRef.length
883
+ const varStart = bounds.start + varStartInExpr
884
+ const varEnd = bounds.start + varEndInExpr
885
+
886
+ edits.push({
887
+ offset: varStart,
888
+ deleteCount: varEnd - varStart,
889
+ insert: `(${varName}?.${field.key} ?? [])`,
890
+ })
891
+ }
892
+ }
893
+
894
+ // Find first child element inside the .map() expression and add data-sk-field
895
+ const firstChildElement = findFirstChildElement(mapExpr.node)
896
+ if (firstChildElement) {
897
+ const hasSkField = firstChildElement.attributes?.some((a) => a.name === 'data-sk-field')
898
+ if (!hasSkField) {
899
+ const skFieldExpr = `data-sk-field={\`${sectionKey}.${field.key}.\${${indexParam}}\`}`
900
+ addAttributeToElement(source, firstChildElement, skFieldExpr, edits)
901
+ }
902
+ }
903
+
904
+ // Add data-sk-field to inner elements that reference item properties (e.g. {t.quote})
905
+ // This enables live DOM patching for individual fields within array items.
906
+ patchArrayItemProperties(source, sectionKey, field, itemParam, indexParam, mapExpr.node, edits)
907
+ }
908
+
909
+ /**
910
+ * Convert a static <ul>/<ol> with hardcoded <li> items into a CMS-driven .map() loop.
911
+ * Matches the <ul> by comparing its <li> text content against field.defaultValue.
912
+ * Replaces all <li> children with a single {(...).map()} expression.
913
+ */
914
+ function patchStaticListField(
915
+ source: string,
916
+ sectionKey: string,
917
+ field: PatchField,
918
+ varName: string,
919
+ ast: AstNode,
920
+ edits: Edit[],
921
+ ): void {
922
+ if (!Array.isArray(field.defaultValue) || field.defaultValue.length === 0) return
923
+ const items = field.defaultValue as string[]
924
+
925
+ // Find the <ul>/<ol> whose static <li> texts exactly match the field's defaultValue
926
+ let matchedUl: AstNode | null = null
927
+ walkAst(ast, (node) => {
928
+ if (matchedUl) return
929
+ if (node.type !== 'element') return
930
+ if (node.name !== 'ul' && node.name !== 'ol') return
931
+ // Skip if already has .map() inside
932
+ let hasMap = false
933
+ walkAst(node, (n) => {
934
+ if (hasMap) return
935
+ if (n.type === 'expression') {
936
+ const code = (n.children ?? []).map(c => c.value ?? '').join('')
937
+ if (/\.map\s*\(/.test(code)) hasMap = true
938
+ }
939
+ })
940
+ if (hasMap) return
941
+ // Compare <li> text content against field items.
942
+ // items[i] may contain HTML (if formatting: true) — strip tags for comparison.
943
+ const stripHtml = (s: string) => s.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
944
+ const liChildren = (node.children ?? []).filter(c => c.type === 'element' && c.name === 'li')
945
+ if (liChildren.length !== items.length) return
946
+ const allMatch = liChildren.every((li, i) => {
947
+ const text = getElementTextContent(li).replace(/\s+/g, ' ').trim()
948
+ return text === stripHtml(items[i]!)
949
+ })
950
+ if (allMatch) matchedUl = node
951
+ })
952
+
953
+ if (!matchedUl) return
954
+
955
+ const ulNode = matchedUl as AstNode
956
+ const liChildren = (ulNode.children ?? []).filter(
957
+ c => c.type === 'element' && c.name === 'li',
958
+ ) as AstNode[]
959
+ if (liChildren.length === 0) return
960
+
961
+ const firstLi = liChildren[0]!
962
+ const lastLi = liChildren[liChildren.length - 1]!
963
+ const rangeStart = firstLi.position?.start?.offset
964
+ const rangeEnd = lastLi.position?.end?.offset
965
+ if (rangeStart == null || rangeEnd == null) return
966
+
967
+ // Build the <li> template from the first <li>'s source
968
+ const firstLiEnd = firstLi.position?.end?.offset ?? rangeEnd
969
+ let liTemplate = source.slice(rangeStart, firstLiEnd)
970
+
971
+ // Add data-sk-field to the <li> opening tag (before the first `>`)
972
+ const liTagEnd = liTemplate.indexOf('>')
973
+ if (liTagEnd === -1) return
974
+ const bindingKey = `${sectionKey}.${field.key}`
975
+ liTemplate =
976
+ liTemplate.slice(0, liTagEnd) +
977
+ ` data-sk-field={\`${bindingKey}.\${_i}\`}>` +
978
+ liTemplate.slice(liTagEnd + 1)
979
+
980
+ // Replace the content span with set:html={item}.
981
+ // Handles both plain-text spans and spans with nested elements (<strong>, <em>, etc.).
982
+ // Bullet-dot spans (empty content) are left untouched.
983
+ liTemplate = liTemplate.replace(
984
+ /<span([^>]*)>([\s\S]*?)<\/span>/g,
985
+ (_m, attrs: string, content: string) => {
986
+ // Skip empty spans (bullet dots) and spans with CMS attributes already
987
+ if (!content.trim()) return _m
988
+ if (attrs.includes('set:html') || attrs.includes('data-sk-field')) return _m
989
+ return `<span${attrs} set:html={item}></span>`
990
+ },
991
+ )
992
+
993
+ // Build the fallback array literal.
994
+ // HTML items (formatting: true) are wrapped in backtick strings to avoid escaping issues.
995
+ const hasHtmlItems = items.some(s => /<[a-z]/.test(s))
996
+ const fallbackItems = hasHtmlItems
997
+ ? items.map(s => `\`${s.replace(/`/g, '\\`').replace(/\$/g, '\\$')}\``).join(', ')
998
+ : items.map(s => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`).join(', ')
999
+
1000
+ // Determine indentation of the first <li> for formatting
1001
+ const textBeforeFirstLi = source.slice(0, rangeStart)
1002
+ const lastNewline = textBeforeFirstLi.lastIndexOf('\n')
1003
+ const indent = lastNewline >= 0 ? textBeforeFirstLi.slice(lastNewline + 1).match(/^[ \t]*/)?.[0] ?? '' : ''
1004
+
1005
+ const replacement =
1006
+ `{(${varName}?.${field.key} ?? [${fallbackItems}]).map((item, _i) => (\n` +
1007
+ `${indent}${liTemplate}\n` +
1008
+ `${indent}))}`
1009
+
1010
+ edits.push({
1011
+ offset: rangeStart,
1012
+ deleteCount: rangeEnd - rangeStart,
1013
+ insert: replacement,
1014
+ })
1015
+ }
1016
+
1017
+ /**
1018
+ * Walk the children of a .map() expression and add data-sk-field attributes
1019
+ * to elements that contain property access expressions like {t.quote}.
1020
+ * This enables live DOM patching for individual fields within array items.
1021
+ */
1022
+ function patchArrayItemProperties(
1023
+ source: string,
1024
+ sectionKey: string,
1025
+ field: PatchField,
1026
+ itemParam: string,
1027
+ indexParam: string,
1028
+ mapNode: AstNode,
1029
+ edits: Edit[],
1030
+ ): void {
1031
+ // Walk all nodes inside the .map() expression
1032
+ walkAst(mapNode, (node, parent) => {
1033
+ if (!parent || node.type !== 'expression' || !node.children) return
1034
+
1035
+ // Get expression text
1036
+ const exprText = node.children
1037
+ .filter((c) => c.type === 'text')
1038
+ .map((c) => c.value ?? '')
1039
+ .join('')
1040
+ .trim()
1041
+
1042
+ // Check for nested .map() call: e.g. {section.items.map((item) => ...)}
1043
+ // These need separate handling to add _j index + inner data-sk-field bindings.
1044
+ const nestedMapMatch = exprText.match(new RegExp(`^${itemParam}\\.(\\w+)\\.map\\s*\\(`))
1045
+ if (nestedMapMatch) {
1046
+ const innerFieldKey = nestedMapMatch[1]!
1047
+ patchNestedMap(source, sectionKey, field.key, innerFieldKey, indexParam, node, edits)
1048
+ return
1049
+ }
1050
+
1051
+ // Match property access: itemParam.propName or itemParam.nested.prop
1052
+ // e.g. t.quote, t.author, t.address.street
1053
+ const propMatch = exprText.match(new RegExp(`^${itemParam}\\.(\\w+(?:\\.\\w+)*)$`))
1054
+ if (!propMatch) return
1055
+
1056
+ const propPath = propMatch[1]!
1057
+ const bindingKey = `${sectionKey}.${field.key}.\${${indexParam}}.${propPath}`
1058
+
1059
+ // Check if parent element already has data-sk-field
1060
+ if (!isElement(parent) || !parent.attributes) return
1061
+ if (parent.attributes.some((a) => a.name === 'data-sk-field')) return
1062
+
1063
+ // If parent has only this expression (+ whitespace) as content, add attr to parent
1064
+ const meaningfulChildren = (parent.children ?? []).filter(
1065
+ (c) => !(c.type === 'text' && !c.value?.trim()),
1066
+ )
1067
+ if (meaningfulChildren.length === 1 && meaningfulChildren[0] === node) {
1068
+ const skFieldExpr = `data-sk-field={\`${bindingKey}\`}`
1069
+ addAttributeToElement(source, parent, skFieldExpr, edits)
1070
+ return
1071
+ }
1072
+
1073
+ // Mixed content: wrap expression in <span data-sk-field>
1074
+ const bounds = findExpressionBounds(source, node)
1075
+ if (!bounds) return
1076
+ const exprSource = source.slice(bounds.start, bounds.end)
1077
+ edits.push({
1078
+ offset: bounds.start,
1079
+ deleteCount: bounds.end - bounds.start,
1080
+ insert: `<span data-sk-field={\`${bindingKey}\`}>${exprSource}</span>`,
1081
+ })
1082
+ })
1083
+ }
1084
+
1085
+ /**
1086
+ * Patch a nested .map() call inside an outer array map.
1087
+ * Handles patterns like: {section.items.map((item) => (<a>...</a>))}
1088
+ *
1089
+ * Adds:
1090
+ * - _j index parameter to the inner .map() callback
1091
+ * - data-sk-field container binding on the first child element
1092
+ * - data-sk-field bindings on individual inner field elements
1093
+ *
1094
+ * Path format: sectionKey.outerField.${_i}.innerField.${_j}.propName
1095
+ */
1096
+ function patchNestedMap(
1097
+ source: string,
1098
+ sectionKey: string,
1099
+ outerFieldKey: string,
1100
+ innerFieldKey: string,
1101
+ outerIndexParam: string,
1102
+ exprNode: AstNode,
1103
+ edits: Edit[],
1104
+ ): void {
1105
+ const bounds = findExpressionBounds(source, exprNode)
1106
+ if (!bounds) return
1107
+ const exprSrc = source.slice(bounds.start, bounds.end)
1108
+
1109
+ // Parse inner .map() callback parameter
1110
+ const mapParamRegex = /\.map\s*\(\s*\(?\s*(\w+)(\s*,\s*(\w+))?\s*\)?/
1111
+ const paramMatch = exprSrc.match(mapParamRegex)
1112
+ if (!paramMatch) return
1113
+
1114
+ const innerItemParam = paramMatch[1]!
1115
+ let innerIndexParam = paramMatch[3]
1116
+
1117
+ // Add _j index if missing
1118
+ if (!innerIndexParam) {
1119
+ innerIndexParam = '_j'
1120
+ const sigRegex = new RegExp(`\\.map\\s*\\(\\s*\\(?\\s*${innerItemParam}(\\s*\\)?)`)
1121
+ const sigMatch = exprSrc.match(sigRegex)
1122
+ if (sigMatch) {
1123
+ const insertOffset =
1124
+ bounds.start +
1125
+ exprSrc.indexOf(sigMatch[0]) +
1126
+ sigMatch[0].length -
1127
+ (sigMatch[1]?.length ?? 0)
1128
+ edits.push({ offset: insertOffset, deleteCount: 0, insert: `, ${innerIndexParam}` })
1129
+ }
1130
+ }
1131
+
1132
+ // Base path for inner item bindings
1133
+ const basePath = `${sectionKey}.${outerFieldKey}.\${${outerIndexParam}}.${innerFieldKey}.\${${innerIndexParam}}`
1134
+
1135
+ // Add container data-sk-field to the first child element of the inner map
1136
+ const firstChild = findFirstChildElement(exprNode)
1137
+ if (firstChild) {
1138
+ const hasSkField = firstChild.attributes?.some((a) => a.name === 'data-sk-field')
1139
+ if (!hasSkField) {
1140
+ addAttributeToElement(source, firstChild, `data-sk-field={\`${basePath}\`}`, edits)
1141
+ }
1142
+ }
1143
+
1144
+ // Walk inner map content and add data-sk-field to individual field elements
1145
+ walkAst(exprNode, (node, parent) => {
1146
+ if (!parent || node.type !== 'expression' || !node.children) return
1147
+
1148
+ const exprText = node.children
1149
+ .filter((c) => c.type === 'text')
1150
+ .map((c) => c.value ?? '')
1151
+ .join('')
1152
+ .trim()
1153
+
1154
+ const propMatch = exprText.match(new RegExp(`^${innerItemParam}\\.(\\w+(?:\\.\\w+)*)$`))
1155
+ if (!propMatch) return
1156
+
1157
+ const propPath = propMatch[1]!
1158
+ const bindingKey = `${basePath}.${propPath}`
1159
+
1160
+ if (!isElement(parent) || !parent.attributes) return
1161
+ if (parent.attributes.some((a) => a.name === 'data-sk-field')) return
1162
+
1163
+ const meaningfulChildren = (parent.children ?? []).filter(
1164
+ (c) => !(c.type === 'text' && !c.value?.trim()),
1165
+ )
1166
+ if (meaningfulChildren.length === 1 && meaningfulChildren[0] === node) {
1167
+ addAttributeToElement(source, parent, `data-sk-field={\`${bindingKey}\`}`, edits)
1168
+ return
1169
+ }
1170
+
1171
+ // Mixed content: wrap the expression in a span with data-sk-field
1172
+ const nodeBounds = findExpressionBounds(source, node)
1173
+ if (!nodeBounds) return
1174
+ const nodeSrc = source.slice(nodeBounds.start, nodeBounds.end)
1175
+ edits.push({
1176
+ offset: nodeBounds.start,
1177
+ deleteCount: nodeBounds.end - nodeBounds.start,
1178
+ insert: `<span data-sk-field={\`${bindingKey}\`}>${nodeSrc}</span>`,
1179
+ })
1180
+ })
1181
+ }
1182
+
1183
+ // ---------------------------------------------------------------------------
1184
+ // Repeated element group patching
1185
+ // ---------------------------------------------------------------------------
1186
+
1187
+ /**
1188
+ * Collapse repeated HTML elements (e.g. 3× <article>) into a single .map() loop.
1189
+ *
1190
+ * Uses pre-computed RepeatedGroup data from the analyzer — no own detection.
1191
+ * For each group:
1192
+ * 1. Extract template instance source (instance 0)
1193
+ * 2. Replace inner field values with {item.fieldKey}
1194
+ * 3. Rewrite nested .map() sources (e.g. starterFeatures → item.list)
1195
+ * 4. Wrap optional fields in conditional rendering
1196
+ * 5. Make differing CSS classes dynamic (item._classN)
1197
+ * 6. Wrap in .map() with data-sk-field binding
1198
+ * 7. Delete instances 1..N
1199
+ */
1200
+
1201
+ /**
1202
+ * Detect CSS classes that differ between instances and collect edits.
1203
+ * Uses AST-based classAttrs (collected by the analyzer) to match elements
1204
+ * across instances by structural path. Adds edits to the innerEdits array
1205
+ * so they're applied together with text edits in reverse order.
1206
+ */
1207
+ function collectDynamicClassEdits(
1208
+ innerEdits: Array<{ offset: number; deleteCount: number; insert: string }>,
1209
+ source: string,
1210
+ instances: Array<{ start: number; end: number }>,
1211
+ tmpl: { start: number; end: number },
1212
+ group: RepeatedGroup,
1213
+ ): void {
1214
+ const classAttrs = group.classAttrs
1215
+ if (!classAttrs || classAttrs.length < 2 || instances.length < 2) return
1216
+
1217
+ const tmplAttrs = classAttrs[0]! // class attrs of template instance (instance 0)
1218
+ if (tmplAttrs.length === 0) return
1219
+
1220
+ // Build lookup structures for each non-template instance
1221
+ const otherInstAttrs = classAttrs.slice(1)
1222
+
1223
+ /** Find the best matching class attr in another instance for a given template attr.
1224
+ * All strategies validate with CSS overlap to handle optional elements
1225
+ * that shift positional indices (e.g. badge wrapper in Pro pricing card).
1226
+ *
1227
+ * Key insight: a correct match ALWAYS shares at least 1 CSS class.
1228
+ * Zero overlap means the path matched a structurally different element
1229
+ * (an optional element shifted indices).
1230
+ *
1231
+ * 1. Exact path match — only if at least 1 shared class
1232
+ * 2. Same tag-path (without indices) — pick best CSS overlap among candidates
1233
+ * If neither matches, the element doesn't exist in this instance → null (use template default)
1234
+ */
1235
+ function findMatch(
1236
+ tmplAttr: { path: string; value: string },
1237
+ instAttrs: Array<{ path: string; value: string }>,
1238
+ claimed: Set<number>,
1239
+ ): string | null {
1240
+ const tmplParts = new Set(tmplAttr.value.split(/\s+/).filter(Boolean))
1241
+
1242
+ function sharedClassCount(instValue: string): number {
1243
+ const instParts = new Set(instValue.split(/\s+/).filter(Boolean))
1244
+ if (tmplParts.size === 0 && instParts.size === 0) return 1 // both empty = match
1245
+ return [...tmplParts].filter(p => instParts.has(p)).length
1246
+ }
1247
+
1248
+ // 1. Exact path — only accept if at least 1 CSS class is shared
1249
+ for (let i = 0; i < instAttrs.length; i++) {
1250
+ if (!claimed.has(i) && instAttrs[i]!.path === tmplAttr.path) {
1251
+ if (sharedClassCount(instAttrs[i]!.value) > 0) {
1252
+ claimed.add(i)
1253
+ return instAttrs[i]!.value
1254
+ }
1255
+ }
1256
+ }
1257
+
1258
+ // 2. Same tag-path (without indices) — pick the one with most shared classes
1259
+ const stripIdx = (p: string) => p.split('/').map(s => s.replace(/:\d+$/, '')).join('/')
1260
+ const tmplTagPath = stripIdx(tmplAttr.path)
1261
+ let bestIdx = -1
1262
+ let bestShared = 0
1263
+ for (let i = 0; i < instAttrs.length; i++) {
1264
+ if (claimed.has(i)) continue
1265
+ if (stripIdx(instAttrs[i]!.path) !== tmplTagPath) continue
1266
+ const shared = sharedClassCount(instAttrs[i]!.value)
1267
+ if (shared > bestShared) { bestShared = shared; bestIdx = i }
1268
+ }
1269
+ if (bestIdx >= 0 && bestShared > 0) {
1270
+ claimed.add(bestIdx)
1271
+ return instAttrs[bestIdx]!.value
1272
+ }
1273
+
1274
+ // No cross-tag fallback: if exact-path and tag-path don't match, the element
1275
+ // likely doesn't exist in this instance (e.g. optional "/ Monat" span missing
1276
+ // in Business). Return null → caller uses template value as default.
1277
+ return null
1278
+ }
1279
+
1280
+ // Build skip map: for each template class attr, which non-template instances
1281
+ // should skip matching because the attr's element corresponds to an optional
1282
+ // field that is missing in that instance.
1283
+ // Without this, findMatch greedily grabs a wrong class attr (e.g. the description
1284
+ // class for the missing "/ Monat" span), causing a cascade of misassignments.
1285
+ const skipForInstance: Array<Set<number>> = tmplAttrs.map(() => new Set())
1286
+ for (const field of group.fields) {
1287
+ if (field.required) continue
1288
+ const tmplPos = field.positions[0]
1289
+ if (!tmplPos) continue // field not in template (injected from other instance)
1290
+
1291
+ // Find the template class attr on the same element as this field.
1292
+ // Match by leaf tag (path ending) + smallest absolute distance.
1293
+ // We can't rely on "class attr before text" because byte-to-char offset
1294
+ // adjustments can put the class attr offset slightly after the text offset.
1295
+ const fieldTag = field.tag
1296
+ let bestAttrIdx = -1
1297
+ let bestDist = Infinity
1298
+ for (let ai = 0; ai < tmplAttrs.length; ai++) {
1299
+ // Extract leaf tag from path: "div:0/div:0/span:1" → "span"
1300
+ const pathParts = tmplAttrs[ai]!.path.split('/')
1301
+ const leafTag = (pathParts[pathParts.length - 1] ?? '').replace(/:\d+$/, '')
1302
+ // Root element (path="") has no leaf tag — skip unless field tag matches group tag
1303
+ if (!leafTag && fieldTag !== group.tag) continue
1304
+ if (leafTag && leafTag !== fieldTag) continue
1305
+
1306
+ const dist = Math.abs(tmplPos.offset - tmplAttrs[ai]!.sourceOffset)
1307
+ if (dist < bestDist) {
1308
+ bestDist = dist
1309
+ bestAttrIdx = ai
1310
+ }
1311
+ }
1312
+ if (bestAttrIdx < 0) continue
1313
+
1314
+ // For each non-template instance where this field is missing, skip matching
1315
+ for (let instIdx = 1; instIdx < instances.length; instIdx++) {
1316
+ if (!field.positions[instIdx]) {
1317
+ skipForInstance[bestAttrIdx]!.add(instIdx - 1) // ii = instIdx - 1
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ // Find which template class attrs differ across instances
1323
+ // Claimed sets track which instance attrs have already been matched (one per instance)
1324
+ const claimedPerInstance = otherInstAttrs.map(() => new Set<number>())
1325
+ let dynamicCount = 0
1326
+
1327
+ for (let ai = 0; ai < tmplAttrs.length; ai++) {
1328
+ const tmplAttr = tmplAttrs[ai]!
1329
+ const values: string[] = [tmplAttr.value]
1330
+ let anyDiffers = false
1331
+
1332
+ for (let ii = 0; ii < otherInstAttrs.length; ii++) {
1333
+ // Skip matching for instances where the optional field is missing
1334
+ if (skipForInstance[ai]!.has(ii)) {
1335
+ values.push(tmplAttr.value)
1336
+ continue
1337
+ }
1338
+ const match = findMatch(tmplAttr, otherInstAttrs[ii]!, claimedPerInstance[ii]!)
1339
+ if (match == null) {
1340
+ values.push(tmplAttr.value)
1341
+ } else {
1342
+ values.push(match)
1343
+ if (match !== tmplAttr.value) anyDiffers = true
1344
+ }
1345
+ }
1346
+
1347
+ if (!anyDiffers) continue
1348
+
1349
+ const relOffset = tmplAttr.sourceOffset - tmpl.start
1350
+ if (relOffset < 0) continue
1351
+
1352
+ const fieldKey = `_class${dynamicCount++}`
1353
+
1354
+ group.fields.push({
1355
+ key: fieldKey,
1356
+ type: 'text',
1357
+ tag: 'class',
1358
+ required: true,
1359
+ positions: instances.map(() => null),
1360
+ defaultValues: values,
1361
+ })
1362
+
1363
+ innerEdits.push({
1364
+ offset: relOffset,
1365
+ deleteCount: tmplAttr.sourceLength,
1366
+ insert: `class={item.${fieldKey}}`,
1367
+ })
1368
+ }
1369
+ }
1370
+
1371
+ function patchRepeatedGroups(
1372
+ source: string,
1373
+ sectionKey: string,
1374
+ varName: string,
1375
+ groups: RepeatedGroup[],
1376
+ ): Edit[] {
1377
+ const edits: Edit[] = []
1378
+
1379
+ for (const group of groups) {
1380
+ const { tag, fieldKey, instances, fields } = group
1381
+ if (instances.length < 2) continue
1382
+
1383
+ // Template instance = first one
1384
+ const tmpl = instances[0]!
1385
+ let templateSrc = source.slice(tmpl.start, tmpl.end)
1386
+
1387
+ // Apply inner field replacements to the template source.
1388
+ // Work in reverse offset order (relative to template start) to keep positions stable.
1389
+ const innerEdits: Array<{ offset: number; deleteCount: number; insert: string }> = []
1390
+
1391
+ // Add dynamic class replacements as inner edits (all edits applied together in reverse order)
1392
+ collectDynamicClassEdits(innerEdits, source, instances, tmpl, group)
1393
+
1394
+ for (const field of fields) {
1395
+ const pos = field.positions[0] // position in template instance
1396
+ if (!pos) continue // field not present in template instance (optional, only in other instances)
1397
+
1398
+ const relOffset = pos.offset - tmpl.start
1399
+ if (relOffset < 0 || relOffset >= templateSrc.length) continue
1400
+
1401
+ if (field.type === 'array' && pos.source) {
1402
+ // Nested .map(): replace variable name with item.fieldKey
1403
+ // e.g. "starterFeatures.map(" → "(item.list ?? []).map("
1404
+ const mapPattern = `${pos.source}.map(`
1405
+ const mapIdx = templateSrc.indexOf(mapPattern)
1406
+ if (mapIdx !== -1) {
1407
+ innerEdits.push({
1408
+ offset: mapIdx,
1409
+ deleteCount: mapPattern.length,
1410
+ insert: `(item.${field.key} ?? []).map(`,
1411
+ })
1412
+ }
1413
+ } else if (field.type === 'link') {
1414
+ // Replace href value: href="/signup" → href={item.link}
1415
+ const fieldSrc = templateSrc.slice(relOffset, relOffset + pos.length)
1416
+ const hrefMatch = fieldSrc.match(/href\s*=\s*"([^"]*)"/)
1417
+ if (hrefMatch) {
1418
+ const hrefStart = relOffset + fieldSrc.indexOf(hrefMatch[0])
1419
+ innerEdits.push({
1420
+ offset: hrefStart,
1421
+ deleteCount: hrefMatch[0].length,
1422
+ insert: `href={item.${field.key}}`,
1423
+ })
1424
+ }
1425
+ } else if (field.type === 'text') {
1426
+ // Replace text content with {item.fieldKey} (whitespace-normalized search)
1427
+ const defaultVal = field.defaultValues[0]
1428
+ if (typeof defaultVal === 'string' && defaultVal.length >= 1) {
1429
+ const searchFrom = relOffset > 10 ? relOffset - 10 : 0
1430
+ const found = findNormalizedText(templateSrc, defaultVal, searchFrom)
1431
+ if (found) {
1432
+ innerEdits.push({
1433
+ offset: found.start,
1434
+ deleteCount: found.end - found.start,
1435
+ insert: `{item.${field.key}}`,
1436
+ })
1437
+ }
1438
+ }
1439
+ }
1440
+ }
1441
+
1442
+ // Handle optional fields: fields present in OTHER instances but not in template.
1443
+ // These need to be injected into the template with conditional rendering.
1444
+ for (const field of fields) {
1445
+ if (field.required) continue
1446
+ const pos = field.positions[0]
1447
+ if (pos) continue // present in template, already handled above
1448
+
1449
+ // Find the first instance that HAS this field to get its source
1450
+ let donorIdx = -1
1451
+ let donorPos = null
1452
+ for (let i = 1; i < instances.length; i++) {
1453
+ if (field.positions[i]) {
1454
+ donorIdx = i
1455
+ donorPos = field.positions[i]!
1456
+ break
1457
+ }
1458
+ }
1459
+ if (!donorPos || donorIdx < 0) continue
1460
+
1461
+ // Extract the element source from the donor instance
1462
+ const donorInst = instances[donorIdx]!
1463
+ const donorSrc = source.slice(donorInst.start, donorInst.end)
1464
+ const donorRelOffset = donorPos.offset - donorInst.start
1465
+ let elementSrc = donorSrc.slice(donorRelOffset, donorRelOffset + donorPos.length)
1466
+
1467
+ // Check if the element has a parent wrapper that only exists in this donor
1468
+ // (e.g. <div class="absolute -top-4 ..."> around a badge <span>).
1469
+ // If so, include the wrapper in the conditional to preserve positioning.
1470
+ const templateSrcForCheck = source.slice(tmpl.start, tmpl.end)
1471
+ const beforeElement = donorSrc.slice(0, donorRelOffset).trimEnd()
1472
+ const parentCloseMatch = beforeElement.match(/<(\w+)\s+[^>]*class="([^"]*)"[^>]*>\s*$/)
1473
+ if (parentCloseMatch) {
1474
+ const parentTag = parentCloseMatch[1]!
1475
+ const parentClass = parentCloseMatch[2]!
1476
+ // Check if this parent class exists in the template instance
1477
+ if (!templateSrcForCheck.includes(`class="${parentClass}"`)) {
1478
+ // Find the parent's opening tag start and its closing tag
1479
+ const parentStart = beforeElement.lastIndexOf(`<${parentTag}`)
1480
+ if (parentStart >= 0) {
1481
+ const closingTag = `</${parentTag}>`
1482
+ const afterElement = donorSrc.slice(donorRelOffset + donorPos.length)
1483
+ const closingIdx = afterElement.indexOf(closingTag)
1484
+ if (closingIdx >= 0) {
1485
+ // Expand elementSrc to include parent wrapper
1486
+ const wrapperEnd = donorRelOffset + donorPos.length + closingIdx + closingTag.length
1487
+ elementSrc = donorSrc.slice(parentStart, wrapperEnd).trim()
1488
+ }
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ // Replace content with item reference (whitespace-normalized search)
1494
+ const defaultVal = field.defaultValues[donorIdx]
1495
+ if (typeof defaultVal === 'string' && defaultVal.length >= 1) {
1496
+ const found = findNormalizedText(elementSrc, defaultVal, 0)
1497
+ if (found) {
1498
+ elementSrc = elementSrc.slice(0, found.start) + `{item.${field.key}}` + elementSrc.slice(found.end)
1499
+ }
1500
+ }
1501
+
1502
+ // Wrap in conditional: {item.fieldKey && <element>...</element>}
1503
+ const conditionalBlock = `{item.${field.key} && ${elementSrc}}`
1504
+
1505
+ // Insert after the opening tag of the template instance
1506
+ const openTagEnd = templateSrc.indexOf('>', 0) + 1
1507
+ if (openTagEnd > 0) {
1508
+ innerEdits.push({
1509
+ offset: openTagEnd,
1510
+ deleteCount: 0,
1511
+ insert: `\n ${conditionalBlock}`,
1512
+ })
1513
+ }
1514
+ }
1515
+
1516
+ // Apply inner edits in reverse order
1517
+ innerEdits.sort((a, b) => b.offset - a.offset)
1518
+ for (const edit of innerEdits) {
1519
+ templateSrc = templateSrc.slice(0, edit.offset) + edit.insert + templateSrc.slice(edit.offset + edit.deleteCount)
1520
+ }
1521
+
1522
+ // Add data-sk-field to the template element's opening tag (container binding)
1523
+ const tagEndIdx = templateSrc.indexOf('>')
1524
+ if (tagEndIdx !== -1) {
1525
+ const skFieldAttr = ` data-sk-field={\`${sectionKey}.${fieldKey}.\${_i}\`}`
1526
+ templateSrc = templateSrc.slice(0, tagEndIdx) + skFieldAttr + templateSrc.slice(tagEndIdx)
1527
+ }
1528
+
1529
+ // Add data-sk-field to inner field elements (individual field bindings)
1530
+ templateSrc = addInnerFieldBindings(templateSrc, sectionKey, fieldKey, fields)
1531
+
1532
+ // Wrap in .map()
1533
+ const indent = detectIndent(source, tmpl.start)
1534
+ const wrapped = `{(${varName}?.${fieldKey} ?? []).map((item, _i) => (\n${indent} ${templateSrc}\n${indent}))}`
1535
+
1536
+ // Edit 1: Replace template instance with wrapped .map()
1537
+ edits.push({
1538
+ offset: tmpl.start,
1539
+ deleteCount: tmpl.end - tmpl.start,
1540
+ insert: wrapped,
1541
+ })
1542
+
1543
+ // Edit 2: Delete instances 1..N (in reverse order to maintain positions)
1544
+ for (let i = instances.length - 1; i >= 1; i--) {
1545
+ const inst = instances[i]!
1546
+ // Also remove surrounding whitespace/newlines
1547
+ let deleteStart = inst.start
1548
+ let deleteEnd = inst.end
1549
+ // Extend backwards to eat preceding whitespace
1550
+ while (deleteStart > 0 && /\s/.test(source[deleteStart - 1]!) && source[deleteStart - 1] !== '\n') {
1551
+ deleteStart--
1552
+ }
1553
+ // Eat the preceding newline too
1554
+ if (deleteStart > 0 && source[deleteStart - 1] === '\n') deleteStart--
1555
+ edits.push({
1556
+ offset: deleteStart,
1557
+ deleteCount: deleteEnd - deleteStart,
1558
+ insert: '',
1559
+ })
1560
+ }
1561
+ }
1562
+
1563
+ return edits
1564
+ }
1565
+
1566
+ /**
1567
+ * Add data-sk-field attributes to inner field elements within a repeated group template.
1568
+ * Searches for {item.fieldKey} references and adds bindings to their enclosing HTML elements.
1569
+ */
1570
+ function addInnerFieldBindings(
1571
+ templateSrc: string,
1572
+ sectionKey: string,
1573
+ groupFieldKey: string,
1574
+ fields: Array<{ key: string; type: string }>,
1575
+ ): string {
1576
+ const inserts: Array<{ offset: number; text: string }> = []
1577
+
1578
+ for (const field of fields) {
1579
+ // Skip arrays (nested .map loops) and links (bound via href attribute)
1580
+ if (field.type === 'array' || field.type === 'link') continue
1581
+
1582
+ const itemRef = `{item.${field.key}}`
1583
+ const idx = templateSrc.indexOf(itemRef)
1584
+ if (idx === -1) continue
1585
+
1586
+ // Search backwards from the expression to find the enclosing opening tag
1587
+ let tagStart = idx - 1
1588
+ while (tagStart >= 0 && templateSrc[tagStart] !== '<') tagStart--
1589
+ if (tagStart < 0 || templateSrc[tagStart + 1] === '/') continue // closing tag
1590
+
1591
+ // Find the > that closes this opening tag (between tagStart and idx)
1592
+ let tagEnd = -1
1593
+ let inQ: string | null = null
1594
+ let inE = 0
1595
+ for (let i = tagStart; i < idx; i++) {
1596
+ const ch = templateSrc[i]!
1597
+ if (inQ) { if (ch === inQ && templateSrc[i - 1] !== '\\') inQ = null; continue }
1598
+ if (ch === '{') { inE++; continue }
1599
+ if (ch === '}' && inE > 0) { inE--; continue }
1600
+ if (inE > 0) continue
1601
+ if (ch === '"' || ch === "'") { inQ = ch; continue }
1602
+ if (ch === '>') tagEnd = i
1603
+ }
1604
+ if (tagEnd === -1) continue
1605
+
1606
+ // Skip if this tag already has data-sk-field
1607
+ const tagStr = templateSrc.slice(tagStart, tagEnd + 1)
1608
+ if (tagStr.includes('data-sk-field')) continue
1609
+
1610
+ inserts.push({
1611
+ offset: tagEnd,
1612
+ text: ` data-sk-field={\`${sectionKey}.${groupFieldKey}.\${_i}.${field.key}\`}`,
1613
+ })
1614
+ }
1615
+
1616
+ // Apply inserts in reverse order to maintain positions
1617
+ inserts.sort((a, b) => b.offset - a.offset)
1618
+ for (const ins of inserts) {
1619
+ templateSrc = templateSrc.slice(0, ins.offset) + ins.text + templateSrc.slice(ins.offset)
1620
+ }
1621
+
1622
+ return templateSrc
1623
+ }
1624
+
1625
+ /** Detect the indentation level at a given source offset. */
1626
+ function detectIndent(source: string, offset: number): string {
1627
+ let lineStart = offset
1628
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--
1629
+ return source.slice(lineStart, offset)
1630
+ }
1631
+
1632
+ // ---------------------------------------------------------------------------
1633
+ // Section ID patching
1634
+ // ---------------------------------------------------------------------------
1635
+
1636
+ /**
1637
+ * Ensure the first element in the template body has id="section-{sectionKey}".
1638
+ * Finds the first <section>, <div>, or any root element after frontmatter and adds
1639
+ * the id attribute if not already present.
1640
+ */
1641
+ function patchSectionId(
1642
+ source: string,
1643
+ ast: AstNode,
1644
+ sectionKey: string,
1645
+ edits: Edit[],
1646
+ options?: { mode?: 'component' | 'page' },
1647
+ ): void {
1648
+ const templateChildren = (ast.children ?? []).filter((c) => c.type !== 'frontmatter')
1649
+
1650
+ let rootElement: AstNode | null = null
1651
+
1652
+ if (options?.mode === 'page') {
1653
+ // Page mode: top-level element is often an Astro component (e.g. <BaseLayout>).
1654
+ // The preview middleware looks for <section> elements, so we need to find the
1655
+ // first real HTML element — even if it's nested inside a component.
1656
+ function findFirstHtmlElement(nodes: AstNode[]): AstNode | null {
1657
+ for (const child of nodes) {
1658
+ if (!isElement(child)) continue
1659
+ // Lowercase name = HTML element (section, div, main, …)
1660
+ if (child.name && /^[a-z]/.test(child.name)) return child
1661
+ // Uppercase = Astro component — recurse into its children (slot content)
1662
+ const inner = findFirstHtmlElement(child.children ?? [])
1663
+ if (inner) return inner
1664
+ }
1665
+ return null
1666
+ }
1667
+ rootElement = findFirstHtmlElement(templateChildren)
1668
+ } else {
1669
+ for (const child of templateChildren) {
1670
+ if (isElement(child)) {
1671
+ rootElement = child
1672
+ break
1673
+ }
1674
+ }
1675
+ }
1676
+
1677
+ if (!rootElement) return
1678
+
1679
+ // Check if it already has an id attribute
1680
+ const hasId = rootElement.attributes?.some((a) => a.name === 'id')
1681
+ if (hasId) return
1682
+
1683
+ // Add id="section-{sectionKey}"
1684
+ addAttributeToElement(source, rootElement, `id="section-${sectionKey}"`, edits)
1685
+ }
1686
+
1687
+ // ---------------------------------------------------------------------------
1688
+ // Helpers
1689
+ // ---------------------------------------------------------------------------
1690
+
1691
+ /** Extract the CMS data variable name from Astro frontmatter. */
1692
+ function extractVarName(frontmatter: string): string | null {
1693
+ // const { data: varName } = Astro.props
1694
+ const propsMatch = frontmatter.match(/const\s*\{\s*data\s*:\s*(\w+)\s*\}/)
1695
+ if (propsMatch) return propsMatch[1]!
1696
+
1697
+ // const varName = getSection('key')
1698
+ const getSectionMatch = frontmatter.match(/(?:const|let)\s+(\w+)\s*=.*getSection/)
1699
+ if (getSectionMatch) return getSectionMatch[1]!
1700
+
1701
+ return null
1702
+ }
1703
+
1704
+ /** Type guard for element-like nodes (element, component, custom-element). */
1705
+ function isElement(node: AstNode): node is AstNode & { attributes: AstAttr[]; children: AstNode[] } {
1706
+ return (
1707
+ (node.type === 'element' || node.type === 'component' || node.type === 'custom-element') &&
1708
+ Array.isArray(node.attributes)
1709
+ )
1710
+ }
1711
+
1712
+ /** Recursive AST walk with parent tracking. */
1713
+ function walkAst(node: AstNode, callback: (node: AstNode, parent: AstNode | null) => void, parent: AstNode | null = null): void {
1714
+ callback(node, parent)
1715
+ if (node.children) {
1716
+ for (const child of node.children) {
1717
+ walkAst(child, callback, node)
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ /** Find the first element node among an expression's children (recursively). */
1723
+ function findFirstChildElement(node: AstNode): (AstNode & { attributes: AstAttr[] }) | null {
1724
+ if (!node.children) return null
1725
+ for (const child of node.children) {
1726
+ if (isElement(child)) return child
1727
+ const deeper = findFirstChildElement(child)
1728
+ if (deeper) return deeper
1729
+ }
1730
+ return null
1731
+ }
1732
+
1733
+ /**
1734
+ * Add an attribute string to an element's opening tag.
1735
+ * Inserts before the closing `>` of the opening tag.
1736
+ * Uses the element's tag name to locate it reliably in the source.
1737
+ */
1738
+ function addAttributeToElement(source: string, element: AstNode, attrStr: string, edits: Edit[]): void {
1739
+ const approxStart = element.position?.start?.offset
1740
+ if (approxStart == null) return
1741
+
1742
+ // Find the actual `<tagName` near the AST position
1743
+ const tagName = element.name
1744
+ if (!tagName) return
1745
+ const searchStart = Math.max(0, approxStart - 10)
1746
+ const tagPattern = `<${tagName}`
1747
+ const tagIdx = source.indexOf(tagPattern, searchStart)
1748
+ if (tagIdx === -1 || tagIdx > approxStart + 10) return
1749
+
1750
+ // Find the `>` that closes the opening tag
1751
+ const insertOffset = findOpeningTagEnd(source, tagIdx)
1752
+ if (insertOffset === -1) return
1753
+
1754
+ edits.push({
1755
+ offset: insertOffset,
1756
+ deleteCount: 0,
1757
+ insert: ` ${attrStr}`,
1758
+ })
1759
+ }
1760
+
1761
+ /**
1762
+ * Find the offset of the `>` that closes an opening tag.
1763
+ * Handles quoted attribute values and expression attributes correctly.
1764
+ */
1765
+ function findOpeningTagEnd(source: string, startOffset: number): number {
1766
+ let inQuote: string | null = null
1767
+ let inExpr = 0
1768
+ for (let i = startOffset; i < source.length; i++) {
1769
+ const ch = source[i]!
1770
+ if (inQuote) {
1771
+ if (ch === inQuote && source[i - 1] !== '\\') inQuote = null
1772
+ continue
1773
+ }
1774
+ if (ch === '{') { inExpr++; continue }
1775
+ if (ch === '}' && inExpr > 0) { inExpr--; continue }
1776
+ if (inExpr > 0) continue
1777
+ if (ch === '"' || ch === "'") { inQuote = ch; continue }
1778
+ if (ch === '>') return i
1779
+ }
1780
+ return -1
1781
+ }
1782
+
1783
+ /** Apply edits in reverse offset order to maintain position correctness. */
1784
+ function applyEdits(source: string, edits: Edit[]): string {
1785
+ if (edits.length === 0) return source
1786
+
1787
+ // Sort by offset descending so later edits don't shift earlier offsets
1788
+ const sorted = [...edits].sort((a, b) => b.offset - a.offset)
1789
+
1790
+ let result = source
1791
+ for (const edit of sorted) {
1792
+ result = result.slice(0, edit.offset) + edit.insert + result.slice(edit.offset + edit.deleteCount)
1793
+ }
1794
+ return result
1795
+ }
1796
+
1797
+ /**
1798
+ * Remove old frontmatter variable declarations that match CMS field keys.
1799
+ * Called AFTER expression patching so that variable references have already
1800
+ * been replaced with CMS expressions.
1801
+ */
1802
+ function removeOldVarDeclarations(source: string, fields: PatchField[], repeatedGroups?: RepeatedGroup[], cmsVarName = 'skData'): string {
1803
+ const fmStart = source.indexOf('---')
1804
+ if (fmStart === -1) return source
1805
+ const fmEnd = source.indexOf('---', fmStart + 3)
1806
+ if (fmEnd === -1) return source
1807
+
1808
+ let frontmatter = source.slice(fmStart + 4, fmEnd) // after "---\n", before closing "---"
1809
+ const fieldKeys = new Set(fields.map((f) => f.key))
1810
+
1811
+ // Also remove frontmatter variables that were inner array sources in repeated groups
1812
+ // (e.g. starterFeatures, proFeatures, businessFeatures → replaced by item.list)
1813
+ if (repeatedGroups) {
1814
+ for (const g of repeatedGroups) {
1815
+ for (const f of g.fields) {
1816
+ for (const pos of f.positions) {
1817
+ if (pos?.source) fieldKeys.add(pos.source)
1818
+ }
1819
+ }
1820
+ }
1821
+ }
1822
+
1823
+ // Collect all ranges to remove (relative to frontmatter string)
1824
+ const removals: Array<{ start: number; end: number }> = []
1825
+ // Aliases that must be appended AFTER the cmsVarName (= skData) declaration to avoid TDZ
1826
+ const aliases: string[] = []
1827
+
1828
+ const templatePart = source.slice(fmEnd + 3)
1829
+
1830
+ for (const key of fieldKeys) {
1831
+ const varDeclRegex = new RegExp(`(?:const|let)\\s+${key}\\s*=\\s*`)
1832
+ const declMatch = varDeclRegex.exec(frontmatter)
1833
+ if (!declMatch) continue
1834
+
1835
+ // Never remove exported declarations (e.g. "export const prerender = true")
1836
+ const lineStart = frontmatter.lastIndexOf('\n', declMatch.index) + 1
1837
+ const linePrefix = frontmatter.slice(lineStart, declMatch.index).trim()
1838
+ if (linePrefix === 'export') continue
1839
+
1840
+ const declStart = declMatch.index
1841
+ const afterEquals = declStart + declMatch[0].length
1842
+ const rhsRaw = frontmatter.slice(afterEquals)
1843
+ const leadingWs = rhsRaw.length - rhsRaw.trimStart().length
1844
+ const rhs = rhsRaw.trimStart()
1845
+
1846
+ let valueEnd: number // relative to afterEquals + leadingWs
1847
+ if (rhs.startsWith('[') || rhs.startsWith('{')) {
1848
+ // Multi-line: find matching bracket (handle strings to avoid false matches)
1849
+ const open = rhs[0]!
1850
+ const close = open === '[' ? ']' : '}'
1851
+ let depth = 1
1852
+ let inStr: string | null = null
1853
+ let i = 1
1854
+ for (; i < rhs.length && depth > 0; i++) {
1855
+ const ch = rhs[i]!
1856
+ if (inStr) {
1857
+ if (ch === inStr && rhs[i - 1] !== '\\') inStr = null
1858
+ continue
1859
+ }
1860
+ if (ch === "'" || ch === '"' || ch === '`') { inStr = ch; continue }
1861
+ if (ch === open) depth++
1862
+ if (ch === close) depth--
1863
+ }
1864
+ valueEnd = i
1865
+ } else {
1866
+ // Single-line: find end of line
1867
+ const lineEnd = rhs.indexOf('\n')
1868
+ valueEnd = lineEnd === -1 ? rhs.length : lineEnd
1869
+ }
1870
+
1871
+ let end = afterEquals + leadingWs + valueEnd
1872
+ // Include trailing semicolon + newline (e.g. "];\n" — the "]" is covered by valueEnd,
1873
+ // but ";" and "\n" were previously left behind, causing stray ";" in the output)
1874
+ if (end < frontmatter.length && frontmatter[end] === ';') end++
1875
+ if (end < frontmatter.length && frontmatter[end] === '\n') end++
1876
+
1877
+ // If the template still references this variable (e.g. {fields.length}), add a
1878
+ // CMS alias at the END of the frontmatter (after cmsVarName is defined) to avoid TDZ.
1879
+ const stillReferenced = new RegExp(`\\b${key}[.([\\[]`).test(templatePart)
1880
+ if (stillReferenced) {
1881
+ aliases.push(`const ${key} = ${cmsVarName}?.${key} ?? []`)
1882
+ }
1883
+
1884
+ removals.push({ start: declStart, end })
1885
+ }
1886
+
1887
+ if (removals.length === 0) return source
1888
+
1889
+ // Apply removals in reverse order to preserve positions
1890
+ removals.sort((a, b) => b.start - a.start)
1891
+ for (const { start, end } of removals) {
1892
+ frontmatter = frontmatter.slice(0, start) + frontmatter.slice(end)
1893
+ }
1894
+
1895
+ // Append aliases after existing declarations (cmsVarName is already at end)
1896
+ if (aliases.length > 0) {
1897
+ frontmatter = frontmatter.trimEnd() + '\n' + aliases.join('\n') + '\n'
1898
+ }
1899
+
1900
+ // Clean up: remove multiple consecutive blank lines
1901
+ frontmatter = frontmatter.replace(/\n{3,}/g, '\n\n')
1902
+
1903
+ return source.slice(0, fmStart + 4) + frontmatter + source.slice(fmEnd)
1904
+ }
1905
+
1906
+ // ---------------------------------------------------------------------------
1907
+ // stripTemplateFallbacks
1908
+ //
1909
+ // Removes ?? 'fallback' expressions from already-adopted templates where
1910
+ // content should live in the JSON file, not the template.
1911
+ //
1912
+ // Two patterns are handled:
1913
+ // 1. set:html={varName?.field ?? 'value'} → set:html={varName?.field}
1914
+ // set:html={varName?.field ?? `multi\nline`} → set:html={varName?.field}
1915
+ // 2. (varName?.items ?? ['a', 'b']).map( → (varName?.items ?? []).map(
1916
+ //
1917
+ // Returns the cleaned source and a map of fieldKey → extracted value.
1918
+ // The caller decides which values to write to JSON:
1919
+ // - If the JSON already has a value for that field → just strip (JSON wins)
1920
+ // - If the JSON has no value → use the extracted fallback
1921
+ // ---------------------------------------------------------------------------
1922
+ export function stripTemplateFallbacks(source: string): {
1923
+ source: string
1924
+ fallbacks: Record<string, unknown>
1925
+ } {
1926
+ const fallbacks: Record<string, unknown> = {}
1927
+ let result = source
1928
+
1929
+ // Pattern 1: set:html={varName?.fieldKey ?? `...`} or '...' or "..."
1930
+ // The template literal variant may span multiple lines.
1931
+ result = result.replace(
1932
+ /set:html=\{(\w+)\?\.(\w+)\s*\?\?\s*(?:`([\s\S]*?)`|'([^']*)'|"([^"]*)")\}/g,
1933
+ (_match, varName, fieldKey, tq, sq, dq) => {
1934
+ const value = (tq ?? sq ?? dq ?? '').trim()
1935
+ fallbacks[fieldKey] = value
1936
+ return `set:html={${varName}?.${fieldKey}}`
1937
+ },
1938
+ )
1939
+
1940
+ // Pattern 2: (varName?.fieldKey ?? ['item1', `item2`, ...]).map(
1941
+ // Items can be single-quoted, double-quoted, or backtick strings.
1942
+ result = result.replace(
1943
+ /\((\w+)\?\.(\w+)\s*\?\?\s*\[([^\]]*)\]\)\.map\(/gs,
1944
+ (_match, varName, fieldKey, itemsRaw) => {
1945
+ const items: string[] = []
1946
+ const itemRe = /`([^`]*)`|'([^']*)'|"([^"]*)"/g
1947
+ let m: RegExpExecArray | null
1948
+ while ((m = itemRe.exec(itemsRaw)) !== null) {
1949
+ items.push(m[1] ?? m[2] ?? m[3] ?? '')
1950
+ }
1951
+ if (items.length > 0) fallbacks[fieldKey] = items
1952
+ return `(${varName}?.${fieldKey} ?? []).map(`
1953
+ },
1954
+ )
1955
+
1956
+ return { source: result, fallbacks }
1957
+ }