@bettercms-ai/convert 0.14.0 → 0.16.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,17 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ ## 0.15.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 0ddfbdd: `--forms` now sees a form built from components. A component tag handed a `name` or `id` (`<TextInput name="firstName" />`) is the control; a tag handed neither (`<EmailFooterInput />`) is matched by the controls in its own imported file, which are never written. Before, such a form was reported `FORM_NOT_IN_SOURCE` although its fields were all there. A component's file is found by RESOLVING its import — a relative path, or one of the project's own tsconfig `paths` aliases — so a bare package specifier reads no local file that merely shares its last segment, and the receipt notes any field whose marker went unwritten because its control lives in an imported component.
14
+
3
15
  ## 0.14.0
4
16
 
5
17
  ### Minor Changes
package/dist/cli.js CHANGED
@@ -6391,9 +6391,11 @@ function* elementsIn(nodes) {
6391
6391
  var childElements = (node) => node.children.flatMap(
6392
6392
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6393
6393
  );
6394
- function candidateOf(file, dialect, form) {
6394
+ var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6395
+ function candidateOf(file, dialect, form, imported) {
6395
6396
  const controls = /* @__PURE__ */ new Map();
6396
6397
  const wrapper = /* @__PURE__ */ new Map();
6398
+ const foreign = /* @__PURE__ */ new Set();
6397
6399
  const all = [];
6398
6400
  const walk = (parent) => {
6399
6401
  const own = [];
@@ -6401,6 +6403,17 @@ function candidateOf(file, dialect, form) {
6401
6403
  if (CONTROLS.has(child.tag.toLowerCase())) {
6402
6404
  own.push(child);
6403
6405
  all.push(child);
6406
+ } else if (isComponentTag(dialect, child.tag)) {
6407
+ const inFile = attrOf(child, "name") ? [] : imported.get(child.tag) ?? [];
6408
+ if (inFile.length) {
6409
+ for (const control of inFile) {
6410
+ all.push(control);
6411
+ foreign.add(control);
6412
+ }
6413
+ } else if (attrOf(child, "name") ?? attrOf(child, "id")) {
6414
+ own.push(child);
6415
+ all.push(child);
6416
+ }
6404
6417
  }
6405
6418
  walk(child);
6406
6419
  }
@@ -6412,10 +6425,32 @@ function candidateOf(file, dialect, form) {
6412
6425
  if (name && !controls.has(name)) controls.set(name, control);
6413
6426
  }
6414
6427
  for (const control of all) {
6428
+ if (foreign.has(control)) continue;
6415
6429
  const id = attrOf(control, "id")?.value;
6416
6430
  if (id && !controls.has(id)) controls.set(id, control);
6417
6431
  }
6418
- return { file, dialect, el: form, controls, wrapper };
6432
+ return { file, dialect, el: form, controls, wrapper, foreign };
6433
+ }
6434
+ async function importedControls(source, parsed, dialect, project, cache) {
6435
+ const out = /* @__PURE__ */ new Map();
6436
+ for (const [local, { specifier }] of importsIn(parsed, dialect)) {
6437
+ const path = resolveSpecifier(source.path, specifier, project.files, project.aliases);
6438
+ const child = path === null || path === source.path ? void 0 : project.byPath.get(path);
6439
+ if (!child) continue;
6440
+ let controls = cache.get(child.path);
6441
+ if (!controls) {
6442
+ controls = (async () => {
6443
+ const own = dialectOf(child.path, child.content);
6444
+ if (!own || !WIRABLE.includes(own)) return [];
6445
+ const file = await parseFile4(own, child.content);
6446
+ return file.error ? [] : [...elementsIn(file.roots)].filter((el) => CONTROLS.has(el.tag.toLowerCase()));
6447
+ })();
6448
+ cache.set(child.path, controls);
6449
+ }
6450
+ const found = await controls;
6451
+ if (found.length) out.set(local, found);
6452
+ }
6453
+ return out;
6419
6454
  }
6420
6455
  var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6421
6456
  function wire(source, candidate, form) {
@@ -6434,6 +6469,7 @@ function wire(source, candidate, form) {
6434
6469
  source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6435
6470
  for (const field of form.fields) {
6436
6471
  const control = candidate.controls.get(field.key);
6472
+ if (candidate.foreign.has(control)) continue;
6437
6473
  const host = candidate.wrapper.get(control) ?? control;
6438
6474
  source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6439
6475
  const name = attrOf(control, "name");
@@ -6447,11 +6483,17 @@ function wire(source, candidate, form) {
6447
6483
  source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6448
6484
  }
6449
6485
  }
6450
- async function wireForms(briefIn, sources) {
6486
+ async function wireForms(briefIn, sources, options_ = {}) {
6451
6487
  const brief = readBrief(briefIn);
6452
6488
  const forms = brief.forms ?? [];
6453
6489
  const candidates = [];
6454
6490
  const unreadable = [];
6491
+ const componentFiles = /* @__PURE__ */ new Map();
6492
+ const project = {
6493
+ byPath: new Map(sources.map((s) => [s.path, s])),
6494
+ files: new Set(sources.map((s) => s.path)),
6495
+ aliases: aliasesFrom(sources)
6496
+ };
6455
6497
  for (const source of sources) {
6456
6498
  if (!/<form[\s/>]/i.test(source.content)) continue;
6457
6499
  const dialect = dialectOf(source.path, source.content);
@@ -6472,13 +6514,40 @@ async function wireForms(briefIn, sources) {
6472
6514
  });
6473
6515
  continue;
6474
6516
  }
6517
+ const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6475
6518
  for (const el of elementsIn(parsed.roots)) {
6476
- if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
6519
+ if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el, imported));
6477
6520
  }
6478
6521
  }
6479
6522
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6480
6523
  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
+ const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
6481
6526
  const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6527
+ const pinned = /* @__PURE__ */ new Map();
6528
+ for (const [ref, file] of Object.entries(options_.pins ?? {})) {
6529
+ const form = forms.find((f) => f.id === ref || f.name === ref);
6530
+ if (!form) throw new Error(`--pin ${ref}: the brief lists no form with that id or name.`);
6531
+ pinned.set(form.id, file);
6532
+ options.set(form.id, options.get(form.id).filter((c) => samePath(c.file, file)));
6533
+ 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)));
6535
+ }
6536
+ }
6537
+ for (const c of candidates) {
6538
+ const claimants = forms.filter((f) => options.get(f.id).includes(c));
6539
+ if (claimants.length < 2) continue;
6540
+ const top = Math.max(...claimants.map((f) => holds(c, f)));
6541
+ const winners = claimants.filter((f) => holds(c, f) === top);
6542
+ 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));
6544
+ }
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);
6550
+ }
6482
6551
  const wanted = /* @__PURE__ */ new Map();
6483
6552
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6484
6553
  const matched = [];
@@ -6489,7 +6558,12 @@ async function wireForms(briefIn, sources) {
6489
6558
  for (const form of forms) {
6490
6559
  const list = options.get(form.id) ?? [];
6491
6560
  if (list.length > 1) {
6492
- pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
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
+ );
6493
6567
  continue;
6494
6568
  }
6495
6569
  if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
@@ -6498,7 +6572,13 @@ async function wireForms(briefIn, sources) {
6498
6572
  }
6499
6573
  if (list.length === 0) {
6500
6574
  const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6501
- if (best) {
6575
+ const pin = pinned.get(form.id);
6576
+ if (pin) {
6577
+ pend2(form, "FORM_NOT_IN_SOURCE", pin, `the pinned file has no <form> posting ${form.fields.map((f) => f.key).join(", ")}.`);
6578
+ } else if (best && best.n === form.fields.length) {
6579
+ const owner = forms.find((f) => f !== form && options.get(f.id).includes(best.c));
6580
+ 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.`);
6581
+ } else if (best) {
6502
6582
  const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6503
6583
  pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6504
6584
  } else if (unreadable[0]) {
@@ -6546,6 +6626,14 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6546
6626
  files.push({ path, operation: "modify", content: source.toString() });
6547
6627
  }
6548
6628
  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.`);
6629
+ for (const { form, candidate } of matched) {
6630
+ const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6631
+ if (keys.length > 0) {
6632
+ notes.push(
6633
+ `form ${form.name}: ${keys.join(", ")} matched a control inside an imported component, so no data-bcms-form-field marker was written for ${keys.length === 1 ? "it" : "them"}.`
6634
+ );
6635
+ }
6636
+ }
6549
6637
  return {
6550
6638
  files,
6551
6639
  receipt: {
@@ -9638,7 +9726,7 @@ function emitStubs2(dialects, slugs, contents, out) {
9638
9726
  // package.json
9639
9727
  var package_default = {
9640
9728
  name: "@bettercms-ai/convert",
9641
- version: "0.14.0",
9729
+ version: "0.16.0",
9642
9730
  type: "module",
9643
9731
  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.",
9644
9732
  bin: {
@@ -9699,6 +9787,7 @@ function parseArgs(argv) {
9699
9787
  brief: void 0,
9700
9788
  componentize: false,
9701
9789
  forms: false,
9790
+ pins: {},
9702
9791
  xray: void 0,
9703
9792
  plan: void 0,
9704
9793
  copy: "bind",
@@ -9715,7 +9804,11 @@ function parseArgs(argv) {
9715
9804
  if (arg === "--brief") args.brief = argv[++i];
9716
9805
  else if (arg === "--componentize") args.componentize = true;
9717
9806
  else if (arg === "--forms") args.forms = true;
9718
- else if (arg === "--xray") args.xray = argv[++i];
9807
+ 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);
9811
+ } else if (arg === "--xray") args.xray = argv[++i];
9719
9812
  else if (arg === "--plan") args.plan = argv[++i];
9720
9813
  else if (arg === "--copy") {
9721
9814
  const mode = argv[++i];
@@ -9751,6 +9844,9 @@ var HELP = `bettercms-convert ${VERSION}
9751
9844
  endpoint, the id, a marker per field and the submit script. A form
9752
9845
  the brief lists as a DRAFT is wired and receipted: publish it in the
9753
9846
  Forms tab, or it rejects every submission with a 403.
9847
+ --pin <form>=<file> With --forms: the file a form's <form> is in, when the run reports
9848
+ FORM_AMBIGUOUS (two files post exactly its fields). Form by id or
9849
+ name. Repeatable.
9754
9850
  --xray <dist-dir> Measure a LOCAL build instead of converting: how much of each page's
9755
9851
  visible copy no binding owns, and the biggest buckets of it. Run it
9756
9852
  after a build to see what the next conversion still has to reach,
@@ -9973,8 +10069,11 @@ async function main() {
9973
10069
  reportSections(files2, receipt2, args.dryRun);
9974
10070
  return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
9975
10071
  }
10072
+ if (Object.keys(args.pins).length > 0 && !args.forms) {
10073
+ throw new CliError("--pin only means something with --forms. See --help.");
10074
+ }
9976
10075
  if (args.forms) {
9977
- const { files: files2, receipt: receipt2 } = await wireForms(brief, sources);
10076
+ const { files: files2, receipt: receipt2 } = await wireForms(brief, sources, { pins: args.pins });
9978
10077
  await written(files2);
9979
10078
  await saveReceipt(receipt2);
9980
10079
  reportForms(files2, receipt2, args.dryRun);