@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/dist/index.d.ts CHANGED
@@ -990,7 +990,11 @@ declare const FORM_SCRIPT = "<script>\nfunction bcmsPhone(cc,raw,trunk){var d=St
990
990
  * `ConversionReceipt` with an empty path arithmetic and the lane's own account under `forms`, so
991
991
  * `submit_conversion_receipt` takes it unchanged — the server's invariant reads `paths` only.
992
992
  */
993
- declare function wireForms(briefIn: Brief, sources: SourceFile[]): Promise<{
993
+ interface WireFormsOptions {
994
+ /** Form id or name → the file its `<form>` is in. The operator's answer to FORM_AMBIGUOUS. */
995
+ pins?: Record<string, string>;
996
+ }
997
+ declare function wireForms(briefIn: Brief, sources: SourceFile[], options_?: WireFormsOptions): Promise<{
994
998
  files: PlanFile[];
995
999
  receipt: ConversionReceipt;
996
1000
  }>;
package/dist/index.js CHANGED
@@ -6401,9 +6401,11 @@ function* elementsIn(nodes) {
6401
6401
  var childElements = (node) => node.children.flatMap(
6402
6402
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6403
6403
  );
6404
- function candidateOf(file, dialect, form) {
6404
+ var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6405
+ function candidateOf(file, dialect, form, imported) {
6405
6406
  const controls = /* @__PURE__ */ new Map();
6406
6407
  const wrapper = /* @__PURE__ */ new Map();
6408
+ const foreign = /* @__PURE__ */ new Set();
6407
6409
  const all = [];
6408
6410
  const walk = (parent) => {
6409
6411
  const own = [];
@@ -6411,6 +6413,17 @@ function candidateOf(file, dialect, form) {
6411
6413
  if (CONTROLS.has(child.tag.toLowerCase())) {
6412
6414
  own.push(child);
6413
6415
  all.push(child);
6416
+ } else if (isComponentTag(dialect, child.tag)) {
6417
+ const inFile = attrOf(child, "name") ? [] : imported.get(child.tag) ?? [];
6418
+ if (inFile.length) {
6419
+ for (const control of inFile) {
6420
+ all.push(control);
6421
+ foreign.add(control);
6422
+ }
6423
+ } else if (attrOf(child, "name") ?? attrOf(child, "id")) {
6424
+ own.push(child);
6425
+ all.push(child);
6426
+ }
6414
6427
  }
6415
6428
  walk(child);
6416
6429
  }
@@ -6422,10 +6435,32 @@ function candidateOf(file, dialect, form) {
6422
6435
  if (name && !controls.has(name)) controls.set(name, control);
6423
6436
  }
6424
6437
  for (const control of all) {
6438
+ if (foreign.has(control)) continue;
6425
6439
  const id = attrOf(control, "id")?.value;
6426
6440
  if (id && !controls.has(id)) controls.set(id, control);
6427
6441
  }
6428
- return { file, dialect, el: form, controls, wrapper };
6442
+ return { file, dialect, el: form, controls, wrapper, foreign };
6443
+ }
6444
+ async function importedControls(source, parsed, dialect, project, cache) {
6445
+ const out = /* @__PURE__ */ new Map();
6446
+ for (const [local, { specifier }] of importsIn(parsed, dialect)) {
6447
+ const path = resolveSpecifier(source.path, specifier, project.files, project.aliases);
6448
+ const child = path === null || path === source.path ? void 0 : project.byPath.get(path);
6449
+ if (!child) continue;
6450
+ let controls = cache.get(child.path);
6451
+ if (!controls) {
6452
+ controls = (async () => {
6453
+ const own = dialectOf(child.path, child.content);
6454
+ if (!own || !WIRABLE.includes(own)) return [];
6455
+ const file = await parseFile4(own, child.content);
6456
+ return file.error ? [] : [...elementsIn(file.roots)].filter((el) => CONTROLS.has(el.tag.toLowerCase()));
6457
+ })();
6458
+ cache.set(child.path, controls);
6459
+ }
6460
+ const found = await controls;
6461
+ if (found.length) out.set(local, found);
6462
+ }
6463
+ return out;
6429
6464
  }
6430
6465
  var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6431
6466
  function wire(source, candidate, form) {
@@ -6444,6 +6479,7 @@ function wire(source, candidate, form) {
6444
6479
  source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6445
6480
  for (const field of form.fields) {
6446
6481
  const control = candidate.controls.get(field.key);
6482
+ if (candidate.foreign.has(control)) continue;
6447
6483
  const host = candidate.wrapper.get(control) ?? control;
6448
6484
  source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6449
6485
  const name = attrOf(control, "name");
@@ -6457,11 +6493,17 @@ function wire(source, candidate, form) {
6457
6493
  source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6458
6494
  }
6459
6495
  }
6460
- async function wireForms(briefIn, sources) {
6496
+ async function wireForms(briefIn, sources, options_ = {}) {
6461
6497
  const brief = readBrief(briefIn);
6462
6498
  const forms = brief.forms ?? [];
6463
6499
  const candidates = [];
6464
6500
  const unreadable = [];
6501
+ const componentFiles = /* @__PURE__ */ new Map();
6502
+ const project = {
6503
+ byPath: new Map(sources.map((s) => [s.path, s])),
6504
+ files: new Set(sources.map((s) => s.path)),
6505
+ aliases: aliasesFrom(sources)
6506
+ };
6465
6507
  for (const source of sources) {
6466
6508
  if (!/<form[\s/>]/i.test(source.content)) continue;
6467
6509
  const dialect = dialectOf(source.path, source.content);
@@ -6482,13 +6524,40 @@ async function wireForms(briefIn, sources) {
6482
6524
  });
6483
6525
  continue;
6484
6526
  }
6527
+ const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6485
6528
  for (const el of elementsIn(parsed.roots)) {
6486
- if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
6529
+ if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el, imported));
6487
6530
  }
6488
6531
  }
6489
6532
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6490
6533
  const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6534
+ const posts = (c) => new Set(c.controls.values()).size;
6535
+ const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
6491
6536
  const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6537
+ const pinned = /* @__PURE__ */ new Map();
6538
+ for (const [ref, file] of Object.entries(options_.pins ?? {})) {
6539
+ const form = forms.find((f) => f.id === ref || f.name === ref);
6540
+ if (!form) throw new Error(`--pin ${ref}: the brief lists no form with that id or name.`);
6541
+ pinned.set(form.id, file);
6542
+ options.set(form.id, options.get(form.id).filter((c) => samePath(c.file, file)));
6543
+ for (const other of forms) {
6544
+ if (other.id !== form.id) options.set(other.id, options.get(other.id).filter((c) => !samePath(c.file, file)));
6545
+ }
6546
+ }
6547
+ for (const c of candidates) {
6548
+ const claimants = forms.filter((f) => options.get(f.id).includes(c));
6549
+ if (claimants.length < 2) continue;
6550
+ const top = Math.max(...claimants.map((f) => holds(c, f)));
6551
+ const winners = claimants.filter((f) => holds(c, f) === top);
6552
+ if (winners.length !== 1) continue;
6553
+ for (const f of claimants) if (f !== winners[0]) options.set(f.id, options.get(f.id).filter((x) => x !== c));
6554
+ }
6555
+ for (const f of forms) {
6556
+ const list = options.get(f.id);
6557
+ if (list.length < 2) continue;
6558
+ const exact = list.filter((c) => posts(c) === f.fields.length);
6559
+ if (exact.length === 1) options.set(f.id, exact);
6560
+ }
6492
6561
  const wanted = /* @__PURE__ */ new Map();
6493
6562
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6494
6563
  const matched = [];
@@ -6499,7 +6568,12 @@ async function wireForms(briefIn, sources) {
6499
6568
  for (const form of forms) {
6500
6569
  const list = options.get(form.id) ?? [];
6501
6570
  if (list.length > 1) {
6502
- pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
6571
+ pend2(
6572
+ form,
6573
+ "FORM_AMBIGUOUS",
6574
+ void 0,
6575
+ `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}. Name the right one with --pin "${form.name}=<file>".`
6576
+ );
6503
6577
  continue;
6504
6578
  }
6505
6579
  if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
@@ -6508,7 +6582,13 @@ async function wireForms(briefIn, sources) {
6508
6582
  }
6509
6583
  if (list.length === 0) {
6510
6584
  const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6511
- if (best) {
6585
+ const pin = pinned.get(form.id);
6586
+ if (pin) {
6587
+ pend2(form, "FORM_NOT_IN_SOURCE", pin, `the pinned file has no <form> posting ${form.fields.map((f) => f.key).join(", ")}.`);
6588
+ } else if (best && best.n === form.fields.length) {
6589
+ const owner = forms.find((f) => f !== form && options.get(f.id).includes(best.c));
6590
+ 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.`);
6591
+ } else if (best) {
6512
6592
  const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6513
6593
  pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6514
6594
  } else if (unreadable[0]) {
@@ -6556,6 +6636,14 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6556
6636
  files.push({ path, operation: "modify", content: source.toString() });
6557
6637
  }
6558
6638
  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.`);
6639
+ for (const { form, candidate } of matched) {
6640
+ const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6641
+ if (keys.length > 0) {
6642
+ notes.push(
6643
+ `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"}.`
6644
+ );
6645
+ }
6646
+ }
6559
6647
  return {
6560
6648
  files,
6561
6649
  receipt: {