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