@bettercms-ai/convert 0.18.2 → 0.20.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.d.ts CHANGED
@@ -428,8 +428,12 @@ interface ConversionReceipt {
428
428
  * `ACTION_IS_EXPRESSION` the `<form>`'s `action` is code; replacing it would rewrite a program.
429
429
  * `DIALECT_UNSUPPORTED` the `<form>` is in a svelte/vue file, which this lane does not write.
430
430
  * `PARSE_ERROR` the file holding it did not parse.
431
+ * `FORM_NAME_AMBIGUOUS` an Astro form is wired by name, and two CMS forms share this one's.
432
+ * `HELPER_CONFLICT` `src/bcms-forms.ts` exists with bytes this package did not write.
433
+ * `FORM_WIRED_TO_OTHER_NAME` an Astro `<form>` is already wired by another name. Never rewritten:
434
+ * the name is what every environment building the repo shares.
431
435
  */
432
- type FormPendingReason = "FORM_NOT_IN_SOURCE" | "FORM_AMBIGUOUS" | "FIELD_UNMATCHED" | "ACTION_IS_EXPRESSION" | "DIALECT_UNSUPPORTED" | "PARSE_ERROR";
436
+ type FormPendingReason = "FORM_NOT_IN_SOURCE" | "FORM_AMBIGUOUS" | "FIELD_UNMATCHED" | "ACTION_IS_EXPRESSION" | "DIALECT_UNSUPPORTED" | "PARSE_ERROR" | "FORM_NAME_AMBIGUOUS" | "HELPER_CONFLICT" | "FORM_WIRED_TO_OTHER_NAME";
433
437
  interface PendingForm {
434
438
  id: string;
435
439
  name: string;
@@ -951,7 +955,7 @@ declare function attrsFor(path: string, kind: string, props?: AttrBinding[], sco
951
955
  declare function propsAttribute(props: AttrBinding[]): string;
952
956
 
953
957
  /** Bumped whenever the emitted source changes. `isKnownHelper` upgrades anything older. */
954
- declare const HELPER_VERSION = 8;
958
+ declare const HELPER_VERSION = 9;
955
959
  /** Where each dialect's helper lives. Null = this dialect reads nothing at build time. */
956
960
  declare const HELPER_PATH: Record<Dialect, string | null>;
957
961
  /** The directory the snapshots live in, at the repository root. */
package/dist/index.js CHANGED
@@ -4187,7 +4187,7 @@ async function bindImages(ctx, targets) {
4187
4187
 
4188
4188
  // src/helper.ts
4189
4189
  import { createHash as createHash2 } from "crypto";
4190
- var HELPER_VERSION = 8;
4190
+ var HELPER_VERSION = 9;
4191
4191
  var HEADER = "// @bettercms-ai/convert helper v";
4192
4192
  var HELPER_PATH = Object.fromEntries(
4193
4193
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4352,9 +4352,19 @@ export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: st
4352
4352
  * the fallback the template shipped with \u2014 which is why a section renders correctly against the
4353
4353
  * committed \`{}\` stub.
4354
4354
  */
4355
- export function bcmsSection(page: unknown, bind?: string, overrides?: unknown): Record<string, unknown> {
4355
+ export function bcmsSection(
4356
+ page: unknown,
4357
+ bind?: string,
4358
+ overrides?: unknown,
4359
+ bindPaths?: Record<string, string>,
4360
+ ): Record<string, unknown> {
4356
4361
  const group = bind ? readPath(page, bind) : undefined;
4357
4362
  const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
4363
+ // A locale copy on its twin's component spells its fields its own way: each prop's copy is
4364
+ // read from the path the placement names for it.
4365
+ if (bind && isRecord(bindPaths)) {
4366
+ for (const [key, path] of Object.entries(bindPaths)) base[key] = readPath(page, path);
4367
+ }
4358
4368
  if (!isRecord(overrides)) return base;
4359
4369
  for (const key of Object.keys(overrides)) {
4360
4370
  const value = overrides[key];
@@ -4417,21 +4427,25 @@ export function bcmsSectionPaths(
4417
4427
  overrides: unknown,
4418
4428
  prefix: string,
4419
4429
  map: BcmsPathMap,
4430
+ bindPaths?: Record<string, string>,
4420
4431
  ): Record<string, unknown> {
4421
4432
  const out: Record<string, unknown> = isRecord(page) ? { ...page } : {};
4422
4433
  const own = isRecord(overrides) ? overrides : {};
4423
4434
  const separator = prefix.slice(-1);
4424
- const fromPage = (path: string): unknown =>
4425
- bind ? readPath(page, bind + separator + path.slice(prefix.length)) : undefined;
4435
+ // A re-keyed locale copy names each prop's path on its own page; any other placement re-bases.
4436
+ const fromPage = (path: string, key: string): unknown =>
4437
+ !bind ? undefined
4438
+ : isRecord(bindPaths) && typeof bindPaths[key] === "string" ? readPath(page, bindPaths[key])
4439
+ : readPath(page, bind + separator + path.slice(prefix.length));
4426
4440
  for (const [path, key] of Object.entries(map.scalars ?? {})) {
4427
4441
  const value = own[key];
4428
- out[path] = value !== undefined && value !== null ? value : fromPage(path);
4442
+ out[path] = value !== undefined && value !== null ? value : fromPage(path, key);
4429
4443
  }
4430
4444
  for (const [path, table] of Object.entries(map.tables ?? {})) {
4431
4445
  const held = own[table.key];
4432
4446
  const rows = isRecord(held) && Array.isArray(held.rows) ? held.rows : held;
4433
4447
  if (!Array.isArray(rows)) {
4434
- out[path] = fromPage(path);
4448
+ out[path] = fromPage(path, table.key);
4435
4449
  continue;
4436
4450
  }
4437
4451
  const pageColumn = new Map(Object.entries(table.columns).map(([column, key]) => [key, column]));
@@ -4446,12 +4460,17 @@ export function bcmsSectionPaths(
4446
4460
  }
4447
4461
 
4448
4462
  export function bcmsFields(
4449
- placement: { blockId?: string; bind?: string; overrides?: unknown },
4463
+ placement: { blockId?: string; bind?: string; overrides?: unknown; bindPaths?: Record<string, string> },
4450
4464
  pathPrefix: string,
4451
4465
  map?: BcmsPathMap,
4452
4466
  ): (path: string) => string {
4453
4467
  const id = placement.blockId;
4454
4468
  const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
4469
+ /** A re-keyed locale copy's own path for a prop, with whatever follows the prop kept. */
4470
+ const moved = (key: string | undefined, rest: string): string | undefined => {
4471
+ const paths = placement.bind && isRecord(placement.bindPaths) ? placement.bindPaths : undefined;
4472
+ return key !== undefined && paths && typeof paths[key] === "string" ? paths[key] + rest : undefined;
4473
+ };
4455
4474
  /**
4456
4475
  * What replaces the prefix the markup was cut with, or null for "leave every path alone".
4457
4476
  *
@@ -4470,11 +4489,14 @@ export function bcmsFields(
4470
4489
  const separator = pathPrefix.slice(-1);
4471
4490
  return (path: string): string => {
4472
4491
  if (!path.startsWith(pathPrefix) || !(own || placement.bind)) return path;
4473
- if (!own) return placement.bind + separator + path.slice(pathPrefix.length);
4474
4492
  const scalar = map.scalars?.[path];
4475
- if (scalar !== undefined) return id + "__overrides." + scalar;
4476
4493
  const row = /^([^[]+)\\[(\\d+)\\]\\.(.+)$/.exec(path);
4477
4494
  const table = row ? map.tables?.[row[1]] : undefined;
4495
+ if (!own) {
4496
+ return moved(scalar, "") ?? moved(table?.key, row ? "[" + row[2] + "]." + row[3] : "")
4497
+ ?? placement.bind + separator + path.slice(pathPrefix.length);
4498
+ }
4499
+ if (scalar !== undefined) return id + "__overrides." + scalar;
4478
4500
  const column = row && table ? table.columns[row[3]] : undefined;
4479
4501
  return table && column !== undefined ? id + "__overrides." + table.key + "[" + row![2] + "]." + column : path;
4480
4502
  };
@@ -4484,7 +4506,9 @@ export function bcmsFields(
4484
4506
  // it names a key the instance does not hold and a group this placement is not bound to.
4485
4507
  // (An EMPTY prefix is a plan saying the prop key IS the page path, and matches everything.)
4486
4508
  if (lane === null || !path.startsWith(pathPrefix)) return path;
4487
- return lane + path.slice(pathPrefix.length);
4509
+ const rest = path.slice(pathPrefix.length);
4510
+ const key = /^[^.[]+/.exec(rest)?.[0];
4511
+ return (own ? undefined : moved(key, rest.slice(key?.length ?? 0))) ?? lane + rest;
4488
4512
  };
4489
4513
  }
4490
4514
  `;
@@ -4497,7 +4521,8 @@ var HISTORICAL = {
4497
4521
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4498
4522
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4499
4523
  6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4500
- 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4524
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93",
4525
+ 8: "484e72852d2b496e016a1f2a2b1a77bb993f8a163b290781522d6114b6519bb9"
4501
4526
  },
4502
4527
  "src/lib/bcms-content.ts": {
4503
4528
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
@@ -4506,7 +4531,8 @@ var HISTORICAL = {
4506
4531
  4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4507
4532
  5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba",
4508
4533
  6: "63f1fa29e47e70c5193b81ee24addb238ba100247124d55bc17713102d587df5",
4509
- 7: "ed92fb88de953d9951abbe6a1edae1d048f4050eaf6fb607ac08167f94d8bcc7"
4534
+ 7: "ed92fb88de953d9951abbe6a1edae1d048f4050eaf6fb607ac08167f94d8bcc7",
4535
+ 8: "1a6f669d76d42effe77f082436e9d21c0efa6fc499e919e3d116bc3a27a7f3d5"
4510
4536
  },
4511
4537
  "composables/bcms-content.ts": {
4512
4538
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
@@ -4515,7 +4541,8 @@ var HISTORICAL = {
4515
4541
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4516
4542
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4517
4543
  6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4518
- 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4544
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93",
4545
+ 8: "484e72852d2b496e016a1f2a2b1a77bb993f8a163b290781522d6114b6519bb9"
4519
4546
  }
4520
4547
  };
4521
4548
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -6416,6 +6443,114 @@ function readPlan(value) {
6416
6443
  // src/forms.ts
6417
6444
  import MagicString4 from "magic-string";
6418
6445
  var WIRABLE = ["html", "astro", "jsx"];
6446
+ var BY_NAME = ["astro"];
6447
+ var FORMS_HELPER_PATH = "src/bcms-forms.ts";
6448
+ var FORMS_HELPER_VERSION = 2;
6449
+ var FORMS_HELPER_HEADER = /^\/\/ @bettercms-ai\/convert forms-helper v(\d+)\r?\n/;
6450
+ var formsHelperVersion = (content) => {
6451
+ const m = FORMS_HELPER_HEADER.exec(content);
6452
+ return m ? Number(m[1]) : null;
6453
+ };
6454
+ var FORMS_HELPER_SOURCE = `// @bettercms-ai/convert forms-helper v${FORMS_HELPER_VERSION}
6455
+ // Written by @bettercms-ai/convert; a re-run rewrites it, so do not edit it.
6456
+ // Resolves a BetterCMS form by NAME at build, for the environment that is building this site: the id
6457
+ // from bcms-content.json (written into the tree before the build) and the endpoint from BCMS_API_URL.
6458
+ // The source names no host and no id, so every BetterCMS environment builds its own wiring.
6459
+ // A form it cannot resolve renders unwired (no action, no method, no id) with one warning line, and
6460
+ // the build goes on. Form NAMES are the contract between environments: rename a form on every one.
6461
+ import { existsSync, readFileSync } from "node:fs";
6462
+
6463
+ type SnapshotForm = { id: string; name: string; projectId?: string | null; ignoredAt?: string | null };
6464
+ type Snapshot = { projectId: string | null; forms: SnapshotForm[] };
6465
+ type Wiring = { id: string; action: string };
6466
+
6467
+ let snapshot: Snapshot | null | undefined;
6468
+ const resolved = new Map<string, Wiring | undefined>();
6469
+
6470
+ const env = (key: string): string | undefined => process.env[key] || process.env[\`PUBLIC_\${key}\`] || undefined;
6471
+
6472
+ const isForm = (value: unknown): value is SnapshotForm =>
6473
+ typeof value === "object" &&
6474
+ value !== null &&
6475
+ typeof (value as { id?: unknown }).id === "string" &&
6476
+ typeof (value as { name?: unknown }).name === "string";
6477
+
6478
+ function readSnapshot(): Snapshot | null {
6479
+ for (const file of ["bcms-content.json", "bcms-content/forms.json"]) {
6480
+ if (!existsSync(file)) continue;
6481
+ try {
6482
+ const data: unknown = JSON.parse(readFileSync(file, "utf8"));
6483
+ const record = (Array.isArray(data) ? { forms: data } : (data ?? {})) as { forms?: unknown; projectId?: unknown };
6484
+ const forms: unknown[] = Array.isArray(record.forms) ? record.forms : [];
6485
+ if (!forms.every(isForm)) throw new Error("a form entry has no id or name");
6486
+ return { projectId: typeof record.projectId === "string" ? record.projectId : null, forms };
6487
+ } catch (error) {
6488
+ // Read once per build, so this is the one line however many forms the site has.
6489
+ console.warn(\`[bcms-forms] FORMS_SNAPSHOT_UNREADABLE: \${file} could not be read (\${error instanceof Error ? error.message : String(error)}); every form renders unwired.\`);
6490
+ return null;
6491
+ }
6492
+ }
6493
+ return null;
6494
+ }
6495
+
6496
+ function unwired(code: string, name: string, why: string): undefined {
6497
+ console.warn(\`[bcms-forms] \${code}: form "\${name}" renders unwired; \${why}.\`);
6498
+ return undefined;
6499
+ }
6500
+
6501
+ function resolve(name: string): Wiring | undefined {
6502
+ if (snapshot === undefined) snapshot = readSnapshot();
6503
+ // No snapshot (a local dev build), or one it could not read: the form renders unwired, as before.
6504
+ if (snapshot === null) return undefined;
6505
+ const project = env("BCMS_PROJECT_ID") ?? snapshot.projectId;
6506
+ // The forms the conversion brief names from: this project's own, ignored ones left out.
6507
+ const own = snapshot.forms.filter((f) => !f.ignoredAt && (!project || f.projectId === project));
6508
+ const hits = own.filter((f) => f.name === name);
6509
+ const form = hits.length === 1 ? hits[0] : undefined;
6510
+ if (!form && hits.length > 1) {
6511
+ return unwired("BCMS_FORM_NAME_AMBIGUOUS", name, \`\${hits.length} forms in this project share that name; rename one in the Forms tab\`);
6512
+ }
6513
+ if (!form) {
6514
+ return unwired(
6515
+ "BCMS_FORM_NOT_FOUND",
6516
+ name,
6517
+ own.length === 0
6518
+ ? "this project has no forms yet; the release of this build derives them"
6519
+ : "no form in this project has that name; a name is shared by every environment, so rename the form on each one",
6520
+ );
6521
+ }
6522
+ const api = env("BCMS_API_URL");
6523
+ if (!api) return unwired("BCMS_FORM_API_URL_MISSING", name, "BCMS_API_URL is not set, so it has nowhere to post");
6524
+ return { id: form.id, action: \`\${api.replace(/\\/$/, "")}/api/v1/forms/public/\${form.id}/submissions\` };
6525
+ }
6526
+
6527
+ /** Resolved once per name, so a form's three attributes print one warning, not three. */
6528
+ function wiring(name: string): Wiring | undefined {
6529
+ if (!resolved.has(name)) resolved.set(name, resolve(name));
6530
+ return resolved.get(name);
6531
+ }
6532
+
6533
+ export const bcmsFormId = (name: string): string | undefined => wiring(name)?.id;
6534
+
6535
+ export const bcmsFormAction = (name: string): string | undefined => wiring(name)?.action;
6536
+
6537
+ /** "post" only for a form that resolved: an unwired form keeps no method, like its action and id. */
6538
+ export const bcmsFormMethod = (name: string): "post" | undefined => (wiring(name) ? "post" : undefined);
6539
+ `;
6540
+ var idCall = (form) => `bcmsFormId(${JSON.stringify(form.name)})`;
6541
+ var actionCall = (form) => `bcmsFormAction(${JSON.stringify(form.name)})`;
6542
+ var methodCall = (form) => `bcmsFormMethod(${JSON.stringify(form.name)})`;
6543
+ function calledName(code, fn) {
6544
+ const m = code === null ? null : new RegExp(`^${fn}\\s*\\(\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1\\s*,?\\s*\\)$`).exec(code);
6545
+ if (!m) return null;
6546
+ const [, quote, raw] = m;
6547
+ if (quote === "`" && raw.includes("${")) return null;
6548
+ try {
6549
+ return JSON.parse(`"${raw.replace(/\\(['`])/g, "$1").replace(/(?<!\\)"/g, '\\"')}"`);
6550
+ } catch {
6551
+ return null;
6552
+ }
6553
+ }
6419
6554
  var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6420
6555
  var FORM_SCRIPT = `<script>
6421
6556
  function bcmsPhone(cc,raw,trunk){var d=String(raw||'').trim();if(!d)return '';if(d.charAt(0)==='+'){var i=d.replace(/D/g,'');return i?'+'+i:'';}if(!cc)return d;var n=d.replace(/D/g,'');if(trunk&&n.charAt(0)==='0')n=n.slice(1);return n?'+'+cc+n:'';}
@@ -6477,6 +6612,10 @@ function* elementsIn(nodes) {
6477
6612
  var childElements = (node) => node.children.flatMap(
6478
6613
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6479
6614
  );
6615
+ function exprOf(c, name) {
6616
+ const range = attrOf(c.el, name)?.expressionRange;
6617
+ return range ? c.content.slice(range.start + 1, range.end - 1).trim() : null;
6618
+ }
6480
6619
  var LANDMARK_TAGS = /* @__PURE__ */ new Set(["footer", "header", "nav", "main", "aside", "dialog"]);
6481
6620
  function landmarkOf(chain) {
6482
6621
  for (let i = chain.length - 1; i >= 0; i--) {
@@ -6498,7 +6637,7 @@ function* formsIn(nodes, chain = []) {
6498
6637
  }
6499
6638
  }
6500
6639
  var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6501
- function candidateOf(file, dialect, form, imported, landmark, content) {
6640
+ function candidateOf(file, dialect, form, imported, landmark, content, importAt) {
6502
6641
  const controls = /* @__PURE__ */ new Map();
6503
6642
  const wrapper = /* @__PURE__ */ new Map();
6504
6643
  const foreign = /* @__PURE__ */ new Set();
@@ -6539,7 +6678,7 @@ function candidateOf(file, dialect, form, imported, landmark, content) {
6539
6678
  content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`/g, "")
6540
6679
  );
6541
6680
  const dynamic = all.some((control) => !foreign.has(control) && computed(attrOf(control, "name")));
6542
- return { file, dialect, el: form, controls, wrapper, foreign, landmark, dynamic };
6681
+ return { file, dialect, el: form, controls, wrapper, foreign, landmark, dynamic, content, importAt };
6543
6682
  }
6544
6683
  async function importedControls(source, parsed, dialect, project, cache) {
6545
6684
  const out = /* @__PURE__ */ new Map();
@@ -6572,11 +6711,20 @@ function wire(source, candidate, form) {
6572
6711
  else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6573
6712
  else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6574
6713
  };
6575
- set("action", form.submitUrl);
6576
- set("method", "post");
6577
- set("data-bcms-form", form.id);
6714
+ const setExpr = (name, code) => {
6715
+ const attr = attrOf(el, name);
6716
+ if (!attr) inserts.push(`${name}={${code}}`);
6717
+ else source.overwrite(attr.range.start, attr.range.end, `${name}={${code}}`);
6718
+ };
6719
+ const byName = BY_NAME.includes(dialect);
6720
+ if (byName) setExpr("action", actionCall(form));
6721
+ else set("action", form.submitUrl);
6722
+ if (byName) setExpr("method", methodCall(form));
6723
+ else set("method", "post");
6724
+ if (byName) setExpr("data-bcms-form", idCall(form));
6725
+ else set("data-bcms-form", form.id);
6578
6726
  if (form.successMessage) set("data-bcms-form-success", form.successMessage);
6579
- source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6727
+ if (inserts.length > 0) source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6580
6728
  for (const inner of elementsIn(el.children)) {
6581
6729
  for (const attr of inner.attrs) {
6582
6730
  if (!DECLARED_BINDING_ATTR.test(attr.name)) continue;
@@ -6633,12 +6781,20 @@ async function wireForms(briefIn, sources, options_ = {}) {
6633
6781
  }
6634
6782
  const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6635
6783
  for (const { el, landmark } of formsIn(parsed.roots)) {
6636
- candidates.push(candidateOf(source.path, dialect, el, imported, landmark, source.content));
6784
+ candidates.push(candidateOf(source.path, dialect, el, imported, landmark, source.content, parsed.importAt));
6637
6785
  }
6638
6786
  }
6639
6787
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6640
- const wiredTo = (c, f) => attrOf(c.el, "data-bcms-form")?.value === f.id;
6641
- const covers2 = (c, f) => c.dynamic && wiredTo(c, f) || f.fields.length > 0 && holds(c, f) === f.fields.length;
6788
+ const byId = (c, f) => !attrOf(c.el, "data-bcms-form")?.expression && attrOf(c.el, "data-bcms-form")?.value === f.id;
6789
+ const programAction = (c) => !!attrOf(c.el, "action")?.expression && calledName(exprOf(c, "action"), "bcmsFormAction") === null;
6790
+ const wiredTo = (c, f) => BY_NAME.includes(c.dialect) ? calledName(exprOf(c, "data-bcms-form"), "bcmsFormId") === f.name || byId(c, f) && programAction(c) : byId(c, f);
6791
+ const wiredName = (c) => BY_NAME.includes(c.dialect) ? calledName(exprOf(c, "data-bcms-form"), "bcmsFormId") : null;
6792
+ const staticPost = (c) => {
6793
+ const method = attrOf(c.el, "method");
6794
+ return !!method && !method.expression && method.value.toLowerCase() === "post";
6795
+ };
6796
+ const claims = (c, f) => wiredTo(c, f) || byId(c, f);
6797
+ const covers2 = (c, f) => c.dynamic && claims(c, f) || f.fields.length > 0 && holds(c, f) === f.fields.length;
6642
6798
  const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
6643
6799
  const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6644
6800
  const pinned = /* @__PURE__ */ new Map();
@@ -6682,7 +6838,14 @@ async function wireForms(briefIn, sources, options_ = {}) {
6682
6838
  const wanted = /* @__PURE__ */ new Map();
6683
6839
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6684
6840
  const matched = [];
6841
+ const methodUpgrades = [];
6842
+ const pendedByName = /* @__PURE__ */ new Set();
6685
6843
  const pending = [];
6844
+ const existingHelper = sources.find((s) => samePath(s.path, FORMS_HELPER_PATH))?.content;
6845
+ const helperVersion = existingHelper === void 0 ? null : formsHelperVersion(existingHelper);
6846
+ const helperOk = existingHelper === void 0 || helperVersion !== null;
6847
+ const helperStale = existingHelper !== void 0 && helperVersion !== null && helperVersion <= FORMS_HELPER_VERSION && existingHelper !== FORMS_HELPER_SOURCE;
6848
+ let byNameOnSite = false;
6686
6849
  const onSite = [];
6687
6850
  let alreadyWired = 0;
6688
6851
  const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
@@ -6721,12 +6884,25 @@ async function wireForms(briefIn, sources, options_ = {}) {
6721
6884
  continue;
6722
6885
  }
6723
6886
  const candidate = list[0];
6887
+ const byName = BY_NAME.includes(candidate.dialect);
6888
+ if (byName && forms.filter((f) => f.name === form.name).length > 1) {
6889
+ pend2(form, "FORM_NAME_AMBIGUOUS", candidate.file, `more than one CMS form is named "${form.name}"; rename one in the Forms tab.`);
6890
+ continue;
6891
+ }
6724
6892
  if (wiredTo(candidate, form)) {
6725
6893
  alreadyWired++;
6726
6894
  onSite.push({ form, file: candidate.file });
6895
+ if (byName) byNameOnSite = true;
6896
+ if (wiredName(candidate) === form.name && staticPost(candidate) && helperOk) methodUpgrades.push({ form, candidate });
6897
+ continue;
6898
+ }
6899
+ const other = wiredName(candidate);
6900
+ if (other !== null) {
6901
+ pendedByName.add(candidate);
6902
+ pend2(form, "FORM_WIRED_TO_OTHER_NAME", candidate.file, `its <form> is wired by name to "${other}", and a wiring by name is never rewritten: form names are shared by every environment, so give this form the same name on each one.`);
6727
6903
  continue;
6728
6904
  }
6729
- if (attrOf(candidate.el, "action")?.expression) {
6905
+ if (programAction(candidate)) {
6730
6906
  pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6731
6907
  continue;
6732
6908
  }
@@ -6738,18 +6914,46 @@ async function wireForms(briefIn, sources, options_ = {}) {
6738
6914
  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.`);
6739
6915
  continue;
6740
6916
  }
6917
+ if (byName && !helperOk) {
6918
+ pend2(form, "HELPER_CONFLICT", FORMS_HELPER_PATH, `${FORMS_HELPER_PATH} exists and is not the file @bettercms-ai/convert writes, so nothing may import it. Move it and re-run.`);
6919
+ continue;
6920
+ }
6741
6921
  matched.push({ form, candidate });
6742
6922
  onSite.push({ form, file: candidate.file });
6923
+ if (byName) byNameOnSite = true;
6743
6924
  }
6744
6925
  const contents = new Map(sources.map((s) => [s.path, s.content]));
6745
6926
  const byFile = /* @__PURE__ */ new Map();
6746
- for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
6927
+ const editsOf = (path) => {
6928
+ if (!byFile.has(path)) byFile.set(path, { wire: [], upgrade: [] });
6929
+ return byFile.get(path);
6930
+ };
6931
+ for (const m of matched) editsOf(m.candidate.file).wire.push(m);
6932
+ for (const m of methodUpgrades) editsOf(m.candidate.file).upgrade.push(m);
6747
6933
  const files = [];
6748
- for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6934
+ for (const [path, { wire: list, upgrade }] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6749
6935
  const content = contents.get(path);
6750
6936
  const source = new MagicString4(content);
6751
6937
  for (const { form, candidate } of list) wire(source, candidate, form);
6752
- if (!content.includes("form[data-bcms-form]")) {
6938
+ for (const { form, candidate } of upgrade) {
6939
+ const method = attrOf(candidate.el, "method");
6940
+ source.overwrite(method.range.start, method.range.end, `method={${methodCall(form)}}`);
6941
+ }
6942
+ const first = (list[0] ?? upgrade[0]).candidate;
6943
+ if (BY_NAME.includes(first.dialect) && first.importAt) {
6944
+ const at = first.importAt;
6945
+ const helper = relativeImport(path, FORMS_HELPER_PATH.replace(/\.ts$/, ""));
6946
+ const names = !/\bbcmsFormId\b/.test(content) ? "bcmsFormAction, bcmsFormId, bcmsFormMethod" : !/\bbcmsFormMethod\b/.test(content) ? "bcmsFormMethod" : null;
6947
+ if (names) {
6948
+ const line = `import { ${names} } from "${helper}";`;
6949
+ if (at.wrap) source.appendLeft(at.at, `${at.wrap.before}${line}${at.wrap.after}`);
6950
+ else if (at.at === 0) source.appendLeft(0, `${line}
6951
+ `);
6952
+ else source.appendLeft(at.at, `
6953
+ ${line}`);
6954
+ }
6955
+ }
6956
+ if (list.length > 0 && !content.includes("form[data-bcms-form]")) {
6753
6957
  const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6754
6958
  const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6755
6959
  source.appendRight(last.candidate.el.end, `
@@ -6757,6 +6961,10 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6757
6961
  }
6758
6962
  files.push({ path, operation: "modify", content: source.toString() });
6759
6963
  }
6964
+ const helperAdded = existingHelper === void 0 && (matched.some((m) => BY_NAME.includes(m.candidate.dialect)) || methodUpgrades.length > 0);
6965
+ if (helperAdded) files.push({ path: FORMS_HELPER_PATH, operation: "add", content: FORMS_HELPER_SOURCE });
6966
+ const helperUpgraded = helperStale && byNameOnSite;
6967
+ if (helperUpgraded) files.push({ path: FORMS_HELPER_PATH, operation: "modify", content: FORMS_HELPER_SOURCE });
6760
6968
  const notes = onSite.filter(({ form }) => form.status === "draft").map(({ form }) => `publish form ${form.name} in the Forms tab \u2014 a draft form rejects every submission with 403.`);
6761
6969
  for (const { form, candidate } of matched) {
6762
6970
  const tie = beat.get(candidate);
@@ -6772,6 +6980,13 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6772
6980
  );
6773
6981
  }
6774
6982
  }
6983
+ for (const c of candidates) {
6984
+ const name = wiredName(c);
6985
+ if (name === null || pendedByName.has(c) || forms.some((f) => f.name === name)) continue;
6986
+ notes.push(
6987
+ `${c.file}: its <form> is wired by name to "${name}", which no form of this project has, so this environment's build renders it unwired; it was not rewritten \u2014 form names are shared by every environment, so give the form that name here too.`
6988
+ );
6989
+ }
6775
6990
  for (const { form, candidate } of matched) {
6776
6991
  const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6777
6992
  if (keys.length > 0) {
@@ -6789,8 +7004,8 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6789
7004
  paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6790
7005
  occurrences: { located: 0, rewritten: 0 },
6791
7006
  files: [],
6792
- helpers: [],
6793
- helperUpgraded: false,
7007
+ helpers: helperAdded || helperUpgraded ? [FORMS_HELPER_PATH] : [],
7008
+ helperUpgraded,
6794
7009
  dynamicBindings: [],
6795
7010
  forms: assertForms(forms.length, {
6796
7011
  wired: matched.length,
@@ -8131,6 +8346,7 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathP
8131
8346
  provided: [
8132
8347
  "blockId",
8133
8348
  "bind",
8349
+ "bindPaths",
8134
8350
  "overrides",
8135
8351
  "page",
8136
8352
  fieldName,
@@ -8145,8 +8361,8 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathP
8145
8361
  const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
8146
8362
  const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
8147
8363
  ` : "";
8148
- const copy = !snapshot ? "" : flat3 ? `const ${snapshot} = bcmsSectionPaths(page, bind, overrides, ${JSON.stringify(pathPrefix)}, ${JSON.stringify(pathMap)});` : `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides) };`;
8149
- const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)}${flat3 ? `, ${JSON.stringify(pathMap)}` : ""});` : "";
8364
+ const copy = !snapshot ? "" : flat3 ? `const ${snapshot} = bcmsSectionPaths(page, bind, overrides, ${JSON.stringify(pathPrefix)}, ${JSON.stringify(pathMap)}, bindPaths);` : `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides, bindPaths) };`;
8365
+ const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, bindPaths, overrides }, ${JSON.stringify(pathPrefix)}${flat3 ? `, ${JSON.stringify(pathMap)}` : ""});` : "";
8150
8366
  const declared = [copy, field].filter(Boolean).join("\n");
8151
8367
  const styles = dialect === "astro" ? carriedStyles(content) : "";
8152
8368
  if (dialect === "astro") {
@@ -8159,12 +8375,14 @@ interface Props {
8159
8375
  blockId?: string;
8160
8376
  /** The page field group this placement renders \u2014 Phase A keeps the copy where P2 put it. */
8161
8377
  bind?: string;
8378
+ /** A locale copy's own field path for each prop, when its keys differ from this component's. */
8379
+ bindPaths?: Record<string, string>;
8162
8380
  /** The instance's own copy, when an editor gave it any. Wins over the bound group. */
8163
8381
  overrides?: Record<string, unknown>;
8164
8382
  /** The page snapshot, so one component can serve every page whose group has this shape. */
8165
8383
  page?: unknown;
8166
8384
  }
8167
- const { blockId, bind, overrides, page } = Astro.props;
8385
+ const { blockId, bind, bindPaths, overrides, page } = Astro.props;
8168
8386
  ${carriedDeclarations}${declared}
8169
8387
  ---
8170
8388
  ${markup}
@@ -8176,7 +8394,7 @@ ${styles}
8176
8394
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
8177
8395
  ${carriedDeclarations === "" ? "" : `
8178
8396
  ${carriedDeclarations}`}
8179
- export default function ${resolved.name}({ blockId, bind, overrides, page }: BcmsSectionProps) {
8397
+ export default function ${resolved.name}({ blockId, bind, bindPaths, overrides, page }: BcmsSectionProps) {
8180
8398
  ${declared.split("\n").join("\n ")}
8181
8399
  return (
8182
8400
  ${markup}
@@ -8538,6 +8756,7 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
8538
8756
  export interface BcmsSectionProps {
8539
8757
  blockId?: string;
8540
8758
  bind?: string;
8759
+ bindPaths?: Record<string, string>;
8541
8760
  overrides?: Record<string, unknown>;
8542
8761
  page?: unknown;
8543
8762
  }
@@ -8547,6 +8766,8 @@ export interface BcmsPlacement {
8547
8766
  blockId?: string;
8548
8767
  slug: string;
8549
8768
  bind?: string;
8769
+ /** A locale copy's own field path for each prop, when its keys differ from the component's. */
8770
+ bindPaths?: Record<string, string>;
8550
8771
  overrides?: Record<string, unknown>;
8551
8772
  }
8552
8773
 
@@ -8607,6 +8828,7 @@ export function pageSections(pageSlug: string): BcmsPlacement[] {
8607
8828
  blockId: typeof block.id === "string" ? block.id : undefined,
8608
8829
  slug,
8609
8830
  bind: typeof props.bind === "string" ? props.bind : undefined,
8831
+ bindPaths: isRecord(props.bindPaths) ? (props.bindPaths as Record<string, string>) : undefined,
8610
8832
  overrides: isRecord(props.overrides) ? props.overrides : undefined,
8611
8833
  });
8612
8834
  }
@@ -8672,6 +8894,7 @@ const snapshot = pageSnapshot(page);
8672
8894
  <Component
8673
8895
  blockId={placement.blockId}
8674
8896
  bind={placement.bind}
8897
+ bindPaths={placement.bindPaths}
8675
8898
  overrides={placement.overrides}
8676
8899
  page={snapshot}
8677
8900
  />
@@ -8694,6 +8917,7 @@ export default function Sections({ page }: { page: string }) {
8694
8917
  key={placement.blockId ?? \`\${placement.slug}-\${i}\`}
8695
8918
  blockId={placement.blockId}
8696
8919
  bind={placement.bind}
8920
+ bindPaths={placement.bindPaths}
8697
8921
  overrides={placement.overrides}
8698
8922
  page={snapshot}
8699
8923
  />