@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 +18 -0
- package/README.md +23 -0
- package/dist/cli.js +164 -15
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +66 -4
- package/dist/index.js +116 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2643
|
-
|
|
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
|
-
|
|
2745
|
-
const
|
|
2746
|
-
|
|
2747
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.");
|