@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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import MagicString5 from "magic-string";
2
+ import MagicString6 from "magic-string";
3
3
 
4
4
  // src/brief.ts
5
5
  import { createHash } from "crypto";
@@ -2560,8 +2560,18 @@ 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
2564
  var norm = (value) => decodeEntities(value.normalize("NFC")).replace(/\s+/g, " ").trim();
2564
- var BCMS_ATTRS = ["data-bcms-field", "data-bcms-kind", "data-bcms-props", "data-bcms-layout-field"];
2565
+ var BCMS_ATTRS = [
2566
+ "data-bcms-field",
2567
+ "data-bcms-kind",
2568
+ "data-bcms-props",
2569
+ "data-bcms-layout-field",
2570
+ // The forms lane's two. Listed here so `collectSites` never matches a brief literal against a
2571
+ // marker this package itself wrote — a form id or a field key is not the page's copy.
2572
+ "data-bcms-form",
2573
+ "data-bcms-form-field"
2574
+ ];
2565
2575
  var attrInsertAfter = (attrs, start, tag) => attrs.length > 0 ? Math.max(...attrs.map((a) => a.range.end)) : start + 1 + tag.length;
2566
2576
  function openTagEnd(content, start) {
2567
2577
  let quote = null;
@@ -2583,6 +2593,7 @@ function openTagEnd(content, start) {
2583
2593
  }
2584
2594
  return content.length;
2585
2595
  }
2596
+ var OPAQUE_TAGS = /* @__PURE__ */ new Set(["pre"]);
2586
2597
  var isBlank = (value) => value.trim() === "";
2587
2598
  function renderedText(node) {
2588
2599
  if (node.type === "text") return node.value;
@@ -2590,6 +2601,11 @@ function renderedText(node) {
2590
2601
  return "";
2591
2602
  }
2592
2603
  var attrsOf = (node) => Object.fromEntries(node.attrs.map((a) => [a.name, a.value]));
2604
+ function nonHtmlSubtree(node) {
2605
+ return node.children.some(
2606
+ (c) => c.type === "expr" || c.type === "element" && (!isIntrinsic(c.tag) || c.tag === "slot" || nonHtmlSubtree(c))
2607
+ );
2608
+ }
2593
2609
  function soleTextChild(node) {
2594
2610
  const texts = node.children.filter((c) => c.type === "text" && !isBlank(c.value));
2595
2611
  return texts.length === 1 ? texts[0] : null;
@@ -2626,10 +2642,12 @@ function collectSites(roots, literals) {
2626
2642
  if (node.type !== "element") return matched;
2627
2643
  const attrs = attrsOf(node);
2628
2644
  let index = 0;
2629
- for (const child of node.children) {
2630
- const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2631
- if (child.type === "element") index++;
2632
- for (const hit of visit(child, childWalk)) matched.add(hit);
2645
+ if (!OPAQUE_TAGS.has(node.tag)) {
2646
+ for (const child of node.children) {
2647
+ const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2648
+ if (child.type === "element") index++;
2649
+ for (const hit of visit(child, childWalk)) matched.add(hit);
2650
+ }
2633
2651
  }
2634
2652
  if (node.raw) {
2635
2653
  const text2 = norm(renderedTextRaw(node));
@@ -2655,6 +2673,7 @@ function collectSites(roots, literals) {
2655
2673
  textRange: soleTextChild(node)?.range ?? null,
2656
2674
  literal,
2657
2675
  partial: false,
2676
+ ...nonHtmlSubtree(node) ? { nonHtml: true } : {},
2658
2677
  ancestors: walk.ancestors,
2659
2678
  siblingIndex: walk.siblingIndex
2660
2679
  });
@@ -2680,6 +2699,7 @@ function collectSites(roots, literals) {
2680
2699
  textRange: own.range,
2681
2700
  literal,
2682
2701
  partial: false,
2702
+ ...nonHtmlSubtree(node) ? { nonHtml: true } : {},
2683
2703
  ancestors: walk.ancestors,
2684
2704
  siblingIndex: walk.siblingIndex
2685
2705
  });
@@ -2730,10 +2750,12 @@ function partials(node, literals, walk, out) {
2730
2750
  if (node.type !== "element" || node.raw) return false;
2731
2751
  let deeper = false;
2732
2752
  let index = 0;
2733
- for (const child of node.children) {
2734
- const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2735
- if (child.type === "element") index++;
2736
- if (partials(child, literals, childWalk, out)) deeper = true;
2753
+ if (!OPAQUE_TAGS.has(node.tag)) {
2754
+ for (const child of node.children) {
2755
+ const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2756
+ if (child.type === "element") index++;
2757
+ if (partials(child, literals, childWalk, out)) deeper = true;
2758
+ }
2737
2759
  }
2738
2760
  if (deeper) return true;
2739
2761
  const text = norm(renderedText(node));
@@ -4373,14 +4395,23 @@ function isKnownHelper(content, dialect) {
4373
4395
 
4374
4396
  // src/locate.ts
4375
4397
  var flat = (value) => value.replace(/\s+/g, " ").trim();
4376
- var stripTags = (value) => value.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
4398
+ var stripTags = (value, br = " ") => value.replace(/<br\s*\/?>/gi, br).replace(/<[^>]*>/g, "");
4377
4399
  function locate(brief, sources, options) {
4378
4400
  const flattened = sources.map((s) => {
4379
4401
  const readable = unwrapJsxStrings(decodeEntities(s.content));
4380
4402
  return {
4381
4403
  path: s.path,
4382
4404
  content: flat(readable),
4383
- text: options?.stripTags ? flat(stripTags(readable)) : null
4405
+ text: options?.stripTags ? flat(stripTags(readable)) : null,
4406
+ /**
4407
+ * 🔴 A `<br>` IS A SPACE TO A READER AND NOTHING TO `textContent`, and the derive lane reads
4408
+ * the DOM. `<h1>Launch your site<br /><span>fast.</span></h1>` is one INLINE richtext field
4409
+ * whose value is `Launch your sitefast.` — no space, because no text node holds one — while
4410
+ * this prefilter's only reading inserted one, so the file was never opened and every split
4411
+ * heading on every imported site came back NOT_IN_SOURCE. Both readings are searched: this
4412
+ * decides which FILES to open, and `collectSites` still has to match an element exactly.
4413
+ */
4414
+ tight: options?.stripTags ? flat(stripTags(readable, "")) : null
4384
4415
  };
4385
4416
  });
4386
4417
  const located = [];
@@ -4392,7 +4423,9 @@ function locate(brief, sources, options) {
4392
4423
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "no-original" });
4393
4424
  continue;
4394
4425
  }
4395
- const files = flattened.filter((f) => f.content.includes(original) || f.text !== null && f.text.includes(original)).map((f) => f.path);
4426
+ const files = flattened.filter(
4427
+ (f) => f.content.includes(original) || f.text !== null && f.text.includes(original) || f.tight !== null && f.tight.includes(original)
4428
+ ).map((f) => f.path);
4396
4429
  if (files.length === 0) {
4397
4430
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "not-in-source" });
4398
4431
  continue;
@@ -5091,7 +5124,7 @@ function rewriteFile(content, dialect, rewrites, imports, extra = []) {
5091
5124
  addDeclaration(site.attrInsertAt, declaration(rewrite));
5092
5125
  continue;
5093
5126
  }
5094
- if (kind === "richtext" && !(site.mixed && site.textRange)) {
5127
+ if (ownsSubtree(kind, site, rewrite.inline)) {
5095
5128
  if (!isIntrinsic(site.tag)) continue;
5096
5129
  if (rule.richtext && site.inner) {
5097
5130
  source.remove(site.inner.start, site.inner.end);
@@ -5160,6 +5193,22 @@ function importLines(imports) {
5160
5193
 
5161
5194
  // src/receipt.ts
5162
5195
  var CANVAS_REASONS = /* @__PURE__ */ new Set(["CANVAS_BRIDGE_MANUAL", "SSR_DRAFT_ROUTE"]);
5196
+ var FormInvariantError = class extends Error {
5197
+ code = "FORM_INVARIANT";
5198
+ constructor(message) {
5199
+ super(message);
5200
+ this.name = "FormInvariantError";
5201
+ }
5202
+ };
5203
+ function assertForms(total, forms) {
5204
+ const sum = forms.wired + forms.alreadyWired + forms.pending.length;
5205
+ if (sum !== total) {
5206
+ throw new FormInvariantError(
5207
+ `The receipt does not account for every form: ${total} listed but ${forms.wired} wired + ${forms.alreadyWired} already wired + ${forms.pending.length} pending = ${sum}.`
5208
+ );
5209
+ }
5210
+ return forms;
5211
+ }
5163
5212
  var ReceiptInvariantError = class extends Error {
5164
5213
  code = "RECEIPT_INVARIANT";
5165
5214
  constructor(message) {
@@ -6011,6 +6060,17 @@ function readBrief(value) {
6011
6060
  }
6012
6061
  }
6013
6062
  }
6063
+ for (const [at, entry] of check.array(brief.forms ?? [], `the brief's "forms"`).entries()) {
6064
+ const where = `brief.forms[${at}]`;
6065
+ const form = check.record(entry, where);
6066
+ check.segment(form.id, `${where}.id`);
6067
+ check.string(form.name, `${where}.name`);
6068
+ check.string(form.submitUrl, `${where}.submitUrl`);
6069
+ check.optionalString(form.successMessage, `${where}.successMessage`);
6070
+ for (const [n, held] of check.array(form.fields, `${where}.fields`).entries()) {
6071
+ check.segment(check.record(held, `${where}.fields[${n}]`).key, `${where}.fields[${n}].key`);
6072
+ }
6073
+ }
6014
6074
  return brief;
6015
6075
  }
6016
6076
  function readPlan(value) {
@@ -6057,6 +6117,236 @@ function readPlan(value) {
6057
6117
  return plan2;
6058
6118
  }
6059
6119
 
6120
+ // src/forms.ts
6121
+ import MagicString4 from "magic-string";
6122
+ var WIRABLE = ["html", "astro", "jsx"];
6123
+ var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6124
+ var FORM_SCRIPT = `<script>
6125
+ document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
6126
+ f.addEventListener('submit',function(e){
6127
+ e.preventDefault();
6128
+ var msg=f.querySelector('.bcms-form-msg');
6129
+ var data={};
6130
+ // A checkboxes group posts one entry per checked box under the SAME name; the old
6131
+ // one-at-a-time assignment kept only the last, so "pick many" delivered one answer.
6132
+ // One checked box still arrives as a scalar \u2014 FormData cannot tell a group from a
6133
+ // lone field \u2014 which is exactly why lib/forms/validate.ts's chosenValues() accepts
6134
+ // both an array and a comma string.
6135
+ new FormData(f).forEach(function(v,k){
6136
+ if(k==='cf-turnstile-response')return;
6137
+ if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);
6138
+ else data[k]=v;
6139
+ });
6140
+ var body={data:data};
6141
+ var tok=f.querySelector('[name="cf-turnstile-response"]');
6142
+ if(tok&&tok.value)body['cf-turnstile-response']=tok.value;
6143
+ fetch(f.action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
6144
+ .then(function(r){return r.ok;})
6145
+ .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();})
6146
+ .catch(function(){if(msg){msg.hidden=false;msg.textContent='Sorry, something went wrong.';}});
6147
+ });
6148
+ });
6149
+ </script>`;
6150
+ var SCRIPT_BODY = FORM_SCRIPT.slice("<script>".length, -"</script>".length);
6151
+ function scriptFor(dialect) {
6152
+ if (dialect === "jsx") return `<script dangerouslySetInnerHTML={{ __html: \`${SCRIPT_BODY}\` }} />`;
6153
+ if (dialect === "astro") return `<script is:inline>${SCRIPT_BODY}</script>`;
6154
+ return FORM_SCRIPT;
6155
+ }
6156
+ var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
6157
+ var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
6158
+ var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
6159
+ function* elementsIn(nodes) {
6160
+ for (const node of nodes) {
6161
+ if (node.type === "expr") {
6162
+ yield* elementsIn(node.children);
6163
+ continue;
6164
+ }
6165
+ if (node.type !== "element") continue;
6166
+ yield node;
6167
+ yield* elementsIn(node.children);
6168
+ }
6169
+ }
6170
+ var childElements = (node) => node.children.flatMap(
6171
+ (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6172
+ );
6173
+ function candidateOf(file, dialect, form) {
6174
+ const controls = /* @__PURE__ */ new Map();
6175
+ const wrapper = /* @__PURE__ */ new Map();
6176
+ const all = [];
6177
+ const walk = (parent) => {
6178
+ const own = [];
6179
+ for (const child of childElements(parent)) {
6180
+ if (CONTROLS.has(child.tag.toLowerCase())) {
6181
+ own.push(child);
6182
+ all.push(child);
6183
+ }
6184
+ walk(child);
6185
+ }
6186
+ if (own.length === 1 && parent !== form) wrapper.set(own[0], parent);
6187
+ };
6188
+ walk(form);
6189
+ for (const control of all) {
6190
+ const name = attrOf(control, "name")?.value;
6191
+ if (name && !controls.has(name)) controls.set(name, control);
6192
+ }
6193
+ for (const control of all) {
6194
+ const id = attrOf(control, "id")?.value;
6195
+ if (id && !controls.has(id)) controls.set(id, control);
6196
+ }
6197
+ return { file, dialect, el: form, controls, wrapper };
6198
+ }
6199
+ var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6200
+ function wire(source, candidate, form) {
6201
+ const { el, dialect } = candidate;
6202
+ const inserts = [];
6203
+ const set = (name, value) => {
6204
+ const attr = attrOf(el, name);
6205
+ if (!attr) inserts.push(`${name}="${escapeAttr(value)}"`);
6206
+ else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6207
+ else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6208
+ };
6209
+ set("action", form.submitUrl);
6210
+ set("method", "post");
6211
+ inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
6212
+ if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
6213
+ source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6214
+ for (const field of form.fields) {
6215
+ const control = candidate.controls.get(field.key);
6216
+ const host = candidate.wrapper.get(control) ?? control;
6217
+ source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6218
+ const name = attrOf(control, "name");
6219
+ if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
6220
+ else if (!name.expression && name.value !== field.key && name.valueRange) {
6221
+ source.overwrite(name.valueRange.start, name.valueRange.end, escapeAttr(field.key));
6222
+ }
6223
+ }
6224
+ if (el.inner && !hasMessage(el)) {
6225
+ const tail = /\s*$/.exec(source.original.slice(el.inner.start, el.inner.end))[0];
6226
+ source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6227
+ }
6228
+ }
6229
+ async function wireForms(briefIn, sources) {
6230
+ const brief = readBrief(briefIn);
6231
+ const forms = brief.forms ?? [];
6232
+ const candidates = [];
6233
+ const unreadable = [];
6234
+ for (const source of sources) {
6235
+ if (!/<form[\s/>]/i.test(source.content)) continue;
6236
+ const dialect = dialectOf(source.path, source.content);
6237
+ if (!dialect || !WIRABLE.includes(dialect)) {
6238
+ unreadable.push({
6239
+ file: source.path,
6240
+ reason: "DIALECT_UNSUPPORTED",
6241
+ message: `${source.path} is ${dialect ?? "not a template"}.`
6242
+ });
6243
+ continue;
6244
+ }
6245
+ const parsed = await parseFile4(dialect, source.content);
6246
+ if (parsed.error) {
6247
+ unreadable.push({
6248
+ file: source.path,
6249
+ reason: parsed.error.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR",
6250
+ message: parsed.error.message
6251
+ });
6252
+ continue;
6253
+ }
6254
+ for (const el of elementsIn(parsed.roots)) {
6255
+ if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
6256
+ }
6257
+ }
6258
+ const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6259
+ const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6260
+ const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6261
+ const wanted = /* @__PURE__ */ new Map();
6262
+ for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6263
+ const matched = [];
6264
+ const pending = [];
6265
+ const onSite = [];
6266
+ let alreadyWired = 0;
6267
+ const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
6268
+ for (const form of forms) {
6269
+ const list = options.get(form.id) ?? [];
6270
+ if (list.length > 1) {
6271
+ pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
6272
+ continue;
6273
+ }
6274
+ if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
6275
+ pend2(form, "FORM_AMBIGUOUS", list[0].file, "more than one CMS form's fields are posted by this <form>.");
6276
+ continue;
6277
+ }
6278
+ if (list.length === 0) {
6279
+ const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6280
+ if (best) {
6281
+ const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6282
+ pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6283
+ } else if (unreadable[0]) {
6284
+ pend2(form, unreadable[0].reason, unreadable[0].file, unreadable[0].message);
6285
+ } else {
6286
+ pend2(form, "FORM_NOT_IN_SOURCE", void 0, `no <form> posts ${form.fields.map((f) => f.key).join(", ") || "any field of this form"}.`);
6287
+ }
6288
+ continue;
6289
+ }
6290
+ const candidate = list[0];
6291
+ if (attrOf(candidate.el, "action")?.expression) {
6292
+ pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6293
+ continue;
6294
+ }
6295
+ const misnamed = form.fields.find((field) => {
6296
+ const attr = attrOf(candidate.controls.get(field.key), "name");
6297
+ return !!attr && attr.value !== field.key && (attr.expression || !attr.valueRange);
6298
+ });
6299
+ if (misnamed) {
6300
+ 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.`);
6301
+ continue;
6302
+ }
6303
+ if (attrOf(candidate.el, "data-bcms-form")) {
6304
+ alreadyWired++;
6305
+ onSite.push(form);
6306
+ continue;
6307
+ }
6308
+ matched.push({ form, candidate });
6309
+ onSite.push(form);
6310
+ }
6311
+ const contents = new Map(sources.map((s) => [s.path, s.content]));
6312
+ const byFile = /* @__PURE__ */ new Map();
6313
+ for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
6314
+ const files = [];
6315
+ for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6316
+ const content = contents.get(path);
6317
+ const source = new MagicString4(content);
6318
+ for (const { form, candidate } of list) wire(source, candidate, form);
6319
+ if (!content.includes("form[data-bcms-form]")) {
6320
+ const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6321
+ const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6322
+ source.appendRight(last.candidate.el.end, `
6323
+ ${indent}${scriptFor(last.candidate.dialect)}`);
6324
+ }
6325
+ files.push({ path, operation: "modify", content: source.toString() });
6326
+ }
6327
+ 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.`);
6328
+ return {
6329
+ files,
6330
+ receipt: {
6331
+ briefDigest: briefDigest(brief),
6332
+ // This lane binds no brief PATH, and says so rather than leaving the arithmetic to a reader:
6333
+ // a receipt claiming zero of zero paths is exactly what a forms-only run did.
6334
+ paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6335
+ occurrences: { located: 0, rewritten: 0 },
6336
+ files: [],
6337
+ helpers: [],
6338
+ helperUpgraded: false,
6339
+ dynamicBindings: [],
6340
+ forms: assertForms(forms.length, {
6341
+ wired: matched.length,
6342
+ alreadyWired,
6343
+ pending,
6344
+ ...notes.length > 0 ? { notes } : {}
6345
+ })
6346
+ }
6347
+ };
6348
+ }
6349
+
6060
6350
  // src/scan.ts
6061
6351
  var SKIP_DIRS = [
6062
6352
  "node_modules/",
@@ -6120,6 +6410,66 @@ function isSourceCandidate(path) {
6120
6410
  return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
6121
6411
  }
6122
6412
 
6413
+ // src/xray.ts
6414
+ var SKIP_TAGS = /* @__PURE__ */ new Set(["svg", "iframe", "head", "title"]);
6415
+ var CONTEXT_TAGS = /* @__PURE__ */ new Set([
6416
+ "nav",
6417
+ "header",
6418
+ "footer",
6419
+ "main",
6420
+ "aside",
6421
+ "form",
6422
+ "button",
6423
+ "a",
6424
+ "ul",
6425
+ "ol",
6426
+ "table",
6427
+ "dialog",
6428
+ "details",
6429
+ "figure",
6430
+ "blockquote"
6431
+ ]);
6432
+ var BOUND_ATTRS = ["data-bcms-field", "data-bcms-layout-field", "data-bcms-path", "data-bcms-block"];
6433
+ 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);
6434
+ var MAX_BUCKETS = 12;
6435
+ var flat2 = (value) => value.replace(/\s+/g, " ").trim();
6436
+ function measureUnaddressable(html) {
6437
+ const parsed = parse2(html);
6438
+ let visible = 0;
6439
+ let unaddressable = 0;
6440
+ const buckets = /* @__PURE__ */ new Map();
6441
+ const visit = (node, tag, context, bound) => {
6442
+ if (node.type === "text") {
6443
+ const text = flat2(node.value);
6444
+ if (!text || isDecorativeGlyph(text)) return;
6445
+ visible += text.length;
6446
+ if (bound) return;
6447
+ unaddressable += text.length;
6448
+ const key = `${tag} @ ${context}`;
6449
+ const held = buckets.get(key) ?? { tag, context, chars: 0, nodes: 0, samples: [] };
6450
+ held.chars += text.length;
6451
+ held.nodes += 1;
6452
+ if (held.samples.length < 2) held.samples.push(text.slice(0, 50));
6453
+ buckets.set(key, held);
6454
+ return;
6455
+ }
6456
+ if (node.type !== "element" || node.raw || SKIP_TAGS.has(node.tag)) return;
6457
+ const attrs = new Map(node.attrs.map((a) => [a.name, a.value]));
6458
+ if (attrs.has("hidden") || attrs.get("aria-hidden") === "true" || /display\s*:\s*none/.test(attrs.get("style") ?? "")) {
6459
+ return;
6460
+ }
6461
+ const owned = bound || BOUND_ATTRS.some((name) => attrs.has(name));
6462
+ const chain = CONTEXT_TAGS.has(node.tag) ? context === "body" ? node.tag : `${context}>${node.tag}` : context;
6463
+ for (const child of node.children) visit(child, node.tag, chain, owned);
6464
+ };
6465
+ for (const root of parsed.roots) visit(root, "body", "body", false);
6466
+ return {
6467
+ visible,
6468
+ unaddressable,
6469
+ buckets: [...buckets.values()].sort((a, b) => b.chars - a.chars).slice(0, MAX_BUCKETS)
6470
+ };
6471
+ }
6472
+
6123
6473
  // src/declared.ts
6124
6474
  async function readDeclarations(file, content) {
6125
6475
  const dialect = dialectOf(file, content);
@@ -6425,7 +6775,7 @@ function moduleScope(parsed, content) {
6425
6775
  }
6426
6776
 
6427
6777
  // src/componentize.ts
6428
- import MagicString4 from "magic-string";
6778
+ import MagicString5 from "magic-string";
6429
6779
  var SectionInvariantError = class extends Error {
6430
6780
  code = "SECTION_INVARIANT";
6431
6781
  constructor(message) {
@@ -7351,7 +7701,7 @@ export default function ${resolved.name}({ blockId, bind, overrides, page }: Bcm
7351
7701
  ` };
7352
7702
  }
7353
7703
  function sectionMarkup(content, root, fieldName) {
7354
- const slice = new MagicString4(content.slice(root.start, root.end));
7704
+ const slice = new MagicString5(content.slice(root.start, root.end));
7355
7705
  const at = (offset) => offset - root.start;
7356
7706
  const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
7357
7707
  if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
@@ -7550,7 +7900,7 @@ async function migrateSections(plan2, contents, written, fileSet, aliases) {
7550
7900
  );
7551
7901
  continue;
7552
7902
  }
7553
- const source = new MagicString4(content);
7903
+ const source = new MagicString5(content);
7554
7904
  const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
7555
7905
  if (duplicates.length > 0) {
7556
7906
  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.`);
@@ -7827,7 +8177,7 @@ export default function Sections({ page }: { page: string }) {
7827
8177
  function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
7828
8178
  const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
7829
8179
  const local = names.get("Sections");
7830
- const source = new MagicString4(content);
8180
+ const source = new MagicString5(content);
7831
8181
  const first = placeable[0].root;
7832
8182
  const last = placeable[placeable.length - 1].root;
7833
8183
  source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
@@ -7847,7 +8197,7 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
7847
8197
  { key: "pageSnapshot", name: "pageSnapshot" }
7848
8198
  ]);
7849
8199
  const snapshot = names.get("pageSnapshot");
7850
- const source = new MagicString4(content);
8200
+ const source = new MagicString5(content);
7851
8201
  for (const resolved of placeable) {
7852
8202
  const component = written.get(resolved.slug);
7853
8203
  const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
@@ -8008,7 +8358,8 @@ async function convertSources(briefIn, sources, options) {
8008
8358
  kind: p.kind,
8009
8359
  fallback: p.original ?? "",
8010
8360
  literal: norm(p.original ?? ""),
8011
- locator: p.locator
8361
+ locator: p.locator,
8362
+ ...p.inline ? { inline: true } : {}
8012
8363
  });
8013
8364
  }
8014
8365
  }
@@ -8233,8 +8584,18 @@ async function convertSources(briefIn, sources, options) {
8233
8584
  declaredRow(target, witness !== null, witness ?? byHand);
8234
8585
  } else rewritten++;
8235
8586
  }
8587
+ const templated = dynamicBindings.filter((d) => d.prop === null);
8588
+ for (let i = pending.length - 1; i >= 0; i--) {
8589
+ const row = pending[i];
8590
+ const target = targets.get(keyOf(row.route, row.scope, row.path));
8591
+ if (row.reason !== "NO_ORIGINAL" || !target || target.fallback !== "") continue;
8592
+ const shape = shapeOfPath(target.path);
8593
+ if (!templated.some((d) => d.path === shape && attributable(target, d.file))) continue;
8594
+ pending.splice(i, 1);
8595
+ rewritten++;
8596
+ }
8236
8597
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
8237
- const source = new MagicString5(contents.get(file));
8598
+ const source = new MagicString6(contents.get(file));
8238
8599
  const seen = /* @__PURE__ */ new Set();
8239
8600
  for (const splice of splices) {
8240
8601
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -8545,6 +8906,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8545
8906
  }
8546
8907
  let refusal = null;
8547
8908
  for (const site of fresh) {
8909
+ if (target.inline && site.where === "text" && !ownsSubtree(target.kind, site, true)) {
8910
+ refusal = "SUBTREE_NOT_HTML";
8911
+ continue;
8912
+ }
8548
8913
  let bindings;
8549
8914
  let bindingsMerge;
8550
8915
  if (site.where === "prop" && site.attr) {
@@ -8579,7 +8944,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8579
8944
  // 🔴 AND ONLY WHEN THE ELEMENT'S SUBTREE IS THE VALUE. A value that is one TEXT NODE of
8580
8945
  // a mixed element gets a wrapper of its own, so taking the element's inner range as its
8581
8946
  // fallback swallowed the `<em>` beside it — the wrapper then rendered the sibling twice.
8582
- fallback: target.kind === "richtext" && site.inner && !(site.mixed && site.textRange) ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
8947
+ ...target.inline ? { inline: true } : {},
8948
+ fallback: site.inner && ownsSubtree(target.kind, site, target.inline) ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
8583
8949
  snapshot: snapshotName(target.slug),
8584
8950
  slug: target.slug,
8585
8951
  scope: target.scope,
@@ -8942,6 +9308,8 @@ export {
8942
9308
  CONTENT_DIR,
8943
9309
  ConvertError,
8944
9310
  DIALECT_RULES,
9311
+ FORM_SCRIPT,
9312
+ FormInvariantError,
8945
9313
  HELPER_PATH,
8946
9314
  HELPER_VERSION,
8947
9315
  PARSE_FILE,
@@ -8957,6 +9325,7 @@ export {
8957
9325
  SectionInvariantError,
8958
9326
  ValidationError,
8959
9327
  aliasesFrom,
9328
+ assertForms,
8960
9329
  assertReceipt,
8961
9330
  assertSections,
8962
9331
  assetCandidates,
@@ -8980,10 +9349,12 @@ export {
8980
9349
  flat,
8981
9350
  helperSource,
8982
9351
  importsIn,
9352
+ isDecorativeGlyph,
8983
9353
  isDynamicRoute,
8984
9354
  isKnownHelper,
8985
9355
  isSourceCandidate,
8986
9356
  locate,
9357
+ measureUnaddressable,
8987
9358
  overlapping,
8988
9359
  pageFilesFor,
8989
9360
  parseFile4 as parseFile,
@@ -8999,6 +9370,7 @@ export {
8999
9370
  routeOfFile,
9000
9371
  stripTags,
9001
9372
  unreadableConfigs,
9002
- walkAst
9373
+ walkAst,
9374
+ wireForms
9003
9375
  };
9004
9376
  //# sourceMappingURL=index.js.map