@bettercms-ai/convert 0.8.0 → 0.9.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 +33 -0
- package/README.md +42 -0
- package/dist/cli.js +366 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +107 -1
- package/dist/index.js +279 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @bettercms-ai/convert
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 94a00c3: `--forms`: point an imported site's own `<form>` at the CMS form behind it.
|
|
8
|
+
|
|
9
|
+
After an import the release hook derives a draft form row per `<form>` in the built site, so the
|
|
10
|
+
Forms tab fills up while the repository's markup still posts wherever it always did — to nothing,
|
|
11
|
+
or to somebody else's endpoint. Nothing joined the two halves, because the id that does is the
|
|
12
|
+
platform's and lives in the brief.
|
|
13
|
+
|
|
14
|
+
`npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt receipt.json` reads the
|
|
15
|
+
brief's new `forms` array and writes, in one `magic-string` pass: `action`, `method`,
|
|
16
|
+
`data-bcms-form`, `data-bcms-form-success`, a `data-bcms-form-field` per field (on the control's
|
|
17
|
+
wrapper where it has one), the CMS key as each control's `name`, the `<p class="bcms-form-msg">`
|
|
18
|
+
the runtime writes into, and the submit script — the same bytes the hosted renderer ships, held
|
|
19
|
+
byte-for-byte by a test, because the `cf-turnstile-response` hoist in the middle of it is what
|
|
20
|
+
keeps enabling Turnstile from 403ing every submission.
|
|
21
|
+
|
|
22
|
+
The match is strict: a `<form>` is the CMS form's only when the names its controls post cover
|
|
23
|
+
every field key, and exactly one `<form>` answers. Everything else is receipted by name —
|
|
24
|
+
`FORM_NOT_IN_SOURCE`, `FORM_AMBIGUOUS`, `FIELD_UNMATCHED`, `ACTION_IS_EXPRESSION`,
|
|
25
|
+
`DIALECT_UNSUPPORTED`, `PARSE_ERROR` — because a form wired to the wrong id sends a customer's
|
|
26
|
+
leads into another form's inbox, silently, forever. A form already carrying `data-bcms-form` is
|
|
27
|
+
`alreadyWired`, so a second run writes nothing.
|
|
28
|
+
|
|
29
|
+
The receipt rides on `ConversionReceipt.forms` and claims no brief path, so
|
|
30
|
+
`submit_conversion_receipt` takes it unchanged. A form the brief lists as a DRAFT is wired AND
|
|
31
|
+
noted: it rejects every submission with a 403 until somebody publishes it in the Forms tab.
|
|
32
|
+
|
|
33
|
+
Also: `--version` now reports the package's real version. The constant had been stale at `0.4.0`
|
|
34
|
+
for four releases.
|
|
35
|
+
|
|
3
36
|
## 0.8.0
|
|
4
37
|
|
|
5
38
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -520,6 +520,48 @@ the now-unused `bcms`/snapshot imports P2 added, and a page whose registered cal
|
|
|
520
520
|
`<Sections>` keeps the imports of the components it used to call. They are harmless at build time
|
|
521
521
|
and removing them would mean editing statements no section asked about.
|
|
522
522
|
|
|
523
|
+
## Forms
|
|
524
|
+
|
|
525
|
+
The third mode, and the shortest. The release-time derive lane reads every `<form>` in the built
|
|
526
|
+
site and creates a DRAFT form per distinct shape, so the Forms tab fills on import while the
|
|
527
|
+
repository's own markup goes on posting wherever it always did. `--forms` joins the two halves.
|
|
528
|
+
|
|
529
|
+
```bash
|
|
530
|
+
# after the import; the brief carries the project's forms
|
|
531
|
+
npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json
|
|
532
|
+
git diff
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
```
|
|
536
|
+
modify src/pages/contact.astro
|
|
537
|
+
|
|
538
|
+
1 forms wired (0 already wired, 0 pending)
|
|
539
|
+
todo publish form Contact in the Forms tab — a draft form rejects every submission with 403.
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
It writes six things onto a matched `<form>` and nothing else: `action="<submitUrl>"`,
|
|
543
|
+
`method="post"`, `data-bcms-form="<id>"` (plus `data-bcms-form-success` when the CMS form has a
|
|
544
|
+
success message), `data-bcms-form-field="<key>"` on each matched control, a
|
|
545
|
+
`<p class="bcms-form-msg" hidden>` before `</form>` when there is none, and the submit script once
|
|
546
|
+
per file — the same bytes the hosted renderer ships, pinned by a test. No helper, no snapshot, no
|
|
547
|
+
import: a form posts to an absolute URL at runtime, so there is nothing to read at build time.
|
|
548
|
+
|
|
549
|
+
The match is the FIELD KEYS: a `<form>` is a CMS form's only when the names its controls post are a
|
|
550
|
+
superset of that form's field keys and exactly one `<form>` answers. Everything else is pending with
|
|
551
|
+
a reason — `FORM_NOT_IN_SOURCE`, `FORM_AMBIGUOUS`, `FIELD_UNMATCHED`, `ACTION_IS_EXPRESSION`,
|
|
552
|
+
`DIALECT_UNSUPPORTED`, `PARSE_ERROR` — because a form wired to the wrong id sends the customer's
|
|
553
|
+
leads into another form's inbox, silently, forever. A `<form>` that already carries
|
|
554
|
+
`data-bcms-form` is `alreadyWired` and is left alone, so a second run is a no-op.
|
|
555
|
+
|
|
556
|
+
`--receipt <file>` writes this run's own receipt (`forms-receipt.json` — a separate file from the
|
|
557
|
+
P2 run's, since the two runs describe different work), and `--strict` exits 2 on any pending form.
|
|
558
|
+
`--forms` and `--componentize` are separate runs; doing both in one command is refused.
|
|
559
|
+
|
|
560
|
+
**Publish the drafts afterwards.** The lane creates forms as drafts and the public submit route
|
|
561
|
+
403s a draft, so a wired draft form is an inviting form on a live site that rejects every
|
|
562
|
+
submission. The receipt carries one `notes` line per draft naming the form to publish in the Forms
|
|
563
|
+
tab; that is the step no codemod can do for you.
|
|
564
|
+
|
|
523
565
|
## Not here yet
|
|
524
566
|
|
|
525
567
|
GROWABLE lists. An array of primitives or tuples is bound (above) but renders the number of rows
|
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";
|
|
@@ -2563,7 +2563,16 @@ var IMAGE_SOURCES = {
|
|
|
2563
2563
|
};
|
|
2564
2564
|
var isImageSource = (site) => site.where === "attr" && (IMAGE_SOURCES[site.tag.toLowerCase()] ?? []).includes(site.attr ?? "");
|
|
2565
2565
|
var norm = (value) => decodeEntities(value.normalize("NFC")).replace(/\s+/g, " ").trim();
|
|
2566
|
-
var BCMS_ATTRS = [
|
|
2566
|
+
var BCMS_ATTRS = [
|
|
2567
|
+
"data-bcms-field",
|
|
2568
|
+
"data-bcms-kind",
|
|
2569
|
+
"data-bcms-props",
|
|
2570
|
+
"data-bcms-layout-field",
|
|
2571
|
+
// The forms lane's two. Listed here so `collectSites` never matches a brief literal against a
|
|
2572
|
+
// marker this package itself wrote — a form id or a field key is not the page's copy.
|
|
2573
|
+
"data-bcms-form",
|
|
2574
|
+
"data-bcms-form-field"
|
|
2575
|
+
];
|
|
2567
2576
|
var attrInsertAfter = (attrs, start, tag) => attrs.length > 0 ? Math.max(...attrs.map((a) => a.range.end)) : start + 1 + tag.length;
|
|
2568
2577
|
function openTagEnd(content, start) {
|
|
2569
2578
|
let quote = null;
|
|
@@ -5162,6 +5171,22 @@ function importLines(imports) {
|
|
|
5162
5171
|
|
|
5163
5172
|
// src/receipt.ts
|
|
5164
5173
|
var CANVAS_REASONS = /* @__PURE__ */ new Set(["CANVAS_BRIDGE_MANUAL", "SSR_DRAFT_ROUTE"]);
|
|
5174
|
+
var FormInvariantError = class extends Error {
|
|
5175
|
+
code = "FORM_INVARIANT";
|
|
5176
|
+
constructor(message) {
|
|
5177
|
+
super(message);
|
|
5178
|
+
this.name = "FormInvariantError";
|
|
5179
|
+
}
|
|
5180
|
+
};
|
|
5181
|
+
function assertForms(total, forms) {
|
|
5182
|
+
const sum = forms.wired + forms.alreadyWired + forms.pending.length;
|
|
5183
|
+
if (sum !== total) {
|
|
5184
|
+
throw new FormInvariantError(
|
|
5185
|
+
`The receipt does not account for every form: ${total} listed but ${forms.wired} wired + ${forms.alreadyWired} already wired + ${forms.pending.length} pending = ${sum}.`
|
|
5186
|
+
);
|
|
5187
|
+
}
|
|
5188
|
+
return forms;
|
|
5189
|
+
}
|
|
5165
5190
|
var ReceiptInvariantError = class extends Error {
|
|
5166
5191
|
code = "RECEIPT_INVARIANT";
|
|
5167
5192
|
constructor(message) {
|
|
@@ -6001,6 +6026,17 @@ function readBrief(value) {
|
|
|
6001
6026
|
}
|
|
6002
6027
|
}
|
|
6003
6028
|
}
|
|
6029
|
+
for (const [at, entry] of check.array(brief.forms ?? [], `the brief's "forms"`).entries()) {
|
|
6030
|
+
const where = `brief.forms[${at}]`;
|
|
6031
|
+
const form = check.record(entry, where);
|
|
6032
|
+
check.segment(form.id, `${where}.id`);
|
|
6033
|
+
check.string(form.name, `${where}.name`);
|
|
6034
|
+
check.string(form.submitUrl, `${where}.submitUrl`);
|
|
6035
|
+
check.optionalString(form.successMessage, `${where}.successMessage`);
|
|
6036
|
+
for (const [n, held] of check.array(form.fields, `${where}.fields`).entries()) {
|
|
6037
|
+
check.segment(check.record(held, `${where}.fields[${n}]`).key, `${where}.fields[${n}].key`);
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6004
6040
|
return brief;
|
|
6005
6041
|
}
|
|
6006
6042
|
function readPlan(value) {
|
|
@@ -6047,6 +6083,236 @@ function readPlan(value) {
|
|
|
6047
6083
|
return plan2;
|
|
6048
6084
|
}
|
|
6049
6085
|
|
|
6086
|
+
// src/forms.ts
|
|
6087
|
+
import MagicString4 from "magic-string";
|
|
6088
|
+
var WIRABLE = ["html", "astro", "jsx"];
|
|
6089
|
+
var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
|
|
6090
|
+
var FORM_SCRIPT = `<script>
|
|
6091
|
+
document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
|
|
6092
|
+
f.addEventListener('submit',function(e){
|
|
6093
|
+
e.preventDefault();
|
|
6094
|
+
var msg=f.querySelector('.bcms-form-msg');
|
|
6095
|
+
var data={};
|
|
6096
|
+
// A checkboxes group posts one entry per checked box under the SAME name; the old
|
|
6097
|
+
// one-at-a-time assignment kept only the last, so "pick many" delivered one answer.
|
|
6098
|
+
// One checked box still arrives as a scalar \u2014 FormData cannot tell a group from a
|
|
6099
|
+
// lone field \u2014 which is exactly why lib/forms/validate.ts's chosenValues() accepts
|
|
6100
|
+
// both an array and a comma string.
|
|
6101
|
+
new FormData(f).forEach(function(v,k){
|
|
6102
|
+
if(k==='cf-turnstile-response')return;
|
|
6103
|
+
if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);
|
|
6104
|
+
else data[k]=v;
|
|
6105
|
+
});
|
|
6106
|
+
var body={data:data};
|
|
6107
|
+
var tok=f.querySelector('[name="cf-turnstile-response"]');
|
|
6108
|
+
if(tok&&tok.value)body['cf-turnstile-response']=tok.value;
|
|
6109
|
+
fetch(f.action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
6110
|
+
.then(function(r){return r.ok;})
|
|
6111
|
+
.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();})
|
|
6112
|
+
.catch(function(){if(msg){msg.hidden=false;msg.textContent='Sorry, something went wrong.';}});
|
|
6113
|
+
});
|
|
6114
|
+
});
|
|
6115
|
+
</script>`;
|
|
6116
|
+
var SCRIPT_BODY = FORM_SCRIPT.slice("<script>".length, -"</script>".length);
|
|
6117
|
+
function scriptFor(dialect) {
|
|
6118
|
+
if (dialect === "jsx") return `<script dangerouslySetInnerHTML={{ __html: \`${SCRIPT_BODY}\` }} />`;
|
|
6119
|
+
if (dialect === "astro") return `<script is:inline>${SCRIPT_BODY}</script>`;
|
|
6120
|
+
return FORM_SCRIPT;
|
|
6121
|
+
}
|
|
6122
|
+
var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
|
|
6123
|
+
var escapeAttr = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
6124
|
+
var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
|
|
6125
|
+
function* elementsIn(nodes) {
|
|
6126
|
+
for (const node of nodes) {
|
|
6127
|
+
if (node.type === "expr") {
|
|
6128
|
+
yield* elementsIn(node.children);
|
|
6129
|
+
continue;
|
|
6130
|
+
}
|
|
6131
|
+
if (node.type !== "element") continue;
|
|
6132
|
+
yield node;
|
|
6133
|
+
yield* elementsIn(node.children);
|
|
6134
|
+
}
|
|
6135
|
+
}
|
|
6136
|
+
var childElements = (node) => node.children.flatMap(
|
|
6137
|
+
(child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
|
|
6138
|
+
);
|
|
6139
|
+
function candidateOf(file, dialect, form) {
|
|
6140
|
+
const controls = /* @__PURE__ */ new Map();
|
|
6141
|
+
const wrapper = /* @__PURE__ */ new Map();
|
|
6142
|
+
const all = [];
|
|
6143
|
+
const walk = (parent) => {
|
|
6144
|
+
const own = [];
|
|
6145
|
+
for (const child of childElements(parent)) {
|
|
6146
|
+
if (CONTROLS.has(child.tag.toLowerCase())) {
|
|
6147
|
+
own.push(child);
|
|
6148
|
+
all.push(child);
|
|
6149
|
+
}
|
|
6150
|
+
walk(child);
|
|
6151
|
+
}
|
|
6152
|
+
if (own.length === 1 && parent !== form) wrapper.set(own[0], parent);
|
|
6153
|
+
};
|
|
6154
|
+
walk(form);
|
|
6155
|
+
for (const control of all) {
|
|
6156
|
+
const name = attrOf(control, "name")?.value;
|
|
6157
|
+
if (name && !controls.has(name)) controls.set(name, control);
|
|
6158
|
+
}
|
|
6159
|
+
for (const control of all) {
|
|
6160
|
+
const id = attrOf(control, "id")?.value;
|
|
6161
|
+
if (id && !controls.has(id)) controls.set(id, control);
|
|
6162
|
+
}
|
|
6163
|
+
return { file, dialect, el: form, controls, wrapper };
|
|
6164
|
+
}
|
|
6165
|
+
var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
|
|
6166
|
+
function wire(source, candidate, form) {
|
|
6167
|
+
const { el, dialect } = candidate;
|
|
6168
|
+
const inserts = [];
|
|
6169
|
+
const set = (name, value) => {
|
|
6170
|
+
const attr = attrOf(el, name);
|
|
6171
|
+
if (!attr) inserts.push(`${name}="${escapeAttr(value)}"`);
|
|
6172
|
+
else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
|
|
6173
|
+
else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
|
|
6174
|
+
};
|
|
6175
|
+
set("action", form.submitUrl);
|
|
6176
|
+
set("method", "post");
|
|
6177
|
+
inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
|
|
6178
|
+
if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
|
|
6179
|
+
source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
|
|
6180
|
+
for (const field of form.fields) {
|
|
6181
|
+
const control = candidate.controls.get(field.key);
|
|
6182
|
+
const host = candidate.wrapper.get(control) ?? control;
|
|
6183
|
+
source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
|
|
6184
|
+
const name = attrOf(control, "name");
|
|
6185
|
+
if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
|
|
6186
|
+
else if (!name.expression && name.value !== field.key && name.valueRange) {
|
|
6187
|
+
source.overwrite(name.valueRange.start, name.valueRange.end, escapeAttr(field.key));
|
|
6188
|
+
}
|
|
6189
|
+
}
|
|
6190
|
+
if (el.inner && !hasMessage(el)) {
|
|
6191
|
+
const tail = /\s*$/.exec(source.original.slice(el.inner.start, el.inner.end))[0];
|
|
6192
|
+
source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
|
|
6193
|
+
}
|
|
6194
|
+
}
|
|
6195
|
+
async function wireForms(briefIn, sources) {
|
|
6196
|
+
const brief = readBrief(briefIn);
|
|
6197
|
+
const forms = brief.forms ?? [];
|
|
6198
|
+
const candidates = [];
|
|
6199
|
+
const unreadable = [];
|
|
6200
|
+
for (const source of sources) {
|
|
6201
|
+
if (!/<form[\s/>]/i.test(source.content)) continue;
|
|
6202
|
+
const dialect = dialectOf(source.path, source.content);
|
|
6203
|
+
if (!dialect || !WIRABLE.includes(dialect)) {
|
|
6204
|
+
unreadable.push({
|
|
6205
|
+
file: source.path,
|
|
6206
|
+
reason: "DIALECT_UNSUPPORTED",
|
|
6207
|
+
message: `${source.path} is ${dialect ?? "not a template"}.`
|
|
6208
|
+
});
|
|
6209
|
+
continue;
|
|
6210
|
+
}
|
|
6211
|
+
const parsed = await parseFile4(dialect, source.content);
|
|
6212
|
+
if (parsed.error) {
|
|
6213
|
+
unreadable.push({
|
|
6214
|
+
file: source.path,
|
|
6215
|
+
reason: parsed.error.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR",
|
|
6216
|
+
message: parsed.error.message
|
|
6217
|
+
});
|
|
6218
|
+
continue;
|
|
6219
|
+
}
|
|
6220
|
+
for (const el of elementsIn(parsed.roots)) {
|
|
6221
|
+
if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
|
|
6222
|
+
}
|
|
6223
|
+
}
|
|
6224
|
+
const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
|
|
6225
|
+
const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
|
|
6226
|
+
const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
|
|
6227
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
6228
|
+
for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
|
|
6229
|
+
const matched = [];
|
|
6230
|
+
const pending = [];
|
|
6231
|
+
const onSite = [];
|
|
6232
|
+
let alreadyWired = 0;
|
|
6233
|
+
const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
|
|
6234
|
+
for (const form of forms) {
|
|
6235
|
+
const list = options.get(form.id) ?? [];
|
|
6236
|
+
if (list.length > 1) {
|
|
6237
|
+
pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
|
|
6238
|
+
continue;
|
|
6239
|
+
}
|
|
6240
|
+
if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
|
|
6241
|
+
pend2(form, "FORM_AMBIGUOUS", list[0].file, "more than one CMS form's fields are posted by this <form>.");
|
|
6242
|
+
continue;
|
|
6243
|
+
}
|
|
6244
|
+
if (list.length === 0) {
|
|
6245
|
+
const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
|
|
6246
|
+
if (best) {
|
|
6247
|
+
const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
|
|
6248
|
+
pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
|
|
6249
|
+
} else if (unreadable[0]) {
|
|
6250
|
+
pend2(form, unreadable[0].reason, unreadable[0].file, unreadable[0].message);
|
|
6251
|
+
} else {
|
|
6252
|
+
pend2(form, "FORM_NOT_IN_SOURCE", void 0, `no <form> posts ${form.fields.map((f) => f.key).join(", ") || "any field of this form"}.`);
|
|
6253
|
+
}
|
|
6254
|
+
continue;
|
|
6255
|
+
}
|
|
6256
|
+
const candidate = list[0];
|
|
6257
|
+
if (attrOf(candidate.el, "action")?.expression) {
|
|
6258
|
+
pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
|
|
6259
|
+
continue;
|
|
6260
|
+
}
|
|
6261
|
+
const misnamed = form.fields.find((field) => {
|
|
6262
|
+
const attr = attrOf(candidate.controls.get(field.key), "name");
|
|
6263
|
+
return !!attr && attr.value !== field.key && (attr.expression || !attr.valueRange);
|
|
6264
|
+
});
|
|
6265
|
+
if (misnamed) {
|
|
6266
|
+
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.`);
|
|
6267
|
+
continue;
|
|
6268
|
+
}
|
|
6269
|
+
if (attrOf(candidate.el, "data-bcms-form")) {
|
|
6270
|
+
alreadyWired++;
|
|
6271
|
+
onSite.push(form);
|
|
6272
|
+
continue;
|
|
6273
|
+
}
|
|
6274
|
+
matched.push({ form, candidate });
|
|
6275
|
+
onSite.push(form);
|
|
6276
|
+
}
|
|
6277
|
+
const contents = new Map(sources.map((s) => [s.path, s.content]));
|
|
6278
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
6279
|
+
for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
|
|
6280
|
+
const files = [];
|
|
6281
|
+
for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
|
|
6282
|
+
const content = contents.get(path);
|
|
6283
|
+
const source = new MagicString4(content);
|
|
6284
|
+
for (const { form, candidate } of list) wire(source, candidate, form);
|
|
6285
|
+
if (!content.includes("form[data-bcms-form]")) {
|
|
6286
|
+
const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
|
|
6287
|
+
const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
|
|
6288
|
+
source.appendRight(last.candidate.el.end, `
|
|
6289
|
+
${indent}${scriptFor(last.candidate.dialect)}`);
|
|
6290
|
+
}
|
|
6291
|
+
files.push({ path, operation: "modify", content: source.toString() });
|
|
6292
|
+
}
|
|
6293
|
+
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.`);
|
|
6294
|
+
return {
|
|
6295
|
+
files,
|
|
6296
|
+
receipt: {
|
|
6297
|
+
briefDigest: briefDigest(brief),
|
|
6298
|
+
// This lane binds no brief PATH, and says so rather than leaving the arithmetic to a reader:
|
|
6299
|
+
// a receipt claiming zero of zero paths is exactly what a forms-only run did.
|
|
6300
|
+
paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
|
|
6301
|
+
occurrences: { located: 0, rewritten: 0 },
|
|
6302
|
+
files: [],
|
|
6303
|
+
helpers: [],
|
|
6304
|
+
helperUpgraded: false,
|
|
6305
|
+
dynamicBindings: [],
|
|
6306
|
+
forms: assertForms(forms.length, {
|
|
6307
|
+
wired: matched.length,
|
|
6308
|
+
alreadyWired,
|
|
6309
|
+
pending,
|
|
6310
|
+
...notes.length > 0 ? { notes } : {}
|
|
6311
|
+
})
|
|
6312
|
+
}
|
|
6313
|
+
};
|
|
6314
|
+
}
|
|
6315
|
+
|
|
6050
6316
|
// src/scan.ts
|
|
6051
6317
|
var SKIP_DIRS = [
|
|
6052
6318
|
"node_modules/",
|
|
@@ -6407,7 +6673,7 @@ function moduleScope(parsed, content) {
|
|
|
6407
6673
|
}
|
|
6408
6674
|
|
|
6409
6675
|
// src/componentize.ts
|
|
6410
|
-
import
|
|
6676
|
+
import MagicString5 from "magic-string";
|
|
6411
6677
|
var SectionInvariantError = class extends Error {
|
|
6412
6678
|
code = "SECTION_INVARIANT";
|
|
6413
6679
|
constructor(message) {
|
|
@@ -7333,7 +7599,7 @@ export default function ${resolved.name}({ blockId, bind, overrides, page }: Bcm
|
|
|
7333
7599
|
` };
|
|
7334
7600
|
}
|
|
7335
7601
|
function sectionMarkup(content, root, fieldName) {
|
|
7336
|
-
const slice = new
|
|
7602
|
+
const slice = new MagicString5(content.slice(root.start, root.end));
|
|
7337
7603
|
const at = (offset) => offset - root.start;
|
|
7338
7604
|
const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
|
|
7339
7605
|
if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
|
|
@@ -7532,7 +7798,7 @@ async function migrateSections(plan2, contents, written, fileSet, aliases) {
|
|
|
7532
7798
|
);
|
|
7533
7799
|
continue;
|
|
7534
7800
|
}
|
|
7535
|
-
const source = new
|
|
7801
|
+
const source = new MagicString5(content);
|
|
7536
7802
|
const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
|
|
7537
7803
|
if (duplicates.length > 0) {
|
|
7538
7804
|
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 +8075,7 @@ export default function Sections({ page }: { page: string }) {
|
|
|
7809
8075
|
function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
|
|
7810
8076
|
const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
|
|
7811
8077
|
const local = names.get("Sections");
|
|
7812
|
-
const source = new
|
|
8078
|
+
const source = new MagicString5(content);
|
|
7813
8079
|
const first = placeable[0].root;
|
|
7814
8080
|
const last = placeable[placeable.length - 1].root;
|
|
7815
8081
|
source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
|
|
@@ -7829,7 +8095,7 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
|
|
|
7829
8095
|
{ key: "pageSnapshot", name: "pageSnapshot" }
|
|
7830
8096
|
]);
|
|
7831
8097
|
const snapshot = names.get("pageSnapshot");
|
|
7832
|
-
const source = new
|
|
8098
|
+
const source = new MagicString5(content);
|
|
7833
8099
|
for (const resolved of placeable) {
|
|
7834
8100
|
const component = written.get(resolved.slug);
|
|
7835
8101
|
const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
|
|
@@ -8216,7 +8482,7 @@ async function convertSources(briefIn, sources, options) {
|
|
|
8216
8482
|
} else rewritten++;
|
|
8217
8483
|
}
|
|
8218
8484
|
for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
|
|
8219
|
-
const source = new
|
|
8485
|
+
const source = new MagicString6(contents.get(file));
|
|
8220
8486
|
const seen = /* @__PURE__ */ new Set();
|
|
8221
8487
|
for (const splice of splices) {
|
|
8222
8488
|
const id = `${splice.start}:${splice.end}:${splice.text}`;
|
|
@@ -8919,8 +9185,62 @@ function emitStubs2(dialects, slugs, contents, out) {
|
|
|
8919
9185
|
}
|
|
8920
9186
|
}
|
|
8921
9187
|
|
|
9188
|
+
// package.json
|
|
9189
|
+
var package_default = {
|
|
9190
|
+
name: "@bettercms-ai/convert",
|
|
9191
|
+
version: "0.9.0",
|
|
9192
|
+
type: "module",
|
|
9193
|
+
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
|
+
bin: {
|
|
9195
|
+
"bettercms-convert": "dist/cli.js"
|
|
9196
|
+
},
|
|
9197
|
+
main: "./dist/index.js",
|
|
9198
|
+
module: "./dist/index.js",
|
|
9199
|
+
types: "./dist/index.d.ts",
|
|
9200
|
+
sideEffects: false,
|
|
9201
|
+
exports: {
|
|
9202
|
+
".": {
|
|
9203
|
+
types: "./dist/index.d.ts",
|
|
9204
|
+
import: "./dist/index.js",
|
|
9205
|
+
default: "./dist/index.js"
|
|
9206
|
+
}
|
|
9207
|
+
},
|
|
9208
|
+
files: [
|
|
9209
|
+
"dist",
|
|
9210
|
+
"README.md",
|
|
9211
|
+
"CHANGELOG.md"
|
|
9212
|
+
],
|
|
9213
|
+
scripts: {
|
|
9214
|
+
typecheck: "tsc --noEmit",
|
|
9215
|
+
build: "tsup",
|
|
9216
|
+
test: "vitest run",
|
|
9217
|
+
"test:watch": "vitest",
|
|
9218
|
+
briefs: "bun --conditions=development scripts/build-briefs.ts"
|
|
9219
|
+
},
|
|
9220
|
+
dependencies: {
|
|
9221
|
+
"@astrojs/compiler": "^2.13.1",
|
|
9222
|
+
"@babel/parser": "^7.29.2",
|
|
9223
|
+
"@bettercms-ai/types": "^1.12.0",
|
|
9224
|
+
"@vue/compiler-sfc": "^3.5.0",
|
|
9225
|
+
"magic-string": "^0.30.21",
|
|
9226
|
+
parse5: "^7.3.0",
|
|
9227
|
+
svelte: "^5.0.0"
|
|
9228
|
+
},
|
|
9229
|
+
devDependencies: {
|
|
9230
|
+
"@types/node": "^20",
|
|
9231
|
+
esbuild: "^0.27.7",
|
|
9232
|
+
tsup: "^8.5.1",
|
|
9233
|
+
typescript: "^5",
|
|
9234
|
+
vitest: "^4.1.4"
|
|
9235
|
+
},
|
|
9236
|
+
publishConfig: {
|
|
9237
|
+
access: "public",
|
|
9238
|
+
registry: "https://registry.npmjs.org/"
|
|
9239
|
+
}
|
|
9240
|
+
};
|
|
9241
|
+
|
|
8922
9242
|
// src/cli.ts
|
|
8923
|
-
var VERSION =
|
|
9243
|
+
var { version: VERSION } = package_default;
|
|
8924
9244
|
var MAX_FILE_BYTES = 512 * 1024;
|
|
8925
9245
|
var CliError = class extends Error {
|
|
8926
9246
|
};
|
|
@@ -8928,6 +9248,7 @@ function parseArgs(argv) {
|
|
|
8928
9248
|
const args = {
|
|
8929
9249
|
brief: void 0,
|
|
8930
9250
|
componentize: false,
|
|
9251
|
+
forms: false,
|
|
8931
9252
|
plan: void 0,
|
|
8932
9253
|
copy: "bind",
|
|
8933
9254
|
root: ".",
|
|
@@ -8942,6 +9263,7 @@ function parseArgs(argv) {
|
|
|
8942
9263
|
const arg = argv[i];
|
|
8943
9264
|
if (arg === "--brief") args.brief = argv[++i];
|
|
8944
9265
|
else if (arg === "--componentize") args.componentize = true;
|
|
9266
|
+
else if (arg === "--forms") args.forms = true;
|
|
8945
9267
|
else if (arg === "--plan") args.plan = argv[++i];
|
|
8946
9268
|
else if (arg === "--copy") {
|
|
8947
9269
|
const mode = argv[++i];
|
|
@@ -8968,9 +9290,14 @@ var HELP = `bettercms-convert ${VERSION}
|
|
|
8968
9290
|
|
|
8969
9291
|
bettercms-convert --brief <json> --root . [options]
|
|
8970
9292
|
bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
|
|
9293
|
+
bettercms-convert --forms --brief <json> --root . [options]
|
|
8971
9294
|
|
|
8972
9295
|
--brief <file> The conversion brief, as get_conversion_brief returns it. Required.
|
|
8973
9296
|
--componentize Lift each planned SECTION into a component the CMS can reorder.
|
|
9297
|
+
--forms Point each <form> in the repository at the CMS form behind it \u2014 the
|
|
9298
|
+
endpoint, the id, a marker per field and the submit script. A form
|
|
9299
|
+
the brief lists as a DRAFT is wired and receipted: publish it in the
|
|
9300
|
+
Forms tab, or it rejects every submission with a 403.
|
|
8974
9301
|
--plan <file> The componentize plan, as get_componentize_plan returns it.
|
|
8975
9302
|
Required with --componentize.
|
|
8976
9303
|
--copy bind|instance Which copy model the plan is expected to describe. Default: bind
|
|
@@ -9053,6 +9380,26 @@ ${rewritten} of ${declared} paths bound (${alreadyDeclared} already declared, ${
|
|
|
9053
9380
|
`);
|
|
9054
9381
|
}
|
|
9055
9382
|
}
|
|
9383
|
+
function reportForms(files, receipt, dryRun) {
|
|
9384
|
+
const { wired, alreadyWired, pending, notes } = receipt.forms;
|
|
9385
|
+
for (const file of files) {
|
|
9386
|
+
process.stdout.write(`${dryRun ? "would " : ""}${file.operation} ${file.path}
|
|
9387
|
+
`);
|
|
9388
|
+
}
|
|
9389
|
+
process.stdout.write(
|
|
9390
|
+
`
|
|
9391
|
+
${wired} forms wired (${alreadyWired} already wired, ${pending.length} pending)
|
|
9392
|
+
`
|
|
9393
|
+
);
|
|
9394
|
+
for (const form of pending) {
|
|
9395
|
+
process.stdout.write(
|
|
9396
|
+
` pending ${form.name} \u2014 ${form.reason}${form.message ? ` (${form.message})` : ""}${form.file ? ` [${form.file}]` : ""}
|
|
9397
|
+
`
|
|
9398
|
+
);
|
|
9399
|
+
}
|
|
9400
|
+
for (const note of notes ?? []) process.stdout.write(` todo ${note}
|
|
9401
|
+
`);
|
|
9402
|
+
}
|
|
9056
9403
|
function reportSections(files, receipt, dryRun) {
|
|
9057
9404
|
const { extracted, inlineOnly, alreadyExtracted, registered, pending } = receipt.sections;
|
|
9058
9405
|
for (const file of files) {
|
|
@@ -9084,6 +9431,9 @@ async function main() {
|
|
|
9084
9431
|
if (args.copy === "instance" && !args.componentize) {
|
|
9085
9432
|
throw new CliError("--copy is only read with --componentize. See --help.");
|
|
9086
9433
|
}
|
|
9434
|
+
if (args.forms && args.componentize) {
|
|
9435
|
+
throw new CliError("--forms and --componentize are separate runs. Do one, then the other. See --help.");
|
|
9436
|
+
}
|
|
9087
9437
|
const root = resolve(args.root);
|
|
9088
9438
|
const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
|
|
9089
9439
|
if (!Array.isArray(brief.pages)) {
|
|
@@ -9124,6 +9474,13 @@ async function main() {
|
|
|
9124
9474
|
reportSections(files2, receipt2, args.dryRun);
|
|
9125
9475
|
return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
|
|
9126
9476
|
}
|
|
9477
|
+
if (args.forms) {
|
|
9478
|
+
const { files: files2, receipt: receipt2 } = await wireForms(brief, sources);
|
|
9479
|
+
await written(files2);
|
|
9480
|
+
await saveReceipt(receipt2);
|
|
9481
|
+
reportForms(files2, receipt2, args.dryRun);
|
|
9482
|
+
return args.strict && receipt2.forms.pending.length > 0 ? 2 : 0;
|
|
9483
|
+
}
|
|
9127
9484
|
const { files, receipt } = await convertSources(brief, sources, {
|
|
9128
9485
|
overwriteHelper: args.overwriteHelper
|
|
9129
9486
|
});
|