@bettercms-ai/convert 0.8.0 → 0.10.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/cli.js CHANGED
@@ -5,7 +5,7 @@ import { readdir, readFile, writeFile, mkdir, stat } from "fs/promises";
5
5
  import { dirname, join as join2, relative, resolve, sep } from "path";
6
6
 
7
7
  // src/index.ts
8
- import MagicString5 from "magic-string";
8
+ import MagicString6 from "magic-string";
9
9
 
10
10
  // src/brief.ts
11
11
  import { createHash } from "crypto";
@@ -2562,8 +2562,18 @@ var IMAGE_SOURCES = {
2562
2562
  video: ["poster"]
2563
2563
  };
2564
2564
  var isImageSource = (site) => site.where === "attr" && (IMAGE_SOURCES[site.tag.toLowerCase()] ?? []).includes(site.attr ?? "");
2565
+ var ownsSubtree = (kind, site, inline) => kind === "richtext" && (inline === true ? !site.nonHtml : !(site.mixed && site.textRange !== null));
2565
2566
  var norm = (value) => decodeEntities(value.normalize("NFC")).replace(/\s+/g, " ").trim();
2566
- var BCMS_ATTRS = ["data-bcms-field", "data-bcms-kind", "data-bcms-props", "data-bcms-layout-field"];
2567
+ var BCMS_ATTRS = [
2568
+ "data-bcms-field",
2569
+ "data-bcms-kind",
2570
+ "data-bcms-props",
2571
+ "data-bcms-layout-field",
2572
+ // The forms lane's two. Listed here so `collectSites` never matches a brief literal against a
2573
+ // marker this package itself wrote — a form id or a field key is not the page's copy.
2574
+ "data-bcms-form",
2575
+ "data-bcms-form-field"
2576
+ ];
2567
2577
  var attrInsertAfter = (attrs, start, tag) => attrs.length > 0 ? Math.max(...attrs.map((a) => a.range.end)) : start + 1 + tag.length;
2568
2578
  function openTagEnd(content, start) {
2569
2579
  let quote = null;
@@ -2585,6 +2595,7 @@ function openTagEnd(content, start) {
2585
2595
  }
2586
2596
  return content.length;
2587
2597
  }
2598
+ var OPAQUE_TAGS = /* @__PURE__ */ new Set(["pre"]);
2588
2599
  var isBlank = (value) => value.trim() === "";
2589
2600
  function renderedText(node) {
2590
2601
  if (node.type === "text") return node.value;
@@ -2592,6 +2603,11 @@ function renderedText(node) {
2592
2603
  return "";
2593
2604
  }
2594
2605
  var attrsOf = (node) => Object.fromEntries(node.attrs.map((a) => [a.name, a.value]));
2606
+ function nonHtmlSubtree(node) {
2607
+ return node.children.some(
2608
+ (c) => c.type === "expr" || c.type === "element" && (!isIntrinsic(c.tag) || c.tag === "slot" || nonHtmlSubtree(c))
2609
+ );
2610
+ }
2595
2611
  function soleTextChild(node) {
2596
2612
  const texts = node.children.filter((c) => c.type === "text" && !isBlank(c.value));
2597
2613
  return texts.length === 1 ? texts[0] : null;
@@ -2628,10 +2644,12 @@ function collectSites(roots, literals) {
2628
2644
  if (node.type !== "element") return matched;
2629
2645
  const attrs = attrsOf(node);
2630
2646
  let index = 0;
2631
- for (const child of node.children) {
2632
- const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2633
- if (child.type === "element") index++;
2634
- for (const hit of visit(child, childWalk)) matched.add(hit);
2647
+ if (!OPAQUE_TAGS.has(node.tag)) {
2648
+ for (const child of node.children) {
2649
+ const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2650
+ if (child.type === "element") index++;
2651
+ for (const hit of visit(child, childWalk)) matched.add(hit);
2652
+ }
2635
2653
  }
2636
2654
  if (node.raw) {
2637
2655
  const text2 = norm(renderedTextRaw(node));
@@ -2657,6 +2675,7 @@ function collectSites(roots, literals) {
2657
2675
  textRange: soleTextChild(node)?.range ?? null,
2658
2676
  literal,
2659
2677
  partial: false,
2678
+ ...nonHtmlSubtree(node) ? { nonHtml: true } : {},
2660
2679
  ancestors: walk.ancestors,
2661
2680
  siblingIndex: walk.siblingIndex
2662
2681
  });
@@ -2682,6 +2701,7 @@ function collectSites(roots, literals) {
2682
2701
  textRange: own.range,
2683
2702
  literal,
2684
2703
  partial: false,
2704
+ ...nonHtmlSubtree(node) ? { nonHtml: true } : {},
2685
2705
  ancestors: walk.ancestors,
2686
2706
  siblingIndex: walk.siblingIndex
2687
2707
  });
@@ -2732,10 +2752,12 @@ function partials(node, literals, walk, out) {
2732
2752
  if (node.type !== "element" || node.raw) return false;
2733
2753
  let deeper = false;
2734
2754
  let index = 0;
2735
- for (const child of node.children) {
2736
- const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2737
- if (child.type === "element") index++;
2738
- if (partials(child, literals, childWalk, out)) deeper = true;
2755
+ if (!OPAQUE_TAGS.has(node.tag)) {
2756
+ for (const child of node.children) {
2757
+ const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2758
+ if (child.type === "element") index++;
2759
+ if (partials(child, literals, childWalk, out)) deeper = true;
2760
+ }
2739
2761
  }
2740
2762
  if (deeper) return true;
2741
2763
  const text = norm(renderedText(node));
@@ -4375,14 +4397,23 @@ function isKnownHelper(content, dialect) {
4375
4397
 
4376
4398
  // src/locate.ts
4377
4399
  var flat = (value) => value.replace(/\s+/g, " ").trim();
4378
- var stripTags = (value) => value.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
4400
+ var stripTags = (value, br = " ") => value.replace(/<br\s*\/?>/gi, br).replace(/<[^>]*>/g, "");
4379
4401
  function locate(brief, sources, options) {
4380
4402
  const flattened = sources.map((s) => {
4381
4403
  const readable = unwrapJsxStrings(decodeEntities(s.content));
4382
4404
  return {
4383
4405
  path: s.path,
4384
4406
  content: flat(readable),
4385
- text: options?.stripTags ? flat(stripTags(readable)) : null
4407
+ text: options?.stripTags ? flat(stripTags(readable)) : null,
4408
+ /**
4409
+ * 🔴 A `<br>` IS A SPACE TO A READER AND NOTHING TO `textContent`, and the derive lane reads
4410
+ * the DOM. `<h1>Launch your site<br /><span>fast.</span></h1>` is one INLINE richtext field
4411
+ * whose value is `Launch your sitefast.` — no space, because no text node holds one — while
4412
+ * this prefilter's only reading inserted one, so the file was never opened and every split
4413
+ * heading on every imported site came back NOT_IN_SOURCE. Both readings are searched: this
4414
+ * decides which FILES to open, and `collectSites` still has to match an element exactly.
4415
+ */
4416
+ tight: options?.stripTags ? flat(stripTags(readable, "")) : null
4386
4417
  };
4387
4418
  });
4388
4419
  const located = [];
@@ -4394,7 +4425,9 @@ function locate(brief, sources, options) {
4394
4425
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "no-original" });
4395
4426
  continue;
4396
4427
  }
4397
- const files = flattened.filter((f) => f.content.includes(original) || f.text !== null && f.text.includes(original)).map((f) => f.path);
4428
+ const files = flattened.filter(
4429
+ (f) => f.content.includes(original) || f.text !== null && f.text.includes(original) || f.tight !== null && f.tight.includes(original)
4430
+ ).map((f) => f.path);
4398
4431
  if (files.length === 0) {
4399
4432
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "not-in-source" });
4400
4433
  continue;
@@ -5093,7 +5126,7 @@ function rewriteFile(content, dialect, rewrites, imports, extra = []) {
5093
5126
  addDeclaration(site.attrInsertAt, declaration(rewrite));
5094
5127
  continue;
5095
5128
  }
5096
- if (kind === "richtext" && !(site.mixed && site.textRange)) {
5129
+ if (ownsSubtree(kind, site, rewrite.inline)) {
5097
5130
  if (!isIntrinsic(site.tag)) continue;
5098
5131
  if (rule.richtext && site.inner) {
5099
5132
  source.remove(site.inner.start, site.inner.end);
@@ -5162,6 +5195,22 @@ function importLines(imports) {
5162
5195
 
5163
5196
  // src/receipt.ts
5164
5197
  var CANVAS_REASONS = /* @__PURE__ */ new Set(["CANVAS_BRIDGE_MANUAL", "SSR_DRAFT_ROUTE"]);
5198
+ var FormInvariantError = class extends Error {
5199
+ code = "FORM_INVARIANT";
5200
+ constructor(message) {
5201
+ super(message);
5202
+ this.name = "FormInvariantError";
5203
+ }
5204
+ };
5205
+ function assertForms(total, forms) {
5206
+ const sum = forms.wired + forms.alreadyWired + forms.pending.length;
5207
+ if (sum !== total) {
5208
+ throw new FormInvariantError(
5209
+ `The receipt does not account for every form: ${total} listed but ${forms.wired} wired + ${forms.alreadyWired} already wired + ${forms.pending.length} pending = ${sum}.`
5210
+ );
5211
+ }
5212
+ return forms;
5213
+ }
5165
5214
  var ReceiptInvariantError = class extends Error {
5166
5215
  code = "RECEIPT_INVARIANT";
5167
5216
  constructor(message) {
@@ -6001,6 +6050,17 @@ function readBrief(value) {
6001
6050
  }
6002
6051
  }
6003
6052
  }
6053
+ for (const [at, entry] of check.array(brief.forms ?? [], `the brief's "forms"`).entries()) {
6054
+ const where = `brief.forms[${at}]`;
6055
+ const form = check.record(entry, where);
6056
+ check.segment(form.id, `${where}.id`);
6057
+ check.string(form.name, `${where}.name`);
6058
+ check.string(form.submitUrl, `${where}.submitUrl`);
6059
+ check.optionalString(form.successMessage, `${where}.successMessage`);
6060
+ for (const [n, held] of check.array(form.fields, `${where}.fields`).entries()) {
6061
+ check.segment(check.record(held, `${where}.fields[${n}]`).key, `${where}.fields[${n}].key`);
6062
+ }
6063
+ }
6004
6064
  return brief;
6005
6065
  }
6006
6066
  function readPlan(value) {
@@ -6047,6 +6107,236 @@ function readPlan(value) {
6047
6107
  return plan2;
6048
6108
  }
6049
6109
 
6110
+ // src/forms.ts
6111
+ import MagicString4 from "magic-string";
6112
+ var WIRABLE = ["html", "astro", "jsx"];
6113
+ var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6114
+ var FORM_SCRIPT = `<script>
6115
+ document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
6116
+ f.addEventListener('submit',function(e){
6117
+ e.preventDefault();
6118
+ var msg=f.querySelector('.bcms-form-msg');
6119
+ var data={};
6120
+ // A checkboxes group posts one entry per checked box under the SAME name; the old
6121
+ // one-at-a-time assignment kept only the last, so "pick many" delivered one answer.
6122
+ // One checked box still arrives as a scalar \u2014 FormData cannot tell a group from a
6123
+ // lone field \u2014 which is exactly why lib/forms/validate.ts's chosenValues() accepts
6124
+ // both an array and a comma string.
6125
+ new FormData(f).forEach(function(v,k){
6126
+ if(k==='cf-turnstile-response')return;
6127
+ if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);
6128
+ else data[k]=v;
6129
+ });
6130
+ var body={data:data};
6131
+ var tok=f.querySelector('[name="cf-turnstile-response"]');
6132
+ if(tok&&tok.value)body['cf-turnstile-response']=tok.value;
6133
+ fetch(f.action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
6134
+ .then(function(r){return r.ok;})
6135
+ .then(function(ok){if(msg){msg.hidden=false;msg.textContent=ok?(f.getAttribute('data-bcms-form-success')||'Thanks! Your submission was received.'):'Sorry, something went wrong.';}if(ok)f.reset();})
6136
+ .catch(function(){if(msg){msg.hidden=false;msg.textContent='Sorry, something went wrong.';}});
6137
+ });
6138
+ });
6139
+ </script>`;
6140
+ var SCRIPT_BODY = FORM_SCRIPT.slice("<script>".length, -"</script>".length);
6141
+ function scriptFor(dialect) {
6142
+ if (dialect === "jsx") return `<script dangerouslySetInnerHTML={{ __html: \`${SCRIPT_BODY}\` }} />`;
6143
+ if (dialect === "astro") return `<script is:inline>${SCRIPT_BODY}</script>`;
6144
+ return FORM_SCRIPT;
6145
+ }
6146
+ var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
6147
+ var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
6148
+ var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
6149
+ function* elementsIn(nodes) {
6150
+ for (const node of nodes) {
6151
+ if (node.type === "expr") {
6152
+ yield* elementsIn(node.children);
6153
+ continue;
6154
+ }
6155
+ if (node.type !== "element") continue;
6156
+ yield node;
6157
+ yield* elementsIn(node.children);
6158
+ }
6159
+ }
6160
+ var childElements = (node) => node.children.flatMap(
6161
+ (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6162
+ );
6163
+ function candidateOf(file, dialect, form) {
6164
+ const controls = /* @__PURE__ */ new Map();
6165
+ const wrapper = /* @__PURE__ */ new Map();
6166
+ const all = [];
6167
+ const walk = (parent) => {
6168
+ const own = [];
6169
+ for (const child of childElements(parent)) {
6170
+ if (CONTROLS.has(child.tag.toLowerCase())) {
6171
+ own.push(child);
6172
+ all.push(child);
6173
+ }
6174
+ walk(child);
6175
+ }
6176
+ if (own.length === 1 && parent !== form) wrapper.set(own[0], parent);
6177
+ };
6178
+ walk(form);
6179
+ for (const control of all) {
6180
+ const name = attrOf(control, "name")?.value;
6181
+ if (name && !controls.has(name)) controls.set(name, control);
6182
+ }
6183
+ for (const control of all) {
6184
+ const id = attrOf(control, "id")?.value;
6185
+ if (id && !controls.has(id)) controls.set(id, control);
6186
+ }
6187
+ return { file, dialect, el: form, controls, wrapper };
6188
+ }
6189
+ var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6190
+ function wire(source, candidate, form) {
6191
+ const { el, dialect } = candidate;
6192
+ const inserts = [];
6193
+ const set = (name, value) => {
6194
+ const attr = attrOf(el, name);
6195
+ if (!attr) inserts.push(`${name}="${escapeAttr(value)}"`);
6196
+ else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6197
+ else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6198
+ };
6199
+ set("action", form.submitUrl);
6200
+ set("method", "post");
6201
+ inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
6202
+ if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
6203
+ source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6204
+ for (const field of form.fields) {
6205
+ const control = candidate.controls.get(field.key);
6206
+ const host = candidate.wrapper.get(control) ?? control;
6207
+ source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6208
+ const name = attrOf(control, "name");
6209
+ if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
6210
+ else if (!name.expression && name.value !== field.key && name.valueRange) {
6211
+ source.overwrite(name.valueRange.start, name.valueRange.end, escapeAttr(field.key));
6212
+ }
6213
+ }
6214
+ if (el.inner && !hasMessage(el)) {
6215
+ const tail = /\s*$/.exec(source.original.slice(el.inner.start, el.inner.end))[0];
6216
+ source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6217
+ }
6218
+ }
6219
+ async function wireForms(briefIn, sources) {
6220
+ const brief = readBrief(briefIn);
6221
+ const forms = brief.forms ?? [];
6222
+ const candidates = [];
6223
+ const unreadable = [];
6224
+ for (const source of sources) {
6225
+ if (!/<form[\s/>]/i.test(source.content)) continue;
6226
+ const dialect = dialectOf(source.path, source.content);
6227
+ if (!dialect || !WIRABLE.includes(dialect)) {
6228
+ unreadable.push({
6229
+ file: source.path,
6230
+ reason: "DIALECT_UNSUPPORTED",
6231
+ message: `${source.path} is ${dialect ?? "not a template"}.`
6232
+ });
6233
+ continue;
6234
+ }
6235
+ const parsed = await parseFile4(dialect, source.content);
6236
+ if (parsed.error) {
6237
+ unreadable.push({
6238
+ file: source.path,
6239
+ reason: parsed.error.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR",
6240
+ message: parsed.error.message
6241
+ });
6242
+ continue;
6243
+ }
6244
+ for (const el of elementsIn(parsed.roots)) {
6245
+ if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
6246
+ }
6247
+ }
6248
+ const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6249
+ const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6250
+ const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6251
+ const wanted = /* @__PURE__ */ new Map();
6252
+ for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6253
+ const matched = [];
6254
+ const pending = [];
6255
+ const onSite = [];
6256
+ let alreadyWired = 0;
6257
+ const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
6258
+ for (const form of forms) {
6259
+ const list = options.get(form.id) ?? [];
6260
+ if (list.length > 1) {
6261
+ pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
6262
+ continue;
6263
+ }
6264
+ if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
6265
+ pend2(form, "FORM_AMBIGUOUS", list[0].file, "more than one CMS form's fields are posted by this <form>.");
6266
+ continue;
6267
+ }
6268
+ if (list.length === 0) {
6269
+ const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6270
+ if (best) {
6271
+ const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6272
+ pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6273
+ } else if (unreadable[0]) {
6274
+ pend2(form, unreadable[0].reason, unreadable[0].file, unreadable[0].message);
6275
+ } else {
6276
+ pend2(form, "FORM_NOT_IN_SOURCE", void 0, `no <form> posts ${form.fields.map((f) => f.key).join(", ") || "any field of this form"}.`);
6277
+ }
6278
+ continue;
6279
+ }
6280
+ const candidate = list[0];
6281
+ if (attrOf(candidate.el, "action")?.expression) {
6282
+ pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6283
+ continue;
6284
+ }
6285
+ const misnamed = form.fields.find((field) => {
6286
+ const attr = attrOf(candidate.controls.get(field.key), "name");
6287
+ return !!attr && attr.value !== field.key && (attr.expression || !attr.valueRange);
6288
+ });
6289
+ if (misnamed) {
6290
+ 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.`);
6291
+ continue;
6292
+ }
6293
+ if (attrOf(candidate.el, "data-bcms-form")) {
6294
+ alreadyWired++;
6295
+ onSite.push(form);
6296
+ continue;
6297
+ }
6298
+ matched.push({ form, candidate });
6299
+ onSite.push(form);
6300
+ }
6301
+ const contents = new Map(sources.map((s) => [s.path, s.content]));
6302
+ const byFile = /* @__PURE__ */ new Map();
6303
+ for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
6304
+ const files = [];
6305
+ for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6306
+ const content = contents.get(path);
6307
+ const source = new MagicString4(content);
6308
+ for (const { form, candidate } of list) wire(source, candidate, form);
6309
+ if (!content.includes("form[data-bcms-form]")) {
6310
+ const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6311
+ const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6312
+ source.appendRight(last.candidate.el.end, `
6313
+ ${indent}${scriptFor(last.candidate.dialect)}`);
6314
+ }
6315
+ files.push({ path, operation: "modify", content: source.toString() });
6316
+ }
6317
+ const notes = onSite.filter((f) => f.status === "draft").map((f) => `publish form ${f.name} in the Forms tab \u2014 a draft form rejects every submission with 403.`);
6318
+ return {
6319
+ files,
6320
+ receipt: {
6321
+ briefDigest: briefDigest(brief),
6322
+ // This lane binds no brief PATH, and says so rather than leaving the arithmetic to a reader:
6323
+ // a receipt claiming zero of zero paths is exactly what a forms-only run did.
6324
+ paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6325
+ occurrences: { located: 0, rewritten: 0 },
6326
+ files: [],
6327
+ helpers: [],
6328
+ helperUpgraded: false,
6329
+ dynamicBindings: [],
6330
+ forms: assertForms(forms.length, {
6331
+ wired: matched.length,
6332
+ alreadyWired,
6333
+ pending,
6334
+ ...notes.length > 0 ? { notes } : {}
6335
+ })
6336
+ }
6337
+ };
6338
+ }
6339
+
6050
6340
  // src/scan.ts
6051
6341
  var SKIP_DIRS = [
6052
6342
  "node_modules/",
@@ -6110,6 +6400,66 @@ function isSourceCandidate(path) {
6110
6400
  return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
6111
6401
  }
6112
6402
 
6403
+ // src/xray.ts
6404
+ var SKIP_TAGS = /* @__PURE__ */ new Set(["svg", "iframe", "head", "title"]);
6405
+ var CONTEXT_TAGS = /* @__PURE__ */ new Set([
6406
+ "nav",
6407
+ "header",
6408
+ "footer",
6409
+ "main",
6410
+ "aside",
6411
+ "form",
6412
+ "button",
6413
+ "a",
6414
+ "ul",
6415
+ "ol",
6416
+ "table",
6417
+ "dialog",
6418
+ "details",
6419
+ "figure",
6420
+ "blockquote"
6421
+ ]);
6422
+ var BOUND_ATTRS = ["data-bcms-field", "data-bcms-layout-field", "data-bcms-path", "data-bcms-block"];
6423
+ var isDecorativeGlyph = (text) => text.length <= 2 || /^\d{1,2}\s*[/.)\-–]?$/.test(text) || !new RegExp("\\p{L}", "u").test(text) || /^made with\b/i.test(text) || /^skip to\b/i.test(text);
6424
+ var MAX_BUCKETS = 12;
6425
+ var flat2 = (value) => value.replace(/\s+/g, " ").trim();
6426
+ function measureUnaddressable(html) {
6427
+ const parsed = parse2(html);
6428
+ let visible = 0;
6429
+ let unaddressable = 0;
6430
+ const buckets = /* @__PURE__ */ new Map();
6431
+ const visit = (node, tag, context, bound) => {
6432
+ if (node.type === "text") {
6433
+ const text = flat2(node.value);
6434
+ if (!text || isDecorativeGlyph(text)) return;
6435
+ visible += text.length;
6436
+ if (bound) return;
6437
+ unaddressable += text.length;
6438
+ const key = `${tag} @ ${context}`;
6439
+ const held = buckets.get(key) ?? { tag, context, chars: 0, nodes: 0, samples: [] };
6440
+ held.chars += text.length;
6441
+ held.nodes += 1;
6442
+ if (held.samples.length < 2) held.samples.push(text.slice(0, 50));
6443
+ buckets.set(key, held);
6444
+ return;
6445
+ }
6446
+ if (node.type !== "element" || node.raw || SKIP_TAGS.has(node.tag)) return;
6447
+ const attrs = new Map(node.attrs.map((a) => [a.name, a.value]));
6448
+ if (attrs.has("hidden") || attrs.get("aria-hidden") === "true" || /display\s*:\s*none/.test(attrs.get("style") ?? "")) {
6449
+ return;
6450
+ }
6451
+ const owned = bound || BOUND_ATTRS.some((name) => attrs.has(name));
6452
+ const chain = CONTEXT_TAGS.has(node.tag) ? context === "body" ? node.tag : `${context}>${node.tag}` : context;
6453
+ for (const child of node.children) visit(child, node.tag, chain, owned);
6454
+ };
6455
+ for (const root of parsed.roots) visit(root, "body", "body", false);
6456
+ return {
6457
+ visible,
6458
+ unaddressable,
6459
+ buckets: [...buckets.values()].sort((a, b) => b.chars - a.chars).slice(0, MAX_BUCKETS)
6460
+ };
6461
+ }
6462
+
6113
6463
  // src/carry.ts
6114
6464
  var GLOBALS = /* @__PURE__ */ new Set([
6115
6465
  "Astro",
@@ -6407,7 +6757,7 @@ function moduleScope(parsed, content) {
6407
6757
  }
6408
6758
 
6409
6759
  // src/componentize.ts
6410
- import MagicString4 from "magic-string";
6760
+ import MagicString5 from "magic-string";
6411
6761
  var SectionInvariantError = class extends Error {
6412
6762
  code = "SECTION_INVARIANT";
6413
6763
  constructor(message) {
@@ -7333,7 +7683,7 @@ export default function ${resolved.name}({ blockId, bind, overrides, page }: Bcm
7333
7683
  ` };
7334
7684
  }
7335
7685
  function sectionMarkup(content, root, fieldName) {
7336
- const slice = new MagicString4(content.slice(root.start, root.end));
7686
+ const slice = new MagicString5(content.slice(root.start, root.end));
7337
7687
  const at = (offset) => offset - root.start;
7338
7688
  const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
7339
7689
  if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
@@ -7532,7 +7882,7 @@ async function migrateSections(plan2, contents, written, fileSet, aliases) {
7532
7882
  );
7533
7883
  continue;
7534
7884
  }
7535
- const source = new MagicString4(content);
7885
+ const source = new MagicString5(content);
7536
7886
  const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
7537
7887
  if (duplicates.length > 0) {
7538
7888
  refused.set(refusalKey(dialect, slug), `${entry.path} has ${duplicates.join(", ")} carrying more than one data-bcms-field, so there is no single address to rewrite.`);
@@ -7809,7 +8159,7 @@ export default function Sections({ page }: { page: string }) {
7809
8159
  function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
7810
8160
  const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
7811
8161
  const local = names.get("Sections");
7812
- const source = new MagicString4(content);
8162
+ const source = new MagicString5(content);
7813
8163
  const first = placeable[0].root;
7814
8164
  const last = placeable[placeable.length - 1].root;
7815
8165
  source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
@@ -7829,7 +8179,7 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
7829
8179
  { key: "pageSnapshot", name: "pageSnapshot" }
7830
8180
  ]);
7831
8181
  const snapshot = names.get("pageSnapshot");
7832
- const source = new MagicString4(content);
8182
+ const source = new MagicString5(content);
7833
8183
  for (const resolved of placeable) {
7834
8184
  const component = written.get(resolved.slug);
7835
8185
  const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
@@ -7990,7 +8340,8 @@ async function convertSources(briefIn, sources, options) {
7990
8340
  kind: p.kind,
7991
8341
  fallback: p.original ?? "",
7992
8342
  literal: norm(p.original ?? ""),
7993
- locator: p.locator
8343
+ locator: p.locator,
8344
+ ...p.inline ? { inline: true } : {}
7994
8345
  });
7995
8346
  }
7996
8347
  }
@@ -8215,8 +8566,18 @@ async function convertSources(briefIn, sources, options) {
8215
8566
  declaredRow(target, witness !== null, witness ?? byHand);
8216
8567
  } else rewritten++;
8217
8568
  }
8569
+ const templated = dynamicBindings.filter((d) => d.prop === null);
8570
+ for (let i = pending.length - 1; i >= 0; i--) {
8571
+ const row = pending[i];
8572
+ const target = targets.get(keyOf(row.route, row.scope, row.path));
8573
+ if (row.reason !== "NO_ORIGINAL" || !target || target.fallback !== "") continue;
8574
+ const shape = shapeOfPath(target.path);
8575
+ if (!templated.some((d) => d.path === shape && attributable(target, d.file))) continue;
8576
+ pending.splice(i, 1);
8577
+ rewritten++;
8578
+ }
8218
8579
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
8219
- const source = new MagicString5(contents.get(file));
8580
+ const source = new MagicString6(contents.get(file));
8220
8581
  const seen = /* @__PURE__ */ new Set();
8221
8582
  for (const splice of splices) {
8222
8583
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -8527,6 +8888,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8527
8888
  }
8528
8889
  let refusal = null;
8529
8890
  for (const site of fresh) {
8891
+ if (target.inline && site.where === "text" && !ownsSubtree(target.kind, site, true)) {
8892
+ refusal = "SUBTREE_NOT_HTML";
8893
+ continue;
8894
+ }
8530
8895
  let bindings;
8531
8896
  let bindingsMerge;
8532
8897
  if (site.where === "prop" && site.attr) {
@@ -8561,7 +8926,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8561
8926
  // 🔴 AND ONLY WHEN THE ELEMENT'S SUBTREE IS THE VALUE. A value that is one TEXT NODE of
8562
8927
  // a mixed element gets a wrapper of its own, so taking the element's inner range as its
8563
8928
  // fallback swallowed the `<em>` beside it — the wrapper then rendered the sibling twice.
8564
- fallback: target.kind === "richtext" && site.inner && !(site.mixed && site.textRange) ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
8929
+ ...target.inline ? { inline: true } : {},
8930
+ fallback: site.inner && ownsSubtree(target.kind, site, target.inline) ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
8565
8931
  snapshot: snapshotName(target.slug),
8566
8932
  slug: target.slug,
8567
8933
  scope: target.scope,
@@ -8919,8 +9285,62 @@ function emitStubs2(dialects, slugs, contents, out) {
8919
9285
  }
8920
9286
  }
8921
9287
 
9288
+ // package.json
9289
+ var package_default = {
9290
+ name: "@bettercms-ai/convert",
9291
+ version: "0.10.0",
9292
+ type: "module",
9293
+ 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.",
9294
+ bin: {
9295
+ "bettercms-convert": "dist/cli.js"
9296
+ },
9297
+ main: "./dist/index.js",
9298
+ module: "./dist/index.js",
9299
+ types: "./dist/index.d.ts",
9300
+ sideEffects: false,
9301
+ exports: {
9302
+ ".": {
9303
+ types: "./dist/index.d.ts",
9304
+ import: "./dist/index.js",
9305
+ default: "./dist/index.js"
9306
+ }
9307
+ },
9308
+ files: [
9309
+ "dist",
9310
+ "README.md",
9311
+ "CHANGELOG.md"
9312
+ ],
9313
+ scripts: {
9314
+ typecheck: "tsc --noEmit",
9315
+ build: "tsup",
9316
+ test: "vitest run",
9317
+ "test:watch": "vitest",
9318
+ briefs: "bun --conditions=development scripts/build-briefs.ts"
9319
+ },
9320
+ dependencies: {
9321
+ "@astrojs/compiler": "^2.13.1",
9322
+ "@babel/parser": "^7.29.2",
9323
+ "@bettercms-ai/types": "^1.12.0",
9324
+ "@vue/compiler-sfc": "^3.5.0",
9325
+ "magic-string": "^0.30.21",
9326
+ parse5: "^7.3.0",
9327
+ svelte: "^5.0.0"
9328
+ },
9329
+ devDependencies: {
9330
+ "@types/node": "^20",
9331
+ esbuild: "^0.27.7",
9332
+ tsup: "^8.5.1",
9333
+ typescript: "^5",
9334
+ vitest: "^4.1.4"
9335
+ },
9336
+ publishConfig: {
9337
+ access: "public",
9338
+ registry: "https://registry.npmjs.org/"
9339
+ }
9340
+ };
9341
+
8922
9342
  // src/cli.ts
8923
- var VERSION = "0.4.0";
9343
+ var { version: VERSION } = package_default;
8924
9344
  var MAX_FILE_BYTES = 512 * 1024;
8925
9345
  var CliError = class extends Error {
8926
9346
  };
@@ -8928,6 +9348,8 @@ function parseArgs(argv) {
8928
9348
  const args = {
8929
9349
  brief: void 0,
8930
9350
  componentize: false,
9351
+ forms: false,
9352
+ xray: void 0,
8931
9353
  plan: void 0,
8932
9354
  copy: "bind",
8933
9355
  root: ".",
@@ -8942,6 +9364,8 @@ function parseArgs(argv) {
8942
9364
  const arg = argv[i];
8943
9365
  if (arg === "--brief") args.brief = argv[++i];
8944
9366
  else if (arg === "--componentize") args.componentize = true;
9367
+ else if (arg === "--forms") args.forms = true;
9368
+ else if (arg === "--xray") args.xray = argv[++i];
8945
9369
  else if (arg === "--plan") args.plan = argv[++i];
8946
9370
  else if (arg === "--copy") {
8947
9371
  const mode = argv[++i];
@@ -8968,9 +9392,20 @@ var HELP = `bettercms-convert ${VERSION}
8968
9392
 
8969
9393
  bettercms-convert --brief <json> --root . [options]
8970
9394
  bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
9395
+ bettercms-convert --forms --brief <json> --root . [options]
9396
+ bettercms-convert --xray <dist-dir>
8971
9397
 
8972
9398
  --brief <file> The conversion brief, as get_conversion_brief returns it. Required.
8973
9399
  --componentize Lift each planned SECTION into a component the CMS can reorder.
9400
+ --forms Point each <form> in the repository at the CMS form behind it \u2014 the
9401
+ endpoint, the id, a marker per field and the submit script. A form
9402
+ the brief lists as a DRAFT is wired and receipted: publish it in the
9403
+ Forms tab, or it rejects every submission with a 403.
9404
+ --xray <dist-dir> Measure a LOCAL build instead of converting: how much of each page's
9405
+ visible copy no binding owns, and the biggest buckets of it. Run it
9406
+ after a build to see what the next conversion still has to reach,
9407
+ without deploying. Takes no --brief. Exit 2 with --strict when any
9408
+ page is above 2 %.
8974
9409
  --plan <file> The componentize plan, as get_componentize_plan returns it.
8975
9410
  Required with --componentize.
8976
9411
  --copy bind|instance Which copy model the plan is expected to describe. Default: bind
@@ -9053,6 +9488,26 @@ ${rewritten} of ${declared} paths bound (${alreadyDeclared} already declared, ${
9053
9488
  `);
9054
9489
  }
9055
9490
  }
9491
+ function reportForms(files, receipt, dryRun) {
9492
+ const { wired, alreadyWired, pending, notes } = receipt.forms;
9493
+ for (const file of files) {
9494
+ process.stdout.write(`${dryRun ? "would " : ""}${file.operation} ${file.path}
9495
+ `);
9496
+ }
9497
+ process.stdout.write(
9498
+ `
9499
+ ${wired} forms wired (${alreadyWired} already wired, ${pending.length} pending)
9500
+ `
9501
+ );
9502
+ for (const form of pending) {
9503
+ process.stdout.write(
9504
+ ` pending ${form.name} \u2014 ${form.reason}${form.message ? ` (${form.message})` : ""}${form.file ? ` [${form.file}]` : ""}
9505
+ `
9506
+ );
9507
+ }
9508
+ for (const note of notes ?? []) process.stdout.write(` todo ${note}
9509
+ `);
9510
+ }
9056
9511
  function reportSections(files, receipt, dryRun) {
9057
9512
  const { extracted, inlineOnly, alreadyExtracted, registered, pending } = receipt.sections;
9058
9513
  for (const file of files) {
@@ -9071,12 +9526,53 @@ ${extracted} sections extracted (${registered} registered, ${inlineOnly} inline-
9071
9526
  );
9072
9527
  }
9073
9528
  }
9529
+ async function xray(dir, strict) {
9530
+ const pages = [];
9531
+ const walk = async (at) => {
9532
+ for (const entry of (await readdir(at, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
9533
+ const full = join2(at, entry.name);
9534
+ if (entry.isDirectory()) await walk(full);
9535
+ else if (/\.html?$/i.test(entry.name)) pages.push(full);
9536
+ }
9537
+ };
9538
+ try {
9539
+ await walk(dir);
9540
+ } catch (error) {
9541
+ throw new CliError(`${dir} could not be read (${error.message}). Build the site first.`);
9542
+ }
9543
+ if (pages.length === 0) throw new CliError(`No .html files under ${dir}. Is that the build output?`);
9544
+ let over = 0;
9545
+ for (const page of pages) {
9546
+ const { visible, unaddressable, buckets } = measureUnaddressable(await readFile(page, "utf-8"));
9547
+ const share = visible === 0 ? 0 : 100 * unaddressable / visible;
9548
+ if (share > 2) over++;
9549
+ process.stdout.write(
9550
+ `${relative(dir, page) || page} visible=${visible} unaddressable=${unaddressable} (${share.toFixed(1)}%)
9551
+ `
9552
+ );
9553
+ for (const bucket of buckets) {
9554
+ const samples = bucket.samples.map((s) => JSON.stringify(s)).join(" | ");
9555
+ process.stdout.write(
9556
+ ` ${String(bucket.chars).padStart(6)}ch ${String(bucket.nodes).padStart(4)}n ${bucket.tag} @ ${bucket.context} e.g. ${samples}
9557
+ `
9558
+ );
9559
+ }
9560
+ }
9561
+ process.stdout.write(`
9562
+ ${pages.length - over} of ${pages.length} pages at or under 2% unaddressable
9563
+ `);
9564
+ return strict && over > 0 ? 2 : 0;
9565
+ }
9074
9566
  async function main() {
9075
9567
  const args = parseArgs(process.argv.slice(2));
9076
9568
  if (args.help) {
9077
9569
  process.stdout.write(HELP);
9078
9570
  return 0;
9079
9571
  }
9572
+ if (args.xray) {
9573
+ if (args.brief) throw new CliError("--xray measures a build; it reads no brief. See --help.");
9574
+ return await xray(resolve(args.xray), args.strict);
9575
+ }
9080
9576
  if (!args.brief) throw new CliError("--brief <file> is required. See --help.");
9081
9577
  if (args.plan && !args.componentize) {
9082
9578
  throw new CliError("--plan is only read with --componentize. See --help.");
@@ -9084,6 +9580,9 @@ async function main() {
9084
9580
  if (args.copy === "instance" && !args.componentize) {
9085
9581
  throw new CliError("--copy is only read with --componentize. See --help.");
9086
9582
  }
9583
+ if (args.forms && args.componentize) {
9584
+ throw new CliError("--forms and --componentize are separate runs. Do one, then the other. See --help.");
9585
+ }
9087
9586
  const root = resolve(args.root);
9088
9587
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
9089
9588
  if (!Array.isArray(brief.pages)) {
@@ -9124,6 +9623,13 @@ async function main() {
9124
9623
  reportSections(files2, receipt2, args.dryRun);
9125
9624
  return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
9126
9625
  }
9626
+ if (args.forms) {
9627
+ const { files: files2, receipt: receipt2 } = await wireForms(brief, sources);
9628
+ await written(files2);
9629
+ await saveReceipt(receipt2);
9630
+ reportForms(files2, receipt2, args.dryRun);
9631
+ return args.strict && receipt2.forms.pending.length > 0 ? 2 : 0;
9632
+ }
9127
9633
  const { files, receipt } = await convertSources(brief, sources, {
9128
9634
  overwriteHelper: args.overwriteHelper
9129
9635
  });