@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.
- package/LICENSE +37 -0
- package/package.json +70 -0
- package/src/admin-page.astro +148 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
- package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
- package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
- package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
- package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
- package/src/api-routes/__tests__/section-management.test.ts +284 -0
- package/src/api-routes/_storage-config.ts +54 -0
- package/src/api-routes/asset-proxy.ts +76 -0
- package/src/api-routes/auth-callback.ts +105 -0
- package/src/api-routes/auth-login.ts +87 -0
- package/src/api-routes/auth-logout.ts +9 -0
- package/src/api-routes/auth-session.ts +36 -0
- package/src/api-routes/catalog-add.ts +151 -0
- package/src/api-routes/catalog-export.ts +86 -0
- package/src/api-routes/catalog-helpers.ts +83 -0
- package/src/api-routes/catalog-list.ts +12 -0
- package/src/api-routes/config.ts +30 -0
- package/src/api-routes/deploy-hook.ts +69 -0
- package/src/api-routes/github-proxy.ts +111 -0
- package/src/api-routes/init-add-section.ts +511 -0
- package/src/api-routes/init-apply.ts +270 -0
- package/src/api-routes/init-migrate.ts +262 -0
- package/src/api-routes/init-scan-page.ts +336 -0
- package/src/api-routes/init-scan.ts +162 -0
- package/src/api-routes/pages.ts +17 -0
- package/src/api-routes/section-add.ts +189 -0
- package/src/api-routes/section-commit-pending.ts +147 -0
- package/src/api-routes/section-delete.ts +141 -0
- package/src/api-routes/section-duplicate.ts +144 -0
- package/src/api-routes/section-management.ts +95 -0
- package/src/api-routes/section-prepare-copy.ts +93 -0
- package/src/api-routes/section-prepare.ts +121 -0
- package/src/env.d.ts +7 -0
- package/src/init/__tests__/page-level.test.ts +1033 -0
- package/src/init/__tests__/page-list-coverage.test.ts +474 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
- package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
- package/src/init/__tests__/section-pipeline.test.ts +393 -0
- package/src/init/analyzer-types.ts +92 -0
- package/src/init/astro-config-patcher.ts +98 -0
- package/src/init/astro-detector.ts +207 -0
- package/src/init/astro-section-analyzer-v2.ts +1663 -0
- package/src/init/field-label-enricher.ts +72 -0
- package/src/init/template-patcher-v2.ts +1957 -0
- 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
|
+
}
|