@bettercms-ai/convert 0.13.0 → 0.15.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
@@ -33,9 +33,12 @@ interface BriefPath {
33
33
  * what it said.
34
34
  */
35
35
  scope?: "page" | "layout";
36
- /** `data-bcms-kind` — "text" | "richtext" | "image", as `kindOf` spells it. */
37
- kind: string;
38
- /** The declared field type, which `kind` deliberately loses (richtext vs document). */
36
+ /**
37
+ * `data-bcms-kind`, as `kindOf` spells it: text | richtext | image | document. The props-lane
38
+ * members of the union (url, number, array) never appear on a brief path.
39
+ */
40
+ kind: BindingKind;
41
+ /** The declared field type. It agrees with `kind` now; the injector reads it for LINK_TYPES. */
39
42
  type: string;
40
43
  /**
41
44
  * The richtext value IS the element's whole inline subtree — the field's `config.inline`.
@@ -229,6 +232,12 @@ interface PendingFix {
229
232
  /** The bytes at `line`, trimmed — so the agent can see it is looking at the right place. */
230
233
  snippet: string;
231
234
  action: FixAction;
235
+ /**
236
+ * `declare-richtext` only: WHICH container this is. A `document` is a whole Body — one element
237
+ * holds every block — and binding a paragraph of it instead publishes the entire article into
238
+ * that paragraph. The two are different instructions, not one with a longer value.
239
+ */
240
+ kind?: "richtext" | "document";
232
241
  /** One sentence, present whenever the action alone does not say enough. */
233
242
  why?: string;
234
243
  }
@@ -287,7 +296,14 @@ declare function routeCounts(pending: readonly PendingPath[]): Map<string, numbe
287
296
  * is only `rewritten` when EVERY located occurrence of it was.
288
297
  */
289
298
 
290
- type PendingReason = "NO_ORIGINAL" | "NOT_IN_SOURCE" | "DIALECT_UNSUPPORTED" | "IN_EXPRESSION" | "IN_SCRIPT_OR_COMMENT" | "IN_DATA_FILE" | "AMBIGUOUS_LITERAL" | "KIND_MISMATCH" | "SUBSTRING_ONLY"
299
+ type PendingReason =
300
+ /**
301
+ * A Body the codemod refuses to touch, and the only pending reason that is a DECISION rather
302
+ * than a failure. Its value is a whole Portable Text array and its `original` is only the first
303
+ * block's text, so locating it lands on one paragraph — and publish then writes the entire
304
+ * article into that paragraph, or refuses it outright. The fix names the wrapper to declare.
305
+ */
306
+ "DOCUMENT_DECLARE_ONLY" | "NO_ORIGINAL" | "NOT_IN_SOURCE" | "DIALECT_UNSUPPORTED" | "IN_EXPRESSION" | "IN_SCRIPT_OR_COMMENT" | "IN_DATA_FILE" | "AMBIGUOUS_LITERAL" | "KIND_MISMATCH" | "SUBSTRING_ONLY"
291
307
  /** An INLINE richtext value whose element holds a component, a `<slot>` or an expression. */
292
308
  | "SUBTREE_NOT_HTML" | "PROP_TARGET_NOT_FOUND" | "PROP_DRILLED_DEEP" | "REPEATER_FIXED_LENGTH"
293
309
  /** One array declaration, two loops over it: the row this leaf belongs to has two homes. */
package/dist/index.js CHANGED
@@ -2560,7 +2560,11 @@ var IMAGE_SOURCES = {
2560
2560
  video: ["poster"]
2561
2561
  };
2562
2562
  var isImageSource = (site) => site.where === "attr" && (IMAGE_SOURCES[site.tag.toLowerCase()] ?? []).includes(site.attr ?? "");
2563
- var ownsSubtree = (kind, site, inline) => kind === "richtext" && (inline === true ? !site.nonHtml : !(site.mixed && site.textRange !== null));
2563
+ var ownsSubtree = (kind, site, inline) => (
2564
+ // A `document` is the same claim at article scale: the element renders EVERY block of the Body,
2565
+ // so its children come from the stored value and nothing else may be bound inside it.
2566
+ (kind === "richtext" || kind === "document") && (inline === true ? !site.nonHtml : !(site.mixed && site.textRange !== null))
2567
+ );
2564
2568
  var norm = (value) => decodeEntities(value.normalize("NFC")).replace(/\s+/g, " ").trim();
2565
2569
  var BCMS_ATTRS = [
2566
2570
  "data-bcms-field",
@@ -5369,7 +5373,7 @@ function actionFor(reason, input, file, inData) {
5369
5373
  return chosen;
5370
5374
  }
5371
5375
  function reasonAction(reason, input, file, inData) {
5372
- const richtext = input.kind === "richtext" || input.inline === true;
5376
+ const richtext = input.kind === "richtext" || input.kind === "document" || input.inline === true;
5373
5377
  switch (reason) {
5374
5378
  case "AMBIGUOUS_LITERAL":
5375
5379
  if (input.routes > 1) {
@@ -5420,6 +5424,11 @@ function reasonAction(reason, input, file, inData) {
5420
5424
  return { action: "wrap-span", why: "Only part of the element's text is this value \u2014 give it its own span." };
5421
5425
  case "SUBTREE_NOT_HTML":
5422
5426
  return { action: "declare-richtext", why: "The value is the element's whole subtree \u2014 bind it as richtext." };
5427
+ case "DOCUMENT_DECLARE_ONLY":
5428
+ return {
5429
+ action: "declare-richtext",
5430
+ why: 'This is a Body: bind the ONE element that renders every block of it \u2014 an article, a main or a div \u2014 with the richtext helper and data-bcms-kind="document". Never the paragraph holding its first block: that is where the whole article would be published.'
5431
+ };
5423
5432
  default:
5424
5433
  return { action: "manual", why: `${reason} \u2014 see the receipt's message for what blocked this path.` };
5425
5434
  }
@@ -5436,7 +5445,10 @@ function fixFor(row, input) {
5436
5445
  ...place.col !== void 0 ? { col: place.col } : {},
5437
5446
  snippet: place.snippet,
5438
5447
  action,
5439
- ...why ? { why } : {}
5448
+ ...why ? { why } : {},
5449
+ // Only `declare-richtext` carries it, and only to say WHICH container is meant: one element
5450
+ // for a rich-text value, the whole Body's wrapper for a document.
5451
+ ...action === "declare-richtext" && (input.kind === "document" || input.kind === "richtext") ? { kind: input.kind } : {}
5440
5452
  };
5441
5453
  }
5442
5454
  function routeCounts(pending) {
@@ -6389,9 +6401,11 @@ function* elementsIn(nodes) {
6389
6401
  var childElements = (node) => node.children.flatMap(
6390
6402
  (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6391
6403
  );
6392
- function candidateOf(file, dialect, form) {
6404
+ var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
6405
+ function candidateOf(file, dialect, form, imported) {
6393
6406
  const controls = /* @__PURE__ */ new Map();
6394
6407
  const wrapper = /* @__PURE__ */ new Map();
6408
+ const foreign = /* @__PURE__ */ new Set();
6395
6409
  const all = [];
6396
6410
  const walk = (parent) => {
6397
6411
  const own = [];
@@ -6399,6 +6413,17 @@ function candidateOf(file, dialect, form) {
6399
6413
  if (CONTROLS.has(child.tag.toLowerCase())) {
6400
6414
  own.push(child);
6401
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
+ }
6402
6427
  }
6403
6428
  walk(child);
6404
6429
  }
@@ -6410,10 +6435,32 @@ function candidateOf(file, dialect, form) {
6410
6435
  if (name && !controls.has(name)) controls.set(name, control);
6411
6436
  }
6412
6437
  for (const control of all) {
6438
+ if (foreign.has(control)) continue;
6413
6439
  const id = attrOf(control, "id")?.value;
6414
6440
  if (id && !controls.has(id)) controls.set(id, control);
6415
6441
  }
6416
- 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;
6417
6464
  }
6418
6465
  var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6419
6466
  function wire(source, candidate, form) {
@@ -6432,6 +6479,7 @@ function wire(source, candidate, form) {
6432
6479
  source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6433
6480
  for (const field of form.fields) {
6434
6481
  const control = candidate.controls.get(field.key);
6482
+ if (candidate.foreign.has(control)) continue;
6435
6483
  const host = candidate.wrapper.get(control) ?? control;
6436
6484
  source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6437
6485
  const name = attrOf(control, "name");
@@ -6450,6 +6498,12 @@ async function wireForms(briefIn, sources) {
6450
6498
  const forms = brief.forms ?? [];
6451
6499
  const candidates = [];
6452
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
+ };
6453
6507
  for (const source of sources) {
6454
6508
  if (!/<form[\s/>]/i.test(source.content)) continue;
6455
6509
  const dialect = dialectOf(source.path, source.content);
@@ -6470,8 +6524,9 @@ async function wireForms(briefIn, sources) {
6470
6524
  });
6471
6525
  continue;
6472
6526
  }
6527
+ const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
6473
6528
  for (const el of elementsIn(parsed.roots)) {
6474
- 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));
6475
6530
  }
6476
6531
  }
6477
6532
  const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
@@ -6544,6 +6599,14 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
6544
6599
  files.push({ path, operation: "modify", content: source.toString() });
6545
6600
  }
6546
6601
  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.`);
6602
+ for (const { form, candidate } of matched) {
6603
+ const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
6604
+ if (keys.length > 0) {
6605
+ notes.push(
6606
+ `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"}.`
6607
+ );
6608
+ }
6609
+ }
6547
6610
  return {
6548
6611
  files,
6549
6612
  receipt: {
@@ -8789,10 +8852,28 @@ async function convertSources(briefIn, sources, options) {
8789
8852
  }
8790
8853
  pend2(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
8791
8854
  }
8855
+ const documents = /* @__PURE__ */ new Set();
8856
+ for (const [key, hit] of locatedByKey) {
8857
+ const target = targets.get(key);
8858
+ if (!target || target.kind !== "document") continue;
8859
+ documents.add(key);
8860
+ const byHand = declaredByHand(target, hit.files);
8861
+ if (byHand !== null) {
8862
+ declaredOnly++;
8863
+ declaredRow(target, false, byHand);
8864
+ continue;
8865
+ }
8866
+ pend2(
8867
+ target,
8868
+ "DOCUMENT_DECLARE_ONLY",
8869
+ hit.files[0],
8870
+ "A Body is bound by declaring the one element that renders every block of it, never the paragraph that happens to hold its first block."
8871
+ );
8872
+ }
8792
8873
  const byFile = /* @__PURE__ */ new Map();
8793
8874
  for (const [key, hit] of locatedByKey) {
8794
8875
  const target = targets.get(key);
8795
- if (!target) continue;
8876
+ if (!target || documents.has(key)) continue;
8796
8877
  for (const file of hit.files) byFile.set(file, [...byFile.get(file) ?? [], target]);
8797
8878
  }
8798
8879
  const drill = {
@@ -8841,6 +8922,7 @@ async function convertSources(briefIn, sources, options) {
8841
8922
  let rewritten = 0;
8842
8923
  let alreadyDeclared = declaredOnly;
8843
8924
  for (const [key, hit] of locatedByKey) {
8925
+ if (documents.has(key)) continue;
8844
8926
  const target = targets.get(key);
8845
8927
  const outcomes = hit.files.map((file) => results.get(file)?.get(target.key) ?? "NOT_IN_SOURCE");
8846
8928
  const worst = outcomes.find((o) => o !== "rewritten" && o !== "declared");