@bettercms-ai/convert 0.19.0 → 0.20.1

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,19 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.20.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 12147f7: The x-ray counts a bare number (a `49` price, a `5` rating) as copy. Only a zero-padded (`01`) or punctuated (`2.`, `3 /`) step marker is an ornament.
8
+
9
+ ## 0.20.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
15
+ 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.
16
+
3
17
  ## 0.19.0
4
18
 
5
19
  ### Minor 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
@@ -6434,6 +6434,114 @@ function readPlan(value) {
6434
6434
  // src/forms.ts
6435
6435
  import MagicString4 from "magic-string";
6436
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
+ }
6437
6545
  var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6438
6546
  var FORM_SCRIPT = `<script>
6439
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:'';}
@@ -6495,6 +6603,10 @@ function* elementsIn(nodes) {
6495
6603
  var childElements = (node) => node.children.flatMap(
6496
6604
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6497
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
+ }
6498
6610
  var LANDMARK_TAGS = /* @__PURE__ */ new Set(["footer", "header", "nav", "main", "aside", "dialog"]);
6499
6611
  function landmarkOf(chain) {
6500
6612
  for (let i = chain.length - 1; i >= 0; i--) {
@@ -6516,7 +6628,7 @@ function* formsIn(nodes, chain = []) {
6516
6628
  }
6517
6629
  }
6518
6630
  var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6519
- function candidateOf(file, dialect, form, imported, landmark, content) {
6631
+ function candidateOf(file, dialect, form, imported, landmark, content, importAt) {
6520
6632
  const controls = /* @__PURE__ */ new Map();
6521
6633
  const wrapper = /* @__PURE__ */ new Map();
6522
6634
  const foreign = /* @__PURE__ */ new Set();
@@ -6557,7 +6669,7 @@ function candidateOf(file, dialect, form, imported, landmark, content) {
6557
6669
  content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`/g, "")
6558
6670
  );
6559
6671
  const dynamic = all.some((control) => !foreign.has(control) && computed(attrOf(control, "name")));
6560
- return { file, dialect, el: form, controls, wrapper, foreign, landmark, dynamic };
6672
+ return { file, dialect, el: form, controls, wrapper, foreign, landmark, dynamic, content, importAt };
6561
6673
  }
6562
6674
  async function importedControls(source, parsed, dialect, project, cache) {
6563
6675
  const out = /* @__PURE__ */ new Map();
@@ -6590,11 +6702,20 @@ function wire(source, candidate, form) {
6590
6702
  else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6591
6703
  else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6592
6704
  };
6593
- set("action", form.submitUrl);
6594
- set("method", "post");
6595
- 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);
6596
6717
  if (form.successMessage) set("data-bcms-form-success", form.successMessage);
6597
- source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6718
+ if (inserts.length > 0) source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6598
6719
  for (const inner of elementsIn(el.children)) {
6599
6720
  for (const attr of inner.attrs) {
6600
6721
  if (!DECLARED_BINDING_ATTR.test(attr.name)) continue;
@@ -6651,12 +6772,20 @@ async function wireForms(briefIn, sources, options_ = {}) {
6651
6772
  }
6652
6773
  const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6653
6774
  for (const { el, landmark } of formsIn(parsed.roots)) {
6654
- 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));
6655
6776
  }
6656
6777
  }
6657
6778
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6658
- const wiredTo = (c, f) => attrOf(c.el, "data-bcms-form")?.value === f.id;
6659
- 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;
6660
6789
  const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
6661
6790
  const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6662
6791
  const pinned = /* @__PURE__ */ new Map();
@@ -6700,7 +6829,14 @@ async function wireForms(briefIn, sources, options_ = {}) {
6700
6829
  const wanted = /* @__PURE__ */ new Map();
6701
6830
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6702
6831
  const matched = [];
6832
+ const methodUpgrades = [];
6833
+ const pendedByName = /* @__PURE__ */ new Set();
6703
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;
6704
6840
  const onSite = [];
6705
6841
  let alreadyWired = 0;
6706
6842
  const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
@@ -6739,12 +6875,25 @@ async function wireForms(briefIn, sources, options_ = {}) {
6739
6875
  continue;
6740
6876
  }
6741
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
+ }
6742
6883
  if (wiredTo(candidate, form)) {
6743
6884
  alreadyWired++;
6744
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 });
6745
6888
  continue;
6746
6889
  }
6747
- if (attrOf(candidate.el, "action")?.expression) {
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.`);
6894
+ continue;
6895
+ }
6896
+ if (programAction(candidate)) {
6748
6897
  pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6749
6898
  continue;
6750
6899
  }
@@ -6756,18 +6905,46 @@ async function wireForms(briefIn, sources, options_ = {}) {
6756
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.`);
6757
6906
  continue;
6758
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
+ }
6759
6912
  matched.push({ form, candidate });
6760
6913
  onSite.push({ form, file: candidate.file });
6914
+ if (byName) byNameOnSite = true;
6761
6915
  }
6762
6916
  const contents = new Map(sources.map((s) => [s.path, s.content]));
6763
6917
  const byFile = /* @__PURE__ */ new Map();
6764
- 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);
6765
6924
  const files = [];
6766
- 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))) {
6767
6926
  const content = contents.get(path);
6768
6927
  const source = new MagicString4(content);
6769
6928
  for (const { form, candidate } of list) wire(source, candidate, form);
6770
- 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]")) {
6771
6948
  const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6772
6949
  const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6773
6950
  source.appendRight(last.candidate.el.end, `
@@ -6775,6 +6952,10 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6775
6952
  }
6776
6953
  files.push({ path, operation: "modify", content: source.toString() });
6777
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 });
6778
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.`);
6779
6960
  for (const { form, candidate } of matched) {
6780
6961
  const tie = beat.get(candidate);
@@ -6790,6 +6971,13 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6790
6971
  );
6791
6972
  }
6792
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
+ }
6793
6981
  for (const { form, candidate } of matched) {
6794
6982
  const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6795
6983
  if (keys.length > 0) {
@@ -6807,8 +6995,8 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6807
6995
  paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6808
6996
  occurrences: { located: 0, rewritten: 0 },
6809
6997
  files: [],
6810
- helpers: [],
6811
- helperUpgraded: false,
6998
+ helpers: helperAdded || helperUpgraded ? [FORMS_HELPER_PATH] : [],
6999
+ helperUpgraded,
6812
7000
  dynamicBindings: [],
6813
7001
  forms: assertForms(forms.length, {
6814
7002
  wired: matched.length,
@@ -6912,7 +7100,7 @@ var CONTEXT_TAGS = /* @__PURE__ */ new Set([
6912
7100
  var BOUND_ATTRS = ["data-bcms-field", "data-bcms-layout-field"];
6913
7101
  var isDecorativeGlyph = (text) => {
6914
7102
  const t = flat2(text);
6915
- return t === "" || !/[\p{L}\p{N}]/u.test(t) || new RegExp("^\\p{Nd}{1,2}\\s*[/.)\\-\u2013\u2014]?$", "u").test(t);
7103
+ return t === "" || !/[\p{L}\p{N}]/u.test(t) || new RegExp("^(?:0\\p{Nd}|\\p{Nd}{1,2}\\s*[/.)\\-\u2013\u2014])$", "u").test(t);
6916
7104
  };
6917
7105
  var PLATFORM_BADGE = /^made with\b/iu;
6918
7106
  var MAX_BUCKETS = 12;
@@ -9988,7 +10176,7 @@ function styleEdits(before, after) {
9988
10176
  // package.json
9989
10177
  var package_default = {
9990
10178
  name: "@bettercms-ai/convert",
9991
- version: "0.19.0",
10179
+ version: "0.20.1",
9992
10180
  type: "module",
9993
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.",
9994
10182
  bin: {