@bettercms-ai/convert 0.15.0 → 0.16.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,24 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.16.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix `--forms` tie-breaking. 0.16.0's second tie-break rule ("the form posting nothing beyond its
8
+ fields wins") could bind a look-alike form — for example a "forgot password" dialog posting only
9
+ `email` — to the newsletter without reporting it. 0.16.1 removes that rule: such cases stay
10
+ `FORM_AMBIGUOUS` until you name the file with `--pin "<form>=<file>"`. When the remaining rule
11
+ (the form posting more of a `<form>`'s fields wins) decides a tie, the receipt now carries a `notes`
12
+ line naming the file and the rival that lost it. `--pin` is also stricter: an empty value or a
13
+ repeated pin for the same form is refused, `--pin` cannot be combined with `--componentize`, and
14
+ two forms pinned to one file both wire.
15
+
16
+ ## 0.16.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 17aaccb: `--forms` breaks the ties that left ScrewFast's two forms `FORM_AMBIGUOUS`: a `<form>` two CMS forms both cover goes to the one it posts more fields of (a contact form is not the newsletter because it also posts `email`), and among several `<form>`s covering one CMS form the one posting nothing beyond its fields wins. For the tie nothing can break — two email-only forms, a footer newsletter and a "forgot password" dialog — the new `--pin "<form>=<file>"` names the file; the pending message says so.
21
+
3
22
  ## 0.15.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -630,6 +630,13 @@ a reason — `FORM_NOT_IN_SOURCE`, `FORM_AMBIGUOUS`, `FIELD_UNMATCHED`, `ACTION_
630
630
  leads into another form's inbox, silently, forever. A `<form>` that already carries
631
631
  `data-bcms-form` is `alreadyWired` and is left alone, so a second run is a no-op.
632
632
 
633
+ Two `<form>`s that both post exactly a form's fields are `FORM_AMBIGUOUS`, and no rule can break
634
+ that tie: `--pin "<form>=<file>"` (repeatable, form by id or name) names the file, and a pin is
635
+ exclusive only against forms that carry no pin of their own, so two forms living in one file can
636
+ both be pinned to it. **Keep passing a pin on every later run** — a pin is not written into the
637
+ tree, so an unpinned run over the same repository reports that form ambiguous again and its
638
+ receipt stops listing the file it is wired to, even though the markup still is.
639
+
633
640
  `--receipt <file>` writes this run's own receipt (`forms-receipt.json` — a separate file from the
634
641
  P2 run's, since the two runs describe different work), and `--strict` exits 2 on any pending form.
635
642
  `--forms` and `--componentize` are separate runs; doing both in one command is refused.
package/dist/cli.js CHANGED
@@ -6483,7 +6483,7 @@ function wire(source, candidate, form) {
6483
6483
  source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6484
6484
  }
6485
6485
  }
6486
- async function wireForms(briefIn, sources) {
6486
+ async function wireForms(briefIn, sources, options_ = {}) {
6487
6487
  const brief = readBrief(briefIn);
6488
6488
  const forms = brief.forms ?? [];
6489
6489
  const candidates = [];
@@ -6521,7 +6521,34 @@ async function wireForms(briefIn, sources) {
6521
6521
  }
6522
6522
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6523
6523
  const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6524
+ const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
6524
6525
  const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6526
+ const pinned = /* @__PURE__ */ new Map();
6527
+ for (const [ref, file] of Object.entries(options_.pins ?? {})) {
6528
+ const form = forms.find((f) => f.id === ref || f.name === ref);
6529
+ if (!form) throw new Error(`--pin ${ref}: the brief lists no form with that id or name.`);
6530
+ if (file.trim() === "") throw new Error(`--pin ${ref}: no file named. Give "<form>=<file>".`);
6531
+ pinned.set(form.id, file);
6532
+ }
6533
+ for (const [id, file] of pinned) {
6534
+ options.set(id, options.get(id).filter((c) => samePath(c.file, file)));
6535
+ for (const other of forms) {
6536
+ const its = pinned.get(other.id);
6537
+ if (other.id === id || its !== void 0 && samePath(its, file)) continue;
6538
+ options.set(other.id, options.get(other.id).filter((c) => !samePath(c.file, file)));
6539
+ }
6540
+ }
6541
+ const beat = /* @__PURE__ */ new Map();
6542
+ for (const c of candidates) {
6543
+ const claimants = forms.filter((f) => options.get(f.id).includes(c));
6544
+ if (claimants.length < 2) continue;
6545
+ const top = Math.max(...claimants.map((f) => holds(c, f)));
6546
+ const winners = claimants.filter((f) => holds(c, f) === top);
6547
+ if (winners.length !== 1) continue;
6548
+ const rivals = claimants.filter((f) => f !== winners[0]);
6549
+ for (const f of rivals) options.set(f.id, options.get(f.id).filter((x) => x !== c));
6550
+ beat.set(c, { winner: winners[0], rivals });
6551
+ }
6525
6552
  const wanted = /* @__PURE__ */ new Map();
6526
6553
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6527
6554
  const matched = [];
@@ -6532,7 +6559,12 @@ async function wireForms(briefIn, sources) {
6532
6559
  for (const form of forms) {
6533
6560
  const list = options.get(form.id) ?? [];
6534
6561
  if (list.length > 1) {
6535
- pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
6562
+ pend2(
6563
+ form,
6564
+ "FORM_AMBIGUOUS",
6565
+ void 0,
6566
+ `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.${pinned.has(form.id) ? " The pin names that file, and it holds more than one." : ` Name the right one with --pin "${form.name}=<file>".`}`
6567
+ );
6536
6568
  continue;
6537
6569
  }
6538
6570
  if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
@@ -6541,7 +6573,13 @@ async function wireForms(briefIn, sources) {
6541
6573
  }
6542
6574
  if (list.length === 0) {
6543
6575
  const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6544
- if (best) {
6576
+ const pin = pinned.get(form.id);
6577
+ if (pin) {
6578
+ pend2(form, "FORM_NOT_IN_SOURCE", pin, `the pinned file has no <form> posting ${form.fields.map((f) => f.key).join(", ")}.`);
6579
+ } else if (best && best.n === form.fields.length) {
6580
+ const owner = forms.find((f) => f !== form && options.get(f.id).includes(best.c));
6581
+ pend2(form, "FORM_NOT_IN_SOURCE", best.c.file, `the only <form> posting ${form.fields.map((f) => f.key).join(", ")} is "${owner?.name ?? "another form"}"'s.`);
6582
+ } else if (best) {
6545
6583
  const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6546
6584
  pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6547
6585
  } else if (unreadable[0]) {
@@ -6589,6 +6627,14 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6589
6627
  files.push({ path, operation: "modify", content: source.toString() });
6590
6628
  }
6591
6629
  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.`);
6630
+ for (const { form, candidate } of matched) {
6631
+ const tie = beat.get(candidate);
6632
+ if (tie?.winner === form) {
6633
+ notes.push(
6634
+ `form ${form.name}: ${candidate.file} posts every field of ${tie.rivals.map((f) => f.name).join(", ")} too, and more of ${form.name}'s, so it went to ${form.name} \u2014 name the file yourself with --pin "<form>=<file>" if that is wrong.`
6635
+ );
6636
+ }
6637
+ }
6592
6638
  for (const { form, candidate } of matched) {
6593
6639
  const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6594
6640
  if (keys.length > 0) {
@@ -9689,7 +9735,7 @@ function emitStubs2(dialects, slugs, contents, out) {
9689
9735
  // package.json
9690
9736
  var package_default = {
9691
9737
  name: "@bettercms-ai/convert",
9692
- version: "0.15.0",
9738
+ version: "0.16.1",
9693
9739
  type: "module",
9694
9740
  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.",
9695
9741
  bin: {
@@ -9750,6 +9796,7 @@ function parseArgs(argv) {
9750
9796
  brief: void 0,
9751
9797
  componentize: false,
9752
9798
  forms: false,
9799
+ pins: {},
9753
9800
  xray: void 0,
9754
9801
  plan: void 0,
9755
9802
  copy: "bind",
@@ -9766,7 +9813,16 @@ function parseArgs(argv) {
9766
9813
  if (arg === "--brief") args.brief = argv[++i];
9767
9814
  else if (arg === "--componentize") args.componentize = true;
9768
9815
  else if (arg === "--forms") args.forms = true;
9769
- else if (arg === "--xray") args.xray = argv[++i];
9816
+ else if (arg === "--pin") {
9817
+ const raw = argv[++i] ?? "";
9818
+ const at = raw.indexOf("=");
9819
+ if (at < 1 || at === raw.length - 1) {
9820
+ throw new CliError(`--pin takes <form>=<file>, e.g. --pin "Newsletter=src/components/FooterSection.astro". See --help.`);
9821
+ }
9822
+ const form = raw.slice(0, at);
9823
+ if (Object.hasOwn(args.pins, form)) throw new CliError(`--pin ${form} twice: give one file. See --help.`);
9824
+ args.pins[form] = raw.slice(at + 1);
9825
+ } else if (arg === "--xray") args.xray = argv[++i];
9770
9826
  else if (arg === "--plan") args.plan = argv[++i];
9771
9827
  else if (arg === "--copy") {
9772
9828
  const mode = argv[++i];
@@ -9802,6 +9858,9 @@ var HELP = `bettercms-convert ${VERSION}
9802
9858
  endpoint, the id, a marker per field and the submit script. A form
9803
9859
  the brief lists as a DRAFT is wired and receipted: publish it in the
9804
9860
  Forms tab, or it rejects every submission with a 403.
9861
+ --pin <form>=<file> With --forms: the file a form's <form> is in, when the run reports
9862
+ FORM_AMBIGUOUS (two files post exactly its fields). Form by id or
9863
+ name. Repeatable.
9805
9864
  --xray <dist-dir> Measure a LOCAL build instead of converting: how much of each page's
9806
9865
  visible copy no binding owns, and the biggest buckets of it. Run it
9807
9866
  after a build to see what the next conversion still has to reach,
@@ -9984,6 +10043,9 @@ async function main() {
9984
10043
  if (args.forms && args.componentize) {
9985
10044
  throw new CliError("--forms and --componentize are separate runs. Do one, then the other. See --help.");
9986
10045
  }
10046
+ if (Object.keys(args.pins).length > 0 && !args.forms) {
10047
+ throw new CliError("--pin only means something with --forms. See --help.");
10048
+ }
9987
10049
  const root = resolve(args.root);
9988
10050
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
9989
10051
  if (!Array.isArray(brief.pages)) {
@@ -10025,7 +10087,7 @@ async function main() {
10025
10087
  return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
10026
10088
  }
10027
10089
  if (args.forms) {
10028
- const { files: files2, receipt: receipt2 } = await wireForms(brief, sources);
10090
+ const { files: files2, receipt: receipt2 } = await wireForms(brief, sources, { pins: args.pins });
10029
10091
  await written(files2);
10030
10092
  await saveReceipt(receipt2);
10031
10093
  reportForms(files2, receipt2, args.dryRun);