@bettercms-ai/convert 0.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ac3a9ff: Declare the shapes the derive lane mints for every visible body tag: a heading whose styled run is
8
+ part of its value (`richtext inline`, bound whole rather than wrapped around its text node), a
9
+ standalone `span`/`time`/`small`/`label`/`option` leaf, a `<pre>` code block (the `<pre>` is the
10
+ field, not the `<code>` inside it), and a group row whose optional leaf is empty (covered by its
11
+ own route's templated declaration instead of reported `NO_ORIGINAL`). A heading split by a `<br />`
12
+ is found again: its value carries no space, because no text node holds one.
13
+
14
+ An inline heading the lane cannot own whole — its element holds a component, a `<slot>` or an
15
+ expression, which no stored string can re-emit — is refused by name (`SUBTREE_NOT_HTML`) instead of
16
+ being bound over the top of it.
17
+
18
+ New `--xray <dist-dir>` mode: how much of each built page's visible copy no binding owns, and the
19
+ biggest buckets of it, so the loop is build -> measure -> fix -> build with no deploy in it.
20
+
3
21
  ## 0.9.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -38,6 +38,27 @@ Exit codes: `0` done — the pending list is printed, not hidden; `1` something
38
38
  | `--strict` | exit 2 when any path is pending. |
39
39
  | `--overwrite-helper` | replace a `bcms-content` helper whose bytes this tool did not write — including one of ours that has since been edited. |
40
40
  | `--verbose` | stack traces, and every file considered. |
41
+ | `--xray <dist-dir>` | a mode of its own: measure a LOCAL build instead of converting one. |
42
+
43
+ ### `--xray` — what is not editable
44
+
45
+ A receipt says what the codemod bound. It cannot say what it was never asked to bind, so a site can
46
+ report full coverage while a quarter of its visible text is unreachable from the editor. `--xray`
47
+ walks a build and counts that, per page, with no deploy in the loop:
48
+
49
+ ```bash
50
+ npm run build && npx @bettercms-ai/convert --xray dist
51
+ ```
52
+
53
+ ```
54
+ docs/index.html visible=3512 unaddressable=701 (20.0%)
55
+ 379ch 4n code @ main e.g. "const site = { direction: 'rtl', language: 'ar'," | "npm install"
56
+ 188ch 12n span @ main>ul e.g. "Performance" | "100"
57
+ ```
58
+
59
+ Glyphs, ordinals, the platform badge and skip links are excluded from BOTH halves of the ratio —
60
+ counting them would put a floor under every route that no amount of binding could lift. `--strict`
61
+ exits 2 while any page is above 2 %. It takes no `--brief`: it measures, it does not convert.
41
62
 
42
63
  ## What it actually does
43
64
 
@@ -152,6 +173,8 @@ rendering that binding — `<img>`, `<source>`, `<video poster>`, or `<Image>`/`
152
173
  different paths share a sentence and position cannot separate them (both are skipped; the rest of
153
174
  the file still converts), `SUBSTRING_ONLY`, `IN_SCRIPT_OR_COMMENT`, `KIND_MISMATCH`,
154
175
  `REPEATER_AMBIGUOUS` (one array declaration, two loops over it — the row has two homes),
176
+ `SUBTREE_NOT_HTML` (an INLINE richtext heading whose element holds a component, a `<slot>` or an
177
+ expression: its whole subtree is the value and no stored string can re-emit that),
155
178
  `IMAGE_ASSET_UNRESOLVED`, `PARSE_ERROR`. `NOT_IN_SOURCE` is reserved for what it says: a path
156
179
  `locate` found in a file is `IN_EXPRESSION` when it sits in the module region or an expression
157
180
  node, and where `NOT_IN_SOURCE` is still the answer the row names the file it was found in. And
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));
@@ -4384,14 +4397,23 @@ function isKnownHelper(content, dialect) {
4384
4397
 
4385
4398
  // src/locate.ts
4386
4399
  var flat = (value) => value.replace(/\s+/g, " ").trim();
4387
- var stripTags = (value) => value.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
4400
+ var stripTags = (value, br = " ") => value.replace(/<br\s*\/?>/gi, br).replace(/<[^>]*>/g, "");
4388
4401
  function locate(brief, sources, options) {
4389
4402
  const flattened = sources.map((s) => {
4390
4403
  const readable = unwrapJsxStrings(decodeEntities(s.content));
4391
4404
  return {
4392
4405
  path: s.path,
4393
4406
  content: flat(readable),
4394
- 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
4395
4417
  };
4396
4418
  });
4397
4419
  const located = [];
@@ -4403,7 +4425,9 @@ function locate(brief, sources, options) {
4403
4425
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "no-original" });
4404
4426
  continue;
4405
4427
  }
4406
- 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);
4407
4431
  if (files.length === 0) {
4408
4432
  unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "not-in-source" });
4409
4433
  continue;
@@ -5102,7 +5126,7 @@ function rewriteFile(content, dialect, rewrites, imports, extra = []) {
5102
5126
  addDeclaration(site.attrInsertAt, declaration(rewrite));
5103
5127
  continue;
5104
5128
  }
5105
- if (kind === "richtext" && !(site.mixed && site.textRange)) {
5129
+ if (ownsSubtree(kind, site, rewrite.inline)) {
5106
5130
  if (!isIntrinsic(site.tag)) continue;
5107
5131
  if (rule.richtext && site.inner) {
5108
5132
  source.remove(site.inner.start, site.inner.end);
@@ -6376,6 +6400,66 @@ function isSourceCandidate(path) {
6376
6400
  return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
6377
6401
  }
6378
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
+
6379
6463
  // src/carry.ts
6380
6464
  var GLOBALS = /* @__PURE__ */ new Set([
6381
6465
  "Astro",
@@ -8256,7 +8340,8 @@ async function convertSources(briefIn, sources, options) {
8256
8340
  kind: p.kind,
8257
8341
  fallback: p.original ?? "",
8258
8342
  literal: norm(p.original ?? ""),
8259
- locator: p.locator
8343
+ locator: p.locator,
8344
+ ...p.inline ? { inline: true } : {}
8260
8345
  });
8261
8346
  }
8262
8347
  }
@@ -8481,6 +8566,16 @@ async function convertSources(briefIn, sources, options) {
8481
8566
  declaredRow(target, witness !== null, witness ?? byHand);
8482
8567
  } else rewritten++;
8483
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
+ }
8484
8579
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
8485
8580
  const source = new MagicString6(contents.get(file));
8486
8581
  const seen = /* @__PURE__ */ new Set();
@@ -8793,6 +8888,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8793
8888
  }
8794
8889
  let refusal = null;
8795
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
+ }
8796
8895
  let bindings;
8797
8896
  let bindingsMerge;
8798
8897
  if (site.where === "prop" && site.attr) {
@@ -8827,7 +8926,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8827
8926
  // 🔴 AND ONLY WHEN THE ELEMENT'S SUBTREE IS THE VALUE. A value that is one TEXT NODE of
8828
8927
  // a mixed element gets a wrapper of its own, so taking the element's inner range as its
8829
8928
  // 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,
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,
8831
8931
  snapshot: snapshotName(target.slug),
8832
8932
  slug: target.slug,
8833
8933
  scope: target.scope,
@@ -9188,7 +9288,7 @@ function emitStubs2(dialects, slugs, contents, out) {
9188
9288
  // package.json
9189
9289
  var package_default = {
9190
9290
  name: "@bettercms-ai/convert",
9191
- version: "0.9.0",
9291
+ version: "0.10.0",
9192
9292
  type: "module",
9193
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.",
9194
9294
  bin: {
@@ -9249,6 +9349,7 @@ function parseArgs(argv) {
9249
9349
  brief: void 0,
9250
9350
  componentize: false,
9251
9351
  forms: false,
9352
+ xray: void 0,
9252
9353
  plan: void 0,
9253
9354
  copy: "bind",
9254
9355
  root: ".",
@@ -9264,6 +9365,7 @@ function parseArgs(argv) {
9264
9365
  if (arg === "--brief") args.brief = argv[++i];
9265
9366
  else if (arg === "--componentize") args.componentize = true;
9266
9367
  else if (arg === "--forms") args.forms = true;
9368
+ else if (arg === "--xray") args.xray = argv[++i];
9267
9369
  else if (arg === "--plan") args.plan = argv[++i];
9268
9370
  else if (arg === "--copy") {
9269
9371
  const mode = argv[++i];
@@ -9291,6 +9393,7 @@ var HELP = `bettercms-convert ${VERSION}
9291
9393
  bettercms-convert --brief <json> --root . [options]
9292
9394
  bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
9293
9395
  bettercms-convert --forms --brief <json> --root . [options]
9396
+ bettercms-convert --xray <dist-dir>
9294
9397
 
9295
9398
  --brief <file> The conversion brief, as get_conversion_brief returns it. Required.
9296
9399
  --componentize Lift each planned SECTION into a component the CMS can reorder.
@@ -9298,6 +9401,11 @@ var HELP = `bettercms-convert ${VERSION}
9298
9401
  endpoint, the id, a marker per field and the submit script. A form
9299
9402
  the brief lists as a DRAFT is wired and receipted: publish it in the
9300
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 %.
9301
9409
  --plan <file> The componentize plan, as get_componentize_plan returns it.
9302
9410
  Required with --componentize.
9303
9411
  --copy bind|instance Which copy model the plan is expected to describe. Default: bind
@@ -9418,12 +9526,53 @@ ${extracted} sections extracted (${registered} registered, ${inlineOnly} inline-
9418
9526
  );
9419
9527
  }
9420
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
+ }
9421
9566
  async function main() {
9422
9567
  const args = parseArgs(process.argv.slice(2));
9423
9568
  if (args.help) {
9424
9569
  process.stdout.write(HELP);
9425
9570
  return 0;
9426
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
+ }
9427
9576
  if (!args.brief) throw new CliError("--brief <file> is required. See --help.");
9428
9577
  if (args.plan && !args.componentize) {
9429
9578
  throw new CliError("--plan is only read with --componentize. See --help.");