@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 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 MagicString5 from "magic-string";
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 = ["data-bcms-field", "data-bcms-kind", "data-bcms-props", "data-bcms-layout-field"];
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, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
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 MagicString4 from "magic-string";
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 MagicString4(content.slice(root.start, root.end));
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 MagicString4(content);
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 MagicString4(content);
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 MagicString4(content);
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 MagicString5(contents.get(file));
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 = "0.4.0";
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
  });