@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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c4bd14b: `--forms` wires an Astro `<form>` by the CMS form's NAME: `data-bcms-form={bcmsFormId("Contact")}`, `action={bcmsFormAction("Contact")}` and `method={bcmsFormMethod("Contact")}`, resolved at build by a committed `src/bcms-forms.ts` from `bcms-content.json` and `BCMS_API_URL`. The source carries no host and no form id, so a repository built by more than one BetterCMS environment wires each build to the environment that built it. An Astro form wired by id is rewired by name once, one wired by name with a static `method="post"` gets `method={bcmsFormMethod(…)}` once, and one already wired by name counts as wired whatever quotes or spacing a formatter gave the calls; a shared name pends `FORM_NAME_AMBIGUOUS`. Form NAMES are the cross-rung contract; rename on both rungs: a wiring by name is never rewritten, so a form this project calls something else pends `FORM_WIRED_TO_OTHER_NAME` with nothing written, and a wiring by a name no form of the brief covers gets one note. At build the helper never fails: a name it cannot resolve among the project's own non-ignored forms, a shared name or a missing `BCMS_API_URL` renders that form unwired (no `action`, no `method`, no `data-bcms-form`) with one warning line naming `BCMS_FORM_NOT_FOUND`, `BCMS_FORM_NAME_AMBIGUOUS` or `BCMS_FORM_API_URL_MISSING` and the form, and a `bcms-content.json` it cannot parse, or one listing a form with no id or name, renders every form unwired with one `FORMS_SNAPSHOT_UNREADABLE` line, because forms are derived only by the release of a successful build. The helper passes a strict site tsconfig (`astro check`). Its first line, `// @bettercms-ai/convert forms-helper v2`, marks it as ours: a copy at that version or older (v1 included) is rewritten to the current bytes, and a `src/bcms-forms.ts` without that line pends `HELPER_CONFLICT`. A matched `<form>` that already carries every attribute no longer gains a stray space.
8
+
9
+ Not changed: html and JSX forms stay wired by id (html has no build, and JSX may render in a client bundle that cannot use `node:fs`). The backend's orchestration-validation workflow is left unsuffixed; only the convert, section-validation and component-validation workflows are per rung.
10
+
11
+ ## 0.19.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 3d022b0: A locale copy placed on its default-locale twin's section component now carries `props.bindPaths` (`{ <propKey>: "<this page's field path>" }`) beside `bind`, because its own field keys differ from the component's. `ComponentProps.bindPaths` is new in types; the convert helper (v9) and the extracted section components and registry read a prop's copy and declare its address through it, so `/fr` renders and edits its own words instead of a field it does not have. Older readers ignore it.
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [12b2d62]
20
+ - Updated dependencies [3d022b0]
21
+ - Updated dependencies [a0a6ad9]
22
+ - Updated dependencies [621a70e]
23
+ - Updated dependencies [2729e58]
24
+ - @bettercms-ai/types@1.25.0
25
+
3
26
  ## 0.18.2
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -624,13 +624,43 @@ It writes six things onto a matched `<form>` and nothing else: `action="<submitU
624
624
  `method="post"`, `data-bcms-form="<id>"` (plus `data-bcms-form-success` when the CMS form has a
625
625
  success message), `data-bcms-form-field="<key>"` on each matched control, a
626
626
  `<p class="bcms-form-msg" hidden>` before `</form>` when there is none, and the submit script once
627
- per file — the same bytes the hosted renderer ships, pinned by a test. No helper, no snapshot, no
628
- import: a form posts to an absolute URL at runtime, so there is nothing to read at build time.
627
+ per file — the same bytes the hosted renderer ships, pinned by a test.
628
+
629
+ In an Astro file the form is named, not addressed: `data-bcms-form={bcmsFormId("Contact")}`,
630
+ `action={bcmsFormAction("Contact")}` and `method={bcmsFormMethod("Contact")}`, imported from
631
+ `src/bcms-forms.ts`, which this run adds. At build that helper reads the form's id from
632
+ `bcms-content.json` and the API from `BCMS_API_URL`, so the source holds no host and no id and every
633
+ BetterCMS environment that builds the repository wires it to itself. The built page is unchanged: an
634
+ id, an absolute action and `post`. The helper reads the forms the brief names from: the building
635
+ project's own, ignored ones left out. With no snapshot (a local build) the form renders unwired. The
636
+ helper never fails a build: a name it cannot resolve, a name two forms share, or a build with no
637
+ `BCMS_API_URL` renders that form unwired too (no `action`, no `method`, no `data-bcms-form`, so
638
+ Astro drops all three) and prints one warning line naming the code (`BCMS_FORM_NOT_FOUND`,
639
+ `BCMS_FORM_NAME_AMBIGUOUS`, `BCMS_FORM_API_URL_MISSING`) and the form; a `bcms-content.json` it
640
+ cannot parse, or one listing a form with no id or name, renders every form unwired with one
641
+ `FORMS_SNAPSHOT_UNREADABLE` line. Forms are derived by the release of a successful build and convert
642
+ runs after it, so a helper that threw would leave a first import or a renamed form undeployable for
643
+ good. The helper type-checks under a strict site tsconfig, because `astro check` lints it. Its first
644
+ line, `// @bettercms-ai/convert forms-helper v2`, is what makes the file ours: a copy at that version
645
+ or older is rewritten to the current bytes, a newer one is left alone, and a file without the line is
646
+ `HELPER_CONFLICT`. An Astro form an earlier run wired by id is rewired by name once, a form wired by
647
+ name with v1's static `method="post"` gets `method={bcmsFormMethod(…)}` once, and a form wired by
648
+ name counts as wired whatever quotes or spacing a formatter gave the calls. html and JSX still get
649
+ the literal id and endpoint of the environment that ran the codemod — html has no build, and a JSX
650
+ form may render in a client bundle that cannot read a file.
651
+
652
+ **Form NAMES are the cross-rung contract; rename on both rungs.** A wiring by name is never
653
+ rewritten, whether or not the name resolves on the environment running the codemod: prod and dev
654
+ share the name and nothing else, so rewriting it for one unwires the form on the other. A form this
655
+ project calls something else is pending `FORM_WIRED_TO_OTHER_NAME` and nothing is written; a wiring by
656
+ a name no form of the brief even covers gets one line in `notes`.
629
657
 
630
658
  The match is the FIELD KEYS: a `<form>` is a CMS form's only when the names its controls post are a
631
659
  superset of that form's field keys and exactly one `<form>` answers. Everything else is pending with
632
660
  a reason — `FORM_NOT_IN_SOURCE`, `FORM_AMBIGUOUS`, `FIELD_UNMATCHED`, `ACTION_IS_EXPRESSION`,
633
- `DIALECT_UNSUPPORTED`, `PARSE_ERROR` — because a form wired to the wrong id sends the customer's
661
+ `DIALECT_UNSUPPORTED`, `PARSE_ERROR`, and for Astro `FORM_NAME_AMBIGUOUS` (two CMS forms share the
662
+ name), `HELPER_CONFLICT` (`src/bcms-forms.ts` is somebody else's file) and
663
+ `FORM_WIRED_TO_OTHER_NAME` (the `<form>` is wired by another name) — because a form wired to the wrong id sends the customer's
634
664
  leads into another form's inbox, silently, forever. A `<form>` that already carries
635
665
  `data-bcms-form` is `alreadyWired` and is left alone, so a second run is a no-op.
636
666
 
package/dist/cli.js CHANGED
@@ -4190,7 +4190,7 @@ async function bindImages(ctx, targets) {
4190
4190
 
4191
4191
  // src/helper.ts
4192
4192
  import { createHash as createHash2 } from "crypto";
4193
- var HELPER_VERSION = 8;
4193
+ var HELPER_VERSION = 9;
4194
4194
  var HEADER = "// @bettercms-ai/convert helper v";
4195
4195
  var HELPER_PATH = Object.fromEntries(
4196
4196
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4355,9 +4355,19 @@ export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: st
4355
4355
  * the fallback the template shipped with \u2014 which is why a section renders correctly against the
4356
4356
  * committed \`{}\` stub.
4357
4357
  */
4358
- export function bcmsSection(page: unknown, bind?: string, overrides?: unknown): Record<string, unknown> {
4358
+ export function bcmsSection(
4359
+ page: unknown,
4360
+ bind?: string,
4361
+ overrides?: unknown,
4362
+ bindPaths?: Record<string, string>,
4363
+ ): Record<string, unknown> {
4359
4364
  const group = bind ? readPath(page, bind) : undefined;
4360
4365
  const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
4366
+ // A locale copy on its twin's component spells its fields its own way: each prop's copy is
4367
+ // read from the path the placement names for it.
4368
+ if (bind && isRecord(bindPaths)) {
4369
+ for (const [key, path] of Object.entries(bindPaths)) base[key] = readPath(page, path);
4370
+ }
4361
4371
  if (!isRecord(overrides)) return base;
4362
4372
  for (const key of Object.keys(overrides)) {
4363
4373
  const value = overrides[key];
@@ -4420,21 +4430,25 @@ export function bcmsSectionPaths(
4420
4430
  overrides: unknown,
4421
4431
  prefix: string,
4422
4432
  map: BcmsPathMap,
4433
+ bindPaths?: Record<string, string>,
4423
4434
  ): Record<string, unknown> {
4424
4435
  const out: Record<string, unknown> = isRecord(page) ? { ...page } : {};
4425
4436
  const own = isRecord(overrides) ? overrides : {};
4426
4437
  const separator = prefix.slice(-1);
4427
- const fromPage = (path: string): unknown =>
4428
- bind ? readPath(page, bind + separator + path.slice(prefix.length)) : undefined;
4438
+ // A re-keyed locale copy names each prop's path on its own page; any other placement re-bases.
4439
+ const fromPage = (path: string, key: string): unknown =>
4440
+ !bind ? undefined
4441
+ : isRecord(bindPaths) && typeof bindPaths[key] === "string" ? readPath(page, bindPaths[key])
4442
+ : readPath(page, bind + separator + path.slice(prefix.length));
4429
4443
  for (const [path, key] of Object.entries(map.scalars ?? {})) {
4430
4444
  const value = own[key];
4431
- out[path] = value !== undefined && value !== null ? value : fromPage(path);
4445
+ out[path] = value !== undefined && value !== null ? value : fromPage(path, key);
4432
4446
  }
4433
4447
  for (const [path, table] of Object.entries(map.tables ?? {})) {
4434
4448
  const held = own[table.key];
4435
4449
  const rows = isRecord(held) && Array.isArray(held.rows) ? held.rows : held;
4436
4450
  if (!Array.isArray(rows)) {
4437
- out[path] = fromPage(path);
4451
+ out[path] = fromPage(path, table.key);
4438
4452
  continue;
4439
4453
  }
4440
4454
  const pageColumn = new Map(Object.entries(table.columns).map(([column, key]) => [key, column]));
@@ -4449,12 +4463,17 @@ export function bcmsSectionPaths(
4449
4463
  }
4450
4464
 
4451
4465
  export function bcmsFields(
4452
- placement: { blockId?: string; bind?: string; overrides?: unknown },
4466
+ placement: { blockId?: string; bind?: string; overrides?: unknown; bindPaths?: Record<string, string> },
4453
4467
  pathPrefix: string,
4454
4468
  map?: BcmsPathMap,
4455
4469
  ): (path: string) => string {
4456
4470
  const id = placement.blockId;
4457
4471
  const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
4472
+ /** A re-keyed locale copy's own path for a prop, with whatever follows the prop kept. */
4473
+ const moved = (key: string | undefined, rest: string): string | undefined => {
4474
+ const paths = placement.bind && isRecord(placement.bindPaths) ? placement.bindPaths : undefined;
4475
+ return key !== undefined && paths && typeof paths[key] === "string" ? paths[key] + rest : undefined;
4476
+ };
4458
4477
  /**
4459
4478
  * What replaces the prefix the markup was cut with, or null for "leave every path alone".
4460
4479
  *
@@ -4473,11 +4492,14 @@ export function bcmsFields(
4473
4492
  const separator = pathPrefix.slice(-1);
4474
4493
  return (path: string): string => {
4475
4494
  if (!path.startsWith(pathPrefix) || !(own || placement.bind)) return path;
4476
- if (!own) return placement.bind + separator + path.slice(pathPrefix.length);
4477
4495
  const scalar = map.scalars?.[path];
4478
- if (scalar !== undefined) return id + "__overrides." + scalar;
4479
4496
  const row = /^([^[]+)\\[(\\d+)\\]\\.(.+)$/.exec(path);
4480
4497
  const table = row ? map.tables?.[row[1]] : undefined;
4498
+ if (!own) {
4499
+ return moved(scalar, "") ?? moved(table?.key, row ? "[" + row[2] + "]." + row[3] : "")
4500
+ ?? placement.bind + separator + path.slice(pathPrefix.length);
4501
+ }
4502
+ if (scalar !== undefined) return id + "__overrides." + scalar;
4481
4503
  const column = row && table ? table.columns[row[3]] : undefined;
4482
4504
  return table && column !== undefined ? id + "__overrides." + table.key + "[" + row![2] + "]." + column : path;
4483
4505
  };
@@ -4487,7 +4509,9 @@ export function bcmsFields(
4487
4509
  // it names a key the instance does not hold and a group this placement is not bound to.
4488
4510
  // (An EMPTY prefix is a plan saying the prop key IS the page path, and matches everything.)
4489
4511
  if (lane === null || !path.startsWith(pathPrefix)) return path;
4490
- return lane + path.slice(pathPrefix.length);
4512
+ const rest = path.slice(pathPrefix.length);
4513
+ const key = /^[^.[]+/.exec(rest)?.[0];
4514
+ return (own ? undefined : moved(key, rest.slice(key?.length ?? 0))) ?? lane + rest;
4491
4515
  };
4492
4516
  }
4493
4517
  `;
@@ -4500,7 +4524,8 @@ var HISTORICAL = {
4500
4524
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4501
4525
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4502
4526
  6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4503
- 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4527
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93",
4528
+ 8: "484e72852d2b496e016a1f2a2b1a77bb993f8a163b290781522d6114b6519bb9"
4504
4529
  },
4505
4530
  "src/lib/bcms-content.ts": {
4506
4531
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
@@ -4509,7 +4534,8 @@ var HISTORICAL = {
4509
4534
  4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4510
4535
  5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba",
4511
4536
  6: "63f1fa29e47e70c5193b81ee24addb238ba100247124d55bc17713102d587df5",
4512
- 7: "ed92fb88de953d9951abbe6a1edae1d048f4050eaf6fb607ac08167f94d8bcc7"
4537
+ 7: "ed92fb88de953d9951abbe6a1edae1d048f4050eaf6fb607ac08167f94d8bcc7",
4538
+ 8: "1a6f669d76d42effe77f082436e9d21c0efa6fc499e919e3d116bc3a27a7f3d5"
4513
4539
  },
4514
4540
  "composables/bcms-content.ts": {
4515
4541
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
@@ -4518,7 +4544,8 @@ var HISTORICAL = {
4518
4544
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4519
4545
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4520
4546
  6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4521
- 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4547
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93",
4548
+ 8: "484e72852d2b496e016a1f2a2b1a77bb993f8a163b290781522d6114b6519bb9"
4522
4549
  }
4523
4550
  };
4524
4551
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -6407,6 +6434,114 @@ function readPlan(value) {
6407
6434
  // src/forms.ts
6408
6435
  import MagicString4 from "magic-string";
6409
6436
  var WIRABLE = ["html", "astro", "jsx"];
6437
+ var BY_NAME = ["astro"];
6438
+ var FORMS_HELPER_PATH = "src/bcms-forms.ts";
6439
+ var FORMS_HELPER_VERSION = 2;
6440
+ var FORMS_HELPER_HEADER = /^\/\/ @bettercms-ai\/convert forms-helper v(\d+)\r?\n/;
6441
+ var formsHelperVersion = (content) => {
6442
+ const m = FORMS_HELPER_HEADER.exec(content);
6443
+ return m ? Number(m[1]) : null;
6444
+ };
6445
+ var FORMS_HELPER_SOURCE = `// @bettercms-ai/convert forms-helper v${FORMS_HELPER_VERSION}
6446
+ // Written by @bettercms-ai/convert; a re-run rewrites it, so do not edit it.
6447
+ // Resolves a BetterCMS form by NAME at build, for the environment that is building this site: the id
6448
+ // from bcms-content.json (written into the tree before the build) and the endpoint from BCMS_API_URL.
6449
+ // The source names no host and no id, so every BetterCMS environment builds its own wiring.
6450
+ // A form it cannot resolve renders unwired (no action, no method, no id) with one warning line, and
6451
+ // the build goes on. Form NAMES are the contract between environments: rename a form on every one.
6452
+ import { existsSync, readFileSync } from "node:fs";
6453
+
6454
+ type SnapshotForm = { id: string; name: string; projectId?: string | null; ignoredAt?: string | null };
6455
+ type Snapshot = { projectId: string | null; forms: SnapshotForm[] };
6456
+ type Wiring = { id: string; action: string };
6457
+
6458
+ let snapshot: Snapshot | null | undefined;
6459
+ const resolved = new Map<string, Wiring | undefined>();
6460
+
6461
+ const env = (key: string): string | undefined => process.env[key] || process.env[\`PUBLIC_\${key}\`] || undefined;
6462
+
6463
+ const isForm = (value: unknown): value is SnapshotForm =>
6464
+ typeof value === "object" &&
6465
+ value !== null &&
6466
+ typeof (value as { id?: unknown }).id === "string" &&
6467
+ typeof (value as { name?: unknown }).name === "string";
6468
+
6469
+ function readSnapshot(): Snapshot | null {
6470
+ for (const file of ["bcms-content.json", "bcms-content/forms.json"]) {
6471
+ if (!existsSync(file)) continue;
6472
+ try {
6473
+ const data: unknown = JSON.parse(readFileSync(file, "utf8"));
6474
+ const record = (Array.isArray(data) ? { forms: data } : (data ?? {})) as { forms?: unknown; projectId?: unknown };
6475
+ const forms: unknown[] = Array.isArray(record.forms) ? record.forms : [];
6476
+ if (!forms.every(isForm)) throw new Error("a form entry has no id or name");
6477
+ return { projectId: typeof record.projectId === "string" ? record.projectId : null, forms };
6478
+ } catch (error) {
6479
+ // Read once per build, so this is the one line however many forms the site has.
6480
+ console.warn(\`[bcms-forms] FORMS_SNAPSHOT_UNREADABLE: \${file} could not be read (\${error instanceof Error ? error.message : String(error)}); every form renders unwired.\`);
6481
+ return null;
6482
+ }
6483
+ }
6484
+ return null;
6485
+ }
6486
+
6487
+ function unwired(code: string, name: string, why: string): undefined {
6488
+ console.warn(\`[bcms-forms] \${code}: form "\${name}" renders unwired; \${why}.\`);
6489
+ return undefined;
6490
+ }
6491
+
6492
+ function resolve(name: string): Wiring | undefined {
6493
+ if (snapshot === undefined) snapshot = readSnapshot();
6494
+ // No snapshot (a local dev build), or one it could not read: the form renders unwired, as before.
6495
+ if (snapshot === null) return undefined;
6496
+ const project = env("BCMS_PROJECT_ID") ?? snapshot.projectId;
6497
+ // The forms the conversion brief names from: this project's own, ignored ones left out.
6498
+ const own = snapshot.forms.filter((f) => !f.ignoredAt && (!project || f.projectId === project));
6499
+ const hits = own.filter((f) => f.name === name);
6500
+ const form = hits.length === 1 ? hits[0] : undefined;
6501
+ if (!form && hits.length > 1) {
6502
+ return unwired("BCMS_FORM_NAME_AMBIGUOUS", name, \`\${hits.length} forms in this project share that name; rename one in the Forms tab\`);
6503
+ }
6504
+ if (!form) {
6505
+ return unwired(
6506
+ "BCMS_FORM_NOT_FOUND",
6507
+ name,
6508
+ own.length === 0
6509
+ ? "this project has no forms yet; the release of this build derives them"
6510
+ : "no form in this project has that name; a name is shared by every environment, so rename the form on each one",
6511
+ );
6512
+ }
6513
+ const api = env("BCMS_API_URL");
6514
+ if (!api) return unwired("BCMS_FORM_API_URL_MISSING", name, "BCMS_API_URL is not set, so it has nowhere to post");
6515
+ return { id: form.id, action: \`\${api.replace(/\\/$/, "")}/api/v1/forms/public/\${form.id}/submissions\` };
6516
+ }
6517
+
6518
+ /** Resolved once per name, so a form's three attributes print one warning, not three. */
6519
+ function wiring(name: string): Wiring | undefined {
6520
+ if (!resolved.has(name)) resolved.set(name, resolve(name));
6521
+ return resolved.get(name);
6522
+ }
6523
+
6524
+ export const bcmsFormId = (name: string): string | undefined => wiring(name)?.id;
6525
+
6526
+ export const bcmsFormAction = (name: string): string | undefined => wiring(name)?.action;
6527
+
6528
+ /** "post" only for a form that resolved: an unwired form keeps no method, like its action and id. */
6529
+ export const bcmsFormMethod = (name: string): "post" | undefined => (wiring(name) ? "post" : undefined);
6530
+ `;
6531
+ var idCall = (form) => `bcmsFormId(${JSON.stringify(form.name)})`;
6532
+ var actionCall = (form) => `bcmsFormAction(${JSON.stringify(form.name)})`;
6533
+ var methodCall = (form) => `bcmsFormMethod(${JSON.stringify(form.name)})`;
6534
+ function calledName(code, fn) {
6535
+ const m = code === null ? null : new RegExp(`^${fn}\\s*\\(\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1\\s*,?\\s*\\)$`).exec(code);
6536
+ if (!m) return null;
6537
+ const [, quote, raw] = m;
6538
+ if (quote === "`" && raw.includes("${")) return null;
6539
+ try {
6540
+ return JSON.parse(`"${raw.replace(/\\(['`])/g, "$1").replace(/(?<!\\)"/g, '\\"')}"`);
6541
+ } catch {
6542
+ return null;
6543
+ }
6544
+ }
6410
6545
  var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6411
6546
  var FORM_SCRIPT = `<script>
6412
6547
  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:'';}
@@ -6468,6 +6603,10 @@ function* elementsIn(nodes) {
6468
6603
  var childElements = (node) => node.children.flatMap(
6469
6604
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6470
6605
  );
6606
+ function exprOf(c, name) {
6607
+ const range = attrOf(c.el, name)?.expressionRange;
6608
+ return range ? c.content.slice(range.start + 1, range.end - 1).trim() : null;
6609
+ }
6471
6610
  var LANDMARK_TAGS = /* @__PURE__ */ new Set(["footer", "header", "nav", "main", "aside", "dialog"]);
6472
6611
  function landmarkOf(chain) {
6473
6612
  for (let i = chain.length - 1; i >= 0; i--) {
@@ -6489,7 +6628,7 @@ function* formsIn(nodes, chain = []) {
6489
6628
  }
6490
6629
  }
6491
6630
  var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6492
- function candidateOf(file, dialect, form, imported, landmark, content) {
6631
+ function candidateOf(file, dialect, form, imported, landmark, content, importAt) {
6493
6632
  const controls = /* @__PURE__ */ new Map();
6494
6633
  const wrapper = /* @__PURE__ */ new Map();
6495
6634
  const foreign = /* @__PURE__ */ new Set();
@@ -6530,7 +6669,7 @@ function candidateOf(file, dialect, form, imported, landmark, content) {
6530
6669
  content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`/g, "")
6531
6670
  );
6532
6671
  const dynamic = all.some((control) => !foreign.has(control) && computed(attrOf(control, "name")));
6533
- return { file, dialect, el: form, controls, wrapper, foreign, landmark, dynamic };
6672
+ return { file, dialect, el: form, controls, wrapper, foreign, landmark, dynamic, content, importAt };
6534
6673
  }
6535
6674
  async function importedControls(source, parsed, dialect, project, cache) {
6536
6675
  const out = /* @__PURE__ */ new Map();
@@ -6563,11 +6702,20 @@ function wire(source, candidate, form) {
6563
6702
  else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6564
6703
  else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6565
6704
  };
6566
- set("action", form.submitUrl);
6567
- set("method", "post");
6568
- set("data-bcms-form", form.id);
6705
+ const setExpr = (name, code) => {
6706
+ const attr = attrOf(el, name);
6707
+ if (!attr) inserts.push(`${name}={${code}}`);
6708
+ else source.overwrite(attr.range.start, attr.range.end, `${name}={${code}}`);
6709
+ };
6710
+ const byName = BY_NAME.includes(dialect);
6711
+ if (byName) setExpr("action", actionCall(form));
6712
+ else set("action", form.submitUrl);
6713
+ if (byName) setExpr("method", methodCall(form));
6714
+ else set("method", "post");
6715
+ if (byName) setExpr("data-bcms-form", idCall(form));
6716
+ else set("data-bcms-form", form.id);
6569
6717
  if (form.successMessage) set("data-bcms-form-success", form.successMessage);
6570
- source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6718
+ if (inserts.length > 0) source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6571
6719
  for (const inner of elementsIn(el.children)) {
6572
6720
  for (const attr of inner.attrs) {
6573
6721
  if (!DECLARED_BINDING_ATTR.test(attr.name)) continue;
@@ -6624,12 +6772,20 @@ async function wireForms(briefIn, sources, options_ = {}) {
6624
6772
  }
6625
6773
  const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6626
6774
  for (const { el, landmark } of formsIn(parsed.roots)) {
6627
- candidates.push(candidateOf(source.path, dialect, el, imported, landmark, source.content));
6775
+ candidates.push(candidateOf(source.path, dialect, el, imported, landmark, source.content, parsed.importAt));
6628
6776
  }
6629
6777
  }
6630
6778
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6631
- const wiredTo = (c, f) => attrOf(c.el, "data-bcms-form")?.value === f.id;
6632
- const covers2 = (c, f) => c.dynamic && wiredTo(c, f) || f.fields.length > 0 && holds(c, f) === f.fields.length;
6779
+ const byId = (c, f) => !attrOf(c.el, "data-bcms-form")?.expression && attrOf(c.el, "data-bcms-form")?.value === f.id;
6780
+ const programAction = (c) => !!attrOf(c.el, "action")?.expression && calledName(exprOf(c, "action"), "bcmsFormAction") === null;
6781
+ 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);
6782
+ const wiredName = (c) => BY_NAME.includes(c.dialect) ? calledName(exprOf(c, "data-bcms-form"), "bcmsFormId") : null;
6783
+ const staticPost = (c) => {
6784
+ const method = attrOf(c.el, "method");
6785
+ return !!method && !method.expression && method.value.toLowerCase() === "post";
6786
+ };
6787
+ const claims = (c, f) => wiredTo(c, f) || byId(c, f);
6788
+ const covers2 = (c, f) => c.dynamic && claims(c, f) || f.fields.length > 0 && holds(c, f) === f.fields.length;
6633
6789
  const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
6634
6790
  const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6635
6791
  const pinned = /* @__PURE__ */ new Map();
@@ -6673,7 +6829,14 @@ async function wireForms(briefIn, sources, options_ = {}) {
6673
6829
  const wanted = /* @__PURE__ */ new Map();
6674
6830
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6675
6831
  const matched = [];
6832
+ const methodUpgrades = [];
6833
+ const pendedByName = /* @__PURE__ */ new Set();
6676
6834
  const pending = [];
6835
+ const existingHelper = sources.find((s) => samePath(s.path, FORMS_HELPER_PATH))?.content;
6836
+ const helperVersion = existingHelper === void 0 ? null : formsHelperVersion(existingHelper);
6837
+ const helperOk = existingHelper === void 0 || helperVersion !== null;
6838
+ const helperStale = existingHelper !== void 0 && helperVersion !== null && helperVersion <= FORMS_HELPER_VERSION && existingHelper !== FORMS_HELPER_SOURCE;
6839
+ let byNameOnSite = false;
6677
6840
  const onSite = [];
6678
6841
  let alreadyWired = 0;
6679
6842
  const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
@@ -6712,12 +6875,25 @@ async function wireForms(briefIn, sources, options_ = {}) {
6712
6875
  continue;
6713
6876
  }
6714
6877
  const candidate = list[0];
6878
+ const byName = BY_NAME.includes(candidate.dialect);
6879
+ if (byName && forms.filter((f) => f.name === form.name).length > 1) {
6880
+ pend2(form, "FORM_NAME_AMBIGUOUS", candidate.file, `more than one CMS form is named "${form.name}"; rename one in the Forms tab.`);
6881
+ continue;
6882
+ }
6715
6883
  if (wiredTo(candidate, form)) {
6716
6884
  alreadyWired++;
6717
6885
  onSite.push({ form, file: candidate.file });
6886
+ if (byName) byNameOnSite = true;
6887
+ if (wiredName(candidate) === form.name && staticPost(candidate) && helperOk) methodUpgrades.push({ form, candidate });
6888
+ continue;
6889
+ }
6890
+ const other = wiredName(candidate);
6891
+ if (other !== null) {
6892
+ pendedByName.add(candidate);
6893
+ 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.`);
6718
6894
  continue;
6719
6895
  }
6720
- if (attrOf(candidate.el, "action")?.expression) {
6896
+ if (programAction(candidate)) {
6721
6897
  pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6722
6898
  continue;
6723
6899
  }
@@ -6729,18 +6905,46 @@ async function wireForms(briefIn, sources, options_ = {}) {
6729
6905
  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.`);
6730
6906
  continue;
6731
6907
  }
6908
+ if (byName && !helperOk) {
6909
+ 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.`);
6910
+ continue;
6911
+ }
6732
6912
  matched.push({ form, candidate });
6733
6913
  onSite.push({ form, file: candidate.file });
6914
+ if (byName) byNameOnSite = true;
6734
6915
  }
6735
6916
  const contents = new Map(sources.map((s) => [s.path, s.content]));
6736
6917
  const byFile = /* @__PURE__ */ new Map();
6737
- for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
6918
+ const editsOf = (path) => {
6919
+ if (!byFile.has(path)) byFile.set(path, { wire: [], upgrade: [] });
6920
+ return byFile.get(path);
6921
+ };
6922
+ for (const m of matched) editsOf(m.candidate.file).wire.push(m);
6923
+ for (const m of methodUpgrades) editsOf(m.candidate.file).upgrade.push(m);
6738
6924
  const files = [];
6739
- for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6925
+ for (const [path, { wire: list, upgrade }] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6740
6926
  const content = contents.get(path);
6741
6927
  const source = new MagicString4(content);
6742
6928
  for (const { form, candidate } of list) wire(source, candidate, form);
6743
- if (!content.includes("form[data-bcms-form]")) {
6929
+ for (const { form, candidate } of upgrade) {
6930
+ const method = attrOf(candidate.el, "method");
6931
+ source.overwrite(method.range.start, method.range.end, `method={${methodCall(form)}}`);
6932
+ }
6933
+ const first = (list[0] ?? upgrade[0]).candidate;
6934
+ if (BY_NAME.includes(first.dialect) && first.importAt) {
6935
+ const at = first.importAt;
6936
+ const helper = relativeImport(path, FORMS_HELPER_PATH.replace(/\.ts$/, ""));
6937
+ const names = !/\bbcmsFormId\b/.test(content) ? "bcmsFormAction, bcmsFormId, bcmsFormMethod" : !/\bbcmsFormMethod\b/.test(content) ? "bcmsFormMethod" : null;
6938
+ if (names) {
6939
+ const line = `import { ${names} } from "${helper}";`;
6940
+ if (at.wrap) source.appendLeft(at.at, `${at.wrap.before}${line}${at.wrap.after}`);
6941
+ else if (at.at === 0) source.appendLeft(0, `${line}
6942
+ `);
6943
+ else source.appendLeft(at.at, `
6944
+ ${line}`);
6945
+ }
6946
+ }
6947
+ if (list.length > 0 && !content.includes("form[data-bcms-form]")) {
6744
6948
  const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6745
6949
  const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6746
6950
  source.appendRight(last.candidate.el.end, `
@@ -6748,6 +6952,10 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6748
6952
  }
6749
6953
  files.push({ path, operation: "modify", content: source.toString() });
6750
6954
  }
6955
+ const helperAdded = existingHelper === void 0 && (matched.some((m) => BY_NAME.includes(m.candidate.dialect)) || methodUpgrades.length > 0);
6956
+ if (helperAdded) files.push({ path: FORMS_HELPER_PATH, operation: "add", content: FORMS_HELPER_SOURCE });
6957
+ const helperUpgraded = helperStale && byNameOnSite;
6958
+ if (helperUpgraded) files.push({ path: FORMS_HELPER_PATH, operation: "modify", content: FORMS_HELPER_SOURCE });
6751
6959
  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.`);
6752
6960
  for (const { form, candidate } of matched) {
6753
6961
  const tie = beat.get(candidate);
@@ -6763,6 +6971,13 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6763
6971
  );
6764
6972
  }
6765
6973
  }
6974
+ for (const c of candidates) {
6975
+ const name = wiredName(c);
6976
+ if (name === null || pendedByName.has(c) || forms.some((f) => f.name === name)) continue;
6977
+ notes.push(
6978
+ `${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.`
6979
+ );
6980
+ }
6766
6981
  for (const { form, candidate } of matched) {
6767
6982
  const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6768
6983
  if (keys.length > 0) {
@@ -6780,8 +6995,8 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6780
6995
  paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6781
6996
  occurrences: { located: 0, rewritten: 0 },
6782
6997
  files: [],
6783
- helpers: [],
6784
- helperUpgraded: false,
6998
+ helpers: helperAdded || helperUpgraded ? [FORMS_HELPER_PATH] : [],
6999
+ helperUpgraded,
6785
7000
  dynamicBindings: [],
6786
7001
  forms: assertForms(forms.length, {
6787
7002
  wired: matched.length,
@@ -8114,6 +8329,7 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathP
8114
8329
  provided: [
8115
8330
  "blockId",
8116
8331
  "bind",
8332
+ "bindPaths",
8117
8333
  "overrides",
8118
8334
  "page",
8119
8335
  fieldName,
@@ -8128,8 +8344,8 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathP
8128
8344
  const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
8129
8345
  const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
8130
8346
  ` : "";
8131
- const copy = !snapshot ? "" : flat3 ? `const ${snapshot} = bcmsSectionPaths(page, bind, overrides, ${JSON.stringify(pathPrefix)}, ${JSON.stringify(pathMap)});` : `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides) };`;
8132
- const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)}${flat3 ? `, ${JSON.stringify(pathMap)}` : ""});` : "";
8347
+ 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) };`;
8348
+ const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, bindPaths, overrides }, ${JSON.stringify(pathPrefix)}${flat3 ? `, ${JSON.stringify(pathMap)}` : ""});` : "";
8133
8349
  const declared = [copy, field].filter(Boolean).join("\n");
8134
8350
  const styles = dialect === "astro" ? carriedStyles(content) : "";
8135
8351
  if (dialect === "astro") {
@@ -8142,12 +8358,14 @@ interface Props {
8142
8358
  blockId?: string;
8143
8359
  /** The page field group this placement renders \u2014 Phase A keeps the copy where P2 put it. */
8144
8360
  bind?: string;
8361
+ /** A locale copy's own field path for each prop, when its keys differ from this component's. */
8362
+ bindPaths?: Record<string, string>;
8145
8363
  /** The instance's own copy, when an editor gave it any. Wins over the bound group. */
8146
8364
  overrides?: Record<string, unknown>;
8147
8365
  /** The page snapshot, so one component can serve every page whose group has this shape. */
8148
8366
  page?: unknown;
8149
8367
  }
8150
- const { blockId, bind, overrides, page } = Astro.props;
8368
+ const { blockId, bind, bindPaths, overrides, page } = Astro.props;
8151
8369
  ${carriedDeclarations}${declared}
8152
8370
  ---
8153
8371
  ${markup}
@@ -8159,7 +8377,7 @@ ${styles}
8159
8377
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
8160
8378
  ${carriedDeclarations === "" ? "" : `
8161
8379
  ${carriedDeclarations}`}
8162
- export default function ${resolved.name}({ blockId, bind, overrides, page }: BcmsSectionProps) {
8380
+ export default function ${resolved.name}({ blockId, bind, bindPaths, overrides, page }: BcmsSectionProps) {
8163
8381
  ${declared.split("\n").join("\n ")}
8164
8382
  return (
8165
8383
  ${markup}
@@ -8521,6 +8739,7 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
8521
8739
  export interface BcmsSectionProps {
8522
8740
  blockId?: string;
8523
8741
  bind?: string;
8742
+ bindPaths?: Record<string, string>;
8524
8743
  overrides?: Record<string, unknown>;
8525
8744
  page?: unknown;
8526
8745
  }
@@ -8530,6 +8749,8 @@ export interface BcmsPlacement {
8530
8749
  blockId?: string;
8531
8750
  slug: string;
8532
8751
  bind?: string;
8752
+ /** A locale copy's own field path for each prop, when its keys differ from the component's. */
8753
+ bindPaths?: Record<string, string>;
8533
8754
  overrides?: Record<string, unknown>;
8534
8755
  }
8535
8756
 
@@ -8590,6 +8811,7 @@ export function pageSections(pageSlug: string): BcmsPlacement[] {
8590
8811
  blockId: typeof block.id === "string" ? block.id : undefined,
8591
8812
  slug,
8592
8813
  bind: typeof props.bind === "string" ? props.bind : undefined,
8814
+ bindPaths: isRecord(props.bindPaths) ? (props.bindPaths as Record<string, string>) : undefined,
8593
8815
  overrides: isRecord(props.overrides) ? props.overrides : undefined,
8594
8816
  });
8595
8817
  }
@@ -8655,6 +8877,7 @@ const snapshot = pageSnapshot(page);
8655
8877
  <Component
8656
8878
  blockId={placement.blockId}
8657
8879
  bind={placement.bind}
8880
+ bindPaths={placement.bindPaths}
8658
8881
  overrides={placement.overrides}
8659
8882
  page={snapshot}
8660
8883
  />
@@ -8677,6 +8900,7 @@ export default function Sections({ page }: { page: string }) {
8677
8900
  key={placement.blockId ?? \`\${placement.slug}-\${i}\`}
8678
8901
  blockId={placement.blockId}
8679
8902
  bind={placement.bind}
8903
+ bindPaths={placement.bindPaths}
8680
8904
  overrides={placement.overrides}
8681
8905
  page={snapshot}
8682
8906
  />
@@ -9952,7 +10176,7 @@ function styleEdits(before, after) {
9952
10176
  // package.json
9953
10177
  var package_default = {
9954
10178
  name: "@bettercms-ai/convert",
9955
- version: "0.18.2",
10179
+ version: "0.20.0",
9956
10180
  type: "module",
9957
10181
  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.",
9958
10182
  bin: {
@@ -9985,7 +10209,7 @@ var package_default = {
9985
10209
  dependencies: {
9986
10210
  "@astrojs/compiler": "^2.13.1",
9987
10211
  "@babel/parser": "^7.29.2",
9988
- "@bettercms-ai/types": "^1.12.0",
10212
+ "@bettercms-ai/types": "^1.25.0",
9989
10213
  "@vue/compiler-sfc": "^3.5.0",
9990
10214
  "magic-string": "^0.30.21",
9991
10215
  parse5: "^7.3.0",