@bettercms-ai/convert 0.14.0 → 0.16.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 +12 -0
- package/dist/cli.js +108 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +94 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @bettercms-ai/convert
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 17aaccb: `--forms` breaks the ties that left ScrewFast's two forms `FORM_AMBIGUOUS`: a `<form>` two CMS forms both cover goes to the one it posts more fields of (a contact form is not the newsletter because it also posts `email`), and among several `<form>`s covering one CMS form the one posting nothing beyond its fields wins. For the tie nothing can break — two email-only forms, a footer newsletter and a "forgot password" dialog — the new `--pin "<form>=<file>"` names the file; the pending message says so.
|
|
8
|
+
|
|
9
|
+
## 0.15.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 0ddfbdd: `--forms` now sees a form built from components. A component tag handed a `name` or `id` (`<TextInput name="firstName" />`) is the control; a tag handed neither (`<EmailFooterInput />`) is matched by the controls in its own imported file, which are never written. Before, such a form was reported `FORM_NOT_IN_SOURCE` although its fields were all there. A component's file is found by RESOLVING its import — a relative path, or one of the project's own tsconfig `paths` aliases — so a bare package specifier reads no local file that merely shares its last segment, and the receipt notes any field whose marker went unwritten because its control lives in an imported component.
|
|
14
|
+
|
|
3
15
|
## 0.14.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/dist/cli.js
CHANGED
|
@@ -6391,9 +6391,11 @@ function* elementsIn(nodes) {
|
|
|
6391
6391
|
var childElements = (node) => node.children.flatMap(
|
|
6392
6392
|
(child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
|
|
6393
6393
|
);
|
|
6394
|
-
|
|
6394
|
+
var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
|
|
6395
|
+
function candidateOf(file, dialect, form, imported) {
|
|
6395
6396
|
const controls = /* @__PURE__ */ new Map();
|
|
6396
6397
|
const wrapper = /* @__PURE__ */ new Map();
|
|
6398
|
+
const foreign = /* @__PURE__ */ new Set();
|
|
6397
6399
|
const all = [];
|
|
6398
6400
|
const walk = (parent) => {
|
|
6399
6401
|
const own = [];
|
|
@@ -6401,6 +6403,17 @@ function candidateOf(file, dialect, form) {
|
|
|
6401
6403
|
if (CONTROLS.has(child.tag.toLowerCase())) {
|
|
6402
6404
|
own.push(child);
|
|
6403
6405
|
all.push(child);
|
|
6406
|
+
} else if (isComponentTag(dialect, child.tag)) {
|
|
6407
|
+
const inFile = attrOf(child, "name") ? [] : imported.get(child.tag) ?? [];
|
|
6408
|
+
if (inFile.length) {
|
|
6409
|
+
for (const control of inFile) {
|
|
6410
|
+
all.push(control);
|
|
6411
|
+
foreign.add(control);
|
|
6412
|
+
}
|
|
6413
|
+
} else if (attrOf(child, "name") ?? attrOf(child, "id")) {
|
|
6414
|
+
own.push(child);
|
|
6415
|
+
all.push(child);
|
|
6416
|
+
}
|
|
6404
6417
|
}
|
|
6405
6418
|
walk(child);
|
|
6406
6419
|
}
|
|
@@ -6412,10 +6425,32 @@ function candidateOf(file, dialect, form) {
|
|
|
6412
6425
|
if (name && !controls.has(name)) controls.set(name, control);
|
|
6413
6426
|
}
|
|
6414
6427
|
for (const control of all) {
|
|
6428
|
+
if (foreign.has(control)) continue;
|
|
6415
6429
|
const id = attrOf(control, "id")?.value;
|
|
6416
6430
|
if (id && !controls.has(id)) controls.set(id, control);
|
|
6417
6431
|
}
|
|
6418
|
-
return { file, dialect, el: form, controls, wrapper };
|
|
6432
|
+
return { file, dialect, el: form, controls, wrapper, foreign };
|
|
6433
|
+
}
|
|
6434
|
+
async function importedControls(source, parsed, dialect, project, cache) {
|
|
6435
|
+
const out = /* @__PURE__ */ new Map();
|
|
6436
|
+
for (const [local, { specifier }] of importsIn(parsed, dialect)) {
|
|
6437
|
+
const path = resolveSpecifier(source.path, specifier, project.files, project.aliases);
|
|
6438
|
+
const child = path === null || path === source.path ? void 0 : project.byPath.get(path);
|
|
6439
|
+
if (!child) continue;
|
|
6440
|
+
let controls = cache.get(child.path);
|
|
6441
|
+
if (!controls) {
|
|
6442
|
+
controls = (async () => {
|
|
6443
|
+
const own = dialectOf(child.path, child.content);
|
|
6444
|
+
if (!own || !WIRABLE.includes(own)) return [];
|
|
6445
|
+
const file = await parseFile4(own, child.content);
|
|
6446
|
+
return file.error ? [] : [...elementsIn(file.roots)].filter((el) => CONTROLS.has(el.tag.toLowerCase()));
|
|
6447
|
+
})();
|
|
6448
|
+
cache.set(child.path, controls);
|
|
6449
|
+
}
|
|
6450
|
+
const found = await controls;
|
|
6451
|
+
if (found.length) out.set(local, found);
|
|
6452
|
+
}
|
|
6453
|
+
return out;
|
|
6419
6454
|
}
|
|
6420
6455
|
var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
|
|
6421
6456
|
function wire(source, candidate, form) {
|
|
@@ -6434,6 +6469,7 @@ function wire(source, candidate, form) {
|
|
|
6434
6469
|
source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
|
|
6435
6470
|
for (const field of form.fields) {
|
|
6436
6471
|
const control = candidate.controls.get(field.key);
|
|
6472
|
+
if (candidate.foreign.has(control)) continue;
|
|
6437
6473
|
const host = candidate.wrapper.get(control) ?? control;
|
|
6438
6474
|
source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
|
|
6439
6475
|
const name = attrOf(control, "name");
|
|
@@ -6447,11 +6483,17 @@ function wire(source, candidate, form) {
|
|
|
6447
6483
|
source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
|
|
6448
6484
|
}
|
|
6449
6485
|
}
|
|
6450
|
-
async function wireForms(briefIn, sources) {
|
|
6486
|
+
async function wireForms(briefIn, sources, options_ = {}) {
|
|
6451
6487
|
const brief = readBrief(briefIn);
|
|
6452
6488
|
const forms = brief.forms ?? [];
|
|
6453
6489
|
const candidates = [];
|
|
6454
6490
|
const unreadable = [];
|
|
6491
|
+
const componentFiles = /* @__PURE__ */ new Map();
|
|
6492
|
+
const project = {
|
|
6493
|
+
byPath: new Map(sources.map((s) => [s.path, s])),
|
|
6494
|
+
files: new Set(sources.map((s) => s.path)),
|
|
6495
|
+
aliases: aliasesFrom(sources)
|
|
6496
|
+
};
|
|
6455
6497
|
for (const source of sources) {
|
|
6456
6498
|
if (!/<form[\s/>]/i.test(source.content)) continue;
|
|
6457
6499
|
const dialect = dialectOf(source.path, source.content);
|
|
@@ -6472,13 +6514,40 @@ async function wireForms(briefIn, sources) {
|
|
|
6472
6514
|
});
|
|
6473
6515
|
continue;
|
|
6474
6516
|
}
|
|
6517
|
+
const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
|
|
6475
6518
|
for (const el of elementsIn(parsed.roots)) {
|
|
6476
|
-
if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
|
|
6519
|
+
if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el, imported));
|
|
6477
6520
|
}
|
|
6478
6521
|
}
|
|
6479
6522
|
const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
|
|
6480
6523
|
const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
|
|
6524
|
+
const posts = (c) => new Set(c.controls.values()).size;
|
|
6525
|
+
const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
6481
6526
|
const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
|
|
6527
|
+
const pinned = /* @__PURE__ */ new Map();
|
|
6528
|
+
for (const [ref, file] of Object.entries(options_.pins ?? {})) {
|
|
6529
|
+
const form = forms.find((f) => f.id === ref || f.name === ref);
|
|
6530
|
+
if (!form) throw new Error(`--pin ${ref}: the brief lists no form with that id or name.`);
|
|
6531
|
+
pinned.set(form.id, file);
|
|
6532
|
+
options.set(form.id, options.get(form.id).filter((c) => samePath(c.file, file)));
|
|
6533
|
+
for (const other of forms) {
|
|
6534
|
+
if (other.id !== form.id) options.set(other.id, options.get(other.id).filter((c) => !samePath(c.file, file)));
|
|
6535
|
+
}
|
|
6536
|
+
}
|
|
6537
|
+
for (const c of candidates) {
|
|
6538
|
+
const claimants = forms.filter((f) => options.get(f.id).includes(c));
|
|
6539
|
+
if (claimants.length < 2) continue;
|
|
6540
|
+
const top = Math.max(...claimants.map((f) => holds(c, f)));
|
|
6541
|
+
const winners = claimants.filter((f) => holds(c, f) === top);
|
|
6542
|
+
if (winners.length !== 1) continue;
|
|
6543
|
+
for (const f of claimants) if (f !== winners[0]) options.set(f.id, options.get(f.id).filter((x) => x !== c));
|
|
6544
|
+
}
|
|
6545
|
+
for (const f of forms) {
|
|
6546
|
+
const list = options.get(f.id);
|
|
6547
|
+
if (list.length < 2) continue;
|
|
6548
|
+
const exact = list.filter((c) => posts(c) === f.fields.length);
|
|
6549
|
+
if (exact.length === 1) options.set(f.id, exact);
|
|
6550
|
+
}
|
|
6482
6551
|
const wanted = /* @__PURE__ */ new Map();
|
|
6483
6552
|
for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
|
|
6484
6553
|
const matched = [];
|
|
@@ -6489,7 +6558,12 @@ async function wireForms(briefIn, sources) {
|
|
|
6489
6558
|
for (const form of forms) {
|
|
6490
6559
|
const list = options.get(form.id) ?? [];
|
|
6491
6560
|
if (list.length > 1) {
|
|
6492
|
-
pend2(
|
|
6561
|
+
pend2(
|
|
6562
|
+
form,
|
|
6563
|
+
"FORM_AMBIGUOUS",
|
|
6564
|
+
void 0,
|
|
6565
|
+
`${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}. Name the right one with --pin "${form.name}=<file>".`
|
|
6566
|
+
);
|
|
6493
6567
|
continue;
|
|
6494
6568
|
}
|
|
6495
6569
|
if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
|
|
@@ -6498,7 +6572,13 @@ async function wireForms(briefIn, sources) {
|
|
|
6498
6572
|
}
|
|
6499
6573
|
if (list.length === 0) {
|
|
6500
6574
|
const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
|
|
6501
|
-
|
|
6575
|
+
const pin = pinned.get(form.id);
|
|
6576
|
+
if (pin) {
|
|
6577
|
+
pend2(form, "FORM_NOT_IN_SOURCE", pin, `the pinned file has no <form> posting ${form.fields.map((f) => f.key).join(", ")}.`);
|
|
6578
|
+
} else if (best && best.n === form.fields.length) {
|
|
6579
|
+
const owner = forms.find((f) => f !== form && options.get(f.id).includes(best.c));
|
|
6580
|
+
pend2(form, "FORM_NOT_IN_SOURCE", best.c.file, `the only <form> posting ${form.fields.map((f) => f.key).join(", ")} is "${owner?.name ?? "another form"}"'s.`);
|
|
6581
|
+
} else if (best) {
|
|
6502
6582
|
const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
|
|
6503
6583
|
pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
|
|
6504
6584
|
} else if (unreadable[0]) {
|
|
@@ -6546,6 +6626,14 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
|
|
|
6546
6626
|
files.push({ path, operation: "modify", content: source.toString() });
|
|
6547
6627
|
}
|
|
6548
6628
|
const notes = onSite.filter(({ form }) => form.status === "draft").map(({ form }) => `publish form ${form.name} in the Forms tab \u2014 a draft form rejects every submission with 403.`);
|
|
6629
|
+
for (const { form, candidate } of matched) {
|
|
6630
|
+
const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
|
|
6631
|
+
if (keys.length > 0) {
|
|
6632
|
+
notes.push(
|
|
6633
|
+
`form ${form.name}: ${keys.join(", ")} matched a control inside an imported component, so no data-bcms-form-field marker was written for ${keys.length === 1 ? "it" : "them"}.`
|
|
6634
|
+
);
|
|
6635
|
+
}
|
|
6636
|
+
}
|
|
6549
6637
|
return {
|
|
6550
6638
|
files,
|
|
6551
6639
|
receipt: {
|
|
@@ -9638,7 +9726,7 @@ function emitStubs2(dialects, slugs, contents, out) {
|
|
|
9638
9726
|
// package.json
|
|
9639
9727
|
var package_default = {
|
|
9640
9728
|
name: "@bettercms-ai/convert",
|
|
9641
|
-
version: "0.
|
|
9729
|
+
version: "0.16.0",
|
|
9642
9730
|
type: "module",
|
|
9643
9731
|
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.",
|
|
9644
9732
|
bin: {
|
|
@@ -9699,6 +9787,7 @@ function parseArgs(argv) {
|
|
|
9699
9787
|
brief: void 0,
|
|
9700
9788
|
componentize: false,
|
|
9701
9789
|
forms: false,
|
|
9790
|
+
pins: {},
|
|
9702
9791
|
xray: void 0,
|
|
9703
9792
|
plan: void 0,
|
|
9704
9793
|
copy: "bind",
|
|
@@ -9715,7 +9804,11 @@ function parseArgs(argv) {
|
|
|
9715
9804
|
if (arg === "--brief") args.brief = argv[++i];
|
|
9716
9805
|
else if (arg === "--componentize") args.componentize = true;
|
|
9717
9806
|
else if (arg === "--forms") args.forms = true;
|
|
9718
|
-
else if (arg === "--
|
|
9807
|
+
else if (arg === "--pin") {
|
|
9808
|
+
const at = (argv[++i] ?? "").indexOf("=");
|
|
9809
|
+
if (at < 1) throw new CliError(`--pin takes <form>=<file>, e.g. --pin "Newsletter=src/components/FooterSection.astro". See --help.`);
|
|
9810
|
+
args.pins[argv[i].slice(0, at)] = argv[i].slice(at + 1);
|
|
9811
|
+
} else if (arg === "--xray") args.xray = argv[++i];
|
|
9719
9812
|
else if (arg === "--plan") args.plan = argv[++i];
|
|
9720
9813
|
else if (arg === "--copy") {
|
|
9721
9814
|
const mode = argv[++i];
|
|
@@ -9751,6 +9844,9 @@ var HELP = `bettercms-convert ${VERSION}
|
|
|
9751
9844
|
endpoint, the id, a marker per field and the submit script. A form
|
|
9752
9845
|
the brief lists as a DRAFT is wired and receipted: publish it in the
|
|
9753
9846
|
Forms tab, or it rejects every submission with a 403.
|
|
9847
|
+
--pin <form>=<file> With --forms: the file a form's <form> is in, when the run reports
|
|
9848
|
+
FORM_AMBIGUOUS (two files post exactly its fields). Form by id or
|
|
9849
|
+
name. Repeatable.
|
|
9754
9850
|
--xray <dist-dir> Measure a LOCAL build instead of converting: how much of each page's
|
|
9755
9851
|
visible copy no binding owns, and the biggest buckets of it. Run it
|
|
9756
9852
|
after a build to see what the next conversion still has to reach,
|
|
@@ -9973,8 +10069,11 @@ async function main() {
|
|
|
9973
10069
|
reportSections(files2, receipt2, args.dryRun);
|
|
9974
10070
|
return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
|
|
9975
10071
|
}
|
|
10072
|
+
if (Object.keys(args.pins).length > 0 && !args.forms) {
|
|
10073
|
+
throw new CliError("--pin only means something with --forms. See --help.");
|
|
10074
|
+
}
|
|
9976
10075
|
if (args.forms) {
|
|
9977
|
-
const { files: files2, receipt: receipt2 } = await wireForms(brief, sources);
|
|
10076
|
+
const { files: files2, receipt: receipt2 } = await wireForms(brief, sources, { pins: args.pins });
|
|
9978
10077
|
await written(files2);
|
|
9979
10078
|
await saveReceipt(receipt2);
|
|
9980
10079
|
reportForms(files2, receipt2, args.dryRun);
|