@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/cli.js CHANGED
@@ -5,7 +5,7 @@ import { readdir, readFile, writeFile, mkdir, stat } from "fs/promises";
5
5
  import { dirname, join as join2, relative, resolve, sep } from "path";
6
6
 
7
7
  // src/index.ts
8
- import MagicString5 from "magic-string";
8
+ import MagicString6 from "magic-string";
9
9
 
10
10
  // src/brief.ts
11
11
  import { createHash } from "crypto";
@@ -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)));
@@ -2560,7 +2563,16 @@ var IMAGE_SOURCES = {
2560
2563
  };
2561
2564
  var isImageSource = (site) => site.where === "attr" && (IMAGE_SOURCES[site.tag.toLowerCase()] ?? []).includes(site.attr ?? "");
2562
2565
  var norm = (value) => decodeEntities(value.normalize("NFC")).replace(/\s+/g, " ").trim();
2563
- var BCMS_ATTRS = ["data-bcms-field", "data-bcms-kind", "data-bcms-props", "data-bcms-layout-field"];
2566
+ var BCMS_ATTRS = [
2567
+ "data-bcms-field",
2568
+ "data-bcms-kind",
2569
+ "data-bcms-props",
2570
+ "data-bcms-layout-field",
2571
+ // The forms lane's two. Listed here so `collectSites` never matches a brief literal against a
2572
+ // marker this package itself wrote — a form id or a field key is not the page's copy.
2573
+ "data-bcms-form",
2574
+ "data-bcms-form-field"
2575
+ ];
2564
2576
  var attrInsertAfter = (attrs, start, tag) => attrs.length > 0 ? Math.max(...attrs.map((a) => a.range.end)) : start + 1 + tag.length;
2565
2577
  function openTagEnd(content, start) {
2566
2578
  let quote = null;
@@ -3029,7 +3041,7 @@ function receptionIn(component, propName, offset, content) {
3029
3041
  let receiver = null;
3030
3042
  let rest = null;
3031
3043
  let hasBindings = false;
3032
- let destructured = false;
3044
+ let destructured2 = false;
3033
3045
  for (const node of component ? [component] : []) {
3034
3046
  const params = node.params ?? [];
3035
3047
  const first = params[0];
@@ -3055,7 +3067,7 @@ function receptionIn(component, propName, offset, content) {
3055
3067
  if (key !== propName) continue;
3056
3068
  const value = property.value;
3057
3069
  if (value?.type === "Identifier") names.add(String(value.name));
3058
- destructured = true;
3070
+ destructured2 = true;
3059
3071
  receivable = true;
3060
3072
  receive = patternClose(content, first, offset);
3061
3073
  }
@@ -3065,7 +3077,7 @@ function receptionIn(component, propName, offset, content) {
3065
3077
  viaProps = true;
3066
3078
  }
3067
3079
  }
3068
- return { names, receive, viaProps, typeName, receivable, inline, receiver, rest, hasBindings, destructured };
3080
+ return { names, receive, viaProps, typeName, receivable, inline, receiver, rest, hasBindings, destructured: destructured2 };
3069
3081
  }
3070
3082
  function astroReception(ast, propName, offset, content) {
3071
3083
  const names = /* @__PURE__ */ new Set();
@@ -3248,8 +3260,7 @@ function splitGroup(path) {
3248
3260
  return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
3249
3261
  }
3250
3262
  function moduleOf(parsed) {
3251
- if (!parsed.ast) return null;
3252
- return { ast: parsed.ast, offset: parsed.script?.offset ?? 0 };
3263
+ return { ast: parsed.ast ?? null, offset: parsed.script?.offset ?? 0 };
3253
3264
  }
3254
3265
  function arrayLiterals(ast) {
3255
3266
  const out = /* @__PURE__ */ new Map();
@@ -3262,6 +3273,19 @@ function arrayLiterals(ast) {
3262
3273
  });
3263
3274
  return out;
3264
3275
  }
3276
+ function exportedNames(ast) {
3277
+ const out = /* @__PURE__ */ new Set();
3278
+ walkAst(ast, (node) => {
3279
+ if (node.type !== "ExportNamedDeclaration") return;
3280
+ const declaration2 = node.declaration;
3281
+ if (declaration2?.type !== "VariableDeclaration") return;
3282
+ for (const declarator of declaration2.declarations ?? []) {
3283
+ const id = declarator.id;
3284
+ if (id?.type === "Identifier") out.add(String(id.name));
3285
+ }
3286
+ });
3287
+ return out;
3288
+ }
3265
3289
  function propertyFor(row, literal) {
3266
3290
  if (row?.type !== "ObjectExpression") return null;
3267
3291
  for (const property of row.properties ?? []) {
@@ -3286,21 +3310,36 @@ function iterations(roots, identifier2) {
3286
3310
  for (const root of roots) visit(root);
3287
3311
  return out;
3288
3312
  }
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);
3313
+ 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*=>`;
3314
+ function paramsIn(text, pattern, base) {
3315
+ const match = text.match(pattern);
3294
3316
  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);
3317
+ if (match[5] !== void 0) return { row: match[5], names: null, index: null, addIndexAt: -1 };
3318
+ const bound = match[1] ?? match[2];
3319
+ const within = match[0].lastIndexOf(bound);
3298
3320
  return {
3299
- row,
3300
- index: match[3] ?? null,
3301
- addIndexAt: expression.range.start + match.index + within + row.length
3321
+ // One of the two, never both: a row is either named or taken apart. @see bindLists for the
3322
+ // second shape — `([question, answer]) =>`, which is how a TUPLE reaches the markup.
3323
+ row: match[1] ?? null,
3324
+ names: match[2] === void 0 ? null : destructured(match[2]),
3325
+ index: match[4] ?? null,
3326
+ addIndexAt: base + match.index + within + bound.length
3302
3327
  };
3303
3328
  }
3329
+ function callbackParams(expression, identifier2) {
3330
+ return paramsIn(
3331
+ expression.value,
3332
+ new RegExp(`(?<![\\w$])${identifier2}(?![\\w$])${CALLBACK}`),
3333
+ expression.range.start
3334
+ );
3335
+ }
3336
+ function mapParams(content, at, until) {
3337
+ return paramsIn(content.slice(at, until), new RegExp(`^${CALLBACK}`), at);
3338
+ }
3339
+ function destructured(pattern) {
3340
+ const names = pattern.slice(1, -1).split(",").map((part) => part.trim());
3341
+ return names.every((name) => /^[A-Za-z_$][\w$]*$/.test(name)) ? names : null;
3342
+ }
3304
3343
  function renders(content, node, row) {
3305
3344
  if (!node.inner) return null;
3306
3345
  const inner = content.slice(node.inner.start, node.inner.end).trim();
@@ -3388,11 +3427,11 @@ function bindScalar(ctx, member, literals, offset) {
3388
3427
  }
3389
3428
  return { splices, refusal: null };
3390
3429
  }
3391
- function bindDataLiterals(ctx, members) {
3392
- const out = { splices: [], usesRead: false, usesRows: false, slugs: [], bound: [], declarations: [], refused: [] };
3430
+ async function bindDataLiterals(ctx, members) {
3431
+ const out = { splices: [], usesRead: false, usesRows: false, usesList: false, usesTuples: false, notes: [], slugs: [], bound: [], declarations: [], refused: [], componentEdits: [], props: [] };
3393
3432
  const rule = DIALECT_RULES[ctx.dialect];
3394
3433
  const module = moduleOf(ctx.parsed);
3395
- if (!module || !rule.text) return out;
3434
+ if (!rule.text) return out;
3396
3435
  const groups = /* @__PURE__ */ new Map();
3397
3436
  const scalars = [];
3398
3437
  for (const member of members) {
@@ -3405,6 +3444,7 @@ function bindDataLiterals(ctx, members) {
3405
3444
  const key = `${member.slug}\0${split.array}`;
3406
3445
  groups.set(key, [...groups.get(key) ?? [], held]);
3407
3446
  }
3447
+ const indexes = /* @__PURE__ */ new Set();
3408
3448
  const strings = stringLiterals(module.ast);
3409
3449
  for (const member of scalars) {
3410
3450
  const scalar = bindScalar(ctx, member, strings, module.offset);
@@ -3415,7 +3455,6 @@ function bindDataLiterals(ctx, members) {
3415
3455
  out.slugs.push(member.slug);
3416
3456
  out.bound.push(member.key);
3417
3457
  }
3418
- if (groups.size === 0) return out;
3419
3458
  const literals = arrayLiterals(module.ast);
3420
3459
  for (const all of groups.values()) {
3421
3460
  const array = all[0].array;
@@ -3456,23 +3495,36 @@ function bindDataLiterals(ctx, members) {
3456
3495
  }
3457
3496
  const loop = loops[0];
3458
3497
  const params = callbackParams(loop, identifier2);
3459
- if (!params) {
3498
+ if (!params || params.row === null) {
3460
3499
  refuse("IN_EXPRESSION");
3461
3500
  continue;
3462
3501
  }
3463
- const elements = leafElements(ctx.content, loop, params.row);
3502
+ const rowName = params.row;
3503
+ const elements = leafElements(ctx.content, loop, rowName);
3464
3504
  const placed = /* @__PURE__ */ new Map();
3505
+ const passed = [];
3465
3506
  let refusal = null;
3466
3507
  for (const property of keys.keys()) {
3467
3508
  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);
3509
+ if (found.length === 1) {
3510
+ placed.set(property, found[0]);
3511
+ continue;
3512
+ }
3513
+ if (found.length > 1) {
3514
+ refusal = "REPEATER_AMBIGUOUS";
3515
+ continue;
3516
+ }
3517
+ const calls = propCalls(ctx.content, loop, rowName, property);
3518
+ if (ctx.drill && calls.length === 1) passed.push({ property, ...calls[0] });
3519
+ else if (calls.length > 1) refusal = "REPEATER_AMBIGUOUS";
3520
+ else refusal = leafRefusal(loop.value, rowName, property);
3470
3521
  }
3471
3522
  if (refusal !== null) {
3472
3523
  refuse(refusal);
3473
3524
  continue;
3474
3525
  }
3475
- const index = params.index ?? allocateIndex(loop.value);
3526
+ const index = params.index ?? allocateIndex(loop.value, indexes);
3527
+ indexes.add(index);
3476
3528
  const splices = [];
3477
3529
  if (rule.loopKey) {
3478
3530
  const rows = loop.children.filter((child) => child.type === "element");
@@ -3488,6 +3540,36 @@ function bindDataLiterals(ctx, members) {
3488
3540
  }
3489
3541
  splices.push({ start: params.addIndexAt, end: params.addIndexAt, text: `, ${index}` });
3490
3542
  }
3543
+ const drilled = [];
3544
+ const edits = [];
3545
+ const bindings = /* @__PURE__ */ new Map();
3546
+ for (const entry of passed) {
3547
+ const leaf = keys.get(entry.property);
3548
+ const literal = `${array}[\${${index}}].${leaf}`;
3549
+ 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);
3550
+ if (!outcome.ok) {
3551
+ refusal = outcome.reason;
3552
+ break;
3553
+ }
3554
+ edits.push(...outcome.edits);
3555
+ bindings.set(entry.call.attrInsertAt, [
3556
+ ...bindings.get(entry.call.attrInsertAt) ?? [],
3557
+ `${entry.prop}: \`${literal}\``
3558
+ ]);
3559
+ drilled.push({
3560
+ file: outcome.edits[0].file,
3561
+ prop: entry.prop,
3562
+ path: shapeOfPath(literal),
3563
+ literal: `${entry.prop}: \`${literal}\``
3564
+ });
3565
+ }
3566
+ if (refusal !== null) {
3567
+ refuse(refusal);
3568
+ continue;
3569
+ }
3570
+ for (const [at2, entries] of bindings) {
3571
+ splices.push({ start: at2, end: at2, text: ` bcmsBindings={{ ${entries.join(", ")} }}` });
3572
+ }
3491
3573
  for (const [property, element] of placed) {
3492
3574
  const leaf = keys.get(property);
3493
3575
  const literal = `${array}[\${${index}}].${leaf}`;
@@ -3497,7 +3579,7 @@ function bindDataLiterals(ctx, members) {
3497
3579
  ...kind ? [`data-bcms-kind="${kind}"`] : [],
3498
3580
  // A richtext row value is html, so it goes into the dialect's html sink rather than being
3499
3581
  // printed as characters — the same rule `writeLeaf` follows one level down. @see loops.ts
3500
- ...kind === "richtext" && rule.richtext ? [rule.richtext(`${params.row}.${property}`)] : []
3582
+ ...kind === "richtext" && rule.richtext ? [rule.richtext(`${rowName}.${property}`)] : []
3501
3583
  ];
3502
3584
  splices.push({ start: element.attrInsertAt, end: element.attrInsertAt, text: ` ${attributes.join(" ")}` });
3503
3585
  if (kind === "richtext" && rule.richtext && element.inner) {
@@ -3517,19 +3599,319 @@ function bindDataLiterals(ctx, members) {
3517
3599
  splices.push({ start: at.start, end: at.end, text: `${ctx.rowsAs}(${args.join(", ")})` });
3518
3600
  out.splices.push(...splices);
3519
3601
  out.usesRows = true;
3602
+ out.componentEdits.push(...edits);
3603
+ out.props.push(...drilled);
3520
3604
  out.slugs.push(...group.map((member) => member.slug));
3521
3605
  out.bound.push(...group.map((member) => member.key));
3522
3606
  }
3607
+ const settled = /* @__PURE__ */ new Set([...out.bound, ...out.refused.map((refusal) => refusal.key)]);
3608
+ bindLists(ctx, members.filter((member) => !settled.has(member.key)), module, out, indexes);
3609
+ return out;
3610
+ }
3611
+ function literalTable(init) {
3612
+ const elements = (init.elements ?? []).filter((e) => e !== null);
3613
+ const first = elements[0];
3614
+ if (!first || elements.length !== (init.elements ?? []).length) return null;
3615
+ if (elements.some((element) => element.type !== first.type)) return null;
3616
+ if (first.type === "StringLiteral") {
3617
+ return { tuple: false, rows: elements.map((element) => [norm(String(element.value))]) };
3618
+ }
3619
+ if (first.type !== "ArrayExpression") return null;
3620
+ const rows = [];
3621
+ for (const element of elements) {
3622
+ const inner = (element.elements ?? []).filter((x) => x !== null);
3623
+ if (inner.length === 0 || inner.some((x) => x.type !== "StringLiteral")) return null;
3624
+ if (inner.length !== (element.elements ?? []).length) return null;
3625
+ rows.push(inner.map((x) => norm(String(x.value))));
3626
+ }
3627
+ if (new Set(rows.map((row) => row.length)).size !== 1) return null;
3628
+ return { tuple: true, rows };
3629
+ }
3630
+ function openingBracket(text, closeAt) {
3631
+ let depth = 0;
3632
+ for (let at = closeAt; at >= 0; at--) {
3633
+ if (text[at] === "]") depth++;
3634
+ else if (text[at] === "[") {
3635
+ depth--;
3636
+ if (depth === 0) return at;
3637
+ }
3638
+ }
3639
+ return -1;
3640
+ }
3641
+ function arrayLiteral(source) {
3642
+ try {
3643
+ const ast = parseProgram(`(${source})`);
3644
+ const expression = ast.program.body[0]?.expression;
3645
+ if (!expression || expression.type !== "ArrayExpression") return null;
3646
+ return (expression.start ?? -1) === 1 && (expression.end ?? -1) === source.length + 1 ? expression : null;
3647
+ } catch {
3648
+ return null;
3649
+ }
3650
+ }
3651
+ function inlineTables(ctx) {
3652
+ const out = /* @__PURE__ */ new Map();
3653
+ const visit = (node) => {
3654
+ if (node.type === "element") {
3655
+ if (!node.raw) for (const child of node.children) visit(child);
3656
+ return;
3657
+ }
3658
+ if (node.type !== "expr") return;
3659
+ for (const match of node.value.matchAll(/\]\s*\.\s*map\s*\(/g)) {
3660
+ const closeAt = match.index;
3661
+ const openAt = openingBracket(node.value, closeAt);
3662
+ if (openAt <= 0 || /[\w$)\]]/.test(node.value[openAt - 1] ?? "")) continue;
3663
+ const init = arrayLiteral(node.value.slice(openAt, closeAt + 1));
3664
+ const table = init ? literalTable(init) : null;
3665
+ if (!table) continue;
3666
+ const at = { start: node.range.start + openAt, end: node.range.start + closeAt + 1 };
3667
+ out.set(at.start, {
3668
+ ...table,
3669
+ at,
3670
+ label: "an inline array literal",
3671
+ render: () => ({ loop: node, params: mapParams(ctx.content, at.end, node.range.end) })
3672
+ });
3673
+ }
3674
+ for (const child of node.children) visit(child);
3675
+ };
3676
+ for (const root of ctx.parsed.roots) visit(root);
3677
+ return [...out.values()];
3678
+ }
3679
+ function readerElements(content, expression, reads) {
3680
+ const wanted = new Set(reads);
3681
+ const out = /* @__PURE__ */ new Map();
3682
+ const visit = (node) => {
3683
+ if (node.type === "expr") {
3684
+ for (const child of node.children) visit(child);
3685
+ return;
3686
+ }
3687
+ if (node.type !== "element" || node.raw) return;
3688
+ const inner = isIntrinsic(node.tag) && node.inner ? content.slice(node.inner.start, node.inner.end).trim() : "";
3689
+ const read = inner.match(/^\{\s*([A-Za-z_$][\w$]*(?:\[\d+\])?)\s*\}$/)?.[1];
3690
+ if (read && wanted.has(read)) out.set(read, [...out.get(read) ?? [], node]);
3691
+ for (const child of node.children) visit(child);
3692
+ };
3693
+ for (const child of expression.children) visit(child);
3694
+ return out;
3695
+ }
3696
+ function readerWraps(expression, reads) {
3697
+ const wanted = new Set(reads);
3698
+ const out = /* @__PURE__ */ new Map();
3699
+ const visit = (node, parent) => {
3700
+ if (node.type === "expr") {
3701
+ const read = node.value.trim().match(/^\{\s*([A-Za-z_$][\w$]*(?:\[\d+\])?)\s*\}$/)?.[1];
3702
+ const beside = parent !== null && isIntrinsic(parent.tag) && parent.children.some((child) => child !== node && !(child.type === "text" && child.value.trim() === ""));
3703
+ if (read && wanted.has(read) && beside) out.set(read, [...out.get(read) ?? [], node.range]);
3704
+ for (const child of node.children) visit(child, parent);
3705
+ return;
3706
+ }
3707
+ if (node.type !== "element" || node.raw) return;
3708
+ for (const child of node.children) visit(child, node);
3709
+ };
3710
+ for (const child of expression.children) visit(child, null);
3711
+ return out;
3712
+ }
3713
+ function pathTemplates(paths, index) {
3714
+ const out = [];
3715
+ for (let position = 0; position < paths[0].length; position++) {
3716
+ const split = paths.map((row) => splitGroup(row[position]));
3717
+ const first = split[0];
3718
+ if (!first) return null;
3719
+ if (split.some((held, row) => !held || held.array !== first.array || held.leaf !== first.leaf || held.index !== row)) {
3720
+ return null;
3721
+ }
3722
+ out.push(`${first.array}[\${${index}}].${first.leaf}`);
3723
+ }
3724
+ return out;
3725
+ }
3726
+ function bindLists(ctx, members, module, out, indexes) {
3727
+ const rule = DIALECT_RULES[ctx.dialect];
3728
+ const escapes = exportedNames(module.ast);
3729
+ const candidates = [];
3730
+ for (const [name, init] of arrayLiterals(module.ast)) {
3731
+ const table = literalTable(init);
3732
+ if (!table) continue;
3733
+ candidates.push({
3734
+ ...table,
3735
+ at: { start: module.offset + (init.start ?? 0), end: module.offset + (init.end ?? 0) },
3736
+ label: `\`${name}\``,
3737
+ render: () => {
3738
+ const loops = iterations(ctx.parsed.roots, name);
3739
+ if (loops.length > 1) return { refuse: "REPEATER_AMBIGUOUS" };
3740
+ if (loops.length === 0) return null;
3741
+ const mentions2 = ctx.content.match(new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g")) ?? [];
3742
+ if (mentions2.length !== 2 || escapes.has(name)) return { refuse: "IN_EXPRESSION" };
3743
+ return { loop: loops[0], params: callbackParams(loops[0], name) };
3744
+ }
3745
+ });
3746
+ }
3747
+ candidates.push(...inlineTables(ctx));
3748
+ const siblings = /* @__PURE__ */ new Map();
3749
+ const groupOf2 = (member) => `${member.slug}\0${splitGroup(member.path)?.array ?? member.path}`;
3750
+ for (const member of members) {
3751
+ siblings.set(groupOf2(member), [...siblings.get(groupOf2(member)) ?? [], member]);
3752
+ }
3753
+ const fit = (candidate, member) => (siblings.get(groupOf2(member)) ?? [member]).filter(
3754
+ (leaf) => candidate.rows.some((row) => row.includes(leaf.literal))
3755
+ ).length;
3756
+ const best = /* @__PURE__ */ new Map();
3757
+ const bestFit = (member) => {
3758
+ const key = groupOf2(member);
3759
+ if (!best.has(key)) best.set(key, Math.max(0, ...candidates.map((candidate) => fit(candidate, member))));
3760
+ return best.get(key);
3761
+ };
3762
+ const claims = (candidate, member) => candidate.rows.some((row) => row.includes(member.literal)) && fit(candidate, member) === bestFit(member);
3763
+ const holders = (member) => candidates.filter((candidate) => fit(candidate, member) === bestFit(member)).length;
3764
+ for (const candidate of candidates) {
3765
+ const mine = members.filter((member) => claims(candidate, member));
3766
+ if (mine.length === 0 || mine.some((member) => holders(member) > 1)) continue;
3767
+ if (new Set(mine.map((member) => member.slug)).size !== 1) continue;
3768
+ const refuse = (reason) => {
3769
+ for (const member of mine) out.refused.push({ key: member.key, reason });
3770
+ };
3771
+ const paths = candidate.rows.map((row) => row.map(() => ""));
3772
+ const kinds = candidate.rows.map((row) => row.map(() => ""));
3773
+ let clash = false;
3774
+ for (const member of mine) {
3775
+ const spots = candidate.rows.flatMap(
3776
+ (row, r) => row.flatMap((value, p) => value === member.literal ? [[r, p]] : [])
3777
+ );
3778
+ if (spots.length !== 1 || paths[spots[0][0]][spots[0][1]] !== "") clash = true;
3779
+ else {
3780
+ paths[spots[0][0]][spots[0][1]] = member.path;
3781
+ kinds[spots[0][0]][spots[0][1]] = member.kind;
3782
+ }
3783
+ }
3784
+ if (clash || paths.some((row) => row.some((path) => path === ""))) {
3785
+ refuse("IN_EXPRESSION");
3786
+ continue;
3787
+ }
3788
+ const kindAt = kinds[0];
3789
+ if (kinds.some((row) => row.some((kind, position) => kind !== kindAt[position]))) {
3790
+ refuse("IN_EXPRESSION");
3791
+ continue;
3792
+ }
3793
+ const rendered = candidate.render();
3794
+ if (rendered === null) continue;
3795
+ if ("refuse" in rendered) {
3796
+ refuse(rendered.refuse);
3797
+ continue;
3798
+ }
3799
+ const { loop, params } = rendered;
3800
+ 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];
3801
+ if (!params || !reads || reads.length !== candidate.rows[0].length) {
3802
+ refuse("IN_EXPRESSION");
3803
+ continue;
3804
+ }
3805
+ const elements = readerElements(ctx.content, loop, reads);
3806
+ const wraps = readerWraps(loop, reads);
3807
+ const hosts = [];
3808
+ let refusal = null;
3809
+ for (const read of reads) {
3810
+ const found = elements.get(read) ?? [];
3811
+ const beside = elements.has(read) ? [] : wraps.get(read) ?? [];
3812
+ if (found.length === 1) hosts.push({ element: found[0] });
3813
+ else if (found.length === 0 && beside.length === 1) hosts.push({ wrap: beside[0] });
3814
+ else refusal = found.length + beside.length > 1 ? "REPEATER_AMBIGUOUS" : "IN_EXPRESSION";
3815
+ }
3816
+ if (refusal !== null) {
3817
+ refuse(refusal);
3818
+ continue;
3819
+ }
3820
+ const index = params.index ?? allocateIndex(loop.value, indexes);
3821
+ indexes.add(index);
3822
+ const splices = [];
3823
+ if (rule.loopKey) {
3824
+ const rows = loop.children.filter((child) => child.type === "element");
3825
+ const row = rows.length === 1 ? rows[0] : null;
3826
+ if (row && row.tag !== "" && !row.attrs.some((a) => a.name.replace(/^:/, "") === "key")) {
3827
+ splices.push({ start: row.attrInsertAt, end: row.attrInsertAt, text: ` ${rule.loopKey(index)}` });
3828
+ }
3829
+ }
3830
+ if (params.index === null) {
3831
+ if (params.addIndexAt < 0) {
3832
+ refuse("IN_EXPRESSION");
3833
+ continue;
3834
+ }
3835
+ splices.push({ start: params.addIndexAt, end: params.addIndexAt, text: `, ${index}` });
3836
+ }
3837
+ const templates = pathTemplates(paths, index);
3838
+ if (!templates) {
3839
+ refuse("IN_EXPRESSION");
3840
+ continue;
3841
+ }
3842
+ const list = JSON.stringify(candidate.tuple ? paths : paths.map((row) => row[0]));
3843
+ for (const [position, host] of hosts.entries()) {
3844
+ const kind = kindAt[position];
3845
+ const attributes = [
3846
+ rule.attr("data-bcms-field", `\`${templates[position]}\``),
3847
+ ...kind ? [`data-bcms-kind="${kind}"`] : [],
3848
+ // A richtext value is html, so it goes into the dialect's html sink rather than being
3849
+ // printed as characters — the same rule one level up. @see bindDataLiterals
3850
+ ...kind === "richtext" && rule.richtext ? [rule.richtext(reads[position])] : []
3851
+ ];
3852
+ if ("element" in host) {
3853
+ splices.push({ start: host.element.attrInsertAt, end: host.element.attrInsertAt, text: ` ${attributes.join(" ")}` });
3854
+ if (kind === "richtext" && rule.richtext && host.element.inner) {
3855
+ splices.push({ start: host.element.inner.start, end: host.element.inner.end, text: "" });
3856
+ }
3857
+ } else {
3858
+ const inner = kind === "richtext" && rule.richtext ? "" : ctx.content.slice(host.wrap.start, host.wrap.end);
3859
+ splices.push({ start: host.wrap.start, end: host.wrap.end, text: `<span ${attributes.join(" ")}>${inner}</span>` });
3860
+ }
3861
+ out.declarations.push({ path: shapeOfPath(templates[position]), literal: templates[position] });
3862
+ }
3863
+ const args = [
3864
+ ctx.snapshotName(mine[0].slug),
3865
+ list,
3866
+ ctx.content.slice(candidate.at.start, candidate.at.end),
3867
+ ...ctx.slug ? [ctx.slug] : []
3868
+ ];
3869
+ splices.push({
3870
+ start: candidate.at.start,
3871
+ end: candidate.at.end,
3872
+ text: `${candidate.tuple ? ctx.tuples : ctx.list}(${args.join(", ")})`
3873
+ });
3874
+ out.splices.push(...splices);
3875
+ if (candidate.tuple) out.usesTuples = true;
3876
+ else out.usesList = true;
3877
+ out.slugs.push(...mine.map((member) => member.slug));
3878
+ out.bound.push(...mine.map((member) => member.key));
3879
+ out.notes.push(
3880
+ `${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.`
3881
+ );
3882
+ }
3883
+ }
3884
+ function propCalls(content, expression, row, property) {
3885
+ const named = new RegExp(`^\\{\\s*${row}\\s*\\.\\s*${property}\\s*\\}$`);
3886
+ const spread = new RegExp(`^\\{\\s*\\.\\.\\.\\s*${row}\\s*\\}$`);
3887
+ const out = [];
3888
+ const visit = (node) => {
3889
+ if (node.type === "expr") {
3890
+ for (const child of node.children) visit(child);
3891
+ return;
3892
+ }
3893
+ if (node.type !== "element" || node.raw) return;
3894
+ if (node.tag !== "" && !isIntrinsic(node.tag)) {
3895
+ for (const attr of node.attrs) {
3896
+ if (!attr.expressionRange) continue;
3897
+ const text = content.slice(attr.expressionRange.start, attr.expressionRange.end).trim();
3898
+ if (spread.test(text)) out.push({ call: node, prop: property });
3899
+ else if (named.test(text)) out.push({ call: node, prop: attr.name });
3900
+ }
3901
+ }
3902
+ for (const child of node.children) visit(child);
3903
+ };
3904
+ for (const child of expression.children) visit(child);
3523
3905
  return out;
3524
3906
  }
3525
3907
  function leafRefusal(loop, row, property) {
3526
3908
  const prop = new RegExp(`=\\s*\\{\\s*${row}\\s*\\.\\s*${property}\\s*\\}|\\{\\s*\\.\\.\\.\\s*${row}\\s*\\}`);
3527
3909
  return prop.test(loop) ? "PROP_TARGET_NOT_FOUND" : "IN_EXPRESSION";
3528
3910
  }
3529
- function allocateIndex(loop) {
3911
+ function allocateIndex(loop, taken) {
3530
3912
  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}`;
3913
+ if (!used.has("i") && !taken.has("i")) return "i";
3914
+ for (let n = 1; ; n++) if (!used.has(`i_${n}`) && !taken.has(`i_${n}`)) return `i_${n}`;
3533
3915
  }
3534
3916
 
3535
3917
  // src/resolve.ts
@@ -3611,6 +3993,13 @@ function isAliasShaped(value) {
3611
3993
  function unreadableConfigs(sources) {
3612
3994
  return sources.filter((source) => isProjectConfig(source.path) && readJsonc(source.content) === null).map((source) => source.path);
3613
3995
  }
3996
+ function extendedConfigs(sources) {
3997
+ return sources.filter((source) => {
3998
+ if (!isProjectConfig(source.path)) return false;
3999
+ const parsed = readJsonc(source.content);
4000
+ return parsed !== null && parsed.extends !== void 0 && parsed.compilerOptions?.paths === void 0;
4001
+ }).map((source) => source.path);
4002
+ }
3614
4003
  function expand(specifier, aliases) {
3615
4004
  const out = [];
3616
4005
  for (const [pattern, targets] of aliases) {
@@ -3740,7 +4129,7 @@ function bindImages(ctx, targets) {
3740
4129
 
3741
4130
  // src/helper.ts
3742
4131
  import { createHash as createHash2 } from "crypto";
3743
- var HELPER_VERSION = 5;
4132
+ var HELPER_VERSION = 6;
3744
4133
  var HEADER = "// @bettercms-ai/convert helper v";
3745
4134
  var HELPER_PATH = Object.fromEntries(
3746
4135
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -3848,6 +4237,30 @@ export function bcmsRowsAs<T>(
3848
4237
  });
3849
4238
  }
3850
4239
 
4240
+ /**
4241
+ * A list of PRIMITIVES \u2014 \`const tags = ["\u2026", "\u2026"]\` \u2014 with one CMS field per ELEMENT.
4242
+ *
4243
+ * \u{1F534} THE LENGTH IS \`paths\`, NOT THE CMS. Unlike \`bcmsRows\`, which reads the array itself and
4244
+ * renders as many rows as the CMS holds, this maps over the path list the codemod wrote \u2014 so a row
4245
+ * an editor ADDS has no path here and never renders, and a row an editor DELETES leaves its path
4246
+ * resolving to nothing, which falls back to the copy the template shipped with. Every row's TEXT is
4247
+ * editable; the number of rows is the template's. @see the PRIMITIVE_LIST receipt note, which says
4248
+ * this out loud rather than leaving an editor to discover it.
4249
+ */
4250
+ export function bcmsList(page: unknown, paths: string[], fallback: string[], slug?: string): string[] {
4251
+ const entries = isRecord(page) ? page.entries : undefined;
4252
+ const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4253
+ return paths.map((path, index) => render(readPath(root, path)) ?? fallback[index] ?? "");
4254
+ }
4255
+
4256
+ /**
4257
+ * The same for a list of TUPLES \u2014 \`[["q", "a"], \u2026]\` \u2014 with one field per tuple POSITION, and the
4258
+ * same ceiling: \`paths\` decides how many rows render, and an added or deleted CMS row does not.
4259
+ */
4260
+ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][], slug?: string): string[][] {
4261
+ return paths.map((row, index) => bcmsList(page, row, fallback[index] ?? [], slug));
4262
+ }
4263
+
3851
4264
  /** An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED */
3852
4265
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
3853
4266
  const entries = isRecord(page) ? page.entries : undefined;
@@ -3939,19 +4352,22 @@ var HISTORICAL = {
3939
4352
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3940
4353
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3941
4354
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3942
- 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
4355
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4356
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
3943
4357
  },
3944
4358
  "src/lib/bcms-content.ts": {
3945
4359
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
3946
4360
  2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
3947
4361
  3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e",
3948
- 4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af"
4362
+ 4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4363
+ 5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba"
3949
4364
  },
3950
4365
  "composables/bcms-content.ts": {
3951
4366
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3952
4367
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3953
4368
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3954
- 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
4369
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4370
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
3955
4371
  }
3956
4372
  };
3957
4373
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -4486,8 +4902,8 @@ function declaringElements(roots, content) {
4486
4902
  const name = attr.name.replace(/^:/, "");
4487
4903
  if (name === "bcmsBindings") {
4488
4904
  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 });
4905
+ for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
4906
+ out.push({ scope: "page", path, kind, own });
4491
4907
  }
4492
4908
  continue;
4493
4909
  }
@@ -4517,7 +4933,7 @@ function declaringElements(roots, content) {
4517
4933
  return out;
4518
4934
  }
4519
4935
  function helperLocals(parsed, dialect, file) {
4520
- const out = { bcms: [], bcmsRows: [], bcmsRowsAs: [], bcmsImage: [], bcmsLayout: [] };
4936
+ const out = { bcms: [], bcmsRows: [], bcmsRowsAs: [], bcmsImage: [], bcmsLayout: [], bcmsList: [], bcmsTuples: [] };
4521
4937
  const helper = HELPER_PATH[dialect];
4522
4938
  if (!helper) return out;
4523
4939
  for (const [local, from] of importsIn(parsed, dialect)) {
@@ -4527,6 +4943,8 @@ function helperLocals(parsed, dialect, file) {
4527
4943
  if (from.imported === "bcmsRowsAs") out.bcmsRowsAs.push(local);
4528
4944
  if (from.imported === "bcmsImage") out.bcmsImage.push(local);
4529
4945
  if (from.imported === "bcmsLayout") out.bcmsLayout.push(local);
4946
+ if (from.imported === "bcmsList") out.bcmsList.push(local);
4947
+ if (from.imported === "bcmsTuples") out.bcmsTuples.push(local);
4530
4948
  }
4531
4949
  return out;
4532
4950
  }
@@ -4599,6 +5017,9 @@ function convertedHere(content, parsed, dialect, target, file) {
4599
5017
  keyed = reads(locals.bcmsRowsAs);
4600
5018
  rows = keyed || reads(locals.bcmsRows);
4601
5019
  }
5020
+ const listed = indexed !== null && snapshot !== null && content.includes(JSON.stringify(target.path)) && [...locals.bcmsList, ...locals.bcmsTuples].some(
5021
+ (name) => new RegExp(`${identifier(name)}\\s*\\(\\s*${identifier(snapshot)}\\s*,`).test(content)
5022
+ );
4602
5023
  let cached = null;
4603
5024
  const moduleRead = () => {
4604
5025
  if (indexed || snapshot === null) return false;
@@ -4614,7 +5035,7 @@ function convertedHere(content, parsed, dialect, target, file) {
4614
5035
  return elementReads(element.own, target, locals, snapshot, false) || moduleRead();
4615
5036
  }
4616
5037
  if (element.path !== shape) return false;
4617
- return rows && (keyed || elementReads(element.own, target, locals, snapshot, true));
5038
+ return listed || rows && (keyed || elementReads(element.own, target, locals, snapshot, true));
4618
5039
  });
4619
5040
  }
4620
5041
 
@@ -4750,6 +5171,22 @@ function importLines(imports) {
4750
5171
 
4751
5172
  // src/receipt.ts
4752
5173
  var CANVAS_REASONS = /* @__PURE__ */ new Set(["CANVAS_BRIDGE_MANUAL", "SSR_DRAFT_ROUTE"]);
5174
+ var FormInvariantError = class extends Error {
5175
+ code = "FORM_INVARIANT";
5176
+ constructor(message) {
5177
+ super(message);
5178
+ this.name = "FormInvariantError";
5179
+ }
5180
+ };
5181
+ function assertForms(total, forms) {
5182
+ const sum = forms.wired + forms.alreadyWired + forms.pending.length;
5183
+ if (sum !== total) {
5184
+ throw new FormInvariantError(
5185
+ `The receipt does not account for every form: ${total} listed but ${forms.wired} wired + ${forms.alreadyWired} already wired + ${forms.pending.length} pending = ${sum}.`
5186
+ );
5187
+ }
5188
+ return forms;
5189
+ }
4753
5190
  var ReceiptInvariantError = class extends Error {
4754
5191
  code = "RECEIPT_INVARIANT";
4755
5192
  constructor(message) {
@@ -5589,6 +6026,17 @@ function readBrief(value) {
5589
6026
  }
5590
6027
  }
5591
6028
  }
6029
+ for (const [at, entry] of check.array(brief.forms ?? [], `the brief's "forms"`).entries()) {
6030
+ const where = `brief.forms[${at}]`;
6031
+ const form = check.record(entry, where);
6032
+ check.segment(form.id, `${where}.id`);
6033
+ check.string(form.name, `${where}.name`);
6034
+ check.string(form.submitUrl, `${where}.submitUrl`);
6035
+ check.optionalString(form.successMessage, `${where}.successMessage`);
6036
+ for (const [n, held] of check.array(form.fields, `${where}.fields`).entries()) {
6037
+ check.segment(check.record(held, `${where}.fields[${n}]`).key, `${where}.fields[${n}].key`);
6038
+ }
6039
+ }
5592
6040
  return brief;
5593
6041
  }
5594
6042
  function readPlan(value) {
@@ -5635,6 +6083,236 @@ function readPlan(value) {
5635
6083
  return plan2;
5636
6084
  }
5637
6085
 
6086
+ // src/forms.ts
6087
+ import MagicString4 from "magic-string";
6088
+ var WIRABLE = ["html", "astro", "jsx"];
6089
+ var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6090
+ var FORM_SCRIPT = `<script>
6091
+ document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
6092
+ f.addEventListener('submit',function(e){
6093
+ e.preventDefault();
6094
+ var msg=f.querySelector('.bcms-form-msg');
6095
+ var data={};
6096
+ // A checkboxes group posts one entry per checked box under the SAME name; the old
6097
+ // one-at-a-time assignment kept only the last, so "pick many" delivered one answer.
6098
+ // One checked box still arrives as a scalar \u2014 FormData cannot tell a group from a
6099
+ // lone field \u2014 which is exactly why lib/forms/validate.ts's chosenValues() accepts
6100
+ // both an array and a comma string.
6101
+ new FormData(f).forEach(function(v,k){
6102
+ if(k==='cf-turnstile-response')return;
6103
+ if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);
6104
+ else data[k]=v;
6105
+ });
6106
+ var body={data:data};
6107
+ var tok=f.querySelector('[name="cf-turnstile-response"]');
6108
+ if(tok&&tok.value)body['cf-turnstile-response']=tok.value;
6109
+ fetch(f.action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
6110
+ .then(function(r){return r.ok;})
6111
+ .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();})
6112
+ .catch(function(){if(msg){msg.hidden=false;msg.textContent='Sorry, something went wrong.';}});
6113
+ });
6114
+ });
6115
+ </script>`;
6116
+ var SCRIPT_BODY = FORM_SCRIPT.slice("<script>".length, -"</script>".length);
6117
+ function scriptFor(dialect) {
6118
+ if (dialect === "jsx") return `<script dangerouslySetInnerHTML={{ __html: \`${SCRIPT_BODY}\` }} />`;
6119
+ if (dialect === "astro") return `<script is:inline>${SCRIPT_BODY}</script>`;
6120
+ return FORM_SCRIPT;
6121
+ }
6122
+ var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
6123
+ var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
6124
+ var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
6125
+ function* elementsIn(nodes) {
6126
+ for (const node of nodes) {
6127
+ if (node.type === "expr") {
6128
+ yield* elementsIn(node.children);
6129
+ continue;
6130
+ }
6131
+ if (node.type !== "element") continue;
6132
+ yield node;
6133
+ yield* elementsIn(node.children);
6134
+ }
6135
+ }
6136
+ var childElements = (node) => node.children.flatMap(
6137
+ (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6138
+ );
6139
+ function candidateOf(file, dialect, form) {
6140
+ const controls = /* @__PURE__ */ new Map();
6141
+ const wrapper = /* @__PURE__ */ new Map();
6142
+ const all = [];
6143
+ const walk = (parent) => {
6144
+ const own = [];
6145
+ for (const child of childElements(parent)) {
6146
+ if (CONTROLS.has(child.tag.toLowerCase())) {
6147
+ own.push(child);
6148
+ all.push(child);
6149
+ }
6150
+ walk(child);
6151
+ }
6152
+ if (own.length === 1 && parent !== form) wrapper.set(own[0], parent);
6153
+ };
6154
+ walk(form);
6155
+ for (const control of all) {
6156
+ const name = attrOf(control, "name")?.value;
6157
+ if (name && !controls.has(name)) controls.set(name, control);
6158
+ }
6159
+ for (const control of all) {
6160
+ const id = attrOf(control, "id")?.value;
6161
+ if (id && !controls.has(id)) controls.set(id, control);
6162
+ }
6163
+ return { file, dialect, el: form, controls, wrapper };
6164
+ }
6165
+ var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6166
+ function wire(source, candidate, form) {
6167
+ const { el, dialect } = candidate;
6168
+ const inserts = [];
6169
+ const set = (name, value) => {
6170
+ const attr = attrOf(el, name);
6171
+ if (!attr) inserts.push(`${name}="${escapeAttr(value)}"`);
6172
+ else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6173
+ else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6174
+ };
6175
+ set("action", form.submitUrl);
6176
+ set("method", "post");
6177
+ inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
6178
+ if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
6179
+ source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6180
+ for (const field of form.fields) {
6181
+ const control = candidate.controls.get(field.key);
6182
+ const host = candidate.wrapper.get(control) ?? control;
6183
+ source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6184
+ const name = attrOf(control, "name");
6185
+ if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
6186
+ else if (!name.expression && name.value !== field.key && name.valueRange) {
6187
+ source.overwrite(name.valueRange.start, name.valueRange.end, escapeAttr(field.key));
6188
+ }
6189
+ }
6190
+ if (el.inner && !hasMessage(el)) {
6191
+ const tail = /\s*$/.exec(source.original.slice(el.inner.start, el.inner.end))[0];
6192
+ source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6193
+ }
6194
+ }
6195
+ async function wireForms(briefIn, sources) {
6196
+ const brief = readBrief(briefIn);
6197
+ const forms = brief.forms ?? [];
6198
+ const candidates = [];
6199
+ const unreadable = [];
6200
+ for (const source of sources) {
6201
+ if (!/<form[\s/>]/i.test(source.content)) continue;
6202
+ const dialect = dialectOf(source.path, source.content);
6203
+ if (!dialect || !WIRABLE.includes(dialect)) {
6204
+ unreadable.push({
6205
+ file: source.path,
6206
+ reason: "DIALECT_UNSUPPORTED",
6207
+ message: `${source.path} is ${dialect ?? "not a template"}.`
6208
+ });
6209
+ continue;
6210
+ }
6211
+ const parsed = await parseFile4(dialect, source.content);
6212
+ if (parsed.error) {
6213
+ unreadable.push({
6214
+ file: source.path,
6215
+ reason: parsed.error.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR",
6216
+ message: parsed.error.message
6217
+ });
6218
+ continue;
6219
+ }
6220
+ for (const el of elementsIn(parsed.roots)) {
6221
+ if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
6222
+ }
6223
+ }
6224
+ const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6225
+ const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6226
+ const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6227
+ const wanted = /* @__PURE__ */ new Map();
6228
+ for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6229
+ const matched = [];
6230
+ const pending = [];
6231
+ const onSite = [];
6232
+ let alreadyWired = 0;
6233
+ const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
6234
+ for (const form of forms) {
6235
+ const list = options.get(form.id) ?? [];
6236
+ if (list.length > 1) {
6237
+ pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
6238
+ continue;
6239
+ }
6240
+ if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
6241
+ pend2(form, "FORM_AMBIGUOUS", list[0].file, "more than one CMS form's fields are posted by this <form>.");
6242
+ continue;
6243
+ }
6244
+ if (list.length === 0) {
6245
+ const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6246
+ if (best) {
6247
+ const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6248
+ pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6249
+ } else if (unreadable[0]) {
6250
+ pend2(form, unreadable[0].reason, unreadable[0].file, unreadable[0].message);
6251
+ } else {
6252
+ pend2(form, "FORM_NOT_IN_SOURCE", void 0, `no <form> posts ${form.fields.map((f) => f.key).join(", ") || "any field of this form"}.`);
6253
+ }
6254
+ continue;
6255
+ }
6256
+ const candidate = list[0];
6257
+ if (attrOf(candidate.el, "action")?.expression) {
6258
+ pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6259
+ continue;
6260
+ }
6261
+ const misnamed = form.fields.find((field) => {
6262
+ const attr = attrOf(candidate.controls.get(field.key), "name");
6263
+ return !!attr && attr.value !== field.key && (attr.expression || !attr.valueRange);
6264
+ });
6265
+ if (misnamed) {
6266
+ 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.`);
6267
+ continue;
6268
+ }
6269
+ if (attrOf(candidate.el, "data-bcms-form")) {
6270
+ alreadyWired++;
6271
+ onSite.push(form);
6272
+ continue;
6273
+ }
6274
+ matched.push({ form, candidate });
6275
+ onSite.push(form);
6276
+ }
6277
+ const contents = new Map(sources.map((s) => [s.path, s.content]));
6278
+ const byFile = /* @__PURE__ */ new Map();
6279
+ for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
6280
+ const files = [];
6281
+ for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6282
+ const content = contents.get(path);
6283
+ const source = new MagicString4(content);
6284
+ for (const { form, candidate } of list) wire(source, candidate, form);
6285
+ if (!content.includes("form[data-bcms-form]")) {
6286
+ const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6287
+ const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6288
+ source.appendRight(last.candidate.el.end, `
6289
+ ${indent}${scriptFor(last.candidate.dialect)}`);
6290
+ }
6291
+ files.push({ path, operation: "modify", content: source.toString() });
6292
+ }
6293
+ 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.`);
6294
+ return {
6295
+ files,
6296
+ receipt: {
6297
+ briefDigest: briefDigest(brief),
6298
+ // This lane binds no brief PATH, and says so rather than leaving the arithmetic to a reader:
6299
+ // a receipt claiming zero of zero paths is exactly what a forms-only run did.
6300
+ paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6301
+ occurrences: { located: 0, rewritten: 0 },
6302
+ files: [],
6303
+ helpers: [],
6304
+ helperUpgraded: false,
6305
+ dynamicBindings: [],
6306
+ forms: assertForms(forms.length, {
6307
+ wired: matched.length,
6308
+ alreadyWired,
6309
+ pending,
6310
+ ...notes.length > 0 ? { notes } : {}
6311
+ })
6312
+ }
6313
+ };
6314
+ }
6315
+
5638
6316
  // src/scan.ts
5639
6317
  var SKIP_DIRS = [
5640
6318
  "node_modules/",
@@ -5692,6 +6370,7 @@ function isSourceCandidate(path) {
5692
6370
  if (SKIP_DIRS.some((dir) => lower === dir.slice(0, -1) || lower.startsWith(dir) || lower.includes(`/${dir}`)))
5693
6371
  return false;
5694
6372
  if (SKIP_FILES.includes(lower.split("/").pop() ?? "")) return false;
6373
+ if (/(^|\/)[jt]sconfig\.json$/.test(lower)) return true;
5695
6374
  if (!SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false;
5696
6375
  if (!PROSE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return true;
5697
6376
  return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
@@ -5994,7 +6673,7 @@ function moduleScope(parsed, content) {
5994
6673
  }
5995
6674
 
5996
6675
  // src/componentize.ts
5997
- import MagicString4 from "magic-string";
6676
+ import MagicString5 from "magic-string";
5998
6677
  var SectionInvariantError = class extends Error {
5999
6678
  code = "SECTION_INVARIANT";
6000
6679
  constructor(message) {
@@ -6920,7 +7599,7 @@ export default function ${resolved.name}({ blockId, bind, overrides, page }: Bcm
6920
7599
  ` };
6921
7600
  }
6922
7601
  function sectionMarkup(content, root, fieldName) {
6923
- const slice = new MagicString4(content.slice(root.start, root.end));
7602
+ const slice = new MagicString5(content.slice(root.start, root.end));
6924
7603
  const at = (offset) => offset - root.start;
6925
7604
  const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
6926
7605
  if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
@@ -7119,7 +7798,7 @@ async function migrateSections(plan2, contents, written, fileSet, aliases) {
7119
7798
  );
7120
7799
  continue;
7121
7800
  }
7122
- const source = new MagicString4(content);
7801
+ const source = new MagicString5(content);
7123
7802
  const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
7124
7803
  if (duplicates.length > 0) {
7125
7804
  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.`);
@@ -7396,7 +8075,7 @@ export default function Sections({ page }: { page: string }) {
7396
8075
  function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
7397
8076
  const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
7398
8077
  const local = names.get("Sections");
7399
- const source = new MagicString4(content);
8078
+ const source = new MagicString5(content);
7400
8079
  const first = placeable[0].root;
7401
8080
  const last = placeable[placeable.length - 1].root;
7402
8081
  source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
@@ -7416,7 +8095,7 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
7416
8095
  { key: "pageSnapshot", name: "pageSnapshot" }
7417
8096
  ]);
7418
8097
  const snapshot = names.get("pageSnapshot");
7419
- const source = new MagicString4(content);
8098
+ const source = new MagicString5(content);
7420
8099
  for (const resolved of placeable) {
7421
8100
  const component = written.get(resolved.slug);
7422
8101
  const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
@@ -7740,6 +8419,7 @@ async function convertSources(briefIn, sources, options) {
7740
8419
  const receiptFiles = [];
7741
8420
  const dialects = /* @__PURE__ */ new Set();
7742
8421
  const dynamicBindings = [];
8422
+ const laneNotes = [];
7743
8423
  const snapshotSlugs = /* @__PURE__ */ new Set();
7744
8424
  let occurrencesLocated = 0;
7745
8425
  let occurrencesRewritten = 0;
@@ -7765,6 +8445,7 @@ async function convertSources(briefIn, sources, options) {
7765
8445
  outFiles.push({ path: file, operation: "modify", content: result.content });
7766
8446
  receiptFiles.push({ path: file, bindings: result.bindings, tier: result.tier });
7767
8447
  dynamicBindings.push(...result.dynamic);
8448
+ laneNotes.push(...result.notes);
7768
8449
  for (const slug of result.snapshots) snapshotSlugs.add(slug);
7769
8450
  }
7770
8451
  }
@@ -7801,7 +8482,7 @@ async function convertSources(briefIn, sources, options) {
7801
8482
  } else rewritten++;
7802
8483
  }
7803
8484
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
7804
- const source = new MagicString5(contents.get(file));
8485
+ const source = new MagicString6(contents.get(file));
7805
8486
  const seen = /* @__PURE__ */ new Set();
7806
8487
  for (const splice of splices) {
7807
8488
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -7861,13 +8542,25 @@ async function convertSources(briefIn, sources, options) {
7861
8542
  helperUpgraded,
7862
8543
  dynamicBindings,
7863
8544
  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
- )
8545
+ notes: [
8546
+ ...unreadableConfigs(sources).map(
8547
+ (file) => ({
8548
+ code: "TSCONFIG_UNREADABLE",
8549
+ file,
8550
+ message: `${file} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
8551
+ })
8552
+ ),
8553
+ // An absent alias table looks exactly like a project that has none, and the two have
8554
+ // opposite consequences for every component reached through one. @see extendedConfigs
8555
+ ...extendedConfigs(sources).map(
8556
+ (file) => ({
8557
+ code: "TSCONFIG_EXTENDS_ONLY",
8558
+ file,
8559
+ 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.`
8560
+ })
8561
+ ),
8562
+ ...laneNotes
8563
+ ]
7871
8564
  });
7872
8565
  return { files: outFiles.sort((a, b) => a.path.localeCompare(b.path)), receipt };
7873
8566
  }
@@ -7889,7 +8582,8 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7889
8582
  dialect: dialectOf(file, content),
7890
8583
  dynamic: [],
7891
8584
  componentEdits: [],
7892
- snapshots: []
8585
+ snapshots: [],
8586
+ notes: []
7893
8587
  };
7894
8588
  if (pendingTargets.length === 0) return empty;
7895
8589
  if (!dialect) {
@@ -7950,7 +8644,8 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7950
8644
  dialect: null,
7951
8645
  dynamic: [],
7952
8646
  componentEdits: [],
7953
- snapshots: []
8647
+ snapshots: [],
8648
+ notes: []
7954
8649
  };
7955
8650
  }
7956
8651
  if (rescued.reason === "unverifiable") {
@@ -8020,6 +8715,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8020
8715
  { key: "helper:bcmsLayout", name: "bcmsLayout" },
8021
8716
  { key: "helper:bcmsRowsAs", name: "bcmsRowsAs" },
8022
8717
  { key: "helper:bcmsImage", name: "bcmsImage" },
8718
+ { key: "helper:bcmsList", name: "bcmsList" },
8719
+ { key: "helper:bcmsTuples", name: "bcmsTuples" },
8023
8720
  // Keyed by the SLUG: two slugs that spell one identifier are still two pages. @see scope.ts
8024
8721
  ...slugs.map((slug) => ({ key: `slug:${slug}`, name: identifierFor2(slug) }))
8025
8722
  ].filter((entry) => !preset.has(entry.key));
@@ -8211,15 +8908,23 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8211
8908
  const collided = new Set(overlapping(kept).map((r) => r.key));
8212
8909
  const applied = kept.filter((r) => !collided.has(r.key));
8213
8910
  for (const key of collided) outcomes.set(key, "AMBIGUOUS_LITERAL");
8214
- const data = bindDataLiterals(
8911
+ const data = await bindDataLiterals(
8215
8912
  {
8216
8913
  content,
8217
8914
  parsed,
8218
8915
  dialect: writing,
8219
8916
  rowsAs: helperName("bcmsRowsAs"),
8917
+ list: helperName("bcmsList"),
8918
+ tuples: helperName("bcmsTuples"),
8220
8919
  read: helperName("bcms"),
8221
8920
  snapshotName,
8222
- slug: params?.slug ?? null
8921
+ slug: params?.slug ?? null,
8922
+ // The row's leaf travels to another file exactly as a scalar prop does, and by the same
8923
+ // machinery — the lane decides WHICH props, this decides what may be touched. @see props.ts
8924
+ drill: async (component, prop, kind) => {
8925
+ const drilled = await drillProp(drill, file, component, prop, kind, 1);
8926
+ return drilled.ok ? { ok: true, edits: drilled.edits } : { ok: false, reason: drilled.reason ?? "PROP_TARGET_NOT_FOUND" };
8927
+ }
8223
8928
  },
8224
8929
  targets.filter((t) => {
8225
8930
  const outcome = outcomes.get(t.key);
@@ -8237,6 +8942,15 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8237
8942
  for (const declaration2 of data.declarations) {
8238
8943
  dynamic.push({ file, prop: null, path: declaration2.path, callSites: [{ file, literal: declaration2.literal }] });
8239
8944
  }
8945
+ componentEdits.push(...data.componentEdits);
8946
+ for (const drilledProp of data.props) {
8947
+ dynamic.push({
8948
+ file: drilledProp.file,
8949
+ prop: drilledProp.prop,
8950
+ path: drilledProp.path,
8951
+ callSites: [{ file, literal: drilledProp.literal }]
8952
+ });
8953
+ }
8240
8954
  const images = bindImages(
8241
8955
  { content, parsed, dialect: writing, image: helperName("bcmsImage"), snapshotName, slug: params?.slug ?? null },
8242
8956
  targets.filter((t) => {
@@ -8248,7 +8962,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8248
8962
  for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
8249
8963
  const lanes = [...data.splices, ...images.splices];
8250
8964
  if (applied.length === 0 && lanes.length === 0) {
8251
- return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [] };
8965
+ return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [], notes: [] };
8252
8966
  }
8253
8967
  const readSlugs = [
8254
8968
  .../* @__PURE__ */ new Set([...applied.filter((r) => r.scope === "page").map((r) => r.slug), ...data.slugs, ...images.slugs])
@@ -8275,6 +8989,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8275
8989
  ] : [],
8276
8990
  ...applied.some((r) => r.scope === "layout") ? [{ name: "bcmsLayout", alias: helperName("bcmsLayout") }] : [],
8277
8991
  ...data.usesRows ? [{ name: "bcmsRowsAs", alias: helperName("bcmsRowsAs") }] : [],
8992
+ ...data.usesList ? [{ name: "bcmsList", alias: helperName("bcmsList") }] : [],
8993
+ // `bcmsTuples` reads each row through `bcmsList`, but the emitted helper exports both and the
8994
+ // page imports only the one it calls — an unused import is dead code in someone else's repo.
8995
+ ...data.usesTuples ? [{ name: "bcmsTuples", alias: helperName("bcmsTuples") }] : [],
8278
8996
  ...data.usesRead ? [{ name: "bcms", alias: helperName("bcms") }] : [],
8279
8997
  ...images.splices.length > 0 ? [{ name: "bcmsImage", alias: helperName("bcmsImage") }] : []
8280
8998
  ].filter((helper2, at, all) => all.findIndex((h) => h.name === helper2.name) === at).filter((helper2) => !preset.has(`helper:${helper2.name}`)),
@@ -8289,7 +9007,9 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8289
9007
  dialect: writing,
8290
9008
  dynamic,
8291
9009
  componentEdits,
8292
- snapshots: rule.text ? readSlugs : []
9010
+ snapshots: rule.text ? readSlugs : [],
9011
+ // The lane reports one sentence per list it bound; the FILE is added here, where it is known.
9012
+ notes: data.notes.map((message) => ({ code: "PRIMITIVE_LIST", file, message }))
8293
9013
  };
8294
9014
  }
8295
9015
  function mergeInto(content, site) {
@@ -8465,8 +9185,62 @@ function emitStubs2(dialects, slugs, contents, out) {
8465
9185
  }
8466
9186
  }
8467
9187
 
9188
+ // package.json
9189
+ var package_default = {
9190
+ name: "@bettercms-ai/convert",
9191
+ version: "0.9.0",
9192
+ type: "module",
9193
+ description: "Deterministic codemod that turns an imported site's text-matched bindings into declared ones \u2014 parser offsets in, one magic-string pass out, plus a receipt of everything it could not do.",
9194
+ bin: {
9195
+ "bettercms-convert": "dist/cli.js"
9196
+ },
9197
+ main: "./dist/index.js",
9198
+ module: "./dist/index.js",
9199
+ types: "./dist/index.d.ts",
9200
+ sideEffects: false,
9201
+ exports: {
9202
+ ".": {
9203
+ types: "./dist/index.d.ts",
9204
+ import: "./dist/index.js",
9205
+ default: "./dist/index.js"
9206
+ }
9207
+ },
9208
+ files: [
9209
+ "dist",
9210
+ "README.md",
9211
+ "CHANGELOG.md"
9212
+ ],
9213
+ scripts: {
9214
+ typecheck: "tsc --noEmit",
9215
+ build: "tsup",
9216
+ test: "vitest run",
9217
+ "test:watch": "vitest",
9218
+ briefs: "bun --conditions=development scripts/build-briefs.ts"
9219
+ },
9220
+ dependencies: {
9221
+ "@astrojs/compiler": "^2.13.1",
9222
+ "@babel/parser": "^7.29.2",
9223
+ "@bettercms-ai/types": "^1.12.0",
9224
+ "@vue/compiler-sfc": "^3.5.0",
9225
+ "magic-string": "^0.30.21",
9226
+ parse5: "^7.3.0",
9227
+ svelte: "^5.0.0"
9228
+ },
9229
+ devDependencies: {
9230
+ "@types/node": "^20",
9231
+ esbuild: "^0.27.7",
9232
+ tsup: "^8.5.1",
9233
+ typescript: "^5",
9234
+ vitest: "^4.1.4"
9235
+ },
9236
+ publishConfig: {
9237
+ access: "public",
9238
+ registry: "https://registry.npmjs.org/"
9239
+ }
9240
+ };
9241
+
8468
9242
  // src/cli.ts
8469
- var VERSION = "0.4.0";
9243
+ var { version: VERSION } = package_default;
8470
9244
  var MAX_FILE_BYTES = 512 * 1024;
8471
9245
  var CliError = class extends Error {
8472
9246
  };
@@ -8474,6 +9248,7 @@ function parseArgs(argv) {
8474
9248
  const args = {
8475
9249
  brief: void 0,
8476
9250
  componentize: false,
9251
+ forms: false,
8477
9252
  plan: void 0,
8478
9253
  copy: "bind",
8479
9254
  root: ".",
@@ -8488,6 +9263,7 @@ function parseArgs(argv) {
8488
9263
  const arg = argv[i];
8489
9264
  if (arg === "--brief") args.brief = argv[++i];
8490
9265
  else if (arg === "--componentize") args.componentize = true;
9266
+ else if (arg === "--forms") args.forms = true;
8491
9267
  else if (arg === "--plan") args.plan = argv[++i];
8492
9268
  else if (arg === "--copy") {
8493
9269
  const mode = argv[++i];
@@ -8514,9 +9290,14 @@ var HELP = `bettercms-convert ${VERSION}
8514
9290
 
8515
9291
  bettercms-convert --brief <json> --root . [options]
8516
9292
  bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
9293
+ bettercms-convert --forms --brief <json> --root . [options]
8517
9294
 
8518
9295
  --brief <file> The conversion brief, as get_conversion_brief returns it. Required.
8519
9296
  --componentize Lift each planned SECTION into a component the CMS can reorder.
9297
+ --forms Point each <form> in the repository at the CMS form behind it \u2014 the
9298
+ endpoint, the id, a marker per field and the submit script. A form
9299
+ the brief lists as a DRAFT is wired and receipted: publish it in the
9300
+ Forms tab, or it rejects every submission with a 403.
8520
9301
  --plan <file> The componentize plan, as get_componentize_plan returns it.
8521
9302
  Required with --componentize.
8522
9303
  --copy bind|instance Which copy model the plan is expected to describe. Default: bind
@@ -8599,6 +9380,26 @@ ${rewritten} of ${declared} paths bound (${alreadyDeclared} already declared, ${
8599
9380
  `);
8600
9381
  }
8601
9382
  }
9383
+ function reportForms(files, receipt, dryRun) {
9384
+ const { wired, alreadyWired, pending, notes } = receipt.forms;
9385
+ for (const file of files) {
9386
+ process.stdout.write(`${dryRun ? "would " : ""}${file.operation} ${file.path}
9387
+ `);
9388
+ }
9389
+ process.stdout.write(
9390
+ `
9391
+ ${wired} forms wired (${alreadyWired} already wired, ${pending.length} pending)
9392
+ `
9393
+ );
9394
+ for (const form of pending) {
9395
+ process.stdout.write(
9396
+ ` pending ${form.name} \u2014 ${form.reason}${form.message ? ` (${form.message})` : ""}${form.file ? ` [${form.file}]` : ""}
9397
+ `
9398
+ );
9399
+ }
9400
+ for (const note of notes ?? []) process.stdout.write(` todo ${note}
9401
+ `);
9402
+ }
8602
9403
  function reportSections(files, receipt, dryRun) {
8603
9404
  const { extracted, inlineOnly, alreadyExtracted, registered, pending } = receipt.sections;
8604
9405
  for (const file of files) {
@@ -8630,6 +9431,9 @@ async function main() {
8630
9431
  if (args.copy === "instance" && !args.componentize) {
8631
9432
  throw new CliError("--copy is only read with --componentize. See --help.");
8632
9433
  }
9434
+ if (args.forms && args.componentize) {
9435
+ throw new CliError("--forms and --componentize are separate runs. Do one, then the other. See --help.");
9436
+ }
8633
9437
  const root = resolve(args.root);
8634
9438
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
8635
9439
  if (!Array.isArray(brief.pages)) {
@@ -8670,6 +9474,13 @@ async function main() {
8670
9474
  reportSections(files2, receipt2, args.dryRun);
8671
9475
  return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
8672
9476
  }
9477
+ if (args.forms) {
9478
+ const { files: files2, receipt: receipt2 } = await wireForms(brief, sources);
9479
+ await written(files2);
9480
+ await saveReceipt(receipt2);
9481
+ reportForms(files2, receipt2, args.dryRun);
9482
+ return args.strict && receipt2.forms.pending.length > 0 ? 2 : 0;
9483
+ }
8673
9484
  const { files, receipt } = await convertSources(brief, sources, {
8674
9485
  overwriteHelper: args.overwriteHelper
8675
9486
  });