@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.
- package/dist/api-routes/init-add-section.js +39 -3
- package/dist/api-routes/init-apply.js +1 -1
- package/dist/api-routes/init-migrate.js +1 -1
- package/dist/api-routes/init-scan-page.js +1 -1
- package/dist/api-routes/init-scan.js +1 -1
- package/dist/api-routes/section-commit-pending.js +1 -1
- package/dist/{chunk-RHJONMLK.js → chunk-Q3N336KR.js} +212 -0
- package/dist/{chunk-K22A4ZBS.js → chunk-TD76R3A6.js} +1 -1
- package/package.json +6 -6
- package/src/api-routes/__tests__/add-section-helpers.test.ts +33 -73
- package/src/api-routes/init-add-section.ts +47 -12
- package/src/init/__tests__/patcher-child-component.test.ts +117 -0
- package/src/init/__tests__/section-pipeline.test.ts +52 -0
- package/src/init/astro-section-analyzer-v2.ts +1 -1
- package/src/init/template-patcher-v2.ts +313 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
detectChildImports,
|
|
3
|
+
patchChildComponentForFieldPrefix,
|
|
2
4
|
patchTemplateForFields,
|
|
3
5
|
stripTemplateFallbacks
|
|
4
|
-
} from "../chunk-
|
|
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
|
-
|
|
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(
|
|
@@ -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.
|
|
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.
|
|
274
|
-
"@setzkasten-cms/catalog": "1.4.
|
|
275
|
-
"@setzkasten-cms/core": "1.4.
|
|
276
|
-
"@setzkasten-cms/github-adapter": "1.4.
|
|
277
|
-
"@setzkasten-cms/ui": "1.4.
|
|
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
|
|
110
|
-
function buildPreviewClone(
|
|
111
|
-
|
|
112
|
-
|
|
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 —
|
|
226
|
+
// 4. sk-preview clone — thin wrapper with correct import depth
|
|
229
227
|
// ---------------------------------------------------------------------------
|
|
230
228
|
|
|
231
229
|
describe('sk-preview clone generation', () => {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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('
|
|
252
|
-
const clone = buildPreviewClone(
|
|
253
|
-
|
|
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('
|
|
259
|
-
const clone = buildPreviewClone(
|
|
260
|
-
expect(clone).toContain("from '
|
|
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('
|
|
264
|
-
const clone = buildPreviewClone(
|
|
265
|
-
expect(clone).toContain(
|
|
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('
|
|
269
|
-
const
|
|
270
|
-
|
|
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:
|
|
346
|
-
const bodyPagePath = 'src/pages/docs.astro'
|
|
320
|
+
it('directory route: relativePage uses resolved path, not body path', () => {
|
|
347
321
|
const resolved = resolvePagePath(
|
|
348
|
-
|
|
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
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
const
|
|
377
|
-
|
|
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
|
|
196
|
-
// The
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|