@bettercms-ai/convert 0.16.1 → 0.17.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/dist/index.d.ts CHANGED
@@ -114,6 +114,13 @@ interface BriefForm {
114
114
  key: string;
115
115
  type: string;
116
116
  }[];
117
+ /**
118
+ * Where the built form sat — footer, header, nav, main, aside, dialog or body. Breaks the tie
119
+ * between several source `<form>`s posting exactly these fields. @see forms.ts
120
+ */
121
+ landmark?: string;
122
+ /** The owner's answer to FORM_AMBIGUOUS: the file this form is in. Applied like `--pin`. */
123
+ pin?: string;
117
124
  }
118
125
 
119
126
  /**
@@ -402,7 +409,7 @@ interface ConversionReceipt {
402
409
  * reader must treat absent as unknown rather than as `"pending"`.
403
410
  */
404
411
  canvas?: {
405
- lane: "bridge" | "pending";
412
+ lane: "bridge" | "draft-route" | "pending";
406
413
  files?: string[];
407
414
  };
408
415
  /**
@@ -431,6 +438,8 @@ interface PendingForm {
431
438
  file?: string;
432
439
  /** What exactly could not be done. Informative; the reason is the contract. */
433
440
  message?: string;
441
+ /** FORM_AMBIGUOUS only: the files that each post every field — what a person picks from. */
442
+ candidates?: string[];
434
443
  }
435
444
  interface FormsReceipt {
436
445
  /** A `<form>` this run pointed at the CMS: action, method, id, field markers and the script. */
@@ -1233,7 +1242,7 @@ interface CanvasSource {
1233
1242
  ast?: unknown;
1234
1243
  }
1235
1244
  interface CanvasResult {
1236
- lane: "bridge" | "pending";
1245
+ lane: "bridge" | "draft-route" | "pending";
1237
1246
  /** Files the bridge was installed in. */
1238
1247
  files: string[];
1239
1248
  /** Rewritten contents, by path. Empty when the lane is pending. */
@@ -1248,8 +1257,8 @@ declare const ASTRO_LANE_PENDING: PendingPath;
1248
1257
  * Install the draft bridge, or say why not.
1249
1258
  *
1250
1259
  * `routes` maps a brief route (`/about`) to its slug, so a route written as a literal in the page
1251
- * can name itself. An Astro project is refused wholesale: bridging it needs a server render, which
1252
- * is a deploy-shaped decision and never a codemod's.
1260
+ * can name itself. An Astro project is never bridged here: it either wired a lane itself (see
1261
+ * {@link astroLane}) or it carries the SSR recipe.
1253
1262
  */
1254
1263
  declare function canvasBridge(sources: CanvasSource[], routes: Map<string, string>): CanvasResult;
1255
1264
 
package/dist/index.js CHANGED
@@ -6090,9 +6090,28 @@ var pend = (reason, hint, file, line) => ({
6090
6090
  hint
6091
6091
  });
6092
6092
  var ASTRO_LANE_PENDING = pend("SSR_DRAFT_ROUTE", SSR_DRAFT_RECIPE);
6093
+ function mounts(content, component) {
6094
+ const from = new RegExp(
6095
+ `import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+["']@bettercms-ai/astro/components/${component}\\.astro["']`
6096
+ );
6097
+ const local = content.match(from)?.[1];
6098
+ return local !== void 0 && new RegExp(`<${local}[\\s/>]`).test(content);
6099
+ }
6100
+ var ASTRO_CONFIG = /(^|\/)astro\.config\.(mjs|cjs|js|ts|mts)$/;
6101
+ function astroLane(sources) {
6102
+ const astro = sources.filter((s) => s.path.toLowerCase().endsWith(".astro"));
6103
+ const server = sources.some(
6104
+ (s) => ASTRO_CONFIG.test(s.path) && /\boutput\s*:\s*["']server["']/.test(s.content)
6105
+ );
6106
+ if (server && astro.some((s) => mounts(s.content, "BcmsLive"))) return "draft-route";
6107
+ if (astro.some((s) => mounts(s.content, "BcmsLiveBlocks"))) return "bridge";
6108
+ return null;
6109
+ }
6093
6110
  function canvasBridge(sources, routes) {
6094
- if (sources.some((s) => s.path.toLowerCase().endsWith(".astro")))
6095
- return { lane: "pending", files: [], edits: /* @__PURE__ */ new Map(), pending: [ASTRO_LANE_PENDING] };
6111
+ if (sources.some((s) => s.path.toLowerCase().endsWith(".astro"))) {
6112
+ const lane = astroLane(sources);
6113
+ return lane ? { lane, files: [], edits: /* @__PURE__ */ new Map(), pending: [] } : { lane: "pending", files: [], edits: /* @__PURE__ */ new Map(), pending: [ASTRO_LANE_PENDING] };
6114
+ }
6096
6115
  const files = [];
6097
6116
  const edits = /* @__PURE__ */ new Map();
6098
6117
  const pending = [];
@@ -6387,6 +6406,7 @@ function scriptFor(dialect) {
6387
6406
  var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
6388
6407
  var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
6389
6408
  var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
6409
+ var DECLARED_BINDING_ATTR = /^data-bcms-(field|kind|props)$/i;
6390
6410
  function* elementsIn(nodes) {
6391
6411
  for (const node of nodes) {
6392
6412
  if (node.type === "expr") {
@@ -6401,8 +6421,28 @@ function* elementsIn(nodes) {
6401
6421
  var childElements = (node) => node.children.flatMap(
6402
6422
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6403
6423
  );
6424
+ var LANDMARK_TAGS = /* @__PURE__ */ new Set(["footer", "header", "nav", "main", "aside", "dialog"]);
6425
+ function landmarkOf(chain) {
6426
+ for (let i = chain.length - 1; i >= 0; i--) {
6427
+ const el = chain[i];
6428
+ if (attrOf(el, "role")?.value === "dialog") return "dialog";
6429
+ if (LANDMARK_TAGS.has(el.tag.toLowerCase())) return el.tag.toLowerCase();
6430
+ }
6431
+ return "body";
6432
+ }
6433
+ function* formsIn(nodes, chain = []) {
6434
+ for (const node of nodes) {
6435
+ if (node.type === "expr") {
6436
+ yield* formsIn(node.children, chain);
6437
+ continue;
6438
+ }
6439
+ if (node.type !== "element") continue;
6440
+ if (node.tag.toLowerCase() === "form") yield { el: node, landmark: landmarkOf(chain) };
6441
+ yield* formsIn(node.children, [...chain, node]);
6442
+ }
6443
+ }
6404
6444
  var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6405
- function candidateOf(file, dialect, form, imported) {
6445
+ function candidateOf(file, dialect, form, imported, landmark) {
6406
6446
  const controls = /* @__PURE__ */ new Map();
6407
6447
  const wrapper = /* @__PURE__ */ new Map();
6408
6448
  const foreign = /* @__PURE__ */ new Set();
@@ -6439,7 +6479,7 @@ function candidateOf(file, dialect, form, imported) {
6439
6479
  const id = attrOf(control, "id")?.value;
6440
6480
  if (id && !controls.has(id)) controls.set(id, control);
6441
6481
  }
6442
- return { file, dialect, el: form, controls, wrapper, foreign };
6482
+ return { file, dialect, el: form, controls, wrapper, foreign, landmark };
6443
6483
  }
6444
6484
  async function importedControls(source, parsed, dialect, project, cache) {
6445
6485
  const out = /* @__PURE__ */ new Map();
@@ -6474,14 +6514,21 @@ function wire(source, candidate, form) {
6474
6514
  };
6475
6515
  set("action", form.submitUrl);
6476
6516
  set("method", "post");
6477
- inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
6478
- if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
6517
+ set("data-bcms-form", form.id);
6518
+ if (form.successMessage) set("data-bcms-form-success", form.successMessage);
6479
6519
  source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6520
+ for (const inner of elementsIn(el.children)) {
6521
+ for (const attr of inner.attrs) {
6522
+ if (!DECLARED_BINDING_ATTR.test(attr.name)) continue;
6523
+ const start = source.original[attr.range.start - 1] === " " ? attr.range.start - 1 : attr.range.start;
6524
+ source.remove(start, attr.range.end);
6525
+ }
6526
+ }
6480
6527
  for (const field of form.fields) {
6481
6528
  const control = candidate.controls.get(field.key);
6482
6529
  if (candidate.foreign.has(control)) continue;
6483
6530
  const host = candidate.wrapper.get(control) ?? control;
6484
- source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6531
+ if (!attrOf(host, "data-bcms-form-field")) source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6485
6532
  const name = attrOf(control, "name");
6486
6533
  if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
6487
6534
  else if (!name.expression && name.value !== field.key && name.valueRange) {
@@ -6525,8 +6572,8 @@ async function wireForms(briefIn, sources, options_ = {}) {
6525
6572
  continue;
6526
6573
  }
6527
6574
  const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6528
- for (const el of elementsIn(parsed.roots)) {
6529
- if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el, imported));
6575
+ for (const { el, landmark } of formsIn(parsed.roots)) {
6576
+ candidates.push(candidateOf(source.path, dialect, el, imported, landmark));
6530
6577
  }
6531
6578
  }
6532
6579
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
@@ -6540,6 +6587,9 @@ async function wireForms(briefIn, sources, options_ = {}) {
6540
6587
  if (file.trim() === "") throw new Error(`--pin ${ref}: no file named. Give "<form>=<file>".`);
6541
6588
  pinned.set(form.id, file);
6542
6589
  }
6590
+ for (const form of forms) {
6591
+ if (form.pin && form.pin.trim() !== "" && !pinned.has(form.id)) pinned.set(form.id, form.pin);
6592
+ }
6543
6593
  for (const [id, file] of pinned) {
6544
6594
  options.set(id, options.get(id).filter((c) => samePath(c.file, file)));
6545
6595
  for (const other of forms) {
@@ -6559,6 +6609,15 @@ async function wireForms(briefIn, sources, options_ = {}) {
6559
6609
  for (const f of rivals) options.set(f.id, options.get(f.id).filter((x) => x !== c));
6560
6610
  beat.set(c, { winner: winners[0], rivals });
6561
6611
  }
6612
+ const placed = /* @__PURE__ */ new Map();
6613
+ for (const form of forms) {
6614
+ const list = options.get(form.id);
6615
+ if (list.length < 2 || !form.landmark || form.landmark === "body") continue;
6616
+ const kept = list.filter((c) => c.landmark === form.landmark);
6617
+ if (kept.length === 0 || kept.length === list.length) continue;
6618
+ options.set(form.id, kept);
6619
+ placed.set(form.id, { kept, dropped: list.filter((c) => !kept.includes(c)) });
6620
+ }
6562
6621
  const wanted = /* @__PURE__ */ new Map();
6563
6622
  for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6564
6623
  const matched = [];
@@ -6569,12 +6628,13 @@ async function wireForms(briefIn, sources, options_ = {}) {
6569
6628
  for (const form of forms) {
6570
6629
  const list = options.get(form.id) ?? [];
6571
6630
  if (list.length > 1) {
6572
- pend2(
6573
- form,
6574
- "FORM_AMBIGUOUS",
6575
- void 0,
6576
- `${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>".`}`
6577
- );
6631
+ pending.push({
6632
+ id: form.id,
6633
+ name: form.name,
6634
+ reason: "FORM_AMBIGUOUS",
6635
+ 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>".`}`,
6636
+ candidates: [...new Set(list.map((c) => c.file))]
6637
+ });
6578
6638
  continue;
6579
6639
  }
6580
6640
  if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
@@ -6612,7 +6672,7 @@ async function wireForms(briefIn, sources, options_ = {}) {
6612
6672
  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.`);
6613
6673
  continue;
6614
6674
  }
6615
- if (attrOf(candidate.el, "data-bcms-form")) {
6675
+ if (attrOf(candidate.el, "data-bcms-form")?.value === form.id) {
6616
6676
  alreadyWired++;
6617
6677
  onSite.push({ form, file: candidate.file });
6618
6678
  continue;
@@ -6644,6 +6704,12 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6644
6704
  `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.`
6645
6705
  );
6646
6706
  }
6707
+ const where = placed.get(form.id);
6708
+ if (where) {
6709
+ notes.push(
6710
+ `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.`
6711
+ );
6712
+ }
6647
6713
  }
6648
6714
  for (const { form, candidate } of matched) {
6649
6715
  const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);