@bettercms-ai/convert 0.7.0 → 0.9.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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import MagicString5 from "magic-string";
2
+ import MagicString6 from "magic-string";
3
3
 
4
4
  // src/brief.ts
5
5
  import { createHash } from "crypto";
@@ -145,7 +145,7 @@ function propsAttribute(props) {
145
145
  var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
146
146
  var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
147
147
  var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
148
- var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
148
+ var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{(?:`[^`]*`|[^}])*\}\}/g;
149
149
  var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
150
150
  const [address, kind] = entry.split("|");
151
151
  if (!address) return [];
@@ -186,7 +186,10 @@ function declarationsIn(roots, content) {
186
186
  for (const root of roots) visit(root);
187
187
  return out;
188
188
  }
189
- var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
189
+ var bindingsPaths = (text) => [
190
+ ...[...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]),
191
+ ...[...text.matchAll(/`([^`]+)`/g)].map((match) => shapeOfPath(match[1]))
192
+ ];
190
193
  var scopedPath = (scope, path) => `${scope}::${path}`;
191
194
  var covers = (declared, target) => declared !== void 0 && (declared.has(scopedPath(target.scope, target.path)) || declared.has(scopedPath(target.scope, shapeOfPath(target.path))));
192
195
  var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
@@ -2558,7 +2561,16 @@ var IMAGE_SOURCES = {
2558
2561
  };
2559
2562
  var isImageSource = (site) => site.where === "attr" && (IMAGE_SOURCES[site.tag.toLowerCase()] ?? []).includes(site.attr ?? "");
2560
2563
  var norm = (value) => decodeEntities(value.normalize("NFC")).replace(/\s+/g, " ").trim();
2561
- var BCMS_ATTRS = ["data-bcms-field", "data-bcms-kind", "data-bcms-props", "data-bcms-layout-field"];
2564
+ var BCMS_ATTRS = [
2565
+ "data-bcms-field",
2566
+ "data-bcms-kind",
2567
+ "data-bcms-props",
2568
+ "data-bcms-layout-field",
2569
+ // The forms lane's two. Listed here so `collectSites` never matches a brief literal against a
2570
+ // marker this package itself wrote — a form id or a field key is not the page's copy.
2571
+ "data-bcms-form",
2572
+ "data-bcms-form-field"
2573
+ ];
2562
2574
  var attrInsertAfter = (attrs, start, tag) => attrs.length > 0 ? Math.max(...attrs.map((a) => a.range.end)) : start + 1 + tag.length;
2563
2575
  function openTagEnd(content, start) {
2564
2576
  let quote = null;
@@ -3027,7 +3039,7 @@ function receptionIn(component, propName, offset, content) {
3027
3039
  let receiver = null;
3028
3040
  let rest = null;
3029
3041
  let hasBindings = false;
3030
- let destructured = false;
3042
+ let destructured2 = false;
3031
3043
  for (const node of component ? [component] : []) {
3032
3044
  const params = node.params ?? [];
3033
3045
  const first = params[0];
@@ -3053,7 +3065,7 @@ function receptionIn(component, propName, offset, content) {
3053
3065
  if (key !== propName) continue;
3054
3066
  const value = property.value;
3055
3067
  if (value?.type === "Identifier") names.add(String(value.name));
3056
- destructured = true;
3068
+ destructured2 = true;
3057
3069
  receivable = true;
3058
3070
  receive = patternClose(content, first, offset);
3059
3071
  }
@@ -3063,7 +3075,7 @@ function receptionIn(component, propName, offset, content) {
3063
3075
  viaProps = true;
3064
3076
  }
3065
3077
  }
3066
- return { names, receive, viaProps, typeName, receivable, inline, receiver, rest, hasBindings, destructured };
3078
+ return { names, receive, viaProps, typeName, receivable, inline, receiver, rest, hasBindings, destructured: destructured2 };
3067
3079
  }
3068
3080
  function astroReception(ast, propName, offset, content) {
3069
3081
  const names = /* @__PURE__ */ new Set();
@@ -3246,8 +3258,7 @@ function splitGroup(path) {
3246
3258
  return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
3247
3259
  }
3248
3260
  function moduleOf(parsed) {
3249
- if (!parsed.ast) return null;
3250
- return { ast: parsed.ast, offset: parsed.script?.offset ?? 0 };
3261
+ return { ast: parsed.ast ?? null, offset: parsed.script?.offset ?? 0 };
3251
3262
  }
3252
3263
  function arrayLiterals(ast) {
3253
3264
  const out = /* @__PURE__ */ new Map();
@@ -3260,6 +3271,19 @@ function arrayLiterals(ast) {
3260
3271
  });
3261
3272
  return out;
3262
3273
  }
3274
+ function exportedNames(ast) {
3275
+ const out = /* @__PURE__ */ new Set();
3276
+ walkAst(ast, (node) => {
3277
+ if (node.type !== "ExportNamedDeclaration") return;
3278
+ const declaration2 = node.declaration;
3279
+ if (declaration2?.type !== "VariableDeclaration") return;
3280
+ for (const declarator of declaration2.declarations ?? []) {
3281
+ const id = declarator.id;
3282
+ if (id?.type === "Identifier") out.add(String(id.name));
3283
+ }
3284
+ });
3285
+ return out;
3286
+ }
3263
3287
  function propertyFor(row, literal) {
3264
3288
  if (row?.type !== "ObjectExpression") return null;
3265
3289
  for (const property of row.properties ?? []) {
@@ -3284,21 +3308,36 @@ function iterations(roots, identifier2) {
3284
3308
  for (const root of roots) visit(root);
3285
3309
  return out;
3286
3310
  }
3287
- function callbackParams(expression, identifier2) {
3288
- const pattern = new RegExp(
3289
- `(?<![\\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*=>`
3290
- );
3291
- const match = expression.value.match(pattern);
3311
+ 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*=>`;
3312
+ function paramsIn(text, pattern, base) {
3313
+ const match = text.match(pattern);
3292
3314
  if (!match || match.index === void 0) return null;
3293
- const row = match[1] ?? match[4];
3294
- if (match[4] !== void 0) return { row, index: null, addIndexAt: -1 };
3295
- const within = match[0].lastIndexOf(row);
3315
+ if (match[5] !== void 0) return { row: match[5], names: null, index: null, addIndexAt: -1 };
3316
+ const bound = match[1] ?? match[2];
3317
+ const within = match[0].lastIndexOf(bound);
3296
3318
  return {
3297
- row,
3298
- index: match[3] ?? null,
3299
- addIndexAt: expression.range.start + match.index + within + row.length
3319
+ // One of the two, never both: a row is either named or taken apart. @see bindLists for the
3320
+ // second shape — `([question, answer]) =>`, which is how a TUPLE reaches the markup.
3321
+ row: match[1] ?? null,
3322
+ names: match[2] === void 0 ? null : destructured(match[2]),
3323
+ index: match[4] ?? null,
3324
+ addIndexAt: base + match.index + within + bound.length
3300
3325
  };
3301
3326
  }
3327
+ function callbackParams(expression, identifier2) {
3328
+ return paramsIn(
3329
+ expression.value,
3330
+ new RegExp(`(?<![\\w$])${identifier2}(?![\\w$])${CALLBACK}`),
3331
+ expression.range.start
3332
+ );
3333
+ }
3334
+ function mapParams(content, at, until) {
3335
+ return paramsIn(content.slice(at, until), new RegExp(`^${CALLBACK}`), at);
3336
+ }
3337
+ function destructured(pattern) {
3338
+ const names = pattern.slice(1, -1).split(",").map((part) => part.trim());
3339
+ return names.every((name) => /^[A-Za-z_$][\w$]*$/.test(name)) ? names : null;
3340
+ }
3302
3341
  function renders(content, node, row) {
3303
3342
  if (!node.inner) return null;
3304
3343
  const inner = content.slice(node.inner.start, node.inner.end).trim();
@@ -3386,11 +3425,11 @@ function bindScalar(ctx, member, literals, offset) {
3386
3425
  }
3387
3426
  return { splices, refusal: null };
3388
3427
  }
3389
- function bindDataLiterals(ctx, members) {
3390
- const out = { splices: [], usesRead: false, usesRows: false, slugs: [], bound: [], declarations: [], refused: [] };
3428
+ async function bindDataLiterals(ctx, members) {
3429
+ const out = { splices: [], usesRead: false, usesRows: false, usesList: false, usesTuples: false, notes: [], slugs: [], bound: [], declarations: [], refused: [], componentEdits: [], props: [] };
3391
3430
  const rule = DIALECT_RULES[ctx.dialect];
3392
3431
  const module = moduleOf(ctx.parsed);
3393
- if (!module || !rule.text) return out;
3432
+ if (!rule.text) return out;
3394
3433
  const groups = /* @__PURE__ */ new Map();
3395
3434
  const scalars = [];
3396
3435
  for (const member of members) {
@@ -3403,6 +3442,7 @@ function bindDataLiterals(ctx, members) {
3403
3442
  const key = `${member.slug}\0${split.array}`;
3404
3443
  groups.set(key, [...groups.get(key) ?? [], held]);
3405
3444
  }
3445
+ const indexes = /* @__PURE__ */ new Set();
3406
3446
  const strings = stringLiterals(module.ast);
3407
3447
  for (const member of scalars) {
3408
3448
  const scalar = bindScalar(ctx, member, strings, module.offset);
@@ -3413,7 +3453,6 @@ function bindDataLiterals(ctx, members) {
3413
3453
  out.slugs.push(member.slug);
3414
3454
  out.bound.push(member.key);
3415
3455
  }
3416
- if (groups.size === 0) return out;
3417
3456
  const literals = arrayLiterals(module.ast);
3418
3457
  for (const all of groups.values()) {
3419
3458
  const array = all[0].array;
@@ -3454,23 +3493,36 @@ function bindDataLiterals(ctx, members) {
3454
3493
  }
3455
3494
  const loop = loops[0];
3456
3495
  const params = callbackParams(loop, identifier2);
3457
- if (!params) {
3496
+ if (!params || params.row === null) {
3458
3497
  refuse("IN_EXPRESSION");
3459
3498
  continue;
3460
3499
  }
3461
- const elements = leafElements(ctx.content, loop, params.row);
3500
+ const rowName = params.row;
3501
+ const elements = leafElements(ctx.content, loop, rowName);
3462
3502
  const placed = /* @__PURE__ */ new Map();
3503
+ const passed = [];
3463
3504
  let refusal = null;
3464
3505
  for (const property of keys.keys()) {
3465
3506
  const found = elements.get(property) ?? [];
3466
- if (found.length === 1) placed.set(property, found[0]);
3467
- else refusal = found.length > 1 ? "REPEATER_AMBIGUOUS" : leafRefusal(loop.value, params.row, property);
3507
+ if (found.length === 1) {
3508
+ placed.set(property, found[0]);
3509
+ continue;
3510
+ }
3511
+ if (found.length > 1) {
3512
+ refusal = "REPEATER_AMBIGUOUS";
3513
+ continue;
3514
+ }
3515
+ const calls = propCalls(ctx.content, loop, rowName, property);
3516
+ if (ctx.drill && calls.length === 1) passed.push({ property, ...calls[0] });
3517
+ else if (calls.length > 1) refusal = "REPEATER_AMBIGUOUS";
3518
+ else refusal = leafRefusal(loop.value, rowName, property);
3468
3519
  }
3469
3520
  if (refusal !== null) {
3470
3521
  refuse(refusal);
3471
3522
  continue;
3472
3523
  }
3473
- const index = params.index ?? allocateIndex(loop.value);
3524
+ const index = params.index ?? allocateIndex(loop.value, indexes);
3525
+ indexes.add(index);
3474
3526
  const splices = [];
3475
3527
  if (rule.loopKey) {
3476
3528
  const rows = loop.children.filter((child) => child.type === "element");
@@ -3486,6 +3538,36 @@ function bindDataLiterals(ctx, members) {
3486
3538
  }
3487
3539
  splices.push({ start: params.addIndexAt, end: params.addIndexAt, text: `, ${index}` });
3488
3540
  }
3541
+ const drilled = [];
3542
+ const edits = [];
3543
+ const bindings = /* @__PURE__ */ new Map();
3544
+ for (const entry of passed) {
3545
+ const leaf = keys.get(entry.property);
3546
+ const literal = `${array}[\${${index}}].${leaf}`;
3547
+ 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);
3548
+ if (!outcome.ok) {
3549
+ refusal = outcome.reason;
3550
+ break;
3551
+ }
3552
+ edits.push(...outcome.edits);
3553
+ bindings.set(entry.call.attrInsertAt, [
3554
+ ...bindings.get(entry.call.attrInsertAt) ?? [],
3555
+ `${entry.prop}: \`${literal}\``
3556
+ ]);
3557
+ drilled.push({
3558
+ file: outcome.edits[0].file,
3559
+ prop: entry.prop,
3560
+ path: shapeOfPath(literal),
3561
+ literal: `${entry.prop}: \`${literal}\``
3562
+ });
3563
+ }
3564
+ if (refusal !== null) {
3565
+ refuse(refusal);
3566
+ continue;
3567
+ }
3568
+ for (const [at2, entries] of bindings) {
3569
+ splices.push({ start: at2, end: at2, text: ` bcmsBindings={{ ${entries.join(", ")} }}` });
3570
+ }
3489
3571
  for (const [property, element] of placed) {
3490
3572
  const leaf = keys.get(property);
3491
3573
  const literal = `${array}[\${${index}}].${leaf}`;
@@ -3495,7 +3577,7 @@ function bindDataLiterals(ctx, members) {
3495
3577
  ...kind ? [`data-bcms-kind="${kind}"`] : [],
3496
3578
  // A richtext row value is html, so it goes into the dialect's html sink rather than being
3497
3579
  // printed as characters — the same rule `writeLeaf` follows one level down. @see loops.ts
3498
- ...kind === "richtext" && rule.richtext ? [rule.richtext(`${params.row}.${property}`)] : []
3580
+ ...kind === "richtext" && rule.richtext ? [rule.richtext(`${rowName}.${property}`)] : []
3499
3581
  ];
3500
3582
  splices.push({ start: element.attrInsertAt, end: element.attrInsertAt, text: ` ${attributes.join(" ")}` });
3501
3583
  if (kind === "richtext" && rule.richtext && element.inner) {
@@ -3515,19 +3597,319 @@ function bindDataLiterals(ctx, members) {
3515
3597
  splices.push({ start: at.start, end: at.end, text: `${ctx.rowsAs}(${args.join(", ")})` });
3516
3598
  out.splices.push(...splices);
3517
3599
  out.usesRows = true;
3600
+ out.componentEdits.push(...edits);
3601
+ out.props.push(...drilled);
3518
3602
  out.slugs.push(...group.map((member) => member.slug));
3519
3603
  out.bound.push(...group.map((member) => member.key));
3520
3604
  }
3605
+ const settled = /* @__PURE__ */ new Set([...out.bound, ...out.refused.map((refusal) => refusal.key)]);
3606
+ bindLists(ctx, members.filter((member) => !settled.has(member.key)), module, out, indexes);
3607
+ return out;
3608
+ }
3609
+ function literalTable(init) {
3610
+ const elements = (init.elements ?? []).filter((e) => e !== null);
3611
+ const first = elements[0];
3612
+ if (!first || elements.length !== (init.elements ?? []).length) return null;
3613
+ if (elements.some((element) => element.type !== first.type)) return null;
3614
+ if (first.type === "StringLiteral") {
3615
+ return { tuple: false, rows: elements.map((element) => [norm(String(element.value))]) };
3616
+ }
3617
+ if (first.type !== "ArrayExpression") return null;
3618
+ const rows = [];
3619
+ for (const element of elements) {
3620
+ const inner = (element.elements ?? []).filter((x) => x !== null);
3621
+ if (inner.length === 0 || inner.some((x) => x.type !== "StringLiteral")) return null;
3622
+ if (inner.length !== (element.elements ?? []).length) return null;
3623
+ rows.push(inner.map((x) => norm(String(x.value))));
3624
+ }
3625
+ if (new Set(rows.map((row) => row.length)).size !== 1) return null;
3626
+ return { tuple: true, rows };
3627
+ }
3628
+ function openingBracket(text, closeAt) {
3629
+ let depth = 0;
3630
+ for (let at = closeAt; at >= 0; at--) {
3631
+ if (text[at] === "]") depth++;
3632
+ else if (text[at] === "[") {
3633
+ depth--;
3634
+ if (depth === 0) return at;
3635
+ }
3636
+ }
3637
+ return -1;
3638
+ }
3639
+ function arrayLiteral(source) {
3640
+ try {
3641
+ const ast = parseProgram(`(${source})`);
3642
+ const expression = ast.program.body[0]?.expression;
3643
+ if (!expression || expression.type !== "ArrayExpression") return null;
3644
+ return (expression.start ?? -1) === 1 && (expression.end ?? -1) === source.length + 1 ? expression : null;
3645
+ } catch {
3646
+ return null;
3647
+ }
3648
+ }
3649
+ function inlineTables(ctx) {
3650
+ const out = /* @__PURE__ */ new Map();
3651
+ const visit = (node) => {
3652
+ if (node.type === "element") {
3653
+ if (!node.raw) for (const child of node.children) visit(child);
3654
+ return;
3655
+ }
3656
+ if (node.type !== "expr") return;
3657
+ for (const match of node.value.matchAll(/\]\s*\.\s*map\s*\(/g)) {
3658
+ const closeAt = match.index;
3659
+ const openAt = openingBracket(node.value, closeAt);
3660
+ if (openAt <= 0 || /[\w$)\]]/.test(node.value[openAt - 1] ?? "")) continue;
3661
+ const init = arrayLiteral(node.value.slice(openAt, closeAt + 1));
3662
+ const table = init ? literalTable(init) : null;
3663
+ if (!table) continue;
3664
+ const at = { start: node.range.start + openAt, end: node.range.start + closeAt + 1 };
3665
+ out.set(at.start, {
3666
+ ...table,
3667
+ at,
3668
+ label: "an inline array literal",
3669
+ render: () => ({ loop: node, params: mapParams(ctx.content, at.end, node.range.end) })
3670
+ });
3671
+ }
3672
+ for (const child of node.children) visit(child);
3673
+ };
3674
+ for (const root of ctx.parsed.roots) visit(root);
3675
+ return [...out.values()];
3676
+ }
3677
+ function readerElements(content, expression, reads) {
3678
+ const wanted = new Set(reads);
3679
+ const out = /* @__PURE__ */ new Map();
3680
+ const visit = (node) => {
3681
+ if (node.type === "expr") {
3682
+ for (const child of node.children) visit(child);
3683
+ return;
3684
+ }
3685
+ if (node.type !== "element" || node.raw) return;
3686
+ const inner = isIntrinsic(node.tag) && node.inner ? content.slice(node.inner.start, node.inner.end).trim() : "";
3687
+ const read = inner.match(/^\{\s*([A-Za-z_$][\w$]*(?:\[\d+\])?)\s*\}$/)?.[1];
3688
+ if (read && wanted.has(read)) out.set(read, [...out.get(read) ?? [], node]);
3689
+ for (const child of node.children) visit(child);
3690
+ };
3691
+ for (const child of expression.children) visit(child);
3692
+ return out;
3693
+ }
3694
+ function readerWraps(expression, reads) {
3695
+ const wanted = new Set(reads);
3696
+ const out = /* @__PURE__ */ new Map();
3697
+ const visit = (node, parent) => {
3698
+ if (node.type === "expr") {
3699
+ const read = node.value.trim().match(/^\{\s*([A-Za-z_$][\w$]*(?:\[\d+\])?)\s*\}$/)?.[1];
3700
+ const beside = parent !== null && isIntrinsic(parent.tag) && parent.children.some((child) => child !== node && !(child.type === "text" && child.value.trim() === ""));
3701
+ if (read && wanted.has(read) && beside) out.set(read, [...out.get(read) ?? [], node.range]);
3702
+ for (const child of node.children) visit(child, parent);
3703
+ return;
3704
+ }
3705
+ if (node.type !== "element" || node.raw) return;
3706
+ for (const child of node.children) visit(child, node);
3707
+ };
3708
+ for (const child of expression.children) visit(child, null);
3709
+ return out;
3710
+ }
3711
+ function pathTemplates(paths, index) {
3712
+ const out = [];
3713
+ for (let position = 0; position < paths[0].length; position++) {
3714
+ const split = paths.map((row) => splitGroup(row[position]));
3715
+ const first = split[0];
3716
+ if (!first) return null;
3717
+ if (split.some((held, row) => !held || held.array !== first.array || held.leaf !== first.leaf || held.index !== row)) {
3718
+ return null;
3719
+ }
3720
+ out.push(`${first.array}[\${${index}}].${first.leaf}`);
3721
+ }
3722
+ return out;
3723
+ }
3724
+ function bindLists(ctx, members, module, out, indexes) {
3725
+ const rule = DIALECT_RULES[ctx.dialect];
3726
+ const escapes = exportedNames(module.ast);
3727
+ const candidates = [];
3728
+ for (const [name, init] of arrayLiterals(module.ast)) {
3729
+ const table = literalTable(init);
3730
+ if (!table) continue;
3731
+ candidates.push({
3732
+ ...table,
3733
+ at: { start: module.offset + (init.start ?? 0), end: module.offset + (init.end ?? 0) },
3734
+ label: `\`${name}\``,
3735
+ render: () => {
3736
+ const loops = iterations(ctx.parsed.roots, name);
3737
+ if (loops.length > 1) return { refuse: "REPEATER_AMBIGUOUS" };
3738
+ if (loops.length === 0) return null;
3739
+ const mentions2 = ctx.content.match(new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g")) ?? [];
3740
+ if (mentions2.length !== 2 || escapes.has(name)) return { refuse: "IN_EXPRESSION" };
3741
+ return { loop: loops[0], params: callbackParams(loops[0], name) };
3742
+ }
3743
+ });
3744
+ }
3745
+ candidates.push(...inlineTables(ctx));
3746
+ const siblings = /* @__PURE__ */ new Map();
3747
+ const groupOf2 = (member) => `${member.slug}\0${splitGroup(member.path)?.array ?? member.path}`;
3748
+ for (const member of members) {
3749
+ siblings.set(groupOf2(member), [...siblings.get(groupOf2(member)) ?? [], member]);
3750
+ }
3751
+ const fit = (candidate, member) => (siblings.get(groupOf2(member)) ?? [member]).filter(
3752
+ (leaf) => candidate.rows.some((row) => row.includes(leaf.literal))
3753
+ ).length;
3754
+ const best = /* @__PURE__ */ new Map();
3755
+ const bestFit = (member) => {
3756
+ const key = groupOf2(member);
3757
+ if (!best.has(key)) best.set(key, Math.max(0, ...candidates.map((candidate) => fit(candidate, member))));
3758
+ return best.get(key);
3759
+ };
3760
+ const claims = (candidate, member) => candidate.rows.some((row) => row.includes(member.literal)) && fit(candidate, member) === bestFit(member);
3761
+ const holders = (member) => candidates.filter((candidate) => fit(candidate, member) === bestFit(member)).length;
3762
+ for (const candidate of candidates) {
3763
+ const mine = members.filter((member) => claims(candidate, member));
3764
+ if (mine.length === 0 || mine.some((member) => holders(member) > 1)) continue;
3765
+ if (new Set(mine.map((member) => member.slug)).size !== 1) continue;
3766
+ const refuse = (reason) => {
3767
+ for (const member of mine) out.refused.push({ key: member.key, reason });
3768
+ };
3769
+ const paths = candidate.rows.map((row) => row.map(() => ""));
3770
+ const kinds = candidate.rows.map((row) => row.map(() => ""));
3771
+ let clash = false;
3772
+ for (const member of mine) {
3773
+ const spots = candidate.rows.flatMap(
3774
+ (row, r) => row.flatMap((value, p) => value === member.literal ? [[r, p]] : [])
3775
+ );
3776
+ if (spots.length !== 1 || paths[spots[0][0]][spots[0][1]] !== "") clash = true;
3777
+ else {
3778
+ paths[spots[0][0]][spots[0][1]] = member.path;
3779
+ kinds[spots[0][0]][spots[0][1]] = member.kind;
3780
+ }
3781
+ }
3782
+ if (clash || paths.some((row) => row.some((path) => path === ""))) {
3783
+ refuse("IN_EXPRESSION");
3784
+ continue;
3785
+ }
3786
+ const kindAt = kinds[0];
3787
+ if (kinds.some((row) => row.some((kind, position) => kind !== kindAt[position]))) {
3788
+ refuse("IN_EXPRESSION");
3789
+ continue;
3790
+ }
3791
+ const rendered = candidate.render();
3792
+ if (rendered === null) continue;
3793
+ if ("refuse" in rendered) {
3794
+ refuse(rendered.refuse);
3795
+ continue;
3796
+ }
3797
+ const { loop, params } = rendered;
3798
+ 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];
3799
+ if (!params || !reads || reads.length !== candidate.rows[0].length) {
3800
+ refuse("IN_EXPRESSION");
3801
+ continue;
3802
+ }
3803
+ const elements = readerElements(ctx.content, loop, reads);
3804
+ const wraps = readerWraps(loop, reads);
3805
+ const hosts = [];
3806
+ let refusal = null;
3807
+ for (const read of reads) {
3808
+ const found = elements.get(read) ?? [];
3809
+ const beside = elements.has(read) ? [] : wraps.get(read) ?? [];
3810
+ if (found.length === 1) hosts.push({ element: found[0] });
3811
+ else if (found.length === 0 && beside.length === 1) hosts.push({ wrap: beside[0] });
3812
+ else refusal = found.length + beside.length > 1 ? "REPEATER_AMBIGUOUS" : "IN_EXPRESSION";
3813
+ }
3814
+ if (refusal !== null) {
3815
+ refuse(refusal);
3816
+ continue;
3817
+ }
3818
+ const index = params.index ?? allocateIndex(loop.value, indexes);
3819
+ indexes.add(index);
3820
+ const splices = [];
3821
+ if (rule.loopKey) {
3822
+ const rows = loop.children.filter((child) => child.type === "element");
3823
+ const row = rows.length === 1 ? rows[0] : null;
3824
+ if (row && row.tag !== "" && !row.attrs.some((a) => a.name.replace(/^:/, "") === "key")) {
3825
+ splices.push({ start: row.attrInsertAt, end: row.attrInsertAt, text: ` ${rule.loopKey(index)}` });
3826
+ }
3827
+ }
3828
+ if (params.index === null) {
3829
+ if (params.addIndexAt < 0) {
3830
+ refuse("IN_EXPRESSION");
3831
+ continue;
3832
+ }
3833
+ splices.push({ start: params.addIndexAt, end: params.addIndexAt, text: `, ${index}` });
3834
+ }
3835
+ const templates = pathTemplates(paths, index);
3836
+ if (!templates) {
3837
+ refuse("IN_EXPRESSION");
3838
+ continue;
3839
+ }
3840
+ const list = JSON.stringify(candidate.tuple ? paths : paths.map((row) => row[0]));
3841
+ for (const [position, host] of hosts.entries()) {
3842
+ const kind = kindAt[position];
3843
+ const attributes = [
3844
+ rule.attr("data-bcms-field", `\`${templates[position]}\``),
3845
+ ...kind ? [`data-bcms-kind="${kind}"`] : [],
3846
+ // A richtext value is html, so it goes into the dialect's html sink rather than being
3847
+ // printed as characters — the same rule one level up. @see bindDataLiterals
3848
+ ...kind === "richtext" && rule.richtext ? [rule.richtext(reads[position])] : []
3849
+ ];
3850
+ if ("element" in host) {
3851
+ splices.push({ start: host.element.attrInsertAt, end: host.element.attrInsertAt, text: ` ${attributes.join(" ")}` });
3852
+ if (kind === "richtext" && rule.richtext && host.element.inner) {
3853
+ splices.push({ start: host.element.inner.start, end: host.element.inner.end, text: "" });
3854
+ }
3855
+ } else {
3856
+ const inner = kind === "richtext" && rule.richtext ? "" : ctx.content.slice(host.wrap.start, host.wrap.end);
3857
+ splices.push({ start: host.wrap.start, end: host.wrap.end, text: `<span ${attributes.join(" ")}>${inner}</span>` });
3858
+ }
3859
+ out.declarations.push({ path: shapeOfPath(templates[position]), literal: templates[position] });
3860
+ }
3861
+ const args = [
3862
+ ctx.snapshotName(mine[0].slug),
3863
+ list,
3864
+ ctx.content.slice(candidate.at.start, candidate.at.end),
3865
+ ...ctx.slug ? [ctx.slug] : []
3866
+ ];
3867
+ splices.push({
3868
+ start: candidate.at.start,
3869
+ end: candidate.at.end,
3870
+ text: `${candidate.tuple ? ctx.tuples : ctx.list}(${args.join(", ")})`
3871
+ });
3872
+ out.splices.push(...splices);
3873
+ if (candidate.tuple) out.usesTuples = true;
3874
+ else out.usesList = true;
3875
+ out.slugs.push(...mine.map((member) => member.slug));
3876
+ out.bound.push(...mine.map((member) => member.key));
3877
+ out.notes.push(
3878
+ `${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.`
3879
+ );
3880
+ }
3881
+ }
3882
+ function propCalls(content, expression, row, property) {
3883
+ const named = new RegExp(`^\\{\\s*${row}\\s*\\.\\s*${property}\\s*\\}$`);
3884
+ const spread = new RegExp(`^\\{\\s*\\.\\.\\.\\s*${row}\\s*\\}$`);
3885
+ const out = [];
3886
+ const visit = (node) => {
3887
+ if (node.type === "expr") {
3888
+ for (const child of node.children) visit(child);
3889
+ return;
3890
+ }
3891
+ if (node.type !== "element" || node.raw) return;
3892
+ if (node.tag !== "" && !isIntrinsic(node.tag)) {
3893
+ for (const attr of node.attrs) {
3894
+ if (!attr.expressionRange) continue;
3895
+ const text = content.slice(attr.expressionRange.start, attr.expressionRange.end).trim();
3896
+ if (spread.test(text)) out.push({ call: node, prop: property });
3897
+ else if (named.test(text)) out.push({ call: node, prop: attr.name });
3898
+ }
3899
+ }
3900
+ for (const child of node.children) visit(child);
3901
+ };
3902
+ for (const child of expression.children) visit(child);
3521
3903
  return out;
3522
3904
  }
3523
3905
  function leafRefusal(loop, row, property) {
3524
3906
  const prop = new RegExp(`=\\s*\\{\\s*${row}\\s*\\.\\s*${property}\\s*\\}|\\{\\s*\\.\\.\\.\\s*${row}\\s*\\}`);
3525
3907
  return prop.test(loop) ? "PROP_TARGET_NOT_FOUND" : "IN_EXPRESSION";
3526
3908
  }
3527
- function allocateIndex(loop) {
3909
+ function allocateIndex(loop, taken) {
3528
3910
  const used = new Set(loop.match(/[A-Za-z_$][\w$]*/g) ?? []);
3529
- if (!used.has("i")) return "i";
3530
- for (let n = 1; ; n++) if (!used.has(`i_${n}`)) return `i_${n}`;
3911
+ if (!used.has("i") && !taken.has("i")) return "i";
3912
+ for (let n = 1; ; n++) if (!used.has(`i_${n}`) && !taken.has(`i_${n}`)) return `i_${n}`;
3531
3913
  }
3532
3914
 
3533
3915
  // src/resolve.ts
@@ -3609,6 +3991,13 @@ function isAliasShaped(value) {
3609
3991
  function unreadableConfigs(sources) {
3610
3992
  return sources.filter((source) => isProjectConfig(source.path) && readJsonc(source.content) === null).map((source) => source.path);
3611
3993
  }
3994
+ function extendedConfigs(sources) {
3995
+ return sources.filter((source) => {
3996
+ if (!isProjectConfig(source.path)) return false;
3997
+ const parsed = readJsonc(source.content);
3998
+ return parsed !== null && parsed.extends !== void 0 && parsed.compilerOptions?.paths === void 0;
3999
+ }).map((source) => source.path);
4000
+ }
3612
4001
  function expand(specifier, aliases) {
3613
4002
  const out = [];
3614
4003
  for (const [pattern, targets] of aliases) {
@@ -3738,7 +4127,7 @@ function bindImages(ctx, targets) {
3738
4127
 
3739
4128
  // src/helper.ts
3740
4129
  import { createHash as createHash2 } from "crypto";
3741
- var HELPER_VERSION = 5;
4130
+ var HELPER_VERSION = 6;
3742
4131
  var HEADER = "// @bettercms-ai/convert helper v";
3743
4132
  var HELPER_PATH = Object.fromEntries(
3744
4133
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -3846,6 +4235,30 @@ export function bcmsRowsAs<T>(
3846
4235
  });
3847
4236
  }
3848
4237
 
4238
+ /**
4239
+ * A list of PRIMITIVES \u2014 \`const tags = ["\u2026", "\u2026"]\` \u2014 with one CMS field per ELEMENT.
4240
+ *
4241
+ * \u{1F534} THE LENGTH IS \`paths\`, NOT THE CMS. Unlike \`bcmsRows\`, which reads the array itself and
4242
+ * renders as many rows as the CMS holds, this maps over the path list the codemod wrote \u2014 so a row
4243
+ * an editor ADDS has no path here and never renders, and a row an editor DELETES leaves its path
4244
+ * resolving to nothing, which falls back to the copy the template shipped with. Every row's TEXT is
4245
+ * editable; the number of rows is the template's. @see the PRIMITIVE_LIST receipt note, which says
4246
+ * this out loud rather than leaving an editor to discover it.
4247
+ */
4248
+ export function bcmsList(page: unknown, paths: string[], fallback: string[], slug?: string): string[] {
4249
+ const entries = isRecord(page) ? page.entries : undefined;
4250
+ const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4251
+ return paths.map((path, index) => render(readPath(root, path)) ?? fallback[index] ?? "");
4252
+ }
4253
+
4254
+ /**
4255
+ * The same for a list of TUPLES \u2014 \`[["q", "a"], \u2026]\` \u2014 with one field per tuple POSITION, and the
4256
+ * same ceiling: \`paths\` decides how many rows render, and an added or deleted CMS row does not.
4257
+ */
4258
+ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][], slug?: string): string[][] {
4259
+ return paths.map((row, index) => bcmsList(page, row, fallback[index] ?? [], slug));
4260
+ }
4261
+
3849
4262
  /** An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED */
3850
4263
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
3851
4264
  const entries = isRecord(page) ? page.entries : undefined;
@@ -3937,19 +4350,22 @@ var HISTORICAL = {
3937
4350
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3938
4351
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3939
4352
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3940
- 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
4353
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4354
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
3941
4355
  },
3942
4356
  "src/lib/bcms-content.ts": {
3943
4357
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
3944
4358
  2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
3945
4359
  3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e",
3946
- 4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af"
4360
+ 4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4361
+ 5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba"
3947
4362
  },
3948
4363
  "composables/bcms-content.ts": {
3949
4364
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3950
4365
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3951
4366
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3952
- 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
4367
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4368
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
3953
4369
  }
3954
4370
  };
3955
4371
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -4484,8 +4900,8 @@ function declaringElements(roots, content) {
4484
4900
  const name = attr.name.replace(/^:/, "");
4485
4901
  if (name === "bcmsBindings") {
4486
4902
  const own = ownBytes(node, content);
4487
- for (const quoted of content.slice(attr.range.start, attr.range.end).matchAll(/["']([^"']+)["']/g)) {
4488
- out.push({ scope: "page", path: quoted[1], kind, own });
4903
+ for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
4904
+ out.push({ scope: "page", path, kind, own });
4489
4905
  }
4490
4906
  continue;
4491
4907
  }
@@ -4515,7 +4931,7 @@ function declaringElements(roots, content) {
4515
4931
  return out;
4516
4932
  }
4517
4933
  function helperLocals(parsed, dialect, file) {
4518
- const out = { bcms: [], bcmsRows: [], bcmsRowsAs: [], bcmsImage: [], bcmsLayout: [] };
4934
+ const out = { bcms: [], bcmsRows: [], bcmsRowsAs: [], bcmsImage: [], bcmsLayout: [], bcmsList: [], bcmsTuples: [] };
4519
4935
  const helper = HELPER_PATH[dialect];
4520
4936
  if (!helper) return out;
4521
4937
  for (const [local, from] of importsIn(parsed, dialect)) {
@@ -4525,6 +4941,8 @@ function helperLocals(parsed, dialect, file) {
4525
4941
  if (from.imported === "bcmsRowsAs") out.bcmsRowsAs.push(local);
4526
4942
  if (from.imported === "bcmsImage") out.bcmsImage.push(local);
4527
4943
  if (from.imported === "bcmsLayout") out.bcmsLayout.push(local);
4944
+ if (from.imported === "bcmsList") out.bcmsList.push(local);
4945
+ if (from.imported === "bcmsTuples") out.bcmsTuples.push(local);
4528
4946
  }
4529
4947
  return out;
4530
4948
  }
@@ -4597,6 +5015,9 @@ function convertedHere(content, parsed, dialect, target, file) {
4597
5015
  keyed = reads(locals.bcmsRowsAs);
4598
5016
  rows = keyed || reads(locals.bcmsRows);
4599
5017
  }
5018
+ const listed = indexed !== null && snapshot !== null && content.includes(JSON.stringify(target.path)) && [...locals.bcmsList, ...locals.bcmsTuples].some(
5019
+ (name) => new RegExp(`${identifier(name)}\\s*\\(\\s*${identifier(snapshot)}\\s*,`).test(content)
5020
+ );
4600
5021
  let cached = null;
4601
5022
  const moduleRead = () => {
4602
5023
  if (indexed || snapshot === null) return false;
@@ -4612,7 +5033,7 @@ function convertedHere(content, parsed, dialect, target, file) {
4612
5033
  return elementReads(element.own, target, locals, snapshot, false) || moduleRead();
4613
5034
  }
4614
5035
  if (element.path !== shape) return false;
4615
- return rows && (keyed || elementReads(element.own, target, locals, snapshot, true));
5036
+ return listed || rows && (keyed || elementReads(element.own, target, locals, snapshot, true));
4616
5037
  });
4617
5038
  }
4618
5039
 
@@ -4748,6 +5169,22 @@ function importLines(imports) {
4748
5169
 
4749
5170
  // src/receipt.ts
4750
5171
  var CANVAS_REASONS = /* @__PURE__ */ new Set(["CANVAS_BRIDGE_MANUAL", "SSR_DRAFT_ROUTE"]);
5172
+ var FormInvariantError = class extends Error {
5173
+ code = "FORM_INVARIANT";
5174
+ constructor(message) {
5175
+ super(message);
5176
+ this.name = "FormInvariantError";
5177
+ }
5178
+ };
5179
+ function assertForms(total, forms) {
5180
+ const sum = forms.wired + forms.alreadyWired + forms.pending.length;
5181
+ if (sum !== total) {
5182
+ throw new FormInvariantError(
5183
+ `The receipt does not account for every form: ${total} listed but ${forms.wired} wired + ${forms.alreadyWired} already wired + ${forms.pending.length} pending = ${sum}.`
5184
+ );
5185
+ }
5186
+ return forms;
5187
+ }
4751
5188
  var ReceiptInvariantError = class extends Error {
4752
5189
  code = "RECEIPT_INVARIANT";
4753
5190
  constructor(message) {
@@ -5599,6 +6036,17 @@ function readBrief(value) {
5599
6036
  }
5600
6037
  }
5601
6038
  }
6039
+ for (const [at, entry] of check.array(brief.forms ?? [], `the brief's "forms"`).entries()) {
6040
+ const where = `brief.forms[${at}]`;
6041
+ const form = check.record(entry, where);
6042
+ check.segment(form.id, `${where}.id`);
6043
+ check.string(form.name, `${where}.name`);
6044
+ check.string(form.submitUrl, `${where}.submitUrl`);
6045
+ check.optionalString(form.successMessage, `${where}.successMessage`);
6046
+ for (const [n, held] of check.array(form.fields, `${where}.fields`).entries()) {
6047
+ check.segment(check.record(held, `${where}.fields[${n}]`).key, `${where}.fields[${n}].key`);
6048
+ }
6049
+ }
5602
6050
  return brief;
5603
6051
  }
5604
6052
  function readPlan(value) {
@@ -5645,6 +6093,236 @@ function readPlan(value) {
5645
6093
  return plan2;
5646
6094
  }
5647
6095
 
6096
+ // src/forms.ts
6097
+ import MagicString4 from "magic-string";
6098
+ var WIRABLE = ["html", "astro", "jsx"];
6099
+ var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6100
+ var FORM_SCRIPT = `<script>
6101
+ document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
6102
+ f.addEventListener('submit',function(e){
6103
+ e.preventDefault();
6104
+ var msg=f.querySelector('.bcms-form-msg');
6105
+ var data={};
6106
+ // A checkboxes group posts one entry per checked box under the SAME name; the old
6107
+ // one-at-a-time assignment kept only the last, so "pick many" delivered one answer.
6108
+ // One checked box still arrives as a scalar \u2014 FormData cannot tell a group from a
6109
+ // lone field \u2014 which is exactly why lib/forms/validate.ts's chosenValues() accepts
6110
+ // both an array and a comma string.
6111
+ new FormData(f).forEach(function(v,k){
6112
+ if(k==='cf-turnstile-response')return;
6113
+ if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);
6114
+ else data[k]=v;
6115
+ });
6116
+ var body={data:data};
6117
+ var tok=f.querySelector('[name="cf-turnstile-response"]');
6118
+ if(tok&&tok.value)body['cf-turnstile-response']=tok.value;
6119
+ fetch(f.action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
6120
+ .then(function(r){return r.ok;})
6121
+ .then(function(ok){if(msg){msg.hidden=false;msg.textContent=ok?(f.getAttribute('data-bcms-form-success')||'Thanks! Your submission was received.'):'Sorry, something went wrong.';}if(ok)f.reset();})
6122
+ .catch(function(){if(msg){msg.hidden=false;msg.textContent='Sorry, something went wrong.';}});
6123
+ });
6124
+ });
6125
+ </script>`;
6126
+ var SCRIPT_BODY = FORM_SCRIPT.slice("<script>".length, -"</script>".length);
6127
+ function scriptFor(dialect) {
6128
+ if (dialect === "jsx") return `<script dangerouslySetInnerHTML={{ __html: \`${SCRIPT_BODY}\` }} />`;
6129
+ if (dialect === "astro") return `<script is:inline>${SCRIPT_BODY}</script>`;
6130
+ return FORM_SCRIPT;
6131
+ }
6132
+ var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
6133
+ var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
6134
+ var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
6135
+ function* elementsIn(nodes) {
6136
+ for (const node of nodes) {
6137
+ if (node.type === "expr") {
6138
+ yield* elementsIn(node.children);
6139
+ continue;
6140
+ }
6141
+ if (node.type !== "element") continue;
6142
+ yield node;
6143
+ yield* elementsIn(node.children);
6144
+ }
6145
+ }
6146
+ var childElements = (node) => node.children.flatMap(
6147
+ (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6148
+ );
6149
+ function candidateOf(file, dialect, form) {
6150
+ const controls = /* @__PURE__ */ new Map();
6151
+ const wrapper = /* @__PURE__ */ new Map();
6152
+ const all = [];
6153
+ const walk = (parent) => {
6154
+ const own = [];
6155
+ for (const child of childElements(parent)) {
6156
+ if (CONTROLS.has(child.tag.toLowerCase())) {
6157
+ own.push(child);
6158
+ all.push(child);
6159
+ }
6160
+ walk(child);
6161
+ }
6162
+ if (own.length === 1 && parent !== form) wrapper.set(own[0], parent);
6163
+ };
6164
+ walk(form);
6165
+ for (const control of all) {
6166
+ const name = attrOf(control, "name")?.value;
6167
+ if (name && !controls.has(name)) controls.set(name, control);
6168
+ }
6169
+ for (const control of all) {
6170
+ const id = attrOf(control, "id")?.value;
6171
+ if (id && !controls.has(id)) controls.set(id, control);
6172
+ }
6173
+ return { file, dialect, el: form, controls, wrapper };
6174
+ }
6175
+ var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6176
+ function wire(source, candidate, form) {
6177
+ const { el, dialect } = candidate;
6178
+ const inserts = [];
6179
+ const set = (name, value) => {
6180
+ const attr = attrOf(el, name);
6181
+ if (!attr) inserts.push(`${name}="${escapeAttr(value)}"`);
6182
+ else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6183
+ else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6184
+ };
6185
+ set("action", form.submitUrl);
6186
+ set("method", "post");
6187
+ inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
6188
+ if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
6189
+ source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6190
+ for (const field of form.fields) {
6191
+ const control = candidate.controls.get(field.key);
6192
+ const host = candidate.wrapper.get(control) ?? control;
6193
+ source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6194
+ const name = attrOf(control, "name");
6195
+ if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
6196
+ else if (!name.expression && name.value !== field.key && name.valueRange) {
6197
+ source.overwrite(name.valueRange.start, name.valueRange.end, escapeAttr(field.key));
6198
+ }
6199
+ }
6200
+ if (el.inner && !hasMessage(el)) {
6201
+ const tail = /\s*$/.exec(source.original.slice(el.inner.start, el.inner.end))[0];
6202
+ source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6203
+ }
6204
+ }
6205
+ async function wireForms(briefIn, sources) {
6206
+ const brief = readBrief(briefIn);
6207
+ const forms = brief.forms ?? [];
6208
+ const candidates = [];
6209
+ const unreadable = [];
6210
+ for (const source of sources) {
6211
+ if (!/<form[\s/>]/i.test(source.content)) continue;
6212
+ const dialect = dialectOf(source.path, source.content);
6213
+ if (!dialect || !WIRABLE.includes(dialect)) {
6214
+ unreadable.push({
6215
+ file: source.path,
6216
+ reason: "DIALECT_UNSUPPORTED",
6217
+ message: `${source.path} is ${dialect ?? "not a template"}.`
6218
+ });
6219
+ continue;
6220
+ }
6221
+ const parsed = await parseFile4(dialect, source.content);
6222
+ if (parsed.error) {
6223
+ unreadable.push({
6224
+ file: source.path,
6225
+ reason: parsed.error.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR",
6226
+ message: parsed.error.message
6227
+ });
6228
+ continue;
6229
+ }
6230
+ for (const el of elementsIn(parsed.roots)) {
6231
+ if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
6232
+ }
6233
+ }
6234
+ const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6235
+ const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6236
+ const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6237
+ const wanted = /* @__PURE__ */ new Map();
6238
+ for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6239
+ const matched = [];
6240
+ const pending = [];
6241
+ const onSite = [];
6242
+ let alreadyWired = 0;
6243
+ const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
6244
+ for (const form of forms) {
6245
+ const list = options.get(form.id) ?? [];
6246
+ if (list.length > 1) {
6247
+ pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
6248
+ continue;
6249
+ }
6250
+ if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
6251
+ pend2(form, "FORM_AMBIGUOUS", list[0].file, "more than one CMS form's fields are posted by this <form>.");
6252
+ continue;
6253
+ }
6254
+ if (list.length === 0) {
6255
+ const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6256
+ if (best) {
6257
+ const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6258
+ pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6259
+ } else if (unreadable[0]) {
6260
+ pend2(form, unreadable[0].reason, unreadable[0].file, unreadable[0].message);
6261
+ } else {
6262
+ pend2(form, "FORM_NOT_IN_SOURCE", void 0, `no <form> posts ${form.fields.map((f) => f.key).join(", ") || "any field of this form"}.`);
6263
+ }
6264
+ continue;
6265
+ }
6266
+ const candidate = list[0];
6267
+ if (attrOf(candidate.el, "action")?.expression) {
6268
+ pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6269
+ continue;
6270
+ }
6271
+ const misnamed = form.fields.find((field) => {
6272
+ const attr = attrOf(candidate.controls.get(field.key), "name");
6273
+ return !!attr && attr.value !== field.key && (attr.expression || !attr.valueRange);
6274
+ });
6275
+ if (misnamed) {
6276
+ pend2(form, "FIELD_UNMATCHED", candidate.file, `the control for ${misnamed.key} posts under an expression, so its name cannot be set to the CMS key.`);
6277
+ continue;
6278
+ }
6279
+ if (attrOf(candidate.el, "data-bcms-form")) {
6280
+ alreadyWired++;
6281
+ onSite.push(form);
6282
+ continue;
6283
+ }
6284
+ matched.push({ form, candidate });
6285
+ onSite.push(form);
6286
+ }
6287
+ const contents = new Map(sources.map((s) => [s.path, s.content]));
6288
+ const byFile = /* @__PURE__ */ new Map();
6289
+ for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
6290
+ const files = [];
6291
+ for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6292
+ const content = contents.get(path);
6293
+ const source = new MagicString4(content);
6294
+ for (const { form, candidate } of list) wire(source, candidate, form);
6295
+ if (!content.includes("form[data-bcms-form]")) {
6296
+ const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6297
+ const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6298
+ source.appendRight(last.candidate.el.end, `
6299
+ ${indent}${scriptFor(last.candidate.dialect)}`);
6300
+ }
6301
+ files.push({ path, operation: "modify", content: source.toString() });
6302
+ }
6303
+ const notes = onSite.filter((f) => f.status === "draft").map((f) => `publish form ${f.name} in the Forms tab \u2014 a draft form rejects every submission with 403.`);
6304
+ return {
6305
+ files,
6306
+ receipt: {
6307
+ briefDigest: briefDigest(brief),
6308
+ // This lane binds no brief PATH, and says so rather than leaving the arithmetic to a reader:
6309
+ // a receipt claiming zero of zero paths is exactly what a forms-only run did.
6310
+ paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6311
+ occurrences: { located: 0, rewritten: 0 },
6312
+ files: [],
6313
+ helpers: [],
6314
+ helperUpgraded: false,
6315
+ dynamicBindings: [],
6316
+ forms: assertForms(forms.length, {
6317
+ wired: matched.length,
6318
+ alreadyWired,
6319
+ pending,
6320
+ ...notes.length > 0 ? { notes } : {}
6321
+ })
6322
+ }
6323
+ };
6324
+ }
6325
+
5648
6326
  // src/scan.ts
5649
6327
  var SKIP_DIRS = [
5650
6328
  "node_modules/",
@@ -5702,6 +6380,7 @@ function isSourceCandidate(path) {
5702
6380
  if (SKIP_DIRS.some((dir) => lower === dir.slice(0, -1) || lower.startsWith(dir) || lower.includes(`/${dir}`)))
5703
6381
  return false;
5704
6382
  if (SKIP_FILES.includes(lower.split("/").pop() ?? "")) return false;
6383
+ if (/(^|\/)[jt]sconfig\.json$/.test(lower)) return true;
5705
6384
  if (!SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false;
5706
6385
  if (!PROSE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return true;
5707
6386
  return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
@@ -6012,7 +6691,7 @@ function moduleScope(parsed, content) {
6012
6691
  }
6013
6692
 
6014
6693
  // src/componentize.ts
6015
- import MagicString4 from "magic-string";
6694
+ import MagicString5 from "magic-string";
6016
6695
  var SectionInvariantError = class extends Error {
6017
6696
  code = "SECTION_INVARIANT";
6018
6697
  constructor(message) {
@@ -6938,7 +7617,7 @@ export default function ${resolved.name}({ blockId, bind, overrides, page }: Bcm
6938
7617
  ` };
6939
7618
  }
6940
7619
  function sectionMarkup(content, root, fieldName) {
6941
- const slice = new MagicString4(content.slice(root.start, root.end));
7620
+ const slice = new MagicString5(content.slice(root.start, root.end));
6942
7621
  const at = (offset) => offset - root.start;
6943
7622
  const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
6944
7623
  if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
@@ -7137,7 +7816,7 @@ async function migrateSections(plan2, contents, written, fileSet, aliases) {
7137
7816
  );
7138
7817
  continue;
7139
7818
  }
7140
- const source = new MagicString4(content);
7819
+ const source = new MagicString5(content);
7141
7820
  const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
7142
7821
  if (duplicates.length > 0) {
7143
7822
  refused.set(refusalKey(dialect, slug), `${entry.path} has ${duplicates.join(", ")} carrying more than one data-bcms-field, so there is no single address to rewrite.`);
@@ -7414,7 +8093,7 @@ export default function Sections({ page }: { page: string }) {
7414
8093
  function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
7415
8094
  const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
7416
8095
  const local = names.get("Sections");
7417
- const source = new MagicString4(content);
8096
+ const source = new MagicString5(content);
7418
8097
  const first = placeable[0].root;
7419
8098
  const last = placeable[placeable.length - 1].root;
7420
8099
  source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
@@ -7434,7 +8113,7 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
7434
8113
  { key: "pageSnapshot", name: "pageSnapshot" }
7435
8114
  ]);
7436
8115
  const snapshot = names.get("pageSnapshot");
7437
- const source = new MagicString4(content);
8116
+ const source = new MagicString5(content);
7438
8117
  for (const resolved of placeable) {
7439
8118
  const component = written.get(resolved.slug);
7440
8119
  const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
@@ -7758,6 +8437,7 @@ async function convertSources(briefIn, sources, options) {
7758
8437
  const receiptFiles = [];
7759
8438
  const dialects = /* @__PURE__ */ new Set();
7760
8439
  const dynamicBindings = [];
8440
+ const laneNotes = [];
7761
8441
  const snapshotSlugs = /* @__PURE__ */ new Set();
7762
8442
  let occurrencesLocated = 0;
7763
8443
  let occurrencesRewritten = 0;
@@ -7783,6 +8463,7 @@ async function convertSources(briefIn, sources, options) {
7783
8463
  outFiles.push({ path: file, operation: "modify", content: result.content });
7784
8464
  receiptFiles.push({ path: file, bindings: result.bindings, tier: result.tier });
7785
8465
  dynamicBindings.push(...result.dynamic);
8466
+ laneNotes.push(...result.notes);
7786
8467
  for (const slug of result.snapshots) snapshotSlugs.add(slug);
7787
8468
  }
7788
8469
  }
@@ -7819,7 +8500,7 @@ async function convertSources(briefIn, sources, options) {
7819
8500
  } else rewritten++;
7820
8501
  }
7821
8502
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
7822
- const source = new MagicString5(contents.get(file));
8503
+ const source = new MagicString6(contents.get(file));
7823
8504
  const seen = /* @__PURE__ */ new Set();
7824
8505
  for (const splice of splices) {
7825
8506
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -7879,13 +8560,25 @@ async function convertSources(briefIn, sources, options) {
7879
8560
  helperUpgraded,
7880
8561
  dynamicBindings,
7881
8562
  canvas: { lane: canvas.lane, ...canvas.files.length > 0 ? { files: canvas.files } : {} },
7882
- notes: unreadableConfigs(sources).map(
7883
- (file) => ({
7884
- code: "TSCONFIG_UNREADABLE",
7885
- file,
7886
- message: `${file} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
7887
- })
7888
- )
8563
+ notes: [
8564
+ ...unreadableConfigs(sources).map(
8565
+ (file) => ({
8566
+ code: "TSCONFIG_UNREADABLE",
8567
+ file,
8568
+ message: `${file} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
8569
+ })
8570
+ ),
8571
+ // An absent alias table looks exactly like a project that has none, and the two have
8572
+ // opposite consequences for every component reached through one. @see extendedConfigs
8573
+ ...extendedConfigs(sources).map(
8574
+ (file) => ({
8575
+ code: "TSCONFIG_EXTENDS_ONLY",
8576
+ file,
8577
+ 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.`
8578
+ })
8579
+ ),
8580
+ ...laneNotes
8581
+ ]
7889
8582
  });
7890
8583
  return { files: outFiles.sort((a, b) => a.path.localeCompare(b.path)), receipt };
7891
8584
  }
@@ -7907,7 +8600,8 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7907
8600
  dialect: dialectOf(file, content),
7908
8601
  dynamic: [],
7909
8602
  componentEdits: [],
7910
- snapshots: []
8603
+ snapshots: [],
8604
+ notes: []
7911
8605
  };
7912
8606
  if (pendingTargets.length === 0) return empty;
7913
8607
  if (!dialect) {
@@ -7968,7 +8662,8 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7968
8662
  dialect: null,
7969
8663
  dynamic: [],
7970
8664
  componentEdits: [],
7971
- snapshots: []
8665
+ snapshots: [],
8666
+ notes: []
7972
8667
  };
7973
8668
  }
7974
8669
  if (rescued.reason === "unverifiable") {
@@ -8038,6 +8733,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8038
8733
  { key: "helper:bcmsLayout", name: "bcmsLayout" },
8039
8734
  { key: "helper:bcmsRowsAs", name: "bcmsRowsAs" },
8040
8735
  { key: "helper:bcmsImage", name: "bcmsImage" },
8736
+ { key: "helper:bcmsList", name: "bcmsList" },
8737
+ { key: "helper:bcmsTuples", name: "bcmsTuples" },
8041
8738
  // Keyed by the SLUG: two slugs that spell one identifier are still two pages. @see scope.ts
8042
8739
  ...slugs.map((slug) => ({ key: `slug:${slug}`, name: identifierFor2(slug) }))
8043
8740
  ].filter((entry) => !preset.has(entry.key));
@@ -8229,15 +8926,23 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8229
8926
  const collided = new Set(overlapping(kept).map((r) => r.key));
8230
8927
  const applied = kept.filter((r) => !collided.has(r.key));
8231
8928
  for (const key of collided) outcomes.set(key, "AMBIGUOUS_LITERAL");
8232
- const data = bindDataLiterals(
8929
+ const data = await bindDataLiterals(
8233
8930
  {
8234
8931
  content,
8235
8932
  parsed,
8236
8933
  dialect: writing,
8237
8934
  rowsAs: helperName("bcmsRowsAs"),
8935
+ list: helperName("bcmsList"),
8936
+ tuples: helperName("bcmsTuples"),
8238
8937
  read: helperName("bcms"),
8239
8938
  snapshotName,
8240
- slug: params?.slug ?? null
8939
+ slug: params?.slug ?? null,
8940
+ // The row's leaf travels to another file exactly as a scalar prop does, and by the same
8941
+ // machinery — the lane decides WHICH props, this decides what may be touched. @see props.ts
8942
+ drill: async (component, prop, kind) => {
8943
+ const drilled = await drillProp(drill, file, component, prop, kind, 1);
8944
+ return drilled.ok ? { ok: true, edits: drilled.edits } : { ok: false, reason: drilled.reason ?? "PROP_TARGET_NOT_FOUND" };
8945
+ }
8241
8946
  },
8242
8947
  targets.filter((t) => {
8243
8948
  const outcome = outcomes.get(t.key);
@@ -8255,6 +8960,15 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8255
8960
  for (const declaration2 of data.declarations) {
8256
8961
  dynamic.push({ file, prop: null, path: declaration2.path, callSites: [{ file, literal: declaration2.literal }] });
8257
8962
  }
8963
+ componentEdits.push(...data.componentEdits);
8964
+ for (const drilledProp of data.props) {
8965
+ dynamic.push({
8966
+ file: drilledProp.file,
8967
+ prop: drilledProp.prop,
8968
+ path: drilledProp.path,
8969
+ callSites: [{ file, literal: drilledProp.literal }]
8970
+ });
8971
+ }
8258
8972
  const images = bindImages(
8259
8973
  { content, parsed, dialect: writing, image: helperName("bcmsImage"), snapshotName, slug: params?.slug ?? null },
8260
8974
  targets.filter((t) => {
@@ -8266,7 +8980,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8266
8980
  for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
8267
8981
  const lanes = [...data.splices, ...images.splices];
8268
8982
  if (applied.length === 0 && lanes.length === 0) {
8269
- return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [] };
8983
+ return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [], notes: [] };
8270
8984
  }
8271
8985
  const readSlugs = [
8272
8986
  .../* @__PURE__ */ new Set([...applied.filter((r) => r.scope === "page").map((r) => r.slug), ...data.slugs, ...images.slugs])
@@ -8293,6 +9007,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8293
9007
  ] : [],
8294
9008
  ...applied.some((r) => r.scope === "layout") ? [{ name: "bcmsLayout", alias: helperName("bcmsLayout") }] : [],
8295
9009
  ...data.usesRows ? [{ name: "bcmsRowsAs", alias: helperName("bcmsRowsAs") }] : [],
9010
+ ...data.usesList ? [{ name: "bcmsList", alias: helperName("bcmsList") }] : [],
9011
+ // `bcmsTuples` reads each row through `bcmsList`, but the emitted helper exports both and the
9012
+ // page imports only the one it calls — an unused import is dead code in someone else's repo.
9013
+ ...data.usesTuples ? [{ name: "bcmsTuples", alias: helperName("bcmsTuples") }] : [],
8296
9014
  ...data.usesRead ? [{ name: "bcms", alias: helperName("bcms") }] : [],
8297
9015
  ...images.splices.length > 0 ? [{ name: "bcmsImage", alias: helperName("bcmsImage") }] : []
8298
9016
  ].filter((helper2, at, all) => all.findIndex((h) => h.name === helper2.name) === at).filter((helper2) => !preset.has(`helper:${helper2.name}`)),
@@ -8307,7 +9025,9 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8307
9025
  dialect: writing,
8308
9026
  dynamic,
8309
9027
  componentEdits,
8310
- snapshots: rule.text ? readSlugs : []
9028
+ snapshots: rule.text ? readSlugs : [],
9029
+ // The lane reports one sentence per list it bound; the FILE is added here, where it is known.
9030
+ notes: data.notes.map((message) => ({ code: "PRIMITIVE_LIST", file, message }))
8311
9031
  };
8312
9032
  }
8313
9033
  function mergeInto(content, site) {
@@ -8488,6 +9208,8 @@ export {
8488
9208
  CONTENT_DIR,
8489
9209
  ConvertError,
8490
9210
  DIALECT_RULES,
9211
+ FORM_SCRIPT,
9212
+ FormInvariantError,
8491
9213
  HELPER_PATH,
8492
9214
  HELPER_VERSION,
8493
9215
  PARSE_FILE,
@@ -8503,6 +9225,7 @@ export {
8503
9225
  SectionInvariantError,
8504
9226
  ValidationError,
8505
9227
  aliasesFrom,
9228
+ assertForms,
8506
9229
  assertReceipt,
8507
9230
  assertSections,
8508
9231
  assetCandidates,
@@ -8520,6 +9243,7 @@ export {
8520
9243
  declaringElements,
8521
9244
  dialectOf,
8522
9245
  exportedFunction,
9246
+ extendedConfigs,
8523
9247
  findSites,
8524
9248
  findSitesTolerant,
8525
9249
  flat,
@@ -8544,6 +9268,7 @@ export {
8544
9268
  routeOfFile,
8545
9269
  stripTags,
8546
9270
  unreadableConfigs,
8547
- walkAst
9271
+ walkAst,
9272
+ wireForms
8548
9273
  };
8549
9274
  //# sourceMappingURL=index.js.map