@setzkasten-cms/astro-admin 1.4.2 → 1.4.6

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.
@@ -1,7 +1,9 @@
1
1
  import {
2
+ detectChildImports,
3
+ patchChildComponentForFieldPrefix,
2
4
  patchTemplateForFields,
3
5
  stripTemplateFallbacks
4
- } from "../chunk-RHJONMLK.js";
6
+ } from "../chunk-Q3N336KR.js";
5
7
  import {
6
8
  prefixPath,
7
9
  resolveStorageConfigForRequest
@@ -146,8 +148,14 @@ var POST = async ({ request, cookies }) => {
146
148
  }
147
149
  if (patchedSource !== pageSource) {
148
150
  filesToCommit.push({ path: fullPagePath, content: patchedSource });
149
- const previewCopySource = patchedSource.replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, "").replace(/(from\s+')(\.\.\/)/g, "$1../$2").replace(/(from\s+")(\.\.\/)/g, "$1../$2");
150
151
  const relativePage = resolvedPagePath.replace(/^src\/pages\//, "");
152
+ const importDepth = "../".repeat(relativePage.split("/").length);
153
+ const previewCopySource = `---
154
+ export const prerender = false;
155
+ import Page from '${importDepth}${relativePage}';
156
+ ---
157
+ <Page />
158
+ `;
151
159
  const previewCopyPath = prefixPath(`src/pages/sk-preview/${relativePage}`, projectPrefix);
152
160
  filesToCommit.push({ path: previewCopyPath, content: previewCopySource });
153
161
  }
@@ -181,6 +189,19 @@ var POST = async ({ request, cookies }) => {
181
189
  filesToCommit[jsonIdx].content = JSON.stringify(sectionData, null, 2);
182
190
  }
183
191
  }
192
+ const allFields = section.allFields ?? section.fields;
193
+ const childPatches = detectChildImports(patchedSource, allFields);
194
+ for (const child of childPatches) {
195
+ const sectionDir = section.componentPath.replace(/\/[^/]+$/, "");
196
+ const resolvedChildPath = resolveRelativePath(sectionDir, child.importPath);
197
+ if (!resolvedChildPath) continue;
198
+ const childSource = await fetchFileContent(owner, repo, branch, resolvedChildPath, githubToken);
199
+ if (!childSource) continue;
200
+ const patchedChild = patchChildComponentForFieldPrefix(childSource, child.innerFields);
201
+ if (patchedChild !== childSource) {
202
+ filesToCommit.push({ path: resolvedChildPath, content: patchedChild });
203
+ }
204
+ }
184
205
  }
185
206
  if (body.pagePath && section.componentName) {
186
207
  const fullPagePath = prefixPath(body.pagePath, projectPrefix);
@@ -288,7 +309,7 @@ function patchPageFile(source, sectionKey, componentName, componentPath, pagePat
288
309
  if (lastEntryMatch && lastEntryMatch.index !== void 0) {
289
310
  const insertPos = registryMatch.index + registryMatch[0].indexOf(registryContent) + lastEntryMatch.index + lastEntryMatch[0].length;
290
311
  const newEntry = `
291
- [normalize('${sectionKey}')]: ${componentName},`;
312
+ '${sectionKey}': ${componentName},`;
292
313
  patched = patched.slice(0, insertPos) + newEntry + patched.slice(insertPos);
293
314
  }
294
315
  }
@@ -309,6 +330,21 @@ function calculateRelativePath(fromDir, toPath) {
309
330
  if (ups === 0) return "./" + remaining;
310
331
  return "../".repeat(ups) + remaining;
311
332
  }
333
+ function resolveRelativePath(baseDir, relativePath) {
334
+ if (relativePath.startsWith("/")) return relativePath.replace(/^\//, "");
335
+ const parts = [...baseDir.split("/").filter(Boolean)];
336
+ for (const segment of relativePath.split("/")) {
337
+ if (segment === ".") continue;
338
+ if (segment === "..") {
339
+ parts.pop();
340
+ continue;
341
+ }
342
+ parts.push(segment);
343
+ }
344
+ const resolved = parts.join("/");
345
+ if (resolved.startsWith("../") || resolved.startsWith("/")) return null;
346
+ return resolved;
347
+ }
312
348
  async function fetchFileContent(owner, repo, branch, path, token) {
313
349
  try {
314
350
  const response = await fetch(
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  patchTemplateForFields
3
- } from "../chunk-RHJONMLK.js";
3
+ } from "../chunk-Q3N336KR.js";
4
4
  import {
5
5
  resolveGitHubTokenForRequest
6
6
  } from "../chunk-NKDATSPA.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  patchTemplateForFields
3
- } from "../chunk-RHJONMLK.js";
3
+ } from "../chunk-Q3N336KR.js";
4
4
  import {
5
5
  prefixPath,
6
6
  resolveStorageConfigForRequest
@@ -2,7 +2,7 @@ import {
2
2
  analyzeAstroSection,
3
3
  extractLayoutImport,
4
4
  extractSectionImports
5
- } from "../chunk-K22A4ZBS.js";
5
+ } from "../chunk-TD76R3A6.js";
6
6
  import {
7
7
  prefixPath,
8
8
  resolveStorageConfigForRequest
@@ -2,7 +2,7 @@ import {
2
2
  analyzeAstroSection,
3
3
  extractSectionImports,
4
4
  findAstroPages
5
- } from "../chunk-K22A4ZBS.js";
5
+ } from "../chunk-TD76R3A6.js";
6
6
  import {
7
7
  resolveGitHubTokenForRequest
8
8
  } from "../chunk-NKDATSPA.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  convertToSetHtml
3
- } from "../chunk-RHJONMLK.js";
3
+ } from "../chunk-Q3N336KR.js";
4
4
  import {
5
5
  readPagesMeta
6
6
  } from "../chunk-FXNOTESI.js";
@@ -155,6 +155,9 @@ const { data: ${varName} } = Astro.props
155
155
  if (edits.length === editsBefore2) {
156
156
  patchStaticListField(source, sectionKey, field, varName, ast, edits);
157
157
  }
158
+ if (edits.length === editsBefore2) {
159
+ patchRepeatedComponentInstances(source, sectionKey, field, varName, ast, edits);
160
+ }
158
161
  continue;
159
162
  }
160
163
  const cmsExpr = cmsExpressions.find((e) => e.fieldKey === field.key);
@@ -856,6 +859,111 @@ function collectDynamicClassEdits(innerEdits, source, instances, tmpl, group) {
856
859
  });
857
860
  }
858
861
  }
862
+ function patchRepeatedComponentInstances(source, sectionKey, field, varName, ast, edits) {
863
+ const compName = field.options?.sourceComponent;
864
+ if (!compName) return;
865
+ const instanceNodes = [];
866
+ walkAst(ast, (node) => {
867
+ if (node.type === "component" && node.name === compName) {
868
+ instanceNodes.push(node);
869
+ }
870
+ });
871
+ if (instanceNodes.length < 2) return;
872
+ const ranges = [];
873
+ for (const node of instanceNodes) {
874
+ const approxStart = node.position?.start?.offset;
875
+ if (approxStart == null) return;
876
+ const searchFrom = Math.max(0, approxStart - 2);
877
+ const tagStart = source.indexOf(`<${compName}`, searchFrom);
878
+ if (tagStart === -1 || tagStart > approxStart + 2) return;
879
+ const tagEnd = findComponentTagEnd(source, tagStart, compName);
880
+ if (tagEnd === -1) return;
881
+ ranges.push({ start: tagStart, end: tagEnd, attrs: node.attributes ?? [] });
882
+ }
883
+ if (ranges.length < 2) return;
884
+ const defaultItems = Array.isArray(field.defaultValue) ? field.defaultValue : [];
885
+ const defaultJson = JSON.stringify(defaultItems);
886
+ const innerFieldKeys = new Set(
887
+ (field.options?.arrayItem?.fields ?? []).map((f) => f.key)
888
+ );
889
+ const skipPropRegex = /^(class|className|id|style|type|role|aria-|data-)$/;
890
+ const firstAttrs = ranges[0].attrs;
891
+ const propLines = [];
892
+ for (const attr of firstAttrs) {
893
+ const name = attr.name;
894
+ if (skipPropRegex.test(name) || name.startsWith("aria-") || name.startsWith("data-")) {
895
+ const raw = attr.kind === "quoted" ? `${name}="${attr.value}"` : `${name}={${attr.value}}`;
896
+ propLines.push(raw);
897
+ continue;
898
+ }
899
+ if (innerFieldKeys.has(name)) {
900
+ propLines.push(`${name}={item.${name}}`);
901
+ } else {
902
+ const raw = attr.kind === "quoted" ? `${name}="${attr.value}"` : `${name}={${attr.value}}`;
903
+ propLines.push(raw);
904
+ }
905
+ }
906
+ propLines.push(`fieldPrefix={\`${sectionKey}.${field.key}.\${_i}\`}`);
907
+ const propsStr = propLines.map((p) => ` ${p}`).join("\n");
908
+ const mapExpr = `{(${varName}?.${field.key} ?? ${defaultJson}).map((item, _i) => (
909
+ <${compName}
910
+ ${propsStr}
911
+ />
912
+ ))}`;
913
+ edits.push({
914
+ offset: ranges[0].start,
915
+ deleteCount: ranges[0].end - ranges[0].start,
916
+ insert: mapExpr
917
+ });
918
+ for (let i = 1; i < ranges.length; i++) {
919
+ const r = ranges[i];
920
+ let deleteStart = r.start;
921
+ while (deleteStart > 0 && /[ \t]/.test(source[deleteStart - 1])) deleteStart--;
922
+ if (deleteStart > 0 && source[deleteStart - 1] === "\n") deleteStart--;
923
+ edits.push({ offset: deleteStart, deleteCount: r.end - deleteStart, insert: "" });
924
+ }
925
+ }
926
+ function findComponentTagEnd(source, tagStart, compName) {
927
+ let i = tagStart + 1;
928
+ let inQuote = null;
929
+ let inExpr = 0;
930
+ while (i < source.length) {
931
+ const ch = source[i];
932
+ if (inQuote) {
933
+ if (ch === inQuote && source[i - 1] !== "\\") inQuote = null;
934
+ i++;
935
+ continue;
936
+ }
937
+ if (ch === "{") {
938
+ inExpr++;
939
+ i++;
940
+ continue;
941
+ }
942
+ if (ch === "}" && inExpr > 0) {
943
+ inExpr--;
944
+ i++;
945
+ continue;
946
+ }
947
+ if (inExpr > 0) {
948
+ i++;
949
+ continue;
950
+ }
951
+ if (ch === '"' || ch === "'") {
952
+ inQuote = ch;
953
+ i++;
954
+ continue;
955
+ }
956
+ if (ch === "/" && source[i + 1] === ">") return i + 2;
957
+ if (ch === ">") {
958
+ const closing = `</${compName}>`;
959
+ const closingIdx = source.indexOf(closing, i + 1);
960
+ if (closingIdx !== -1) return closingIdx + closing.length;
961
+ return i + 1;
962
+ }
963
+ i++;
964
+ }
965
+ return -1;
966
+ }
859
967
  function patchRepeatedGroups(source, sectionKey, varName, groups) {
860
968
  const edits = [];
861
969
  for (const group of groups) {
@@ -1173,6 +1281,15 @@ function removeOldVarDeclarations(source, fields, repeatedGroups, cmsVarName = "
1173
1281
  }
1174
1282
  }
1175
1283
  }
1284
+ const templatePart0 = source.slice(fmEnd + 3);
1285
+ const fmVarRegex2 = /(?:const|let)\s+(\w+)\s*=\s*\[/g;
1286
+ let m;
1287
+ while ((m = fmVarRegex2.exec(frontmatter)) !== null) {
1288
+ const vName = m[1];
1289
+ if (fieldKeys.has(vName)) continue;
1290
+ const stillUsed = new RegExp(`\\b${vName}\\b`).test(templatePart0);
1291
+ if (!stillUsed) fieldKeys.add(vName);
1292
+ }
1176
1293
  const removals = [];
1177
1294
  const aliases = [];
1178
1295
  const templatePart = source.slice(fmEnd + 3);
@@ -1233,6 +1350,99 @@ function removeOldVarDeclarations(source, fields, repeatedGroups, cmsVarName = "
1233
1350
  frontmatter = frontmatter.replace(/\n{3,}/g, "\n\n");
1234
1351
  return source.slice(0, fmStart + 4) + frontmatter + source.slice(fmEnd);
1235
1352
  }
1353
+ function patchChildComponentForFieldPrefix(source, innerFields) {
1354
+ if (source.includes("fieldPrefix?: string") || source.includes("fieldPrefix?:string")) return source;
1355
+ const fmStart = source.indexOf("---");
1356
+ const fmEnd = source.indexOf("---", fmStart + 3);
1357
+ if (fmStart === -1 || fmEnd === -1) return source;
1358
+ let result = source;
1359
+ const interfaceMatch = result.match(/export\s+interface\s+Props\s*\{([\s\S]*?)\}/);
1360
+ if (interfaceMatch) {
1361
+ const ifaceEnd = result.indexOf(interfaceMatch[0]) + interfaceMatch[0].length;
1362
+ const lastPropLine = interfaceMatch[0].slice(0, -1).trimEnd();
1363
+ result = result.slice(0, ifaceEnd - 1) + "\n fieldPrefix?: string;\n}" + result.slice(ifaceEnd);
1364
+ } else {
1365
+ const closeFm = result.indexOf("---", result.indexOf("---") + 3);
1366
+ result = result.slice(0, closeFm) + "interface Props { fieldPrefix?: string }\n" + result.slice(closeFm);
1367
+ }
1368
+ result = result.replace(
1369
+ /(\bconst\s*\{[^}]*)(}\s*=\s*Astro\.props)/,
1370
+ (_m, before, after) => {
1371
+ if (before.includes("fieldPrefix")) return _m;
1372
+ const trimmed = before.trimEnd();
1373
+ const sep = trimmed.endsWith(",") ? " " : ",\n ";
1374
+ return `${trimmed}${sep}fieldPrefix${after}`;
1375
+ }
1376
+ );
1377
+ const closingFm = result.indexOf("---", result.indexOf("---") + 3);
1378
+ const frontmatterPart = result.slice(0, closingFm + 3);
1379
+ let templatePart = result.slice(closingFm + 3);
1380
+ const scalarFields = innerFields.filter((f) => f.type !== "array");
1381
+ const arrayFields = innerFields.filter((f) => f.type === "array");
1382
+ for (const field of scalarFields) {
1383
+ const propExpr = `{${field.key}}`;
1384
+ if (field.key.toLowerCase().includes("href") || field.key.toLowerCase().includes("link")) {
1385
+ templatePart = templatePart.replace(
1386
+ new RegExp(`href=\\{${field.key}\\}`, "g"),
1387
+ `href={${field.key}} data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`
1388
+ );
1389
+ continue;
1390
+ }
1391
+ const tagWithExprRegex = new RegExp(
1392
+ `(<(?!/)(?:p|span|h[1-6]|div|li|td|th|dt|dd|label|button|a)\\b[^>]*?)(\\/?>)([^<]*\\{${field.key}\\})`,
1393
+ "g"
1394
+ );
1395
+ templatePart = templatePart.replace(tagWithExprRegex, (_m, tagOpen, tagClose, rest) => {
1396
+ if (tagOpen.includes("data-sk-field")) return _m;
1397
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`;
1398
+ return `${tagOpen}${attr}${tagClose}${rest}`;
1399
+ });
1400
+ }
1401
+ for (const field of arrayFields) {
1402
+ const mapParamRegex = new RegExp(
1403
+ `(${field.key}\\.map\\s*\\(\\s*\\(\\s*)(\\w+)(\\s*\\))`,
1404
+ "g"
1405
+ );
1406
+ templatePart = templatePart.replace(
1407
+ new RegExp(`(<(?:ul|ol)[^>]*?)(\\/?>)([\\s\\S]*?${field.key}\\.map)`, "g"),
1408
+ (_m, ulOpen, ulClose, rest) => {
1409
+ if (ulOpen.includes("data-sk-field")) return _m;
1410
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`;
1411
+ return `${ulOpen}${attr}${ulClose}${rest}`;
1412
+ }
1413
+ );
1414
+ templatePart = templatePart.replace(mapParamRegex, (_m, before, param, close) => {
1415
+ if (close.trim().startsWith(",")) return _m;
1416
+ return `${before}${param}, _fi${close}`;
1417
+ });
1418
+ const mapBlockRegex = new RegExp(
1419
+ `(${field.key}\\.map\\s*\\([^)]*\\)\\s*=>\\s*\\([\\s\\S]*?)(<(?:span|li|td)\\b)([^>]*?>)([^<]*\\{\\w+\\})`,
1420
+ "g"
1421
+ );
1422
+ templatePart = templatePart.replace(mapBlockRegex, (_m, mapHead, tagOpen, tagClose, content) => {
1423
+ if (tagClose.includes("data-sk-field")) return _m;
1424
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}.\${_fi}\` : undefined}`;
1425
+ return `${mapHead}${tagOpen}${tagClose.slice(0, -1)}${attr}>${content}`;
1426
+ });
1427
+ }
1428
+ return frontmatterPart + templatePart;
1429
+ }
1430
+ function detectChildImports(source, fields) {
1431
+ const result = [];
1432
+ for (const field of fields) {
1433
+ const compName = field.options?.sourceComponent;
1434
+ if (!compName) continue;
1435
+ const innerFields = (field.options?.arrayItem?.fields ?? []).map((f) => ({
1436
+ key: f.key,
1437
+ type: f.type
1438
+ }));
1439
+ const importRegex = new RegExp(`import\\s+${compName}\\s+from\\s+['"]([^'"]+)['"]`);
1440
+ const m = source.match(importRegex);
1441
+ if (!m) continue;
1442
+ result.push({ compName, importPath: m[1], innerFields });
1443
+ }
1444
+ return result;
1445
+ }
1236
1446
  function stripTemplateFallbacks(source) {
1237
1447
  const fallbacks = {};
1238
1448
  let result = source;
@@ -1263,5 +1473,7 @@ function stripTemplateFallbacks(source) {
1263
1473
  export {
1264
1474
  patchTemplateForFields,
1265
1475
  convertToSetHtml,
1476
+ patchChildComponentForFieldPrefix,
1477
+ detectChildImports,
1266
1478
  stripTemplateFallbacks
1267
1479
  };
@@ -1247,7 +1247,7 @@ ${template}`;
1247
1247
  label: `${compName} Liste`,
1248
1248
  confidence: "medium",
1249
1249
  defaultValue: instanceValues.length > 0 ? instanceValues : void 0,
1250
- options: { arrayItem: { type: "object", fields: innerFields } }
1250
+ options: { arrayItem: { type: "object", fields: innerFields }, sourceComponent: compName }
1251
1251
  }, nodeOffset(instances[0]));
1252
1252
  }
1253
1253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setzkasten-cms/astro-admin",
3
- "version": "1.4.2",
3
+ "version": "1.4.6",
4
4
  "description": "Setzkasten Admin-UI, Init-Wizard und Adoptions-Pipeline für Astro",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -270,11 +270,11 @@
270
270
  },
271
271
  "dependencies": {
272
272
  "@astrojs/compiler": "^3.0.0",
273
- "@setzkasten-cms/auth": "1.4.2",
274
- "@setzkasten-cms/catalog": "1.4.2",
275
- "@setzkasten-cms/core": "1.4.2",
276
- "@setzkasten-cms/github-adapter": "1.4.2",
277
- "@setzkasten-cms/ui": "1.4.2"
273
+ "@setzkasten-cms/auth": "1.4.6",
274
+ "@setzkasten-cms/catalog": "1.4.6",
275
+ "@setzkasten-cms/core": "1.4.6",
276
+ "@setzkasten-cms/github-adapter": "1.4.6",
277
+ "@setzkasten-cms/ui": "1.4.6"
278
278
  },
279
279
  "peerDependencies": {
280
280
  "astro": "^5.0.0",
@@ -106,12 +106,10 @@ function buildPageConfig(
106
106
  return { sections: [{ key: sectionKey, enabled: true }] }
107
107
  }
108
108
 
109
- /** Build the sk-preview clone: strip prerender, fix import depths */
110
- function buildPreviewClone(patchedSource: string): string {
111
- return patchedSource
112
- .replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, '')
113
- .replace(/(from\s+')(\.\.\/)/g, '$1../$2')
114
- .replace(/(from\s+")(\.\.\/)/g, '$1../$2')
109
+ /** Build a thin SSR wrapper that imports the production page as a component */
110
+ function buildPreviewClone(relativePage: string): string {
111
+ const importDepth = '../'.repeat(relativePage.split('/').length)
112
+ return `---\nexport const prerender = false;\nimport Page from '${importDepth}${relativePage}';\n---\n<Page />\n`
115
113
  }
116
114
 
117
115
  // ---------------------------------------------------------------------------
@@ -225,50 +223,33 @@ describe('Page config — section deduplication', () => {
225
223
  })
226
224
 
227
225
  // ---------------------------------------------------------------------------
228
- // 4. sk-preview clone — prerender removal and import depth fix
226
+ // 4. sk-preview clone — thin wrapper with correct import depth
229
227
  // ---------------------------------------------------------------------------
230
228
 
231
229
  describe('sk-preview clone generation', () => {
232
- const patched = `---
233
- export const prerender = true;
234
- import BaseLayout from '../../layouts/BaseLayout.astro';
235
- import { getSection } from 'setzkasten:content'
236
- const skData = getSection('_page_docs_architecture')
237
- ---
238
-
239
- <BaseLayout>
240
- <section id="section-_page_docs_architecture">
241
- <h1 set:html={skData?.heading ?? 'Architektur'} />
242
- </section>
243
- </BaseLayout>
244
- `
245
-
246
- it('should remove the prerender export', () => {
247
- const clone = buildPreviewClone(patched)
248
- expect(clone).not.toContain('export const prerender')
230
+ it('top-level page: imports with one ../', () => {
231
+ const clone = buildPreviewClone('impressum.astro')
232
+ expect(clone).toBe("---\nexport const prerender = false;\nimport Page from '../impressum.astro';\n---\n<Page />\n")
249
233
  })
250
234
 
251
- it('should add an extra ../ level to relative imports', () => {
252
- const clone = buildPreviewClone(patched)
253
- // Original: '../../layouts/BaseLayout.astro'
254
- // After fix: '../../../layouts/BaseLayout.astro'
255
- expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
235
+ it('nested page: imports with correct depth', () => {
236
+ const clone = buildPreviewClone('docs/index.astro')
237
+ expect(clone).toContain("import Page from '../../docs/index.astro'")
256
238
  })
257
239
 
258
- it('should not touch absolute module imports', () => {
259
- const clone = buildPreviewClone(patched)
260
- expect(clone).toContain("from 'setzkasten:content'")
240
+ it('three-level page: imports with three ../', () => {
241
+ const clone = buildPreviewClone('docs/api/reference.astro')
242
+ expect(clone).toContain("import Page from '../../../docs/api/reference.astro'")
261
243
  })
262
244
 
263
- it('should preserve getSection call', () => {
264
- const clone = buildPreviewClone(patched)
265
- expect(clone).toContain("getSection('_page_docs_architecture')")
245
+ it('always has export const prerender = false', () => {
246
+ const clone = buildPreviewClone('impressum.astro')
247
+ expect(clone).toContain('export const prerender = false')
266
248
  })
267
249
 
268
- it('should handle double-quoted imports too', () => {
269
- const dq = patched.replace("from '../../layouts", 'from "../../layouts').replace("BaseLayout.astro'", 'BaseLayout.astro"')
270
- const clone = buildPreviewClone(dq)
271
- expect(clone).toContain('../../../layouts/BaseLayout.astro')
250
+ it('renders as <Page />', () => {
251
+ const clone = buildPreviewClone('impressum.astro')
252
+ expect(clone).toContain('<Page />')
272
253
  })
273
254
  })
274
255
 
@@ -309,12 +290,7 @@ describe('calculateRelativePath', () => {
309
290
  // When the UI sends pagePath='src/pages/docs.astro' but the actual file is
310
291
  // src/pages/docs/index.astro (directory route), the sk-preview clone must
311
292
  // be placed at sk-preview/docs/index.astro (NOT sk-preview/docs.astro).
312
- //
313
- // sk-preview/docs.astro (wrong): same depth as src/pages/sk-preview/
314
- // → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✗
315
- //
316
- // sk-preview/docs/index.astro (correct): one level deeper in sk-preview/docs/
317
- // → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✓
293
+ // The import depth must match: docs/index.astro → '../../docs/index.astro'.
318
294
  // ---------------------------------------------------------------------------
319
295
 
320
296
  /** Simulate resolvedPagePath logic from init-add-section.ts step 4 */
@@ -334,7 +310,6 @@ describe('Directory-route clone path (build-failure regression)', () => {
334
310
  })
335
311
 
336
312
  it('directory route: resolvedPagePath falls back to index.astro', () => {
337
- // docs.astro does not exist, docs/index.astro does
338
313
  const resolved = resolvePagePath(
339
314
  'src/pages/docs.astro',
340
315
  (p) => p === 'src/pages/docs/index.astro',
@@ -342,42 +317,27 @@ describe('Directory-route clone path (build-failure regression)', () => {
342
317
  expect(resolved).toBe('src/pages/docs/index.astro')
343
318
  })
344
319
 
345
- it('directory route: clone relativePage uses resolved path, not body path', () => {
346
- const bodyPagePath = 'src/pages/docs.astro'
320
+ it('directory route: relativePage uses resolved path, not body path', () => {
347
321
  const resolved = resolvePagePath(
348
- bodyPagePath,
322
+ 'src/pages/docs.astro',
349
323
  (p) => p === 'src/pages/docs/index.astro',
350
324
  )
351
325
  const relativePage = resolved.replace(/^src\/pages\//, '')
352
- // Must be 'docs/index.astro', NOT 'docs.astro'
353
326
  expect(relativePage).toBe('docs/index.astro')
354
327
  expect(relativePage).not.toBe('docs.astro')
355
328
  })
356
329
 
357
- it('directory route clone gets correct import depth after buildPreviewClone', () => {
358
- // src/pages/docs/index.astro imports '../../layouts/BaseLayout.astro'
359
- // clone at sk-preview/docs/index.astro needs '../../../layouts/BaseLayout.astro'
360
- const source = `---
361
- import BaseLayout from '../../layouts/BaseLayout.astro';
362
- import { getSection } from 'setzkasten:content'
363
- const skData = getSection('_page_docs')
364
- ---
365
- <BaseLayout><slot /></BaseLayout>
366
- `
367
- const clone = buildPreviewClone(source)
368
- expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
330
+ it('directory route: clone imports from correct depth', () => {
331
+ const clone = buildPreviewClone('docs/index.astro')
332
+ expect(clone).toContain("import Page from '../../docs/index.astro'")
369
333
  })
370
334
 
371
- it('wrong clone path (docs.astro) would have incorrect import depth', () => {
372
- // sk-preview/docs.astro is at same depth as sk-preview/*.astro
373
- // It needs '../../layouts/' but buildPreviewClone would produce '../../../layouts/'
374
- // This test documents the bug: if relativePage were 'docs.astro' instead of
375
- // 'docs/index.astro', the clone ends up at the wrong path and imports break.
376
- const bodyPagePath = 'src/pages/docs.astro'
377
- const wrongRelativePage = bodyPagePath.replace(/^src\/pages\//, '') // 'docs.astro' ← the bug
378
- expect(wrongRelativePage).toBe('docs.astro')
379
- // The CORRECT relative page (after fix):
380
- const correctRelativePage = 'src/pages/docs/index.astro'.replace(/^src\/pages\//, '')
381
- expect(correctRelativePage).toBe('docs/index.astro')
335
+ it('wrong relativePage (docs.astro) produces shallow import depth', () => {
336
+ // Documents why resolved path matters: wrong path → wrong import
337
+ const wrongClone = buildPreviewClone('docs.astro')
338
+ expect(wrongClone).toContain("import Page from '../docs.astro'")
339
+ // vs correct:
340
+ const correctClone = buildPreviewClone('docs/index.astro')
341
+ expect(correctClone).toContain("import Page from '../../docs/index.astro'")
382
342
  })
383
343
  })
@@ -2,7 +2,7 @@ import type { APIRoute } from 'astro'
2
2
  import type { InferredSection } from '@setzkasten-cms/core/init'
3
3
  import { addSectionToConfig } from '@setzkasten-cms/core/init'
4
4
  import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
5
- import { patchTemplateForFields, stripTemplateFallbacks } from '../init/template-patcher-v2'
5
+ import { patchTemplateForFields, stripTemplateFallbacks, detectChildImports, patchChildComponentForFieldPrefix } from '../init/template-patcher-v2'
6
6
  import type { RepeatedGroup } from '../init/analyzer-types'
7
7
  import { withTrailers } from './_commit-trailers'
8
8
  import { resolveGitHubTokenForRequest } from './_github-token'
@@ -192,18 +192,16 @@ export const POST: APIRoute = async ({ request, cookies }) => {
192
192
  if (patchedSource !== pageSource) {
193
193
  filesToCommit.push({ path: fullPagePath, content: patchedSource })
194
194
 
195
- // Create an SSR clone under sk-preview/ so the live preview iframe works.
196
- // The patched source already uses getSection() which is draft-aware.
197
- // The clone lives one directory deeper (sk-preview/<page> vs <page>),
198
- // so all relative imports need one extra "../" level.
199
- // Use resolvedPagePath (not body.pagePath) to get correct clone depth for
200
- // directory routes (docs/index.astro → sk-preview/docs/index.astro).
201
- const previewCopySource = patchedSource
202
- .replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, '')
203
- .replace(/(from\s+')(\.\.\/)/g, '$1../$2')
204
- .replace(/(from\s+")(\.\.\/)/g, '$1../$2')
195
+ // Create a thin SSR wrapper under sk-preview/ so the live preview iframe works.
196
+ // The wrapper imports the production page as a component — no content duplication.
197
+ // In SSR context (prerender=false), getSection() runs at request time (draft-aware).
198
+ // In static context (/impressum), the same component runs at build time.
205
199
  // resolvedPagePath: e.g. "src/pages/docs/index.astro" (for directory routes)
206
200
  const relativePage = resolvedPagePath.replace(/^src\/pages\//, '') // "docs/index.astro"
201
+ // Import depth: sk-preview/impressum.astro → '../impressum.astro'
202
+ // sk-preview/docs/index.astro → '../../docs/index.astro'
203
+ const importDepth = '../'.repeat(relativePage.split('/').length)
204
+ const previewCopySource = `---\nexport const prerender = false;\nimport Page from '${importDepth}${relativePage}';\n---\n<Page />\n`
207
205
  const previewCopyPath = prefixPath(`src/pages/sk-preview/${relativePage}`, projectPrefix)
208
206
  filesToCommit.push({ path: previewCopyPath, content: previewCopySource })
209
207
  }
@@ -248,6 +246,24 @@ export const POST: APIRoute = async ({ request, cookies }) => {
248
246
  filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
249
247
  }
250
248
  }
249
+
250
+ // 4b. Patch child components (e.g. PricingCard) used by repeated-component groups.
251
+ // detectChildImports finds any import whose component name matches a field's
252
+ // options.sourceComponent, then patchChildComponentForFieldPrefix injects
253
+ // fieldPrefix prop + data-sk-field bindings into that component file.
254
+ const allFields = section.allFields ?? section.fields
255
+ const childPatches = detectChildImports(patchedSource, allFields as any)
256
+ for (const child of childPatches) {
257
+ const sectionDir = section.componentPath.replace(/\/[^/]+$/, '')
258
+ const resolvedChildPath = resolveRelativePath(sectionDir, child.importPath)
259
+ if (!resolvedChildPath) continue
260
+ const childSource = await fetchFileContent(owner, repo, branch, resolvedChildPath, githubToken)
261
+ if (!childSource) continue
262
+ const patchedChild = patchChildComponentForFieldPrefix(childSource, child.innerFields)
263
+ if (patchedChild !== childSource) {
264
+ filesToCommit.push({ path: resolvedChildPath, content: patchedChild })
265
+ }
266
+ }
251
267
  }
252
268
 
253
269
  // 5. Patch page file — add import and registry entry for new section
@@ -381,7 +397,7 @@ export function patchPageFile(
381
397
  const lastEntryMatch = registryContent.match(/.*\w+Section,?\s*$/m)
382
398
  if (lastEntryMatch && lastEntryMatch.index !== undefined) {
383
399
  const insertPos = registryMatch.index! + registryMatch[0].indexOf(registryContent) + lastEntryMatch.index + lastEntryMatch[0].length
384
- const newEntry = `\n [normalize('${sectionKey}')]: ${componentName},`
400
+ const newEntry = `\n '${sectionKey}': ${componentName},`
385
401
  patched = patched.slice(0, insertPos) + newEntry + patched.slice(insertPos)
386
402
  }
387
403
  }
@@ -414,6 +430,25 @@ export function calculateRelativePath(fromDir: string, toPath: string): string {
414
430
  return '../'.repeat(ups) + remaining
415
431
  }
416
432
 
433
+ /**
434
+ * Resolve a relative import path against a base directory to get the repo-root-relative path.
435
+ * e.g. resolveRelativePath("src/components/sections", "../components/PricingCard.astro")
436
+ * → "src/components/PricingCard.astro"
437
+ */
438
+ function resolveRelativePath(baseDir: string, relativePath: string): string | null {
439
+ if (relativePath.startsWith('/')) return relativePath.replace(/^\//, '')
440
+ const parts = [...baseDir.split('/').filter(Boolean)]
441
+ for (const segment of relativePath.split('/')) {
442
+ if (segment === '.') continue
443
+ if (segment === '..') { parts.pop(); continue }
444
+ parts.push(segment)
445
+ }
446
+ const resolved = parts.join('/')
447
+ // Must stay within src/ or project root — reject anything that escapes
448
+ if (resolved.startsWith('../') || resolved.startsWith('/')) return null
449
+ return resolved
450
+ }
451
+
417
452
  async function fetchFileContent(
418
453
  owner: string,
419
454
  repo: string,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tests for patchChildComponentForFieldPrefix:
3
+ * Injects fieldPrefix prop + data-sk-field bindings into child components
4
+ * used by repeated-component sections (e.g. PricingCard).
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import { readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { patchChildComponentForFieldPrefix, detectChildImports } from '../template-patcher-v2'
11
+
12
+ const CHILD_DIR = join(import.meta.dirname!, '..', '..', '..', '..', '..', 'test-sections', 'child-components')
13
+
14
+ function readFixture(name: string): string {
15
+ return readFileSync(join(CHILD_DIR, name), 'utf-8')
16
+ }
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Inner fields for PricingCard (mirrors what the analyzer produces)
20
+ // ---------------------------------------------------------------------------
21
+ const PRICING_INNER_FIELDS = [
22
+ { key: 'name', type: 'text' },
23
+ { key: 'price', type: 'text' },
24
+ { key: 'priceNote', type: 'text' },
25
+ { key: 'description', type: 'text' },
26
+ { key: 'features', type: 'array' },
27
+ { key: 'cta', type: 'text' },
28
+ { key: 'ctaHref', type: 'text' },
29
+ ]
30
+
31
+ describe('patchChildComponentForFieldPrefix', () => {
32
+ it('adds fieldPrefix to Props interface', () => {
33
+ const source = readFixture('PricingCard.astro')
34
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
35
+ expect(patched).toContain('fieldPrefix?: string')
36
+ })
37
+
38
+ it('adds fieldPrefix to destructuring', () => {
39
+ const source = readFixture('PricingCard.astro')
40
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
41
+ expect(patched).toMatch(/fieldPrefix\s*[,}]/)
42
+ })
43
+
44
+ it('adds data-sk-field for scalar text props', () => {
45
+ const source = readFixture('PricingCard.astro')
46
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
47
+ expect(patched).toContain('fieldPrefix}.name')
48
+ expect(patched).toContain('fieldPrefix}.price')
49
+ expect(patched).toContain('fieldPrefix}.description')
50
+ })
51
+
52
+ it('adds data-sk-field for href prop on <a>', () => {
53
+ const source = readFixture('PricingCard.astro')
54
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
55
+ expect(patched).toContain('fieldPrefix}.ctaHref')
56
+ expect(patched).toContain('fieldPrefix}.cta')
57
+ })
58
+
59
+ it('adds indexed data-sk-field for array prop features', () => {
60
+ const source = readFixture('PricingCard.astro')
61
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
62
+ expect(patched).toMatch(/fieldPrefix\}\.features\._fi|fieldPrefix\}\.features\.\$\{_fi\}/)
63
+ })
64
+
65
+ it('is idempotent — second pass produces no changes', () => {
66
+ const source = readFixture('PricingCard.astro')
67
+ const patched = patchChildComponentForFieldPrefix(source, PRICING_INNER_FIELDS)
68
+ const patchedAgain = patchChildComponentForFieldPrefix(patched, PRICING_INNER_FIELDS)
69
+ expect(patchedAgain).toBe(patched)
70
+ })
71
+
72
+ it('does not modify source when fieldPrefix already present', () => {
73
+ const source = readFixture('PricingCard.astro')
74
+ const prePatched = source.replace(
75
+ 'highlight?: boolean;',
76
+ 'highlight?: boolean;\n fieldPrefix?: string;',
77
+ )
78
+ const result = patchChildComponentForFieldPrefix(prePatched, PRICING_INNER_FIELDS)
79
+ expect(result).toContain('fieldPrefix?: string')
80
+ expect((result.match(/fieldPrefix\?\s*:\s*string/g) ?? []).length).toBe(1)
81
+ })
82
+ })
83
+
84
+ describe('detectChildImports', () => {
85
+ it('detects component imports from fields with sourceComponent', () => {
86
+ const sectionSource = `---
87
+ import PricingCard from '../components/PricingCard.astro'
88
+ interface Props { data: Record<string, any> | null }
89
+ const { data: skData } = Astro.props
90
+ ---
91
+ <section></section>`
92
+
93
+ const fields = [
94
+ {
95
+ key: 'pricingcards',
96
+ type: 'array',
97
+ defaultValue: [],
98
+ options: {
99
+ sourceComponent: 'PricingCard',
100
+ arrayItem: { type: 'object', fields: PRICING_INNER_FIELDS },
101
+ },
102
+ },
103
+ ]
104
+
105
+ const imports = detectChildImports(sectionSource, fields as any)
106
+ expect(imports).toHaveLength(1)
107
+ expect(imports[0]!.compName).toBe('PricingCard')
108
+ expect(imports[0]!.importPath).toBe('../components/PricingCard.astro')
109
+ expect(imports[0]!.innerFields).toEqual(PRICING_INNER_FIELDS)
110
+ })
111
+
112
+ it('returns empty array when no sourceComponent fields', () => {
113
+ const source = `---\nconst x = 1\n---\n<div></div>`
114
+ const fields = [{ key: 'heading', type: 'text' }]
115
+ expect(detectChildImports(source, fields as any)).toHaveLength(0)
116
+ })
117
+ })
@@ -102,6 +102,17 @@ function checkSkFieldBindings(
102
102
  }
103
103
  continue
104
104
  }
105
+ // Array fields from repeated component instances use fieldPrefix= instead of data-sk-field
106
+ // (Astro components don't forward unknown props to the DOM).
107
+ if (field.type === 'array' && (field as any).options?.sourceComponent) {
108
+ const fieldPrefixBinding = `${sectionKey}.${field.key}.\${_i}\``
109
+ if (patched.includes(fieldPrefixBinding)) {
110
+ found.push(`${sectionKey}.${field.key}.*`)
111
+ } else {
112
+ missing.push(`${sectionKey}.${field.key}`)
113
+ }
114
+ continue
115
+ }
105
116
  if (!fieldNeedsSkBinding(field)) {
106
117
  skipped.push(`${sectionKey}.${field.key} (${field.type})`)
107
118
  continue
@@ -390,6 +401,47 @@ describe('Section Pipeline', () => {
390
401
  const bindings = checkSkFieldBindings(patched, sectionKey, section.fields, groups)
391
402
  expect(bindings.missing, `Missing bindings: ${bindings.missing.join(', ')}`).toHaveLength(0)
392
403
  })
404
+
405
+ it('should collapse repeated component instances into .map()', () => {
406
+ const componentArrayFields = section.fields.filter(
407
+ (f: any) => f.type === 'array' && f.options?.sourceComponent,
408
+ )
409
+ if (componentArrayFields.length === 0) return
410
+ for (const field of componentArrayFields) {
411
+ const compName = (field as any).options.sourceComponent as string
412
+ const tagRegex = new RegExp(`<${compName}[\\s/>]`, 'g')
413
+ const originalCount = (source.match(tagRegex) || []).length
414
+ const patchedCount = (patched.match(tagRegex) || []).length
415
+ expect(
416
+ patchedCount,
417
+ `<${compName}> count should be reduced (was ${originalCount}, still ${patchedCount})`,
418
+ ).toBeLessThan(originalCount)
419
+ expect(patched, `Expected .map( after collapsing <${compName}>`).toContain('.map(')
420
+ }
421
+ })
422
+
423
+ it('should remove stale frontmatter vars from repeated component props', () => {
424
+ const componentArrayFields = section.fields.filter(
425
+ (f: any) => f.type === 'array' && f.options?.sourceComponent && Array.isArray(f.defaultValue),
426
+ )
427
+ if (componentArrayFields.length === 0) return
428
+ const fmEnd = patched.indexOf('---', patched.indexOf('---') + 3)
429
+ const patchedFm = fmEnd > 0 ? patched.slice(0, fmEnd) : ''
430
+ const staleVars: string[] = []
431
+ for (const field of componentArrayFields) {
432
+ const items = (field as any).defaultValue as Array<Record<string, unknown>>
433
+ for (const innerField of ((field as any).options?.arrayItem?.fields ?? []) as Array<{ key: string }>) {
434
+ // Only string-array props might have been declared as frontmatter vars
435
+ const allVarLike = items.every(item => Array.isArray(item[innerField.key]))
436
+ if (!allVarLike) continue
437
+ const varNames = items.map((_, i) => `${innerField.key}${i === 0 ? '' : i + 1}`)
438
+ for (const v of varNames) {
439
+ if (patchedFm.includes(`const ${v}`)) staleVars.push(v)
440
+ }
441
+ }
442
+ }
443
+ expect(staleVars, `Stale frontmatter vars: ${staleVars.join(', ')}`).toHaveLength(0)
444
+ })
393
445
  })
394
446
  }
395
447
  })
@@ -1236,7 +1236,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1236
1236
  addField({
1237
1237
  key: cardKey, type: 'array', label: `${compName} Liste`, confidence: 'medium',
1238
1238
  defaultValue: instanceValues.length > 0 ? instanceValues : undefined,
1239
- options: { arrayItem: { type: 'object', fields: innerFields } },
1239
+ options: { arrayItem: { type: 'object', fields: innerFields }, sourceComponent: compName },
1240
1240
  }, nodeOffset(instances[0]!))
1241
1241
  }
1242
1242
  }
@@ -279,6 +279,10 @@ export async function patchTemplateForFields(
279
279
  // No .map() expression found — try static <ul>/<li> conversion
280
280
  patchStaticListField(source, sectionKey, field, varName, ast, edits)
281
281
  }
282
+ if (edits.length === editsBefore) {
283
+ // Still nothing — try collapsing repeated component instances
284
+ patchRepeatedComponentInstances(source, sectionKey, field, varName, ast, edits)
285
+ }
282
286
  continue
283
287
  }
284
288
 
@@ -1473,6 +1477,140 @@ function collectDynamicClassEdits(
1473
1477
  }
1474
1478
  }
1475
1479
 
1480
+ /**
1481
+ * Collapses N repeated instances of the same Astro component into a single
1482
+ * `.map()` expression over a CMS array field.
1483
+ *
1484
+ * Example: 2× <PricingCard name="Free" features={freeFeatures} />
1485
+ * → {(skData?.pricingcards ?? [...]).map((item, _i) => <PricingCard name={item.name} ... />)}
1486
+ *
1487
+ * Only triggered when field.options.sourceComponent is set (by the analyzer).
1488
+ */
1489
+ function patchRepeatedComponentInstances(
1490
+ source: string,
1491
+ sectionKey: string,
1492
+ field: PatchField,
1493
+ varName: string,
1494
+ ast: AstNode,
1495
+ edits: Edit[],
1496
+ ): void {
1497
+ const compName = (field as any).options?.sourceComponent as string | undefined
1498
+ if (!compName) return
1499
+
1500
+ // Collect all component instances from the AST
1501
+ const instanceNodes: AstNode[] = []
1502
+ walkAst(ast, (node) => {
1503
+ if (node.type === 'component' && node.name === compName) {
1504
+ instanceNodes.push(node)
1505
+ }
1506
+ })
1507
+ if (instanceNodes.length < 2) return
1508
+
1509
+ // Resolve source ranges: [start, end) for each instance
1510
+ // The AST gives us approximate positions; we scan forward/backward to find exact tag bounds.
1511
+ const ranges: Array<{ start: number; end: number; attrs: AstAttr[] }> = []
1512
+ for (const node of instanceNodes) {
1513
+ const approxStart = node.position?.start?.offset
1514
+ if (approxStart == null) return
1515
+ const searchFrom = Math.max(0, approxStart - 2)
1516
+ const tagStart = source.indexOf(`<${compName}`, searchFrom)
1517
+ if (tagStart === -1 || tagStart > approxStart + 2) return
1518
+
1519
+ // Find the end: either a self-closing /> or explicit </CompName>
1520
+ const tagEnd = findComponentTagEnd(source, tagStart, compName)
1521
+ if (tagEnd === -1) return
1522
+ ranges.push({ start: tagStart, end: tagEnd, attrs: node.attributes ?? [] })
1523
+ }
1524
+ if (ranges.length < 2) return
1525
+
1526
+ // Build the default value JSON (inline fallback in the .map() call)
1527
+ const defaultItems = Array.isArray(field.defaultValue) ? field.defaultValue : []
1528
+ const defaultJson = JSON.stringify(defaultItems)
1529
+
1530
+ // Build the mapped component tag from the first instance's attributes.
1531
+ // CMS-managed props are replaced with item.propName; structural props are kept as-is.
1532
+ const innerFieldKeys = new Set<string>(
1533
+ ((field as any).options?.arrayItem?.fields ?? []).map((f: { key: string }) => f.key),
1534
+ )
1535
+ const skipPropRegex = /^(class|className|id|style|type|role|aria-|data-)$/
1536
+ const firstAttrs = ranges[0]!.attrs
1537
+ const propLines: string[] = []
1538
+ for (const attr of firstAttrs) {
1539
+ const name = attr.name
1540
+ if (skipPropRegex.test(name) || name.startsWith('aria-') || name.startsWith('data-')) {
1541
+ // Keep structural attributes verbatim
1542
+ const raw = attr.kind === 'quoted' ? `${name}="${attr.value}"` : `${name}={${attr.value}}`
1543
+ propLines.push(raw)
1544
+ continue
1545
+ }
1546
+ if (innerFieldKeys.has(name)) {
1547
+ propLines.push(`${name}={item.${name}}`)
1548
+ } else {
1549
+ // Unknown prop — keep original value
1550
+ const raw = attr.kind === 'quoted' ? `${name}="${attr.value}"` : `${name}={${attr.value}}`
1551
+ propLines.push(raw)
1552
+ }
1553
+ }
1554
+ // fieldPrefix enables child component to add its own data-sk-field bindings.
1555
+ // data-sk-field on component calls is not forwarded to the DOM by Astro.
1556
+ propLines.push(`fieldPrefix={\`${sectionKey}.${field.key}.\${_i}\`}`)
1557
+
1558
+ const propsStr = propLines.map(p => ` ${p}`).join('\n')
1559
+ const mapExpr =
1560
+ `{(${varName}?.${field.key} ?? ${defaultJson}).map((item, _i) => (\n` +
1561
+ ` <${compName}\n${propsStr}\n />\n` +
1562
+ `))}`
1563
+
1564
+ // Replace first instance with the .map() expression; delete the rest.
1565
+ // Also consume any leading whitespace before subsequent instances so we don't
1566
+ // leave blank lines.
1567
+ edits.push({
1568
+ offset: ranges[0]!.start,
1569
+ deleteCount: ranges[0]!.end - ranges[0]!.start,
1570
+ insert: mapExpr,
1571
+ })
1572
+ for (let i = 1; i < ranges.length; i++) {
1573
+ const r = ranges[i]!
1574
+ // Walk back to consume leading whitespace/newline
1575
+ let deleteStart = r.start
1576
+ while (deleteStart > 0 && /[ \t]/.test(source[deleteStart - 1]!)) deleteStart--
1577
+ if (deleteStart > 0 && source[deleteStart - 1] === '\n') deleteStart--
1578
+ edits.push({ offset: deleteStart, deleteCount: r.end - deleteStart, insert: '' })
1579
+ }
1580
+ }
1581
+
1582
+ /**
1583
+ * Find the end offset of a component tag starting at `tagStart`.
1584
+ * Returns the offset AFTER the last character (`>` of `/>` or `</Name>`).
1585
+ * Returns -1 if the end cannot be determined.
1586
+ */
1587
+ function findComponentTagEnd(source: string, tagStart: number, compName: string): number {
1588
+ let i = tagStart + 1 // skip '<'
1589
+ let inQuote: string | null = null
1590
+ let inExpr = 0
1591
+ while (i < source.length) {
1592
+ const ch = source[i]!
1593
+ if (inQuote) {
1594
+ if (ch === inQuote && source[i - 1] !== '\\') inQuote = null
1595
+ i++; continue
1596
+ }
1597
+ if (ch === '{') { inExpr++; i++; continue }
1598
+ if (ch === '}' && inExpr > 0) { inExpr--; i++; continue }
1599
+ if (inExpr > 0) { i++; continue }
1600
+ if (ch === '"' || ch === "'") { inQuote = ch; i++; continue }
1601
+ if (ch === '/' && source[i + 1] === '>') return i + 2 // self-closing />
1602
+ if (ch === '>' ) {
1603
+ // Check for explicit closing tag </CompName>
1604
+ const closing = `</${compName}>`
1605
+ const closingIdx = source.indexOf(closing, i + 1)
1606
+ if (closingIdx !== -1) return closingIdx + closing.length
1607
+ return i + 1
1608
+ }
1609
+ i++
1610
+ }
1611
+ return -1
1612
+ }
1613
+
1476
1614
  function patchRepeatedGroups(
1477
1615
  source: string,
1478
1616
  sectionKey: string,
@@ -1925,6 +2063,19 @@ function removeOldVarDeclarations(source: string, fields: PatchField[], repeated
1925
2063
  }
1926
2064
  }
1927
2065
 
2066
+ // Remove frontmatter array variables that were passed as props to repeated components
2067
+ // and are no longer referenced in the template after patching.
2068
+ const templatePart0 = source.slice(fmEnd + 3)
2069
+ const fmVarRegex2 = /(?:const|let)\s+(\w+)\s*=\s*\[/g
2070
+ let m: RegExpExecArray | null
2071
+ while ((m = fmVarRegex2.exec(frontmatter)) !== null) {
2072
+ const vName = m[1]!
2073
+ if (fieldKeys.has(vName)) continue
2074
+ // If no longer referenced in the template, it was consumed by component patching
2075
+ const stillUsed = new RegExp(`\\b${vName}\\b`).test(templatePart0)
2076
+ if (!stillUsed) fieldKeys.add(vName)
2077
+ }
2078
+
1928
2079
  // Collect all ranges to remove (relative to frontmatter string)
1929
2080
  const removals: Array<{ start: number; end: number }> = []
1930
2081
  // Aliases that must be appended AFTER the cmsVarName (= skData) declaration to avoid TDZ
@@ -2021,6 +2172,168 @@ function removeOldVarDeclarations(source: string, fields: PatchField[], repeated
2021
2172
  //
2022
2173
  // Returns the cleaned source and a map of fieldKey → extracted value.
2023
2174
  // The caller decides which values to write to JSON:
2175
+ // ---------------------------------------------------------------------------
2176
+ // Child-component fieldPrefix patching
2177
+ // ---------------------------------------------------------------------------
2178
+
2179
+ /**
2180
+ * Patches a child Astro component (e.g. PricingCard.astro) to accept a
2181
+ * `fieldPrefix` prop and expose `data-sk-field` bindings on its elements,
2182
+ * enabling the inline editor to identify individual fields.
2183
+ *
2184
+ * Transforms:
2185
+ * <p>{name}</p> → <p data-sk-field={fieldPrefix ? `${fieldPrefix}.name` : undefined}>{name}</p>
2186
+ * href={ctaHref} → data-sk-field={fieldPrefix ? `${fieldPrefix}.ctaHref` : undefined}
2187
+ * features.map() → features.map((feature, _fi) => ... data-sk-field per item
2188
+ *
2189
+ * Idempotent: no-op if fieldPrefix is already present.
2190
+ */
2191
+ export function patchChildComponentForFieldPrefix(
2192
+ source: string,
2193
+ innerFields: Array<{ key: string; type: string }>,
2194
+ ): string {
2195
+ // Idempotency guard — already patched
2196
+ if (source.includes('fieldPrefix?: string') || source.includes("fieldPrefix?:string")) return source
2197
+
2198
+ const fmStart = source.indexOf('---')
2199
+ const fmEnd = source.indexOf('---', fmStart + 3)
2200
+ if (fmStart === -1 || fmEnd === -1) return source
2201
+
2202
+ let result = source
2203
+
2204
+ // 1. Add fieldPrefix to Props interface
2205
+ // Find the closing `}` of the interface block
2206
+ const interfaceMatch = result.match(/export\s+interface\s+Props\s*\{([\s\S]*?)\}/)
2207
+ if (interfaceMatch) {
2208
+ const ifaceEnd = result.indexOf(interfaceMatch[0]) + interfaceMatch[0].length
2209
+ const lastPropLine = interfaceMatch[0].slice(0, -1).trimEnd() // remove trailing `}`
2210
+ result =
2211
+ result.slice(0, ifaceEnd - 1) +
2212
+ '\n fieldPrefix?: string;\n}' +
2213
+ result.slice(ifaceEnd)
2214
+ } else {
2215
+ // No interface — insert before closing ---
2216
+ const closeFm = result.indexOf('---', result.indexOf('---') + 3)
2217
+ result = result.slice(0, closeFm) + 'interface Props { fieldPrefix?: string }\n' + result.slice(closeFm)
2218
+ }
2219
+
2220
+ // 2. Add fieldPrefix to destructuring
2221
+ // Match: const { ..., highlight = false } = Astro.props
2222
+ result = result.replace(
2223
+ /(\bconst\s*\{[^}]*)(}\s*=\s*Astro\.props)/,
2224
+ (_m, before, after) => {
2225
+ // Avoid adding twice
2226
+ if (before.includes('fieldPrefix')) return _m
2227
+ const trimmed = before.trimEnd()
2228
+ const sep = trimmed.endsWith(',') ? ' ' : ',\n '
2229
+ return `${trimmed}${sep}fieldPrefix${after}`
2230
+ },
2231
+ )
2232
+
2233
+ // 3. Patch template elements — work on the template part only
2234
+ const closingFm = result.indexOf('---', result.indexOf('---') + 3)
2235
+ const frontmatterPart = result.slice(0, closingFm + 3)
2236
+ let templatePart = result.slice(closingFm + 3)
2237
+
2238
+ const scalarFields = innerFields.filter(f => f.type !== 'array')
2239
+ const arrayFields = innerFields.filter(f => f.type === 'array')
2240
+
2241
+ // 3a. Scalar fields: add data-sk-field to the element containing {propName}
2242
+ for (const field of scalarFields) {
2243
+ const propExpr = `{${field.key}}`
2244
+ // href attribute: <a href={ctaHref} ...
2245
+ if (field.key.toLowerCase().includes('href') || field.key.toLowerCase().includes('link')) {
2246
+ templatePart = templatePart.replace(
2247
+ new RegExp(`href=\\{${field.key}\\}`, 'g'),
2248
+ `href={${field.key}} data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`,
2249
+ )
2250
+ continue
2251
+ }
2252
+
2253
+ // Inline text in element: find <tag ...>{propName}</tag> or <tag ...>{propName}
2254
+ // Add data-sk-field to the opening tag of the nearest wrapping element
2255
+ const tagWithExprRegex = new RegExp(
2256
+ `(<(?!/)(?:p|span|h[1-6]|div|li|td|th|dt|dd|label|button|a)\\b[^>]*?)(\\/?>)([^<]*\\{${field.key}\\})`,
2257
+ 'g',
2258
+ )
2259
+ templatePart = templatePart.replace(tagWithExprRegex, (_m, tagOpen, tagClose, rest) => {
2260
+ if (tagOpen.includes('data-sk-field')) return _m
2261
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`
2262
+ return `${tagOpen}${attr}${tagClose}${rest}`
2263
+ })
2264
+ }
2265
+
2266
+ // 3b. Array fields: add index param + data-sk-field on innermost element
2267
+ for (const field of arrayFields) {
2268
+ // Match: features.map((feature) => or features.map((feature, fi) =>
2269
+ const mapParamRegex = new RegExp(
2270
+ `(${field.key}\\.map\\s*\\(\\s*\\(\\s*)(\\w+)(\\s*\\))`,
2271
+ 'g',
2272
+ )
2273
+ // Add _fi index if missing, and annotate the ul/ol containing the map
2274
+ templatePart = templatePart.replace(
2275
+ new RegExp(`(<(?:ul|ol)[^>]*?)(\\/?>)([\\s\\S]*?${field.key}\\.map)`, 'g'),
2276
+ (_m, ulOpen, ulClose, rest) => {
2277
+ if (ulOpen.includes('data-sk-field')) return _m
2278
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}\` : undefined}`
2279
+ return `${ulOpen}${attr}${ulClose}${rest}`
2280
+ },
2281
+ )
2282
+
2283
+ // Add _fi index param to .map() callback if not already indexed
2284
+ templatePart = templatePart.replace(mapParamRegex, (_m, before, param, close) => {
2285
+ if (close.trim().startsWith(',')) return _m // already has index
2286
+ return `${before}${param}, _fi${close}`
2287
+ })
2288
+
2289
+ // Add data-sk-field on the innermost span/li that renders the item variable
2290
+ // Pattern: <span ...>{itemVar}</span> or <li ...>{itemVar}</li>
2291
+ // We search within .map() callback blocks
2292
+ const mapBlockRegex = new RegExp(
2293
+ `(${field.key}\\.map\\s*\\([^)]*\\)\\s*=>\\s*\\([\\s\\S]*?)(<(?:span|li|td)\\b)([^>]*?>)([^<]*\\{\\w+\\})`,
2294
+ 'g',
2295
+ )
2296
+ templatePart = templatePart.replace(mapBlockRegex, (_m, mapHead, tagOpen, tagClose, content) => {
2297
+ if (tagClose.includes('data-sk-field')) return _m
2298
+ const attr = ` data-sk-field={fieldPrefix ? \`\${fieldPrefix}.${field.key}.\${_fi}\` : undefined}`
2299
+ return `${mapHead}${tagOpen}${tagClose.slice(0, -1)}${attr}>${content}`
2300
+ })
2301
+ }
2302
+
2303
+ return frontmatterPart + templatePart
2304
+ }
2305
+
2306
+ /**
2307
+ * Scans the section source for import statements matching any field's
2308
+ * `options.sourceComponent`, and returns metadata for child-component patching.
2309
+ */
2310
+ export function detectChildImports(
2311
+ source: string,
2312
+ fields: PatchField[],
2313
+ ): Array<{ compName: string; importPath: string; innerFields: Array<{ key: string; type: string }> }> {
2314
+ const result: Array<{ compName: string; importPath: string; innerFields: Array<{ key: string; type: string }> }> = []
2315
+
2316
+ for (const field of fields) {
2317
+ const compName = (field as any).options?.sourceComponent as string | undefined
2318
+ if (!compName) continue
2319
+
2320
+ const innerFields: Array<{ key: string; type: string }> =
2321
+ ((field as any).options?.arrayItem?.fields ?? []).map((f: { key: string; type: string }) => ({
2322
+ key: f.key,
2323
+ type: f.type,
2324
+ }))
2325
+
2326
+ // Find: import CompName from '...'
2327
+ const importRegex = new RegExp(`import\\s+${compName}\\s+from\\s+['"]([^'"]+)['"]`)
2328
+ const m = source.match(importRegex)
2329
+ if (!m) continue
2330
+
2331
+ result.push({ compName, importPath: m[1]!, innerFields })
2332
+ }
2333
+
2334
+ return result
2335
+ }
2336
+
2024
2337
  // - If the JSON already has a value for that field → just strip (JSON wins)
2025
2338
  // - If the JSON has no value → use the extracted fallback
2026
2339
  // ---------------------------------------------------------------------------