@bettercms-ai/convert 0.16.0 → 0.17.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,29 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 15ecfcb: `--forms` reads two new facts from the brief. `landmark` (where the built form sat: footer, header, nav, main, aside or dialog) breaks the tie between several source `<form>`s posting exactly a form's fields — a footer newsletter is not a "forgot password" dialog — and the choice is disclosed in `notes`. `pin` is the owner's answer from the Forms tab, applied like `--pin`; a flag typed at the keyboard still wins. `FORM_AMBIGUOUS` pending entries now carry `candidates`, the files a person picks from.
8
+
9
+ ### Patch Changes
10
+
11
+ - c2cfcb7: `--forms` rewires a `<form>` that carries a stale `data-bcms-form` id — a repository re-imported after the project it was wired to was deleted. The attribute, the action and the success message are replaced in place (never doubled), existing field markers are kept, and the form counts as wired rather than already wired. Only a form carrying the brief form's own id is "already wired".
12
+ - 994a5e6: `--forms` strips page-copy bindings (`data-bcms-field`, `data-bcms-kind`, `data-bcms-props`) declared on elements inside a `<form>` it wires. A form's labels, placeholders and button copy are the CMS form's, rendered by the section's `form` block; a hand conversion that bound them as page copy made the platform derive them beside the form and put the label on the section component twice. Copy around the form — the heading that names it — is untouched.
13
+
14
+ ## 0.16.1
15
+
16
+ ### Patch Changes
17
+
18
+ - Fix `--forms` tie-breaking. 0.16.0's second tie-break rule ("the form posting nothing beyond its
19
+ fields wins") could bind a look-alike form — for example a "forgot password" dialog posting only
20
+ `email` — to the newsletter without reporting it. 0.16.1 removes that rule: such cases stay
21
+ `FORM_AMBIGUOUS` until you name the file with `--pin "<form>=<file>"`. When the remaining rule
22
+ (the form posting more of a `<form>`'s fields wins) decides a tie, the receipt now carries a `notes`
23
+ line naming the file and the rival that lost it. `--pin` is also stricter: an empty value or a
24
+ repeated pin for the same form is refused, `--pin` cannot be combined with `--componentize`, and
25
+ two forms pinned to one file both wire.
26
+
3
27
  ## 0.16.0
4
28
 
5
29
  ### 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
@@ -6377,6 +6377,7 @@ function scriptFor(dialect) {
6377
6377
  var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
6378
6378
  var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
6379
6379
  var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
6380
+ var DECLARED_BINDING_ATTR = /^data-bcms-(field|kind|props)$/i;
6380
6381
  function* elementsIn(nodes) {
6381
6382
  for (const node of nodes) {
6382
6383
  if (node.type === "expr") {
@@ -6391,8 +6392,28 @@ function* elementsIn(nodes) {
6391
6392
  var childElements = (node) => node.children.flatMap(
6392
6393
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6393
6394
  );
6395
+ var LANDMARK_TAGS = /* @__PURE__ */ new Set(["footer", "header", "nav", "main", "aside", "dialog"]);
6396
+ function landmarkOf(chain) {
6397
+ for (let i = chain.length - 1; i >= 0; i--) {
6398
+ const el = chain[i];
6399
+ if (attrOf(el, "role")?.value === "dialog") return "dialog";
6400
+ if (LANDMARK_TAGS.has(el.tag.toLowerCase())) return el.tag.toLowerCase();
6401
+ }
6402
+ return "body";
6403
+ }
6404
+ function* formsIn(nodes, chain = []) {
6405
+ for (const node of nodes) {
6406
+ if (node.type === "expr") {
6407
+ yield* formsIn(node.children, chain);
6408
+ continue;
6409
+ }
6410
+ if (node.type !== "element") continue;
6411
+ if (node.tag.toLowerCase() === "form") yield { el: node, landmark: landmarkOf(chain) };
6412
+ yield* formsIn(node.children, [...chain, node]);
6413
+ }
6414
+ }
6394
6415
  var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6395
- function candidateOf(file, dialect, form, imported) {
6416
+ function candidateOf(file, dialect, form, imported, landmark) {
6396
6417
  const controls = /* @__PURE__ */ new Map();
6397
6418
  const wrapper = /* @__PURE__ */ new Map();
6398
6419
  const foreign = /* @__PURE__ */ new Set();
@@ -6429,7 +6450,7 @@ function candidateOf(file, dialect, form, imported) {
6429
6450
  const id = attrOf(control, "id")?.value;
6430
6451
  if (id && !controls.has(id)) controls.set(id, control);
6431
6452
  }
6432
- return { file, dialect, el: form, controls, wrapper, foreign };
6453
+ return { file, dialect, el: form, controls, wrapper, foreign, landmark };
6433
6454
  }
6434
6455
  async function importedControls(source, parsed, dialect, project, cache) {
6435
6456
  const out = /* @__PURE__ */ new Map();
@@ -6464,14 +6485,21 @@ function wire(source, candidate, form) {
6464
6485
  };
6465
6486
  set("action", form.submitUrl);
6466
6487
  set("method", "post");
6467
- inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
6468
- if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
6488
+ set("data-bcms-form", form.id);
6489
+ if (form.successMessage) set("data-bcms-form-success", form.successMessage);
6469
6490
  source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6491
+ for (const inner of elementsIn(el.children)) {
6492
+ for (const attr of inner.attrs) {
6493
+ if (!DECLARED_BINDING_ATTR.test(attr.name)) continue;
6494
+ const start = source.original[attr.range.start - 1] === " " ? attr.range.start - 1 : attr.range.start;
6495
+ source.remove(start, attr.range.end);
6496
+ }
6497
+ }
6470
6498
  for (const field of form.fields) {
6471
6499
  const control = candidate.controls.get(field.key);
6472
6500
  if (candidate.foreign.has(control)) continue;
6473
6501
  const host = candidate.wrapper.get(control) ?? control;
6474
- source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6502
+ if (!attrOf(host, "data-bcms-form-field")) source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6475
6503
  const name = attrOf(control, "name");
6476
6504
  if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
6477
6505
  else if (!name.expression && name.value !== field.key && name.valueRange) {
@@ -6515,38 +6543,51 @@ async function wireForms(briefIn, sources, options_ = {}) {
6515
6543
  continue;
6516
6544
  }
6517
6545
  const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6518
- for (const el of elementsIn(parsed.roots)) {
6519
- if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el, imported));
6546
+ for (const { el, landmark } of formsIn(parsed.roots)) {
6547
+ candidates.push(candidateOf(source.path, dialect, el, imported, landmark));
6520
6548
  }
6521
6549
  }
6522
6550
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6523
6551
  const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6524
- const posts = (c) => new Set(c.controls.values()).size;
6525
6552
  const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
6526
6553
  const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6527
6554
  const pinned = /* @__PURE__ */ new Map();
6528
6555
  for (const [ref, file] of Object.entries(options_.pins ?? {})) {
6529
6556
  const form = forms.find((f) => f.id === ref || f.name === ref);
6530
6557
  if (!form) throw new Error(`--pin ${ref}: the brief lists no form with that id or name.`);
6558
+ if (file.trim() === "") throw new Error(`--pin ${ref}: no file named. Give "<form>=<file>".`);
6531
6559
  pinned.set(form.id, file);
6532
- options.set(form.id, options.get(form.id).filter((c) => samePath(c.file, file)));
6560
+ }
6561
+ for (const form of forms) {
6562
+ if (form.pin && form.pin.trim() !== "" && !pinned.has(form.id)) pinned.set(form.id, form.pin);
6563
+ }
6564
+ for (const [id, file] of pinned) {
6565
+ options.set(id, options.get(id).filter((c) => samePath(c.file, file)));
6533
6566
  for (const other of forms) {
6534
- if (other.id !== form.id) options.set(other.id, options.get(other.id).filter((c) => !samePath(c.file, file)));
6567
+ const its = pinned.get(other.id);
6568
+ if (other.id === id || its !== void 0 && samePath(its, file)) continue;
6569
+ options.set(other.id, options.get(other.id).filter((c) => !samePath(c.file, file)));
6535
6570
  }
6536
6571
  }
6572
+ const beat = /* @__PURE__ */ new Map();
6537
6573
  for (const c of candidates) {
6538
6574
  const claimants = forms.filter((f) => options.get(f.id).includes(c));
6539
6575
  if (claimants.length < 2) continue;
6540
6576
  const top = Math.max(...claimants.map((f) => holds(c, f)));
6541
6577
  const winners = claimants.filter((f) => holds(c, f) === top);
6542
6578
  if (winners.length !== 1) continue;
6543
- for (const f of claimants) if (f !== winners[0]) options.set(f.id, options.get(f.id).filter((x) => x !== c));
6579
+ const rivals = claimants.filter((f) => f !== winners[0]);
6580
+ for (const f of rivals) options.set(f.id, options.get(f.id).filter((x) => x !== c));
6581
+ beat.set(c, { winner: winners[0], rivals });
6544
6582
  }
6545
- for (const f of forms) {
6546
- const list = options.get(f.id);
6547
- if (list.length < 2) continue;
6548
- const exact = list.filter((c) => posts(c) === f.fields.length);
6549
- if (exact.length === 1) options.set(f.id, exact);
6583
+ const placed = /* @__PURE__ */ new Map();
6584
+ for (const form of forms) {
6585
+ const list = options.get(form.id);
6586
+ if (list.length < 2 || !form.landmark || form.landmark === "body") continue;
6587
+ const kept = list.filter((c) => c.landmark === form.landmark);
6588
+ if (kept.length === 0 || kept.length === list.length) continue;
6589
+ options.set(form.id, kept);
6590
+ placed.set(form.id, { kept, dropped: list.filter((c) => !kept.includes(c)) });
6550
6591
  }
6551
6592
  const wanted = /* @__PURE__ */ new Map();
6552
6593
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
@@ -6558,12 +6599,13 @@ async function wireForms(briefIn, sources, options_ = {}) {
6558
6599
  for (const form of forms) {
6559
6600
  const list = options.get(form.id) ?? [];
6560
6601
  if (list.length > 1) {
6561
- pend2(
6562
- form,
6563
- "FORM_AMBIGUOUS",
6564
- void 0,
6565
- `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}. Name the right one with --pin "${form.name}=<file>".`
6566
- );
6602
+ pending.push({
6603
+ id: form.id,
6604
+ name: form.name,
6605
+ reason: "FORM_AMBIGUOUS",
6606
+ message: `${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>".`}`,
6607
+ candidates: [...new Set(list.map((c) => c.file))]
6608
+ });
6567
6609
  continue;
6568
6610
  }
6569
6611
  if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
@@ -6601,7 +6643,7 @@ async function wireForms(briefIn, sources, options_ = {}) {
6601
6643
  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.`);
6602
6644
  continue;
6603
6645
  }
6604
- if (attrOf(candidate.el, "data-bcms-form")) {
6646
+ if (attrOf(candidate.el, "data-bcms-form")?.value === form.id) {
6605
6647
  alreadyWired++;
6606
6648
  onSite.push({ form, file: candidate.file });
6607
6649
  continue;
@@ -6626,6 +6668,20 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6626
6668
  files.push({ path, operation: "modify", content: source.toString() });
6627
6669
  }
6628
6670
  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.`);
6671
+ for (const { form, candidate } of matched) {
6672
+ const tie = beat.get(candidate);
6673
+ if (tie?.winner === form) {
6674
+ notes.push(
6675
+ `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.`
6676
+ );
6677
+ }
6678
+ const where = placed.get(form.id);
6679
+ if (where) {
6680
+ notes.push(
6681
+ `form ${form.name}: ${where.dropped.map((c) => c.file).join(", ")} post${where.dropped.length === 1 ? "s" : ""} every field of it too, but the built form sat in the ${form.landmark} and only ${candidate.file}'s does \u2014 name the file yourself with --pin "<form>=<file>" if that is wrong.`
6682
+ );
6683
+ }
6684
+ }
6629
6685
  for (const { form, candidate } of matched) {
6630
6686
  const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6631
6687
  if (keys.length > 0) {
@@ -9726,7 +9782,7 @@ function emitStubs2(dialects, slugs, contents, out) {
9726
9782
  // package.json
9727
9783
  var package_default = {
9728
9784
  name: "@bettercms-ai/convert",
9729
- version: "0.16.0",
9785
+ version: "0.17.0",
9730
9786
  type: "module",
9731
9787
  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.",
9732
9788
  bin: {
@@ -9805,9 +9861,14 @@ function parseArgs(argv) {
9805
9861
  else if (arg === "--componentize") args.componentize = true;
9806
9862
  else if (arg === "--forms") args.forms = true;
9807
9863
  else if (arg === "--pin") {
9808
- const at = (argv[++i] ?? "").indexOf("=");
9809
- if (at < 1) throw new CliError(`--pin takes <form>=<file>, e.g. --pin "Newsletter=src/components/FooterSection.astro". See --help.`);
9810
- args.pins[argv[i].slice(0, at)] = argv[i].slice(at + 1);
9864
+ const raw = argv[++i] ?? "";
9865
+ const at = raw.indexOf("=");
9866
+ if (at < 1 || at === raw.length - 1) {
9867
+ throw new CliError(`--pin takes <form>=<file>, e.g. --pin "Newsletter=src/components/FooterSection.astro". See --help.`);
9868
+ }
9869
+ const form = raw.slice(0, at);
9870
+ if (Object.hasOwn(args.pins, form)) throw new CliError(`--pin ${form} twice: give one file. See --help.`);
9871
+ args.pins[form] = raw.slice(at + 1);
9811
9872
  } else if (arg === "--xray") args.xray = argv[++i];
9812
9873
  else if (arg === "--plan") args.plan = argv[++i];
9813
9874
  else if (arg === "--copy") {
@@ -10029,6 +10090,9 @@ async function main() {
10029
10090
  if (args.forms && args.componentize) {
10030
10091
  throw new CliError("--forms and --componentize are separate runs. Do one, then the other. See --help.");
10031
10092
  }
10093
+ if (Object.keys(args.pins).length > 0 && !args.forms) {
10094
+ throw new CliError("--pin only means something with --forms. See --help.");
10095
+ }
10032
10096
  const root = resolve(args.root);
10033
10097
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
10034
10098
  if (!Array.isArray(brief.pages)) {
@@ -10069,9 +10133,6 @@ async function main() {
10069
10133
  reportSections(files2, receipt2, args.dryRun);
10070
10134
  return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
10071
10135
  }
10072
- if (Object.keys(args.pins).length > 0 && !args.forms) {
10073
- throw new CliError("--pin only means something with --forms. See --help.");
10074
- }
10075
10136
  if (args.forms) {
10076
10137
  const { files: files2, receipt: receipt2 } = await wireForms(brief, sources, { pins: args.pins });
10077
10138
  await written(files2);