@bettercms-ai/convert 0.9.0 → 0.11.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
@@ -2562,6 +2562,7 @@ 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
2567
  var BCMS_ATTRS = [
2567
2568
  "data-bcms-field",
@@ -2594,6 +2595,7 @@ function openTagEnd(content, start) {
2594
2595
  }
2595
2596
  return content.length;
2596
2597
  }
2598
+ var OPAQUE_TAGS = /* @__PURE__ */ new Set(["pre"]);
2597
2599
  var isBlank = (value) => value.trim() === "";
2598
2600
  function renderedText(node) {
2599
2601
  if (node.type === "text") return node.value;
@@ -2601,6 +2603,11 @@ function renderedText(node) {
2601
2603
  return "";
2602
2604
  }
2603
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
+ }
2604
2611
  function soleTextChild(node) {
2605
2612
  const texts = node.children.filter((c) => c.type === "text" && !isBlank(c.value));
2606
2613
  return texts.length === 1 ? texts[0] : null;
@@ -2637,10 +2644,12 @@ function collectSites(roots, literals) {
2637
2644
  if (node.type !== "element") return matched;
2638
2645
  const attrs = attrsOf(node);
2639
2646
  let index = 0;
2640
- for (const child of node.children) {
2641
- const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2642
- if (child.type === "element") index++;
2643
- 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
+ }
2644
2653
  }
2645
2654
  if (node.raw) {
2646
2655
  const text2 = norm(renderedTextRaw(node));
@@ -2666,6 +2675,7 @@ function collectSites(roots, literals) {
2666
2675
  textRange: soleTextChild(node)?.range ?? null,
2667
2676
  literal,
2668
2677
  partial: false,
2678
+ ...nonHtmlSubtree(node) ? { nonHtml: true } : {},
2669
2679
  ancestors: walk.ancestors,
2670
2680
  siblingIndex: walk.siblingIndex
2671
2681
  });
@@ -2691,6 +2701,7 @@ function collectSites(roots, literals) {
2691
2701
  textRange: own.range,
2692
2702
  literal,
2693
2703
  partial: false,
2704
+ ...nonHtmlSubtree(node) ? { nonHtml: true } : {},
2694
2705
  ancestors: walk.ancestors,
2695
2706
  siblingIndex: walk.siblingIndex
2696
2707
  });
@@ -2741,10 +2752,12 @@ function partials(node, literals, walk, out) {
2741
2752
  if (node.type !== "element" || node.raw) return false;
2742
2753
  let deeper = false;
2743
2754
  let index = 0;
2744
- for (const child of node.children) {
2745
- const childWalk = { ancestors: [...walk.ancestors, node.tag], siblingIndex: index };
2746
- if (child.type === "element") index++;
2747
- 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
+ }
2748
2761
  }
2749
2762
  if (deeper) return true;
2750
2763
  const text = norm(renderedText(node));
@@ -4129,7 +4142,7 @@ function bindImages(ctx, targets) {
4129
4142
 
4130
4143
  // src/helper.ts
4131
4144
  import { createHash as createHash2 } from "crypto";
4132
- var HELPER_VERSION = 6;
4145
+ var HELPER_VERSION = 7;
4133
4146
  var HEADER = "// @bettercms-ai/convert helper v";
4134
4147
  var HELPER_PATH = Object.fromEntries(
4135
4148
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4322,9 +4335,65 @@ export function bcmsSection(page: unknown, bind?: string, overrides?: unknown):
4322
4335
  * how the codemod ran: one component renders a Phase A placement on one page and a Phase B
4323
4336
  * instance on the next, and a repository does not have to be re-cut when its copy moves.
4324
4337
  */
4338
+ /** How a flat field family maps page paths to an instance's override keys. @see bcmsSectionPaths */
4339
+ export type BcmsPathMap = {
4340
+ scalars?: Record<string, string>;
4341
+ tables?: Record<string, { key: string; columns: Record<string, string> }>;
4342
+ };
4343
+
4344
+ /**
4345
+ * The snapshot for a section whose page fields are a FLAT family (\`wrap-h2-latest\`, not \`wrap.h2_latest\`).
4346
+ *
4347
+ * \`bcmsSection\` returns a group object read through a dotted path, which a flat family does not have: its
4348
+ * copy lives in top-level keys of the page snapshot. So this answers in the page's own spelling \u2014 the
4349
+ * markup reads \`bcms(snapshot, "wrap-h2-latest", \u2026)\` verbatim \u2014 with every mapped path set explicitly:
4350
+ *
4351
+ * - the instance's override for that key when it holds one (a table's rows re-spelled with the page's
4352
+ * column names, \`{ rows }\` unwrapped as in \`bcmsSection\`);
4353
+ * - else, for a placement bound to a group, the page value at the path re-based onto that group;
4354
+ * - else NOTHING \u2014 a placement that owns its copy never falls back to the page's stale copy, exactly as
4355
+ * \`bcmsSection\` synthesizes no group for it.
4356
+ *
4357
+ * Every other page key passes through, so reads outside the family (a shared link) keep working.
4358
+ */
4359
+ export function bcmsSectionPaths(
4360
+ page: unknown,
4361
+ bind: string | undefined,
4362
+ overrides: unknown,
4363
+ prefix: string,
4364
+ map: BcmsPathMap,
4365
+ ): Record<string, unknown> {
4366
+ const out: Record<string, unknown> = isRecord(page) ? { ...page } : {};
4367
+ const own = isRecord(overrides) ? overrides : {};
4368
+ const separator = prefix.slice(-1);
4369
+ const fromPage = (path: string): unknown =>
4370
+ bind ? readPath(page, bind + separator + path.slice(prefix.length)) : undefined;
4371
+ for (const [path, key] of Object.entries(map.scalars ?? {})) {
4372
+ const value = own[key];
4373
+ out[path] = value !== undefined && value !== null ? value : fromPage(path);
4374
+ }
4375
+ for (const [path, table] of Object.entries(map.tables ?? {})) {
4376
+ const held = own[table.key];
4377
+ const rows = isRecord(held) && Array.isArray(held.rows) ? held.rows : held;
4378
+ if (!Array.isArray(rows)) {
4379
+ out[path] = fromPage(path);
4380
+ continue;
4381
+ }
4382
+ const pageColumn = new Map(Object.entries(table.columns).map(([column, key]) => [key, column]));
4383
+ out[path] = rows.map((row) => {
4384
+ if (!isRecord(row)) return row;
4385
+ const spelled: Record<string, unknown> = {};
4386
+ for (const [key, value] of Object.entries(row)) spelled[pageColumn.get(key) ?? key] = value;
4387
+ return spelled;
4388
+ });
4389
+ }
4390
+ return out;
4391
+ }
4392
+
4325
4393
  export function bcmsFields(
4326
4394
  placement: { blockId?: string; bind?: string; overrides?: unknown },
4327
4395
  pathPrefix: string,
4396
+ map?: BcmsPathMap,
4328
4397
  ): (path: string) => string {
4329
4398
  const id = placement.blockId;
4330
4399
  const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
@@ -4337,6 +4406,24 @@ export function bcmsFields(
4337
4406
  * so prepending one would invent an address rather than correct it.
4338
4407
  */
4339
4408
  const lane = own ? \`\${id}__overrides.\` : placement.bind && pathPrefix ? \`\${placement.bind}.\` : null;
4409
+ if (map) {
4410
+ /**
4411
+ * A FLAT family: the address is not "prefix swapped". The instance lane names the override key the
4412
+ * plan allocated (\`h2_latest\`, \`group_webgl[0].a_webgl\`); the bind lane keeps the page's own
4413
+ * separator (\`landing-h2-latest\`, never \`landing.h2-latest\`).
4414
+ */
4415
+ const separator = pathPrefix.slice(-1);
4416
+ return (path: string): string => {
4417
+ if (!path.startsWith(pathPrefix) || !(own || placement.bind)) return path;
4418
+ if (!own) return placement.bind + separator + path.slice(pathPrefix.length);
4419
+ const scalar = map.scalars?.[path];
4420
+ if (scalar !== undefined) return id + "__overrides." + scalar;
4421
+ const row = /^([^[]+)\\[(\\d+)\\]\\.(.+)$/.exec(path);
4422
+ const table = row ? map.tables?.[row[1]] : undefined;
4423
+ const column = row && table ? table.columns[row[3]] : undefined;
4424
+ return table && column !== undefined ? id + "__overrides." + table.key + "[" + row![2] + "]." + column : path;
4425
+ };
4426
+ }
4340
4427
  return (path: string): string => {
4341
4428
  // A path outside the group this section was cut from is not one this placement re-addresses:
4342
4429
  // it names a key the instance does not hold and a group this placement is not bound to.
@@ -4353,21 +4440,24 @@ var HISTORICAL = {
4353
4440
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
4354
4441
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
4355
4442
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4356
- 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
4443
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4444
+ 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649"
4357
4445
  },
4358
4446
  "src/lib/bcms-content.ts": {
4359
4447
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
4360
4448
  2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
4361
4449
  3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e",
4362
4450
  4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4363
- 5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba"
4451
+ 5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba",
4452
+ 6: "63f1fa29e47e70c5193b81ee24addb238ba100247124d55bc17713102d587df5"
4364
4453
  },
4365
4454
  "composables/bcms-content.ts": {
4366
4455
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
4367
4456
  2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
4368
4457
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
4369
4458
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4370
- 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da"
4459
+ 5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4460
+ 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649"
4371
4461
  }
4372
4462
  };
4373
4463
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -4384,14 +4474,23 @@ function isKnownHelper(content, dialect) {
4384
4474
 
4385
4475
  // src/locate.ts
4386
4476
  var flat = (value) => value.replace(/\s+/g, " ").trim();
4387
- var stripTags = (value) => value.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
4477
+ var stripTags = (value, br = " ") => value.replace(/<br\s*\/?>/gi, br).replace(/<[^>]*>/g, "");
4388
4478
  function locate(brief, sources, options) {
4389
4479
  const flattened = sources.map((s) => {
4390
4480
  const readable = unwrapJsxStrings(decodeEntities(s.content));
4391
4481
  return {
4392
4482
  path: s.path,
4393
4483
  content: flat(readable),
4394
- text: options?.stripTags ? flat(stripTags(readable)) : null
4484
+ text: options?.stripTags ? flat(stripTags(readable)) : null,
4485
+ /**
4486
+ * 🔴 A `<br>` IS A SPACE TO A READER AND NOTHING TO `textContent`, and the derive lane reads
4487
+ * the DOM. `<h1>Launch your site<br /><span>fast.</span></h1>` is one INLINE richtext field
4488
+ * whose value is `Launch your sitefast.` — no space, because no text node holds one — while
4489
+ * this prefilter's only reading inserted one, so the file was never opened and every split
4490
+ * heading on every imported site came back NOT_IN_SOURCE. Both readings are searched: this
4491
+ * decides which FILES to open, and `collectSites` still has to match an element exactly.
4492
+ */
4493
+ tight: options?.stripTags ? flat(stripTags(readable, "")) : null
4395
4494
  };
4396
4495
  });
4397
4496
  const located = [];
@@ -4403,7 +4502,9 @@ function locate(brief, sources, options) {
4403
4502
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "no-original" });
4404
4503
  continue;
4405
4504
  }
4406
- const files = flattened.filter((f) => f.content.includes(original) || f.text !== null && f.text.includes(original)).map((f) => f.path);
4505
+ const files = flattened.filter(
4506
+ (f) => f.content.includes(original) || f.text !== null && f.text.includes(original) || f.tight !== null && f.tight.includes(original)
4507
+ ).map((f) => f.path);
4407
4508
  if (files.length === 0) {
4408
4509
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "not-in-source" });
4409
4510
  continue;
@@ -5102,7 +5203,7 @@ function rewriteFile(content, dialect, rewrites, imports, extra = []) {
5102
5203
  addDeclaration(site.attrInsertAt, declaration(rewrite));
5103
5204
  continue;
5104
5205
  }
5105
- if (kind === "richtext" && !(site.mixed && site.textRange)) {
5206
+ if (ownsSubtree(kind, site, rewrite.inline)) {
5106
5207
  if (!isIntrinsic(site.tag)) continue;
5107
5208
  if (rule.richtext && site.inner) {
5108
5209
  source.remove(site.inner.start, site.inner.end);
@@ -6376,6 +6477,66 @@ function isSourceCandidate(path) {
6376
6477
  return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
6377
6478
  }
6378
6479
 
6480
+ // src/xray.ts
6481
+ var SKIP_TAGS = /* @__PURE__ */ new Set(["svg", "iframe", "head", "title"]);
6482
+ var CONTEXT_TAGS = /* @__PURE__ */ new Set([
6483
+ "nav",
6484
+ "header",
6485
+ "footer",
6486
+ "main",
6487
+ "aside",
6488
+ "form",
6489
+ "button",
6490
+ "a",
6491
+ "ul",
6492
+ "ol",
6493
+ "table",
6494
+ "dialog",
6495
+ "details",
6496
+ "figure",
6497
+ "blockquote"
6498
+ ]);
6499
+ var BOUND_ATTRS = ["data-bcms-field", "data-bcms-layout-field", "data-bcms-path", "data-bcms-block"];
6500
+ 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);
6501
+ var MAX_BUCKETS = 12;
6502
+ var flat2 = (value) => value.replace(/\s+/g, " ").trim();
6503
+ function measureUnaddressable(html) {
6504
+ const parsed = parse2(html);
6505
+ let visible = 0;
6506
+ let unaddressable = 0;
6507
+ const buckets = /* @__PURE__ */ new Map();
6508
+ const visit = (node, tag, context, bound) => {
6509
+ if (node.type === "text") {
6510
+ const text = flat2(node.value);
6511
+ if (!text || isDecorativeGlyph(text)) return;
6512
+ visible += text.length;
6513
+ if (bound) return;
6514
+ unaddressable += text.length;
6515
+ const key = `${tag} @ ${context}`;
6516
+ const held = buckets.get(key) ?? { tag, context, chars: 0, nodes: 0, samples: [] };
6517
+ held.chars += text.length;
6518
+ held.nodes += 1;
6519
+ if (held.samples.length < 2) held.samples.push(text.slice(0, 50));
6520
+ buckets.set(key, held);
6521
+ return;
6522
+ }
6523
+ if (node.type !== "element" || node.raw || SKIP_TAGS.has(node.tag)) return;
6524
+ const attrs = new Map(node.attrs.map((a) => [a.name, a.value]));
6525
+ if (attrs.has("hidden") || attrs.get("aria-hidden") === "true" || /display\s*:\s*none/.test(attrs.get("style") ?? "")) {
6526
+ return;
6527
+ }
6528
+ const owned = bound || BOUND_ATTRS.some((name) => attrs.has(name));
6529
+ const chain = CONTEXT_TAGS.has(node.tag) ? context === "body" ? node.tag : `${context}>${node.tag}` : context;
6530
+ for (const child of node.children) visit(child, node.tag, chain, owned);
6531
+ };
6532
+ for (const root of parsed.roots) visit(root, "body", "body", false);
6533
+ return {
6534
+ visible,
6535
+ unaddressable,
6536
+ buckets: [...buckets.values()].sort((a, b) => b.chars - a.chars).slice(0, MAX_BUCKETS)
6537
+ };
6538
+ }
6539
+
6379
6540
  // src/carry.ts
6380
6541
  var GLOBALS = /* @__PURE__ */ new Set([
6381
6542
  "Astro",
@@ -6785,6 +6946,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
6785
6946
  return held;
6786
6947
  };
6787
6948
  const fresh = /* @__PURE__ */ new Set();
6949
+ const freshFiles = /* @__PURE__ */ new Map();
6788
6950
  const bindOf = (section) => instanceOwned(section) ? void 0 : section.groupKey;
6789
6951
  const placementOf = (slug, section) => {
6790
6952
  const bind = bindOf(section);
@@ -7112,6 +7274,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
7112
7274
  }
7113
7275
  extractable.set(resolved.slug, { name: resolved.name, path, kind: "default", imported: null });
7114
7276
  fresh.add(resolved.slug);
7277
+ freshFiles.set(resolved.slug, path);
7115
7278
  addFile(outFiles, contents, {
7116
7279
  path,
7117
7280
  operation: existing === void 0 ? "add" : "modify",
@@ -7197,6 +7360,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
7197
7360
  sections,
7198
7361
  // What THIS run wrote a file for. A component an earlier run left is reused, not re-reported.
7199
7362
  components: [...fresh].sort(),
7363
+ componentFiles: [...freshFiles].sort(([a], [b]) => a.localeCompare(b)).map(([slug, path]) => ({ slug, path, export: "default" })),
7200
7364
  planPending,
7201
7365
  notes
7202
7366
  }
@@ -7376,7 +7540,7 @@ async function locateSections(sections, briefPaths, sources, treeOf) {
7376
7540
  for (const section of sections) {
7377
7541
  const paths = fieldPaths(section);
7378
7542
  const literals = new Set(
7379
- briefPaths.filter((p) => (p.scope ?? "page") === "page" && groupOf(p.path) === section.groupKey && p.original).map((p) => flat(stripTags(p.original))).filter(Boolean)
7543
+ briefPaths.filter((p) => (p.scope ?? "page") === "page" && inSection(section, p.path) && p.original).map((p) => flat(stripTags(p.original))).filter(Boolean)
7380
7544
  );
7381
7545
  const hits = /* @__PURE__ */ new Map();
7382
7546
  for (const source of sources) {
@@ -7387,7 +7551,7 @@ async function locateSections(sections, briefPaths, sources, treeOf) {
7387
7551
  const found = /* @__PURE__ */ new Map();
7388
7552
  for (const declaration2 of declarationsIn(parsed.roots, source.content)) {
7389
7553
  if (declaration2.scope !== "page") continue;
7390
- if (groupOf(declaration2.path) !== section.groupKey) continue;
7554
+ if (!inSection(section, declaration2.path)) continue;
7391
7555
  const placed2 = elements.get(declaration2.at);
7392
7556
  if (placed2) found.set(declaration2.at, placed2);
7393
7557
  }
@@ -7412,6 +7576,12 @@ async function locateSections(sections, briefPaths, sources, treeOf) {
7412
7576
  return { placed, misses };
7413
7577
  }
7414
7578
  var groupOf = (path) => path.split(/[.[]/)[0] ?? "";
7579
+ var inSection = (section, path) => {
7580
+ if (groupOf(path) === section.groupKey) return true;
7581
+ const rowless = (value) => value.replace(/\[\d+\]/g, "[]");
7582
+ const target = rowless(path);
7583
+ return fieldPaths(section).some((fieldPath) => rowless(fieldPath) === target);
7584
+ };
7415
7585
  var fieldPaths = (section) => (section.fields ?? []).map((field) => field.path ?? `${section.groupKey}.${field.key ?? ""}`);
7416
7586
  function elementIndex(roots) {
7417
7587
  const out = /* @__PURE__ */ new Map();
@@ -7514,6 +7684,9 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathP
7514
7684
  ...addresses.opaque
7515
7685
  ];
7516
7686
  if (unconvertible.length > 0) return { content: "", free: [], duplicates: [], unconvertible };
7687
+ const flat3 = pathPrefix !== "" && !pathPrefix.endsWith(".");
7688
+ const pathMap = flat3 ? flatPathMap(resolved.section, pathPrefix) : null;
7689
+ if (flat3 && !pathMap) return { content: "", free: [], duplicates: [], unconvertible: [`${pathPrefix}* (a repeater leaf without an override address)`] };
7517
7690
  const cut = content.slice(resolved.root.start, resolved.root.end);
7518
7691
  const reserved = /* @__PURE__ */ new Set([...topLevelBindings(dialect, parsed, content), ...referencedNames2(cut)]);
7519
7692
  const introduced = allocateNames(reserved, [
@@ -7532,7 +7705,7 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathP
7532
7705
  const helpers = [
7533
7706
  .../* @__PURE__ */ new Set([
7534
7707
  ...helperNames.map((h) => alias(h.name, h.local)),
7535
- "bcmsSection",
7708
+ flat3 ? "bcmsSectionPaths" : "bcmsSection",
7536
7709
  ...bound > 0 ? [alias("bcmsFields", fieldsName)] : []
7537
7710
  ])
7538
7711
  ].sort();
@@ -7562,9 +7735,10 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathP
7562
7735
  const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
7563
7736
  const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
7564
7737
  ` : "";
7565
- const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides) };` : "";
7566
- const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)});` : "";
7738
+ const copy = !snapshot ? "" : flat3 ? `const ${snapshot} = bcmsSectionPaths(page, bind, overrides, ${JSON.stringify(pathPrefix)}, ${JSON.stringify(pathMap)});` : `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides) };`;
7739
+ const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)}${flat3 ? `, ${JSON.stringify(pathMap)}` : ""});` : "";
7567
7740
  const declared = [copy, field].filter(Boolean).join("\n");
7741
+ const styles = dialect === "astro" ? carriedStyles(content) : "";
7568
7742
  if (dialect === "astro") {
7569
7743
  return { free: [], duplicates: [], unconvertible: [], content: `---
7570
7744
  ${SECTION_MARKER}
@@ -7584,7 +7758,9 @@ const { blockId, bind, overrides, page } = Astro.props;
7584
7758
  ${carriedDeclarations}${declared}
7585
7759
  ---
7586
7760
  ${markup}
7587
- ` };
7761
+ ${styles ? `
7762
+ ${styles}
7763
+ ` : ""}` };
7588
7764
  }
7589
7765
  return { free: [], duplicates: [], unconvertible: [], content: `${SECTION_MARKER}
7590
7766
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
@@ -7858,6 +8034,9 @@ import { bcmsFields } from "${helper}";`);
7858
8034
  return { files, refused };
7859
8035
  }
7860
8036
  function propPrefix(section) {
8037
+ return exactPrefix(section) ?? flatPrefix(section);
8038
+ }
8039
+ function exactPrefix(section) {
7861
8040
  const prefixes = /* @__PURE__ */ new Set();
7862
8041
  for (const field of section.fields ?? []) {
7863
8042
  const { path, key } = field;
@@ -7868,6 +8047,49 @@ function propPrefix(section) {
7868
8047
  if (prefixes.size > 1) return null;
7869
8048
  return prefixes.size === 1 ? [...prefixes][0] : `${section.groupKey}.`;
7870
8049
  }
8050
+ var foldKey = (remainder) => remainder.replace(/\[(\d+)\]/g, "_$1").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "value";
8051
+ function flatPrefix(section) {
8052
+ const prefixes = /* @__PURE__ */ new Set();
8053
+ let informative = false;
8054
+ for (const field of section.fields ?? []) {
8055
+ const { path, key } = field;
8056
+ if (typeof path !== "string" || typeof key !== "string") continue;
8057
+ informative = true;
8058
+ const prefix = ["-", "_"].map((sep2) => `${section.groupKey}${sep2}`).find((candidate) => path.startsWith(candidate) && (typeof field.overrideKey === "string" || foldKey(path.slice(candidate.length)) === key));
8059
+ if (!prefix) return null;
8060
+ prefixes.add(prefix);
8061
+ }
8062
+ return informative && prefixes.size === 1 ? [...prefixes][0] : null;
8063
+ }
8064
+ function flatPathMap(section, prefix) {
8065
+ const scalars = {};
8066
+ const tables = {};
8067
+ for (const field of section.fields ?? []) {
8068
+ const { path, key } = field;
8069
+ if (typeof path !== "string" || typeof key !== "string" || !path.startsWith(prefix)) continue;
8070
+ const row = /^([^[]+)\[\d+\]\.(.+)$/.exec(path);
8071
+ if (!row) {
8072
+ scalars[path] = field.overrideKey ?? key;
8073
+ continue;
8074
+ }
8075
+ const address = typeof field.overrideKey === "string" ? /^([^[]+)\[\d+\]\.(.+)$/.exec(field.overrideKey) : null;
8076
+ if (!address) return null;
8077
+ const table = tables[row[1]] ??= { key: address[1], columns: {} };
8078
+ if (table.key !== address[1]) return null;
8079
+ table.columns[row[2]] = address[2];
8080
+ }
8081
+ return { scalars, tables };
8082
+ }
8083
+ function carriedStyles(content) {
8084
+ const blocks = [];
8085
+ for (const match of content.matchAll(/<style\b([^>]*)>([\s\S]*?)<\/style>/g)) {
8086
+ const attributes = match[1] ?? "";
8087
+ if (/\bis:global\b|\bdefine:vars\b/.test(attributes)) continue;
8088
+ if (/\blang\s*=/.test(attributes) || /@(import|use|forward)\b/.test(match[2] ?? "")) continue;
8089
+ blocks.push(match[0]);
8090
+ }
8091
+ return blocks.join("\n");
8092
+ }
7871
8093
  function referencedNames2(markup) {
7872
8094
  const out = /* @__PURE__ */ new Set();
7873
8095
  for (const [name] of markup.matchAll(/[A-Za-z_$][\w$]*/g)) out.add(name);
@@ -8256,7 +8478,8 @@ async function convertSources(briefIn, sources, options) {
8256
8478
  kind: p.kind,
8257
8479
  fallback: p.original ?? "",
8258
8480
  literal: norm(p.original ?? ""),
8259
- locator: p.locator
8481
+ locator: p.locator,
8482
+ ...p.inline ? { inline: true } : {}
8260
8483
  });
8261
8484
  }
8262
8485
  }
@@ -8481,6 +8704,16 @@ async function convertSources(briefIn, sources, options) {
8481
8704
  declaredRow(target, witness !== null, witness ?? byHand);
8482
8705
  } else rewritten++;
8483
8706
  }
8707
+ const templated = dynamicBindings.filter((d) => d.prop === null);
8708
+ for (let i = pending.length - 1; i >= 0; i--) {
8709
+ const row = pending[i];
8710
+ const target = targets.get(keyOf(row.route, row.scope, row.path));
8711
+ if (row.reason !== "NO_ORIGINAL" || !target || target.fallback !== "") continue;
8712
+ const shape = shapeOfPath(target.path);
8713
+ if (!templated.some((d) => d.path === shape && attributable(target, d.file))) continue;
8714
+ pending.splice(i, 1);
8715
+ rewritten++;
8716
+ }
8484
8717
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
8485
8718
  const source = new MagicString6(contents.get(file));
8486
8719
  const seen = /* @__PURE__ */ new Set();
@@ -8793,6 +9026,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8793
9026
  }
8794
9027
  let refusal = null;
8795
9028
  for (const site of fresh) {
9029
+ if (target.inline && site.where === "text" && !ownsSubtree(target.kind, site, true)) {
9030
+ refusal = "SUBTREE_NOT_HTML";
9031
+ continue;
9032
+ }
8796
9033
  let bindings;
8797
9034
  let bindingsMerge;
8798
9035
  if (site.where === "prop" && site.attr) {
@@ -8827,7 +9064,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8827
9064
  // 🔴 AND ONLY WHEN THE ELEMENT'S SUBTREE IS THE VALUE. A value that is one TEXT NODE of
8828
9065
  // a mixed element gets a wrapper of its own, so taking the element's inner range as its
8829
9066
  // fallback swallowed the `<em>` beside it — the wrapper then rendered the sibling twice.
8830
- fallback: target.kind === "richtext" && site.inner && !(site.mixed && site.textRange) ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
9067
+ ...target.inline ? { inline: true } : {},
9068
+ fallback: site.inner && ownsSubtree(target.kind, site, target.inline) ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
8831
9069
  snapshot: snapshotName(target.slug),
8832
9070
  slug: target.slug,
8833
9071
  scope: target.scope,
@@ -9188,7 +9426,7 @@ function emitStubs2(dialects, slugs, contents, out) {
9188
9426
  // package.json
9189
9427
  var package_default = {
9190
9428
  name: "@bettercms-ai/convert",
9191
- version: "0.9.0",
9429
+ version: "0.11.0",
9192
9430
  type: "module",
9193
9431
  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.",
9194
9432
  bin: {
@@ -9249,6 +9487,7 @@ function parseArgs(argv) {
9249
9487
  brief: void 0,
9250
9488
  componentize: false,
9251
9489
  forms: false,
9490
+ xray: void 0,
9252
9491
  plan: void 0,
9253
9492
  copy: "bind",
9254
9493
  root: ".",
@@ -9264,6 +9503,7 @@ function parseArgs(argv) {
9264
9503
  if (arg === "--brief") args.brief = argv[++i];
9265
9504
  else if (arg === "--componentize") args.componentize = true;
9266
9505
  else if (arg === "--forms") args.forms = true;
9506
+ else if (arg === "--xray") args.xray = argv[++i];
9267
9507
  else if (arg === "--plan") args.plan = argv[++i];
9268
9508
  else if (arg === "--copy") {
9269
9509
  const mode = argv[++i];
@@ -9291,6 +9531,7 @@ var HELP = `bettercms-convert ${VERSION}
9291
9531
  bettercms-convert --brief <json> --root . [options]
9292
9532
  bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
9293
9533
  bettercms-convert --forms --brief <json> --root . [options]
9534
+ bettercms-convert --xray <dist-dir>
9294
9535
 
9295
9536
  --brief <file> The conversion brief, as get_conversion_brief returns it. Required.
9296
9537
  --componentize Lift each planned SECTION into a component the CMS can reorder.
@@ -9298,6 +9539,11 @@ var HELP = `bettercms-convert ${VERSION}
9298
9539
  endpoint, the id, a marker per field and the submit script. A form
9299
9540
  the brief lists as a DRAFT is wired and receipted: publish it in the
9300
9541
  Forms tab, or it rejects every submission with a 403.
9542
+ --xray <dist-dir> Measure a LOCAL build instead of converting: how much of each page's
9543
+ visible copy no binding owns, and the biggest buckets of it. Run it
9544
+ after a build to see what the next conversion still has to reach,
9545
+ without deploying. Takes no --brief. Exit 2 with --strict when any
9546
+ page is above 2 %.
9301
9547
  --plan <file> The componentize plan, as get_componentize_plan returns it.
9302
9548
  Required with --componentize.
9303
9549
  --copy bind|instance Which copy model the plan is expected to describe. Default: bind
@@ -9418,12 +9664,53 @@ ${extracted} sections extracted (${registered} registered, ${inlineOnly} inline-
9418
9664
  );
9419
9665
  }
9420
9666
  }
9667
+ async function xray(dir, strict) {
9668
+ const pages = [];
9669
+ const walk = async (at) => {
9670
+ for (const entry of (await readdir(at, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
9671
+ const full = join2(at, entry.name);
9672
+ if (entry.isDirectory()) await walk(full);
9673
+ else if (/\.html?$/i.test(entry.name)) pages.push(full);
9674
+ }
9675
+ };
9676
+ try {
9677
+ await walk(dir);
9678
+ } catch (error) {
9679
+ throw new CliError(`${dir} could not be read (${error.message}). Build the site first.`);
9680
+ }
9681
+ if (pages.length === 0) throw new CliError(`No .html files under ${dir}. Is that the build output?`);
9682
+ let over = 0;
9683
+ for (const page of pages) {
9684
+ const { visible, unaddressable, buckets } = measureUnaddressable(await readFile(page, "utf-8"));
9685
+ const share = visible === 0 ? 0 : 100 * unaddressable / visible;
9686
+ if (share > 2) over++;
9687
+ process.stdout.write(
9688
+ `${relative(dir, page) || page} visible=${visible} unaddressable=${unaddressable} (${share.toFixed(1)}%)
9689
+ `
9690
+ );
9691
+ for (const bucket of buckets) {
9692
+ const samples = bucket.samples.map((s) => JSON.stringify(s)).join(" | ");
9693
+ process.stdout.write(
9694
+ ` ${String(bucket.chars).padStart(6)}ch ${String(bucket.nodes).padStart(4)}n ${bucket.tag} @ ${bucket.context} e.g. ${samples}
9695
+ `
9696
+ );
9697
+ }
9698
+ }
9699
+ process.stdout.write(`
9700
+ ${pages.length - over} of ${pages.length} pages at or under 2% unaddressable
9701
+ `);
9702
+ return strict && over > 0 ? 2 : 0;
9703
+ }
9421
9704
  async function main() {
9422
9705
  const args = parseArgs(process.argv.slice(2));
9423
9706
  if (args.help) {
9424
9707
  process.stdout.write(HELP);
9425
9708
  return 0;
9426
9709
  }
9710
+ if (args.xray) {
9711
+ if (args.brief) throw new CliError("--xray measures a build; it reads no brief. See --help.");
9712
+ return await xray(resolve(args.xray), args.strict);
9713
+ }
9427
9714
  if (!args.brief) throw new CliError("--brief <file> is required. See --help.");
9428
9715
  if (args.plan && !args.componentize) {
9429
9716
  throw new CliError("--plan is only read with --componentize. See --help.");