@bettercms-ai/convert 0.16.0 → 0.17.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 +24 -0
- package/README.md +7 -0
- package/dist/cli.js +91 -30
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +79 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @bettercms-ai/convert
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 15ecfcb: `--forms` reads two new facts from the brief. `landmark` (where the built form sat: footer, header, nav, main, aside or dialog) breaks the tie between several source `<form>`s posting exactly a form's fields — a footer newsletter is not a "forgot password" dialog — and the choice is disclosed in `notes`. `pin` is the owner's answer from the Forms tab, applied like `--pin`; a flag typed at the keyboard still wins. `FORM_AMBIGUOUS` pending entries now carry `candidates`, the files a person picks from.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- c2cfcb7: `--forms` rewires a `<form>` that carries a stale `data-bcms-form` id — a repository re-imported after the project it was wired to was deleted. The attribute, the action and the success message are replaced in place (never doubled), existing field markers are kept, and the form counts as wired rather than already wired. Only a form carrying the brief form's own id is "already wired".
|
|
12
|
+
- 994a5e6: `--forms` strips page-copy bindings (`data-bcms-field`, `data-bcms-kind`, `data-bcms-props`) declared on elements inside a `<form>` it wires. A form's labels, placeholders and button copy are the CMS form's, rendered by the section's `form` block; a hand conversion that bound them as page copy made the platform derive them beside the form and put the label on the section component twice. Copy around the form — the heading that names it — is untouched.
|
|
13
|
+
|
|
14
|
+
## 0.16.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Fix `--forms` tie-breaking. 0.16.0's second tie-break rule ("the form posting nothing beyond its
|
|
19
|
+
fields wins") could bind a look-alike form — for example a "forgot password" dialog posting only
|
|
20
|
+
`email` — to the newsletter without reporting it. 0.16.1 removes that rule: such cases stay
|
|
21
|
+
`FORM_AMBIGUOUS` until you name the file with `--pin "<form>=<file>"`. When the remaining rule
|
|
22
|
+
(the form posting more of a `<form>`'s fields wins) decides a tie, the receipt now carries a `notes`
|
|
23
|
+
line naming the file and the rival that lost it. `--pin` is also stricter: an empty value or a
|
|
24
|
+
repeated pin for the same form is refused, `--pin` cannot be combined with `--componentize`, and
|
|
25
|
+
two forms pinned to one file both wire.
|
|
26
|
+
|
|
3
27
|
## 0.16.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -630,6 +630,13 @@ a reason — `FORM_NOT_IN_SOURCE`, `FORM_AMBIGUOUS`, `FIELD_UNMATCHED`, `ACTION_
|
|
|
630
630
|
leads into another form's inbox, silently, forever. A `<form>` that already carries
|
|
631
631
|
`data-bcms-form` is `alreadyWired` and is left alone, so a second run is a no-op.
|
|
632
632
|
|
|
633
|
+
Two `<form>`s that both post exactly a form's fields are `FORM_AMBIGUOUS`, and no rule can break
|
|
634
|
+
that tie: `--pin "<form>=<file>"` (repeatable, form by id or name) names the file, and a pin is
|
|
635
|
+
exclusive only against forms that carry no pin of their own, so two forms living in one file can
|
|
636
|
+
both be pinned to it. **Keep passing a pin on every later run** — a pin is not written into the
|
|
637
|
+
tree, so an unpinned run over the same repository reports that form ambiguous again and its
|
|
638
|
+
receipt stops listing the file it is wired to, even though the markup still is.
|
|
639
|
+
|
|
633
640
|
`--receipt <file>` writes this run's own receipt (`forms-receipt.json` — a separate file from the
|
|
634
641
|
P2 run's, since the two runs describe different work), and `--strict` exits 2 on any pending form.
|
|
635
642
|
`--forms` and `--componentize` are separate runs; doing both in one command is refused.
|
package/dist/cli.js
CHANGED
|
@@ -6377,6 +6377,7 @@ function scriptFor(dialect) {
|
|
|
6377
6377
|
var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
|
|
6378
6378
|
var escapeAttr = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
6379
6379
|
var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
|
|
6380
|
+
var DECLARED_BINDING_ATTR = /^data-bcms-(field|kind|props)$/i;
|
|
6380
6381
|
function* elementsIn(nodes) {
|
|
6381
6382
|
for (const node of nodes) {
|
|
6382
6383
|
if (node.type === "expr") {
|
|
@@ -6391,8 +6392,28 @@ function* elementsIn(nodes) {
|
|
|
6391
6392
|
var childElements = (node) => node.children.flatMap(
|
|
6392
6393
|
(child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
|
|
6393
6394
|
);
|
|
6395
|
+
var LANDMARK_TAGS = /* @__PURE__ */ new Set(["footer", "header", "nav", "main", "aside", "dialog"]);
|
|
6396
|
+
function landmarkOf(chain) {
|
|
6397
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
6398
|
+
const el = chain[i];
|
|
6399
|
+
if (attrOf(el, "role")?.value === "dialog") return "dialog";
|
|
6400
|
+
if (LANDMARK_TAGS.has(el.tag.toLowerCase())) return el.tag.toLowerCase();
|
|
6401
|
+
}
|
|
6402
|
+
return "body";
|
|
6403
|
+
}
|
|
6404
|
+
function* formsIn(nodes, chain = []) {
|
|
6405
|
+
for (const node of nodes) {
|
|
6406
|
+
if (node.type === "expr") {
|
|
6407
|
+
yield* formsIn(node.children, chain);
|
|
6408
|
+
continue;
|
|
6409
|
+
}
|
|
6410
|
+
if (node.type !== "element") continue;
|
|
6411
|
+
if (node.tag.toLowerCase() === "form") yield { el: node, landmark: landmarkOf(chain) };
|
|
6412
|
+
yield* formsIn(node.children, [...chain, node]);
|
|
6413
|
+
}
|
|
6414
|
+
}
|
|
6394
6415
|
var isComponentTag = (dialect, tag) => dialect !== "html" && /^[A-Z]/.test(tag);
|
|
6395
|
-
function candidateOf(file, dialect, form, imported) {
|
|
6416
|
+
function candidateOf(file, dialect, form, imported, landmark) {
|
|
6396
6417
|
const controls = /* @__PURE__ */ new Map();
|
|
6397
6418
|
const wrapper = /* @__PURE__ */ new Map();
|
|
6398
6419
|
const foreign = /* @__PURE__ */ new Set();
|
|
@@ -6429,7 +6450,7 @@ function candidateOf(file, dialect, form, imported) {
|
|
|
6429
6450
|
const id = attrOf(control, "id")?.value;
|
|
6430
6451
|
if (id && !controls.has(id)) controls.set(id, control);
|
|
6431
6452
|
}
|
|
6432
|
-
return { file, dialect, el: form, controls, wrapper, foreign };
|
|
6453
|
+
return { file, dialect, el: form, controls, wrapper, foreign, landmark };
|
|
6433
6454
|
}
|
|
6434
6455
|
async function importedControls(source, parsed, dialect, project, cache) {
|
|
6435
6456
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -6464,14 +6485,21 @@ function wire(source, candidate, form) {
|
|
|
6464
6485
|
};
|
|
6465
6486
|
set("action", form.submitUrl);
|
|
6466
6487
|
set("method", "post");
|
|
6467
|
-
|
|
6468
|
-
if (form.successMessage)
|
|
6488
|
+
set("data-bcms-form", form.id);
|
|
6489
|
+
if (form.successMessage) set("data-bcms-form-success", form.successMessage);
|
|
6469
6490
|
source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
|
|
6491
|
+
for (const inner of elementsIn(el.children)) {
|
|
6492
|
+
for (const attr of inner.attrs) {
|
|
6493
|
+
if (!DECLARED_BINDING_ATTR.test(attr.name)) continue;
|
|
6494
|
+
const start = source.original[attr.range.start - 1] === " " ? attr.range.start - 1 : attr.range.start;
|
|
6495
|
+
source.remove(start, attr.range.end);
|
|
6496
|
+
}
|
|
6497
|
+
}
|
|
6470
6498
|
for (const field of form.fields) {
|
|
6471
6499
|
const control = candidate.controls.get(field.key);
|
|
6472
6500
|
if (candidate.foreign.has(control)) continue;
|
|
6473
6501
|
const host = candidate.wrapper.get(control) ?? control;
|
|
6474
|
-
source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
|
|
6502
|
+
if (!attrOf(host, "data-bcms-form-field")) source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
|
|
6475
6503
|
const name = attrOf(control, "name");
|
|
6476
6504
|
if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
|
|
6477
6505
|
else if (!name.expression && name.value !== field.key && name.valueRange) {
|
|
@@ -6515,38 +6543,51 @@ async function wireForms(briefIn, sources, options_ = {}) {
|
|
|
6515
6543
|
continue;
|
|
6516
6544
|
}
|
|
6517
6545
|
const imported = dialect === "html" ? /* @__PURE__ */ new Map() : await importedControls(source, parsed, dialect, project, componentFiles);
|
|
6518
|
-
for (const el of
|
|
6519
|
-
|
|
6546
|
+
for (const { el, landmark } of formsIn(parsed.roots)) {
|
|
6547
|
+
candidates.push(candidateOf(source.path, dialect, el, imported, landmark));
|
|
6520
6548
|
}
|
|
6521
6549
|
}
|
|
6522
6550
|
const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
|
|
6523
6551
|
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
6552
|
const samePath = (a, b) => a.replace(/\\/g, "/").replace(/^\.\//, "") === b.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
6526
6553
|
const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
|
|
6527
6554
|
const pinned = /* @__PURE__ */ new Map();
|
|
6528
6555
|
for (const [ref, file] of Object.entries(options_.pins ?? {})) {
|
|
6529
6556
|
const form = forms.find((f) => f.id === ref || f.name === ref);
|
|
6530
6557
|
if (!form) throw new Error(`--pin ${ref}: the brief lists no form with that id or name.`);
|
|
6558
|
+
if (file.trim() === "") throw new Error(`--pin ${ref}: no file named. Give "<form>=<file>".`);
|
|
6531
6559
|
pinned.set(form.id, file);
|
|
6532
|
-
|
|
6560
|
+
}
|
|
6561
|
+
for (const form of forms) {
|
|
6562
|
+
if (form.pin && form.pin.trim() !== "" && !pinned.has(form.id)) pinned.set(form.id, form.pin);
|
|
6563
|
+
}
|
|
6564
|
+
for (const [id, file] of pinned) {
|
|
6565
|
+
options.set(id, options.get(id).filter((c) => samePath(c.file, file)));
|
|
6533
6566
|
for (const other of forms) {
|
|
6534
|
-
|
|
6567
|
+
const its = pinned.get(other.id);
|
|
6568
|
+
if (other.id === id || its !== void 0 && samePath(its, file)) continue;
|
|
6569
|
+
options.set(other.id, options.get(other.id).filter((c) => !samePath(c.file, file)));
|
|
6535
6570
|
}
|
|
6536
6571
|
}
|
|
6572
|
+
const beat = /* @__PURE__ */ new Map();
|
|
6537
6573
|
for (const c of candidates) {
|
|
6538
6574
|
const claimants = forms.filter((f) => options.get(f.id).includes(c));
|
|
6539
6575
|
if (claimants.length < 2) continue;
|
|
6540
6576
|
const top = Math.max(...claimants.map((f) => holds(c, f)));
|
|
6541
6577
|
const winners = claimants.filter((f) => holds(c, f) === top);
|
|
6542
6578
|
if (winners.length !== 1) continue;
|
|
6543
|
-
|
|
6579
|
+
const rivals = claimants.filter((f) => f !== winners[0]);
|
|
6580
|
+
for (const f of rivals) options.set(f.id, options.get(f.id).filter((x) => x !== c));
|
|
6581
|
+
beat.set(c, { winner: winners[0], rivals });
|
|
6544
6582
|
}
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6583
|
+
const placed = /* @__PURE__ */ new Map();
|
|
6584
|
+
for (const form of forms) {
|
|
6585
|
+
const list = options.get(form.id);
|
|
6586
|
+
if (list.length < 2 || !form.landmark || form.landmark === "body") continue;
|
|
6587
|
+
const kept = list.filter((c) => c.landmark === form.landmark);
|
|
6588
|
+
if (kept.length === 0 || kept.length === list.length) continue;
|
|
6589
|
+
options.set(form.id, kept);
|
|
6590
|
+
placed.set(form.id, { kept, dropped: list.filter((c) => !kept.includes(c)) });
|
|
6550
6591
|
}
|
|
6551
6592
|
const wanted = /* @__PURE__ */ new Map();
|
|
6552
6593
|
for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
|
|
@@ -6558,12 +6599,13 @@ async function wireForms(briefIn, sources, options_ = {}) {
|
|
|
6558
6599
|
for (const form of forms) {
|
|
6559
6600
|
const list = options.get(form.id) ?? [];
|
|
6560
6601
|
if (list.length > 1) {
|
|
6561
|
-
|
|
6562
|
-
form,
|
|
6563
|
-
|
|
6564
|
-
|
|
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
|
-
|
|
6602
|
+
pending.push({
|
|
6603
|
+
id: form.id,
|
|
6604
|
+
name: form.name,
|
|
6605
|
+
reason: "FORM_AMBIGUOUS",
|
|
6606
|
+
message: `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.${pinned.has(form.id) ? " The pin names that file, and it holds more than one." : ` Name the right one with --pin "${form.name}=<file>".`}`,
|
|
6607
|
+
candidates: [...new Set(list.map((c) => c.file))]
|
|
6608
|
+
});
|
|
6567
6609
|
continue;
|
|
6568
6610
|
}
|
|
6569
6611
|
if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
|
|
@@ -6601,7 +6643,7 @@ async function wireForms(briefIn, sources, options_ = {}) {
|
|
|
6601
6643
|
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.`);
|
|
6602
6644
|
continue;
|
|
6603
6645
|
}
|
|
6604
|
-
if (attrOf(candidate.el, "data-bcms-form")) {
|
|
6646
|
+
if (attrOf(candidate.el, "data-bcms-form")?.value === form.id) {
|
|
6605
6647
|
alreadyWired++;
|
|
6606
6648
|
onSite.push({ form, file: candidate.file });
|
|
6607
6649
|
continue;
|
|
@@ -6626,6 +6668,20 @@ ${indent}${scriptFor(last.candidate.dialect)}`);
|
|
|
6626
6668
|
files.push({ path, operation: "modify", content: source.toString() });
|
|
6627
6669
|
}
|
|
6628
6670
|
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.`);
|
|
6671
|
+
for (const { form, candidate } of matched) {
|
|
6672
|
+
const tie = beat.get(candidate);
|
|
6673
|
+
if (tie?.winner === form) {
|
|
6674
|
+
notes.push(
|
|
6675
|
+
`form ${form.name}: ${candidate.file} posts every field of ${tie.rivals.map((f) => f.name).join(", ")} too, and more of ${form.name}'s, so it went to ${form.name} \u2014 name the file yourself with --pin "<form>=<file>" if that is wrong.`
|
|
6676
|
+
);
|
|
6677
|
+
}
|
|
6678
|
+
const where = placed.get(form.id);
|
|
6679
|
+
if (where) {
|
|
6680
|
+
notes.push(
|
|
6681
|
+
`form ${form.name}: ${where.dropped.map((c) => c.file).join(", ")} post${where.dropped.length === 1 ? "s" : ""} every field of it too, but the built form sat in the ${form.landmark} and only ${candidate.file}'s does \u2014 name the file yourself with --pin "<form>=<file>" if that is wrong.`
|
|
6682
|
+
);
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6629
6685
|
for (const { form, candidate } of matched) {
|
|
6630
6686
|
const keys = form.fields.filter((field) => candidate.foreign.has(candidate.controls.get(field.key))).map((field) => field.key);
|
|
6631
6687
|
if (keys.length > 0) {
|
|
@@ -9726,7 +9782,7 @@ function emitStubs2(dialects, slugs, contents, out) {
|
|
|
9726
9782
|
// package.json
|
|
9727
9783
|
var package_default = {
|
|
9728
9784
|
name: "@bettercms-ai/convert",
|
|
9729
|
-
version: "0.
|
|
9785
|
+
version: "0.17.0",
|
|
9730
9786
|
type: "module",
|
|
9731
9787
|
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.",
|
|
9732
9788
|
bin: {
|
|
@@ -9805,9 +9861,14 @@ function parseArgs(argv) {
|
|
|
9805
9861
|
else if (arg === "--componentize") args.componentize = true;
|
|
9806
9862
|
else if (arg === "--forms") args.forms = true;
|
|
9807
9863
|
else if (arg === "--pin") {
|
|
9808
|
-
const
|
|
9809
|
-
|
|
9810
|
-
|
|
9864
|
+
const raw = argv[++i] ?? "";
|
|
9865
|
+
const at = raw.indexOf("=");
|
|
9866
|
+
if (at < 1 || at === raw.length - 1) {
|
|
9867
|
+
throw new CliError(`--pin takes <form>=<file>, e.g. --pin "Newsletter=src/components/FooterSection.astro". See --help.`);
|
|
9868
|
+
}
|
|
9869
|
+
const form = raw.slice(0, at);
|
|
9870
|
+
if (Object.hasOwn(args.pins, form)) throw new CliError(`--pin ${form} twice: give one file. See --help.`);
|
|
9871
|
+
args.pins[form] = raw.slice(at + 1);
|
|
9811
9872
|
} else if (arg === "--xray") args.xray = argv[++i];
|
|
9812
9873
|
else if (arg === "--plan") args.plan = argv[++i];
|
|
9813
9874
|
else if (arg === "--copy") {
|
|
@@ -10029,6 +10090,9 @@ async function main() {
|
|
|
10029
10090
|
if (args.forms && args.componentize) {
|
|
10030
10091
|
throw new CliError("--forms and --componentize are separate runs. Do one, then the other. See --help.");
|
|
10031
10092
|
}
|
|
10093
|
+
if (Object.keys(args.pins).length > 0 && !args.forms) {
|
|
10094
|
+
throw new CliError("--pin only means something with --forms. See --help.");
|
|
10095
|
+
}
|
|
10032
10096
|
const root = resolve(args.root);
|
|
10033
10097
|
const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
|
|
10034
10098
|
if (!Array.isArray(brief.pages)) {
|
|
@@ -10069,9 +10133,6 @@ async function main() {
|
|
|
10069
10133
|
reportSections(files2, receipt2, args.dryRun);
|
|
10070
10134
|
return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
|
|
10071
10135
|
}
|
|
10072
|
-
if (Object.keys(args.pins).length > 0 && !args.forms) {
|
|
10073
|
-
throw new CliError("--pin only means something with --forms. See --help.");
|
|
10074
|
-
}
|
|
10075
10136
|
if (args.forms) {
|
|
10076
10137
|
const { files: files2, receipt: receipt2 } = await wireForms(brief, sources, { pins: args.pins });
|
|
10077
10138
|
await written(files2);
|