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