@bettercms-ai/convert 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -151,7 +151,7 @@ function propsAttribute(props) {
151
151
  var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
152
152
  var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
153
153
  var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
154
- var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
154
+ var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{(?:`[^`]*`|[^}])*\}\}/g;
155
155
  var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
156
156
  const [address, kind] = entry.split("|");
157
157
  if (!address) return [];
@@ -192,7 +192,10 @@ function declarationsIn(roots, content) {
192
192
  for (const root of roots) visit(root);
193
193
  return out;
194
194
  }
195
- var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
195
+ var bindingsPaths = (text) => [
196
+ ...[...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]),
197
+ ...[...text.matchAll(/`([^`]+)`/g)].map((match) => shapeOfPath(match[1]))
198
+ ];
196
199
  var scopedPath = (scope, path) => `${scope}::${path}`;
197
200
  var covers = (declared, target) => declared !== void 0 && (declared.has(scopedPath(target.scope, target.path)) || declared.has(scopedPath(target.scope, shapeOfPath(target.path))));
198
201
  var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
@@ -3029,7 +3032,7 @@ function receptionIn(component, propName, offset, content) {
3029
3032
  let receiver = null;
3030
3033
  let rest = null;
3031
3034
  let hasBindings = false;
3032
- let destructured = false;
3035
+ let destructured2 = false;
3033
3036
  for (const node of component ? [component] : []) {
3034
3037
  const params = node.params ?? [];
3035
3038
  const first = params[0];
@@ -3055,7 +3058,7 @@ function receptionIn(component, propName, offset, content) {
3055
3058
  if (key !== propName) continue;
3056
3059
  const value = property.value;
3057
3060
  if (value?.type === "Identifier") names.add(String(value.name));
3058
- destructured = true;
3061
+ destructured2 = true;
3059
3062
  receivable = true;
3060
3063
  receive = patternClose(content, first, offset);
3061
3064
  }
@@ -3065,7 +3068,7 @@ function receptionIn(component, propName, offset, content) {
3065
3068
  viaProps = true;
3066
3069
  }
3067
3070
  }
3068
- return { names, receive, viaProps, typeName, receivable, inline, receiver, rest, hasBindings, destructured };
3071
+ return { names, receive, viaProps, typeName, receivable, inline, receiver, rest, hasBindings, destructured: destructured2 };
3069
3072
  }
3070
3073
  function astroReception(ast, propName, offset, content) {
3071
3074
  const names = /* @__PURE__ */ new Set();
@@ -3248,8 +3251,7 @@ function splitGroup(path) {
3248
3251
  return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
3249
3252
  }
3250
3253
  function moduleOf(parsed) {
3251
- if (!parsed.ast) return null;
3252
- return { ast: parsed.ast, offset: parsed.script?.offset ?? 0 };
3254
+ return { ast: parsed.ast ?? null, offset: parsed.script?.offset ?? 0 };
3253
3255
  }
3254
3256
  function arrayLiterals(ast) {
3255
3257
  const out = /* @__PURE__ */ new Map();
@@ -3262,6 +3264,19 @@ function arrayLiterals(ast) {
3262
3264
  });
3263
3265
  return out;
3264
3266
  }
3267
+ function exportedNames(ast) {
3268
+ const out = /* @__PURE__ */ new Set();
3269
+ walkAst(ast, (node) => {
3270
+ if (node.type !== "ExportNamedDeclaration") return;
3271
+ const declaration2 = node.declaration;
3272
+ if (declaration2?.type !== "VariableDeclaration") return;
3273
+ for (const declarator of declaration2.declarations ?? []) {
3274
+ const id = declarator.id;
3275
+ if (id?.type === "Identifier") out.add(String(id.name));
3276
+ }
3277
+ });
3278
+ return out;
3279
+ }
3265
3280
  function propertyFor(row, literal) {
3266
3281
  if (row?.type !== "ObjectExpression") return null;
3267
3282
  for (const property of row.properties ?? []) {
@@ -3286,21 +3301,36 @@ function iterations(roots, identifier2) {
3286
3301
  for (const root of roots) visit(root);
3287
3302
  return out;
3288
3303
  }
3289
- function callbackParams(expression, identifier2) {
3290
- const pattern = new RegExp(
3291
- `(?<![\\w$])${identifier2}(?![\\w$])\\s*\\.\\s*map\\s*\\(\\s*(?:\\(\\s*([A-Za-z_$][\\w$]*)\\s*(,\\s*([A-Za-z_$][\\w$]*)\\s*)?\\)|([A-Za-z_$][\\w$]*))\\s*=>`
3292
- );
3293
- const match = expression.value.match(pattern);
3304
+ var CALLBACK = String.raw`\s*\.\s*map\s*\(\s*(?:\(\s*(?:([A-Za-z_$][\w$]*)|(\[[^\]]*\]))\s*(,\s*([A-Za-z_$][\w$]*)\s*)?\)|([A-Za-z_$][\w$]*))\s*=>`;
3305
+ function paramsIn(text, pattern, base) {
3306
+ const match = text.match(pattern);
3294
3307
  if (!match || match.index === void 0) return null;
3295
- const row = match[1] ?? match[4];
3296
- if (match[4] !== void 0) return { row, index: null, addIndexAt: -1 };
3297
- const within = match[0].lastIndexOf(row);
3308
+ if (match[5] !== void 0) return { row: match[5], names: null, index: null, addIndexAt: -1 };
3309
+ const bound = match[1] ?? match[2];
3310
+ const within = match[0].lastIndexOf(bound);
3298
3311
  return {
3299
- row,
3300
- index: match[3] ?? null,
3301
- addIndexAt: expression.range.start + match.index + within + row.length
3312
+ // One of the two, never both: a row is either named or taken apart. @see bindLists for the
3313
+ // second shape — `([question, answer]) =>`, which is how a TUPLE reaches the markup.
3314
+ row: match[1] ?? null,
3315
+ names: match[2] === void 0 ? null : destructured(match[2]),
3316
+ index: match[4] ?? null,
3317
+ addIndexAt: base + match.index + within + bound.length
3302
3318
  };
3303
3319
  }
3320
+ function callbackParams(expression, identifier2) {
3321
+ return paramsIn(
3322
+ expression.value,
3323
+ new RegExp(`(?<![\\w$])${identifier2}(?![\\w$])${CALLBACK}`),
3324
+ expression.range.start
3325
+ );
3326
+ }
3327
+ function mapParams(content, at, until) {
3328
+ return paramsIn(content.slice(at, until), new RegExp(`^${CALLBACK}`), at);
3329
+ }
3330
+ function destructured(pattern) {
3331
+ const names = pattern.slice(1, -1).split(",").map((part) => part.trim());
3332
+ return names.every((name) => /^[A-Za-z_$][\w$]*$/.test(name)) ? names : null;
3333
+ }
3304
3334
  function renders(content, node, row) {
3305
3335
  if (!node.inner) return null;
3306
3336
  const inner = content.slice(node.inner.start, node.inner.end).trim();
@@ -3388,11 +3418,11 @@ function bindScalar(ctx, member, literals, offset) {
3388
3418
  }
3389
3419
  return { splices, refusal: null };
3390
3420
  }
3391
- function bindDataLiterals(ctx, members) {
3392
- const out = { splices: [], usesRead: false, usesRows: false, slugs: [], bound: [], declarations: [], refused: [] };
3421
+ async function bindDataLiterals(ctx, members) {
3422
+ const out = { splices: [], usesRead: false, usesRows: false, usesList: false, usesTuples: false, notes: [], slugs: [], bound: [], declarations: [], refused: [], componentEdits: [], props: [] };
3393
3423
  const rule = DIALECT_RULES[ctx.dialect];
3394
3424
  const module = moduleOf(ctx.parsed);
3395
- if (!module || !rule.text) return out;
3425
+ if (!rule.text) return out;
3396
3426
  const groups = /* @__PURE__ */ new Map();
3397
3427
  const scalars = [];
3398
3428
  for (const member of members) {
@@ -3405,6 +3435,7 @@ function bindDataLiterals(ctx, members) {
3405
3435
  const key = `${member.slug}\0${split.array}`;
3406
3436
  groups.set(key, [...groups.get(key) ?? [], held]);
3407
3437
  }
3438
+ const indexes = /* @__PURE__ */ new Set();
3408
3439
  const strings = stringLiterals(module.ast);
3409
3440
  for (const member of scalars) {
3410
3441
  const scalar = bindScalar(ctx, member, strings, module.offset);
@@ -3415,7 +3446,6 @@ function bindDataLiterals(ctx, members) {
3415
3446
  out.slugs.push(member.slug);
3416
3447
  out.bound.push(member.key);
3417
3448
  }
3418
- if (groups.size === 0) return out;
3419
3449
  const literals = arrayLiterals(module.ast);
3420
3450
  for (const all of groups.values()) {
3421
3451
  const array = all[0].array;
@@ -3456,23 +3486,36 @@ function bindDataLiterals(ctx, members) {
3456
3486
  }
3457
3487
  const loop = loops[0];
3458
3488
  const params = callbackParams(loop, identifier2);
3459
- if (!params) {
3489
+ if (!params || params.row === null) {
3460
3490
  refuse("IN_EXPRESSION");
3461
3491
  continue;
3462
3492
  }
3463
- const elements = leafElements(ctx.content, loop, params.row);
3493
+ const rowName = params.row;
3494
+ const elements = leafElements(ctx.content, loop, rowName);
3464
3495
  const placed = /* @__PURE__ */ new Map();
3496
+ const passed = [];
3465
3497
  let refusal = null;
3466
3498
  for (const property of keys.keys()) {
3467
3499
  const found = elements.get(property) ?? [];
3468
- if (found.length === 1) placed.set(property, found[0]);
3469
- else refusal = found.length > 1 ? "REPEATER_AMBIGUOUS" : leafRefusal(loop.value, params.row, property);
3500
+ if (found.length === 1) {
3501
+ placed.set(property, found[0]);
3502
+ continue;
3503
+ }
3504
+ if (found.length > 1) {
3505
+ refusal = "REPEATER_AMBIGUOUS";
3506
+ continue;
3507
+ }
3508
+ const calls = propCalls(ctx.content, loop, rowName, property);
3509
+ if (ctx.drill && calls.length === 1) passed.push({ property, ...calls[0] });
3510
+ else if (calls.length > 1) refusal = "REPEATER_AMBIGUOUS";
3511
+ else refusal = leafRefusal(loop.value, rowName, property);
3470
3512
  }
3471
3513
  if (refusal !== null) {
3472
3514
  refuse(refusal);
3473
3515
  continue;
3474
3516
  }
3475
- const index = params.index ?? allocateIndex(loop.value);
3517
+ const index = params.index ?? allocateIndex(loop.value, indexes);
3518
+ indexes.add(index);
3476
3519
  const splices = [];
3477
3520
  if (rule.loopKey) {
3478
3521
  const rows = loop.children.filter((child) => child.type === "element");
@@ -3488,6 +3531,36 @@ function bindDataLiterals(ctx, members) {
3488
3531
  }
3489
3532
  splices.push({ start: params.addIndexAt, end: params.addIndexAt, text: `, ${index}` });
3490
3533
  }
3534
+ const drilled = [];
3535
+ const edits = [];
3536
+ const bindings = /* @__PURE__ */ new Map();
3537
+ for (const entry of passed) {
3538
+ const leaf = keys.get(entry.property);
3539
+ const literal = `${array}[\${${index}}].${leaf}`;
3540
+ const outcome = entry.call.attrs.some((a) => a.name === "bcmsBindings") ? { ok: false, reason: "PROP_TARGET_NOT_FOUND" } : await ctx.drill(entry.call.tag, entry.prop, group.find((member) => member.leaf === leaf).kind);
3541
+ if (!outcome.ok) {
3542
+ refusal = outcome.reason;
3543
+ break;
3544
+ }
3545
+ edits.push(...outcome.edits);
3546
+ bindings.set(entry.call.attrInsertAt, [
3547
+ ...bindings.get(entry.call.attrInsertAt) ?? [],
3548
+ `${entry.prop}: \`${literal}\``
3549
+ ]);
3550
+ drilled.push({
3551
+ file: outcome.edits[0].file,
3552
+ prop: entry.prop,
3553
+ path: shapeOfPath(literal),
3554
+ literal: `${entry.prop}: \`${literal}\``
3555
+ });
3556
+ }
3557
+ if (refusal !== null) {
3558
+ refuse(refusal);
3559
+ continue;
3560
+ }
3561
+ for (const [at2, entries] of bindings) {
3562
+ splices.push({ start: at2, end: at2, text: ` bcmsBindings={{ ${entries.join(", ")} }}` });
3563
+ }
3491
3564
  for (const [property, element] of placed) {
3492
3565
  const leaf = keys.get(property);
3493
3566
  const literal = `${array}[\${${index}}].${leaf}`;
@@ -3497,7 +3570,7 @@ function bindDataLiterals(ctx, members) {
3497
3570
  ...kind ? [`data-bcms-kind="${kind}"`] : [],
3498
3571
  // A richtext row value is html, so it goes into the dialect's html sink rather than being
3499
3572
  // printed as characters — the same rule `writeLeaf` follows one level down. @see loops.ts
3500
- ...kind === "richtext" && rule.richtext ? [rule.richtext(`${params.row}.${property}`)] : []
3573
+ ...kind === "richtext" && rule.richtext ? [rule.richtext(`${rowName}.${property}`)] : []
3501
3574
  ];
3502
3575
  splices.push({ start: element.attrInsertAt, end: element.attrInsertAt, text: ` ${attributes.join(" ")}` });
3503
3576
  if (kind === "richtext" && rule.richtext && element.inner) {
@@ -3517,19 +3590,319 @@ function bindDataLiterals(ctx, members) {
3517
3590
  splices.push({ start: at.start, end: at.end, text: `${ctx.rowsAs}(${args.join(", ")})` });
3518
3591
  out.splices.push(...splices);
3519
3592
  out.usesRows = true;
3593
+ out.componentEdits.push(...edits);
3594
+ out.props.push(...drilled);
3520
3595
  out.slugs.push(...group.map((member) => member.slug));
3521
3596
  out.bound.push(...group.map((member) => member.key));
3522
3597
  }
3598
+ const settled = /* @__PURE__ */ new Set([...out.bound, ...out.refused.map((refusal) => refusal.key)]);
3599
+ bindLists(ctx, members.filter((member) => !settled.has(member.key)), module, out, indexes);
3600
+ return out;
3601
+ }
3602
+ function literalTable(init) {
3603
+ const elements = (init.elements ?? []).filter((e) => e !== null);
3604
+ const first = elements[0];
3605
+ if (!first || elements.length !== (init.elements ?? []).length) return null;
3606
+ if (elements.some((element) => element.type !== first.type)) return null;
3607
+ if (first.type === "StringLiteral") {
3608
+ return { tuple: false, rows: elements.map((element) => [norm(String(element.value))]) };
3609
+ }
3610
+ if (first.type !== "ArrayExpression") return null;
3611
+ const rows = [];
3612
+ for (const element of elements) {
3613
+ const inner = (element.elements ?? []).filter((x) => x !== null);
3614
+ if (inner.length === 0 || inner.some((x) => x.type !== "StringLiteral")) return null;
3615
+ if (inner.length !== (element.elements ?? []).length) return null;
3616
+ rows.push(inner.map((x) => norm(String(x.value))));
3617
+ }
3618
+ if (new Set(rows.map((row) => row.length)).size !== 1) return null;
3619
+ return { tuple: true, rows };
3620
+ }
3621
+ function openingBracket(text, closeAt) {
3622
+ let depth = 0;
3623
+ for (let at = closeAt; at >= 0; at--) {
3624
+ if (text[at] === "]") depth++;
3625
+ else if (text[at] === "[") {
3626
+ depth--;
3627
+ if (depth === 0) return at;
3628
+ }
3629
+ }
3630
+ return -1;
3631
+ }
3632
+ function arrayLiteral(source) {
3633
+ try {
3634
+ const ast = parseProgram(`(${source})`);
3635
+ const expression = ast.program.body[0]?.expression;
3636
+ if (!expression || expression.type !== "ArrayExpression") return null;
3637
+ return (expression.start ?? -1) === 1 && (expression.end ?? -1) === source.length + 1 ? expression : null;
3638
+ } catch {
3639
+ return null;
3640
+ }
3641
+ }
3642
+ function inlineTables(ctx) {
3643
+ const out = /* @__PURE__ */ new Map();
3644
+ const visit = (node) => {
3645
+ if (node.type === "element") {
3646
+ if (!node.raw) for (const child of node.children) visit(child);
3647
+ return;
3648
+ }
3649
+ if (node.type !== "expr") return;
3650
+ for (const match of node.value.matchAll(/\]\s*\.\s*map\s*\(/g)) {
3651
+ const closeAt = match.index;
3652
+ const openAt = openingBracket(node.value, closeAt);
3653
+ if (openAt <= 0 || /[\w$)\]]/.test(node.value[openAt - 1] ?? "")) continue;
3654
+ const init = arrayLiteral(node.value.slice(openAt, closeAt + 1));
3655
+ const table = init ? literalTable(init) : null;
3656
+ if (!table) continue;
3657
+ const at = { start: node.range.start + openAt, end: node.range.start + closeAt + 1 };
3658
+ out.set(at.start, {
3659
+ ...table,
3660
+ at,
3661
+ label: "an inline array literal",
3662
+ render: () => ({ loop: node, params: mapParams(ctx.content, at.end, node.range.end) })
3663
+ });
3664
+ }
3665
+ for (const child of node.children) visit(child);
3666
+ };
3667
+ for (const root of ctx.parsed.roots) visit(root);
3668
+ return [...out.values()];
3669
+ }
3670
+ function readerElements(content, expression, reads) {
3671
+ const wanted = new Set(reads);
3672
+ const out = /* @__PURE__ */ new Map();
3673
+ const visit = (node) => {
3674
+ if (node.type === "expr") {
3675
+ for (const child of node.children) visit(child);
3676
+ return;
3677
+ }
3678
+ if (node.type !== "element" || node.raw) return;
3679
+ const inner = isIntrinsic(node.tag) && node.inner ? content.slice(node.inner.start, node.inner.end).trim() : "";
3680
+ const read = inner.match(/^\{\s*([A-Za-z_$][\w$]*(?:\[\d+\])?)\s*\}$/)?.[1];
3681
+ if (read && wanted.has(read)) out.set(read, [...out.get(read) ?? [], node]);
3682
+ for (const child of node.children) visit(child);
3683
+ };
3684
+ for (const child of expression.children) visit(child);
3685
+ return out;
3686
+ }
3687
+ function readerWraps(expression, reads) {
3688
+ const wanted = new Set(reads);
3689
+ const out = /* @__PURE__ */ new Map();
3690
+ const visit = (node, parent) => {
3691
+ if (node.type === "expr") {
3692
+ const read = node.value.trim().match(/^\{\s*([A-Za-z_$][\w$]*(?:\[\d+\])?)\s*\}$/)?.[1];
3693
+ const beside = parent !== null && isIntrinsic(parent.tag) && parent.children.some((child) => child !== node && !(child.type === "text" && child.value.trim() === ""));
3694
+ if (read && wanted.has(read) && beside) out.set(read, [...out.get(read) ?? [], node.range]);
3695
+ for (const child of node.children) visit(child, parent);
3696
+ return;
3697
+ }
3698
+ if (node.type !== "element" || node.raw) return;
3699
+ for (const child of node.children) visit(child, node);
3700
+ };
3701
+ for (const child of expression.children) visit(child, null);
3702
+ return out;
3703
+ }
3704
+ function pathTemplates(paths, index) {
3705
+ const out = [];
3706
+ for (let position = 0; position < paths[0].length; position++) {
3707
+ const split = paths.map((row) => splitGroup(row[position]));
3708
+ const first = split[0];
3709
+ if (!first) return null;
3710
+ if (split.some((held, row) => !held || held.array !== first.array || held.leaf !== first.leaf || held.index !== row)) {
3711
+ return null;
3712
+ }
3713
+ out.push(`${first.array}[\${${index}}].${first.leaf}`);
3714
+ }
3715
+ return out;
3716
+ }
3717
+ function bindLists(ctx, members, module, out, indexes) {
3718
+ const rule = DIALECT_RULES[ctx.dialect];
3719
+ const escapes = exportedNames(module.ast);
3720
+ const candidates = [];
3721
+ for (const [name, init] of arrayLiterals(module.ast)) {
3722
+ const table = literalTable(init);
3723
+ if (!table) continue;
3724
+ candidates.push({
3725
+ ...table,
3726
+ at: { start: module.offset + (init.start ?? 0), end: module.offset + (init.end ?? 0) },
3727
+ label: `\`${name}\``,
3728
+ render: () => {
3729
+ const loops = iterations(ctx.parsed.roots, name);
3730
+ if (loops.length > 1) return { refuse: "REPEATER_AMBIGUOUS" };
3731
+ if (loops.length === 0) return null;
3732
+ const mentions2 = ctx.content.match(new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g")) ?? [];
3733
+ if (mentions2.length !== 2 || escapes.has(name)) return { refuse: "IN_EXPRESSION" };
3734
+ return { loop: loops[0], params: callbackParams(loops[0], name) };
3735
+ }
3736
+ });
3737
+ }
3738
+ candidates.push(...inlineTables(ctx));
3739
+ const siblings = /* @__PURE__ */ new Map();
3740
+ const groupOf2 = (member) => `${member.slug}\0${splitGroup(member.path)?.array ?? member.path}`;
3741
+ for (const member of members) {
3742
+ siblings.set(groupOf2(member), [...siblings.get(groupOf2(member)) ?? [], member]);
3743
+ }
3744
+ const fit = (candidate, member) => (siblings.get(groupOf2(member)) ?? [member]).filter(
3745
+ (leaf) => candidate.rows.some((row) => row.includes(leaf.literal))
3746
+ ).length;
3747
+ const best = /* @__PURE__ */ new Map();
3748
+ const bestFit = (member) => {
3749
+ const key = groupOf2(member);
3750
+ if (!best.has(key)) best.set(key, Math.max(0, ...candidates.map((candidate) => fit(candidate, member))));
3751
+ return best.get(key);
3752
+ };
3753
+ const claims = (candidate, member) => candidate.rows.some((row) => row.includes(member.literal)) && fit(candidate, member) === bestFit(member);
3754
+ const holders = (member) => candidates.filter((candidate) => fit(candidate, member) === bestFit(member)).length;
3755
+ for (const candidate of candidates) {
3756
+ const mine = members.filter((member) => claims(candidate, member));
3757
+ if (mine.length === 0 || mine.some((member) => holders(member) > 1)) continue;
3758
+ if (new Set(mine.map((member) => member.slug)).size !== 1) continue;
3759
+ const refuse = (reason) => {
3760
+ for (const member of mine) out.refused.push({ key: member.key, reason });
3761
+ };
3762
+ const paths = candidate.rows.map((row) => row.map(() => ""));
3763
+ const kinds = candidate.rows.map((row) => row.map(() => ""));
3764
+ let clash = false;
3765
+ for (const member of mine) {
3766
+ const spots = candidate.rows.flatMap(
3767
+ (row, r) => row.flatMap((value, p) => value === member.literal ? [[r, p]] : [])
3768
+ );
3769
+ if (spots.length !== 1 || paths[spots[0][0]][spots[0][1]] !== "") clash = true;
3770
+ else {
3771
+ paths[spots[0][0]][spots[0][1]] = member.path;
3772
+ kinds[spots[0][0]][spots[0][1]] = member.kind;
3773
+ }
3774
+ }
3775
+ if (clash || paths.some((row) => row.some((path) => path === ""))) {
3776
+ refuse("IN_EXPRESSION");
3777
+ continue;
3778
+ }
3779
+ const kindAt = kinds[0];
3780
+ if (kinds.some((row) => row.some((kind, position) => kind !== kindAt[position]))) {
3781
+ refuse("IN_EXPRESSION");
3782
+ continue;
3783
+ }
3784
+ const rendered = candidate.render();
3785
+ if (rendered === null) continue;
3786
+ if ("refuse" in rendered) {
3787
+ refuse(rendered.refuse);
3788
+ continue;
3789
+ }
3790
+ const { loop, params } = rendered;
3791
+ const reads = !params ? null : candidate.tuple ? params.names ?? (params.row === null ? null : candidate.rows[0].map((_, p) => `${params.row}[${p}]`)) : params.row === null ? null : [params.row];
3792
+ if (!params || !reads || reads.length !== candidate.rows[0].length) {
3793
+ refuse("IN_EXPRESSION");
3794
+ continue;
3795
+ }
3796
+ const elements = readerElements(ctx.content, loop, reads);
3797
+ const wraps = readerWraps(loop, reads);
3798
+ const hosts = [];
3799
+ let refusal = null;
3800
+ for (const read of reads) {
3801
+ const found = elements.get(read) ?? [];
3802
+ const beside = elements.has(read) ? [] : wraps.get(read) ?? [];
3803
+ if (found.length === 1) hosts.push({ element: found[0] });
3804
+ else if (found.length === 0 && beside.length === 1) hosts.push({ wrap: beside[0] });
3805
+ else refusal = found.length + beside.length > 1 ? "REPEATER_AMBIGUOUS" : "IN_EXPRESSION";
3806
+ }
3807
+ if (refusal !== null) {
3808
+ refuse(refusal);
3809
+ continue;
3810
+ }
3811
+ const index = params.index ?? allocateIndex(loop.value, indexes);
3812
+ indexes.add(index);
3813
+ const splices = [];
3814
+ if (rule.loopKey) {
3815
+ const rows = loop.children.filter((child) => child.type === "element");
3816
+ const row = rows.length === 1 ? rows[0] : null;
3817
+ if (row && row.tag !== "" && !row.attrs.some((a) => a.name.replace(/^:/, "") === "key")) {
3818
+ splices.push({ start: row.attrInsertAt, end: row.attrInsertAt, text: ` ${rule.loopKey(index)}` });
3819
+ }
3820
+ }
3821
+ if (params.index === null) {
3822
+ if (params.addIndexAt < 0) {
3823
+ refuse("IN_EXPRESSION");
3824
+ continue;
3825
+ }
3826
+ splices.push({ start: params.addIndexAt, end: params.addIndexAt, text: `, ${index}` });
3827
+ }
3828
+ const templates = pathTemplates(paths, index);
3829
+ if (!templates) {
3830
+ refuse("IN_EXPRESSION");
3831
+ continue;
3832
+ }
3833
+ const list = JSON.stringify(candidate.tuple ? paths : paths.map((row) => row[0]));
3834
+ for (const [position, host] of hosts.entries()) {
3835
+ const kind = kindAt[position];
3836
+ const attributes = [
3837
+ rule.attr("data-bcms-field", `\`${templates[position]}\``),
3838
+ ...kind ? [`data-bcms-kind="${kind}"`] : [],
3839
+ // A richtext value is html, so it goes into the dialect's html sink rather than being
3840
+ // printed as characters — the same rule one level up. @see bindDataLiterals
3841
+ ...kind === "richtext" && rule.richtext ? [rule.richtext(reads[position])] : []
3842
+ ];
3843
+ if ("element" in host) {
3844
+ splices.push({ start: host.element.attrInsertAt, end: host.element.attrInsertAt, text: ` ${attributes.join(" ")}` });
3845
+ if (kind === "richtext" && rule.richtext && host.element.inner) {
3846
+ splices.push({ start: host.element.inner.start, end: host.element.inner.end, text: "" });
3847
+ }
3848
+ } else {
3849
+ const inner = kind === "richtext" && rule.richtext ? "" : ctx.content.slice(host.wrap.start, host.wrap.end);
3850
+ splices.push({ start: host.wrap.start, end: host.wrap.end, text: `<span ${attributes.join(" ")}>${inner}</span>` });
3851
+ }
3852
+ out.declarations.push({ path: shapeOfPath(templates[position]), literal: templates[position] });
3853
+ }
3854
+ const args = [
3855
+ ctx.snapshotName(mine[0].slug),
3856
+ list,
3857
+ ctx.content.slice(candidate.at.start, candidate.at.end),
3858
+ ...ctx.slug ? [ctx.slug] : []
3859
+ ];
3860
+ splices.push({
3861
+ start: candidate.at.start,
3862
+ end: candidate.at.end,
3863
+ text: `${candidate.tuple ? ctx.tuples : ctx.list}(${args.join(", ")})`
3864
+ });
3865
+ out.splices.push(...splices);
3866
+ if (candidate.tuple) out.usesTuples = true;
3867
+ else out.usesList = true;
3868
+ out.slugs.push(...mine.map((member) => member.slug));
3869
+ out.bound.push(...mine.map((member) => member.key));
3870
+ out.notes.push(
3871
+ `${candidate.label} renders exactly the ${paths.length} row(s) this run wrote paths for: a row ADDED in the CMS does not appear, and a row DELETED falls back to the copy the template shipped with.`
3872
+ );
3873
+ }
3874
+ }
3875
+ function propCalls(content, expression, row, property) {
3876
+ const named = new RegExp(`^\\{\\s*${row}\\s*\\.\\s*${property}\\s*\\}$`);
3877
+ const spread = new RegExp(`^\\{\\s*\\.\\.\\.\\s*${row}\\s*\\}$`);
3878
+ const out = [];
3879
+ const visit = (node) => {
3880
+ if (node.type === "expr") {
3881
+ for (const child of node.children) visit(child);
3882
+ return;
3883
+ }
3884
+ if (node.type !== "element" || node.raw) return;
3885
+ if (node.tag !== "" && !isIntrinsic(node.tag)) {
3886
+ for (const attr of node.attrs) {
3887
+ if (!attr.expressionRange) continue;
3888
+ const text = content.slice(attr.expressionRange.start, attr.expressionRange.end).trim();
3889
+ if (spread.test(text)) out.push({ call: node, prop: property });
3890
+ else if (named.test(text)) out.push({ call: node, prop: attr.name });
3891
+ }
3892
+ }
3893
+ for (const child of node.children) visit(child);
3894
+ };
3895
+ for (const child of expression.children) visit(child);
3523
3896
  return out;
3524
3897
  }
3525
3898
  function leafRefusal(loop, row, property) {
3526
3899
  const prop = new RegExp(`=\\s*\\{\\s*${row}\\s*\\.\\s*${property}\\s*\\}|\\{\\s*\\.\\.\\.\\s*${row}\\s*\\}`);
3527
3900
  return prop.test(loop) ? "PROP_TARGET_NOT_FOUND" : "IN_EXPRESSION";
3528
3901
  }
3529
- function allocateIndex(loop) {
3902
+ function allocateIndex(loop, taken) {
3530
3903
  const used = new Set(loop.match(/[A-Za-z_$][\w$]*/g) ?? []);
3531
- if (!used.has("i")) return "i";
3532
- for (let n = 1; ; n++) if (!used.has(`i_${n}`)) return `i_${n}`;
3904
+ if (!used.has("i") && !taken.has("i")) return "i";
3905
+ for (let n = 1; ; n++) if (!used.has(`i_${n}`) && !taken.has(`i_${n}`)) return `i_${n}`;
3533
3906
  }
3534
3907
 
3535
3908
  // src/resolve.ts
@@ -3611,6 +3984,13 @@ function isAliasShaped(value) {
3611
3984
  function unreadableConfigs(sources) {
3612
3985
  return sources.filter((source) => isProjectConfig(source.path) && readJsonc(source.content) === null).map((source) => source.path);
3613
3986
  }
3987
+ function extendedConfigs(sources) {
3988
+ return sources.filter((source) => {
3989
+ if (!isProjectConfig(source.path)) return false;
3990
+ const parsed = readJsonc(source.content);
3991
+ return parsed !== null && parsed.extends !== void 0 && parsed.compilerOptions?.paths === void 0;
3992
+ }).map((source) => source.path);
3993
+ }
3614
3994
  function expand(specifier, aliases) {
3615
3995
  const out = [];
3616
3996
  for (const [pattern, targets] of aliases) {
@@ -3740,7 +4120,7 @@ function bindImages(ctx, targets) {
3740
4120
 
3741
4121
  // src/helper.ts
3742
4122
  import { createHash as createHash2 } from "crypto";
3743
- var HELPER_VERSION = 5;
4123
+ var HELPER_VERSION = 6;
3744
4124
  var HEADER = "// @bettercms-ai/convert helper v";
3745
4125
  var HELPER_PATH = Object.fromEntries(
3746
4126
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -3848,6 +4228,30 @@ export function bcmsRowsAs<T>(
3848
4228
  });
3849
4229
  }
3850
4230
 
4231
+ /**
4232
+ * A list of PRIMITIVES \u2014 \`const tags = ["\u2026", "\u2026"]\` \u2014 with one CMS field per ELEMENT.
4233
+ *
4234
+ * \u{1F534} THE LENGTH IS \`paths\`, NOT THE CMS. Unlike \`bcmsRows\`, which reads the array itself and
4235
+ * renders as many rows as the CMS holds, this maps over the path list the codemod wrote \u2014 so a row
4236
+ * an editor ADDS has no path here and never renders, and a row an editor DELETES leaves its path
4237
+ * resolving to nothing, which falls back to the copy the template shipped with. Every row's TEXT is
4238
+ * editable; the number of rows is the template's. @see the PRIMITIVE_LIST receipt note, which says
4239
+ * this out loud rather than leaving an editor to discover it.
4240
+ */
4241
+ export function bcmsList(page: unknown, paths: string[], fallback: string[], slug?: string): string[] {
4242
+ const entries = isRecord(page) ? page.entries : undefined;
4243
+ const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4244
+ return paths.map((path, index) => render(readPath(root, path)) ?? fallback[index] ?? "");
4245
+ }
4246
+
4247
+ /**
4248
+ * The same for a list of TUPLES \u2014 \`[["q", "a"], \u2026]\` \u2014 with one field per tuple POSITION, and the
4249
+ * same ceiling: \`paths\` decides how many rows render, and an added or deleted CMS row does not.
4250
+ */
4251
+ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][], slug?: string): string[][] {
4252
+ return paths.map((row, index) => bcmsList(page, row, fallback[index] ?? [], slug));
4253
+ }
4254
+
3851
4255
  /** An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED */
3852
4256
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
3853
4257
  const entries = isRecord(page) ? page.entries : undefined;
@@ -3939,19 +4343,22 @@ var HISTORICAL = {
3939
4343
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3940
4344
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3941
4345
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3942
- 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
4346
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4347
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
3943
4348
  },
3944
4349
  "src/lib/bcms-content.ts": {
3945
4350
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
3946
4351
  2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
3947
4352
  3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e",
3948
- 4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af"
4353
+ 4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4354
+ 5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba"
3949
4355
  },
3950
4356
  "composables/bcms-content.ts": {
3951
4357
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3952
4358
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3953
4359
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3954
- 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
4360
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4361
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
3955
4362
  }
3956
4363
  };
3957
4364
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -4486,8 +4893,8 @@ function declaringElements(roots, content) {
4486
4893
  const name = attr.name.replace(/^:/, "");
4487
4894
  if (name === "bcmsBindings") {
4488
4895
  const own = ownBytes(node, content);
4489
- for (const quoted of content.slice(attr.range.start, attr.range.end).matchAll(/["']([^"']+)["']/g)) {
4490
- out.push({ scope: "page", path: quoted[1], kind, own });
4896
+ for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
4897
+ out.push({ scope: "page", path, kind, own });
4491
4898
  }
4492
4899
  continue;
4493
4900
  }
@@ -4517,7 +4924,7 @@ function declaringElements(roots, content) {
4517
4924
  return out;
4518
4925
  }
4519
4926
  function helperLocals(parsed, dialect, file) {
4520
- const out = { bcms: [], bcmsRows: [], bcmsRowsAs: [], bcmsImage: [], bcmsLayout: [] };
4927
+ const out = { bcms: [], bcmsRows: [], bcmsRowsAs: [], bcmsImage: [], bcmsLayout: [], bcmsList: [], bcmsTuples: [] };
4521
4928
  const helper = HELPER_PATH[dialect];
4522
4929
  if (!helper) return out;
4523
4930
  for (const [local, from] of importsIn(parsed, dialect)) {
@@ -4527,6 +4934,8 @@ function helperLocals(parsed, dialect, file) {
4527
4934
  if (from.imported === "bcmsRowsAs") out.bcmsRowsAs.push(local);
4528
4935
  if (from.imported === "bcmsImage") out.bcmsImage.push(local);
4529
4936
  if (from.imported === "bcmsLayout") out.bcmsLayout.push(local);
4937
+ if (from.imported === "bcmsList") out.bcmsList.push(local);
4938
+ if (from.imported === "bcmsTuples") out.bcmsTuples.push(local);
4530
4939
  }
4531
4940
  return out;
4532
4941
  }
@@ -4599,6 +5008,9 @@ function convertedHere(content, parsed, dialect, target, file) {
4599
5008
  keyed = reads(locals.bcmsRowsAs);
4600
5009
  rows = keyed || reads(locals.bcmsRows);
4601
5010
  }
5011
+ const listed = indexed !== null && snapshot !== null && content.includes(JSON.stringify(target.path)) && [...locals.bcmsList, ...locals.bcmsTuples].some(
5012
+ (name) => new RegExp(`${identifier(name)}\\s*\\(\\s*${identifier(snapshot)}\\s*,`).test(content)
5013
+ );
4602
5014
  let cached = null;
4603
5015
  const moduleRead = () => {
4604
5016
  if (indexed || snapshot === null) return false;
@@ -4614,7 +5026,7 @@ function convertedHere(content, parsed, dialect, target, file) {
4614
5026
  return elementReads(element.own, target, locals, snapshot, false) || moduleRead();
4615
5027
  }
4616
5028
  if (element.path !== shape) return false;
4617
- return rows && (keyed || elementReads(element.own, target, locals, snapshot, true));
5029
+ return listed || rows && (keyed || elementReads(element.own, target, locals, snapshot, true));
4618
5030
  });
4619
5031
  }
4620
5032
 
@@ -5692,6 +6104,7 @@ function isSourceCandidate(path) {
5692
6104
  if (SKIP_DIRS.some((dir) => lower === dir.slice(0, -1) || lower.startsWith(dir) || lower.includes(`/${dir}`)))
5693
6105
  return false;
5694
6106
  if (SKIP_FILES.includes(lower.split("/").pop() ?? "")) return false;
6107
+ if (/(^|\/)[jt]sconfig\.json$/.test(lower)) return true;
5695
6108
  if (!SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false;
5696
6109
  if (!PROSE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return true;
5697
6110
  return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
@@ -7740,6 +8153,7 @@ async function convertSources(briefIn, sources, options) {
7740
8153
  const receiptFiles = [];
7741
8154
  const dialects = /* @__PURE__ */ new Set();
7742
8155
  const dynamicBindings = [];
8156
+ const laneNotes = [];
7743
8157
  const snapshotSlugs = /* @__PURE__ */ new Set();
7744
8158
  let occurrencesLocated = 0;
7745
8159
  let occurrencesRewritten = 0;
@@ -7765,6 +8179,7 @@ async function convertSources(briefIn, sources, options) {
7765
8179
  outFiles.push({ path: file, operation: "modify", content: result.content });
7766
8180
  receiptFiles.push({ path: file, bindings: result.bindings, tier: result.tier });
7767
8181
  dynamicBindings.push(...result.dynamic);
8182
+ laneNotes.push(...result.notes);
7768
8183
  for (const slug of result.snapshots) snapshotSlugs.add(slug);
7769
8184
  }
7770
8185
  }
@@ -7861,13 +8276,25 @@ async function convertSources(briefIn, sources, options) {
7861
8276
  helperUpgraded,
7862
8277
  dynamicBindings,
7863
8278
  canvas: { lane: canvas.lane, ...canvas.files.length > 0 ? { files: canvas.files } : {} },
7864
- notes: unreadableConfigs(sources).map(
7865
- (file) => ({
7866
- code: "TSCONFIG_UNREADABLE",
7867
- file,
7868
- message: `${file} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
7869
- })
7870
- )
8279
+ notes: [
8280
+ ...unreadableConfigs(sources).map(
8281
+ (file) => ({
8282
+ code: "TSCONFIG_UNREADABLE",
8283
+ file,
8284
+ message: `${file} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
8285
+ })
8286
+ ),
8287
+ // An absent alias table looks exactly like a project that has none, and the two have
8288
+ // opposite consequences for every component reached through one. @see extendedConfigs
8289
+ ...extendedConfigs(sources).map(
8290
+ (file) => ({
8291
+ code: "TSCONFIG_EXTENDS_ONLY",
8292
+ file,
8293
+ message: `${file} declares no \`compilerOptions.paths\` of its own and extends another config, which this package does not follow \u2014 so an import through a path alias resolved to nothing and any prop drilled through one is PROP_TARGET_NOT_FOUND.`
8294
+ })
8295
+ ),
8296
+ ...laneNotes
8297
+ ]
7871
8298
  });
7872
8299
  return { files: outFiles.sort((a, b) => a.path.localeCompare(b.path)), receipt };
7873
8300
  }
@@ -7889,7 +8316,8 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7889
8316
  dialect: dialectOf(file, content),
7890
8317
  dynamic: [],
7891
8318
  componentEdits: [],
7892
- snapshots: []
8319
+ snapshots: [],
8320
+ notes: []
7893
8321
  };
7894
8322
  if (pendingTargets.length === 0) return empty;
7895
8323
  if (!dialect) {
@@ -7950,7 +8378,8 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7950
8378
  dialect: null,
7951
8379
  dynamic: [],
7952
8380
  componentEdits: [],
7953
- snapshots: []
8381
+ snapshots: [],
8382
+ notes: []
7954
8383
  };
7955
8384
  }
7956
8385
  if (rescued.reason === "unverifiable") {
@@ -8020,6 +8449,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8020
8449
  { key: "helper:bcmsLayout", name: "bcmsLayout" },
8021
8450
  { key: "helper:bcmsRowsAs", name: "bcmsRowsAs" },
8022
8451
  { key: "helper:bcmsImage", name: "bcmsImage" },
8452
+ { key: "helper:bcmsList", name: "bcmsList" },
8453
+ { key: "helper:bcmsTuples", name: "bcmsTuples" },
8023
8454
  // Keyed by the SLUG: two slugs that spell one identifier are still two pages. @see scope.ts
8024
8455
  ...slugs.map((slug) => ({ key: `slug:${slug}`, name: identifierFor2(slug) }))
8025
8456
  ].filter((entry) => !preset.has(entry.key));
@@ -8211,15 +8642,23 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8211
8642
  const collided = new Set(overlapping(kept).map((r) => r.key));
8212
8643
  const applied = kept.filter((r) => !collided.has(r.key));
8213
8644
  for (const key of collided) outcomes.set(key, "AMBIGUOUS_LITERAL");
8214
- const data = bindDataLiterals(
8645
+ const data = await bindDataLiterals(
8215
8646
  {
8216
8647
  content,
8217
8648
  parsed,
8218
8649
  dialect: writing,
8219
8650
  rowsAs: helperName("bcmsRowsAs"),
8651
+ list: helperName("bcmsList"),
8652
+ tuples: helperName("bcmsTuples"),
8220
8653
  read: helperName("bcms"),
8221
8654
  snapshotName,
8222
- slug: params?.slug ?? null
8655
+ slug: params?.slug ?? null,
8656
+ // The row's leaf travels to another file exactly as a scalar prop does, and by the same
8657
+ // machinery — the lane decides WHICH props, this decides what may be touched. @see props.ts
8658
+ drill: async (component, prop, kind) => {
8659
+ const drilled = await drillProp(drill, file, component, prop, kind, 1);
8660
+ return drilled.ok ? { ok: true, edits: drilled.edits } : { ok: false, reason: drilled.reason ?? "PROP_TARGET_NOT_FOUND" };
8661
+ }
8223
8662
  },
8224
8663
  targets.filter((t) => {
8225
8664
  const outcome = outcomes.get(t.key);
@@ -8237,6 +8676,15 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8237
8676
  for (const declaration2 of data.declarations) {
8238
8677
  dynamic.push({ file, prop: null, path: declaration2.path, callSites: [{ file, literal: declaration2.literal }] });
8239
8678
  }
8679
+ componentEdits.push(...data.componentEdits);
8680
+ for (const drilledProp of data.props) {
8681
+ dynamic.push({
8682
+ file: drilledProp.file,
8683
+ prop: drilledProp.prop,
8684
+ path: drilledProp.path,
8685
+ callSites: [{ file, literal: drilledProp.literal }]
8686
+ });
8687
+ }
8240
8688
  const images = bindImages(
8241
8689
  { content, parsed, dialect: writing, image: helperName("bcmsImage"), snapshotName, slug: params?.slug ?? null },
8242
8690
  targets.filter((t) => {
@@ -8248,7 +8696,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8248
8696
  for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
8249
8697
  const lanes = [...data.splices, ...images.splices];
8250
8698
  if (applied.length === 0 && lanes.length === 0) {
8251
- return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [] };
8699
+ return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [], notes: [] };
8252
8700
  }
8253
8701
  const readSlugs = [
8254
8702
  .../* @__PURE__ */ new Set([...applied.filter((r) => r.scope === "page").map((r) => r.slug), ...data.slugs, ...images.slugs])
@@ -8275,6 +8723,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8275
8723
  ] : [],
8276
8724
  ...applied.some((r) => r.scope === "layout") ? [{ name: "bcmsLayout", alias: helperName("bcmsLayout") }] : [],
8277
8725
  ...data.usesRows ? [{ name: "bcmsRowsAs", alias: helperName("bcmsRowsAs") }] : [],
8726
+ ...data.usesList ? [{ name: "bcmsList", alias: helperName("bcmsList") }] : [],
8727
+ // `bcmsTuples` reads each row through `bcmsList`, but the emitted helper exports both and the
8728
+ // page imports only the one it calls — an unused import is dead code in someone else's repo.
8729
+ ...data.usesTuples ? [{ name: "bcmsTuples", alias: helperName("bcmsTuples") }] : [],
8278
8730
  ...data.usesRead ? [{ name: "bcms", alias: helperName("bcms") }] : [],
8279
8731
  ...images.splices.length > 0 ? [{ name: "bcmsImage", alias: helperName("bcmsImage") }] : []
8280
8732
  ].filter((helper2, at, all) => all.findIndex((h) => h.name === helper2.name) === at).filter((helper2) => !preset.has(`helper:${helper2.name}`)),
@@ -8289,7 +8741,9 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8289
8741
  dialect: writing,
8290
8742
  dynamic,
8291
8743
  componentEdits,
8292
- snapshots: rule.text ? readSlugs : []
8744
+ snapshots: rule.text ? readSlugs : [],
8745
+ // The lane reports one sentence per list it bound; the FILE is added here, where it is known.
8746
+ notes: data.notes.map((message) => ({ code: "PRIMITIVE_LIST", file, message }))
8293
8747
  };
8294
8748
  }
8295
8749
  function mergeInto(content, site) {