@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/dist/index.d.ts CHANGED
@@ -56,6 +56,11 @@ interface BriefPage {
56
56
  /** Only what the codemod reads. A backend `ConversionBrief` satisfies it. */
57
57
  interface Brief {
58
58
  pages: BriefPage[];
59
+ /**
60
+ * The project's forms, for the `--forms` lane. OPTIONAL: a brief issued before that lane
61
+ * existed carries none, and a run over one simply has no form to wire. @see BriefForm
62
+ */
63
+ forms?: BriefForm[];
59
64
  }
60
65
  /**
61
66
  * The name of one brief — `(route, path, kind, original)` for every page, hashed.
@@ -72,6 +77,29 @@ interface Brief {
72
77
  * coverage meter read zero forever. `brief-digest.test.ts` pins both against one literal hash.
73
78
  */
74
79
  declare function briefDigest(brief: Brief): string;
80
+ /**
81
+ * One CMS form, as the brief lists it — the half the codemod cannot derive from the repository.
82
+ *
83
+ * The `<form>` in the source and the row in the Forms tab are two halves of one thing, and only
84
+ * the platform knows the id that joins them. `submitUrl` is the absolute endpoint a browser posts
85
+ * to (`packages/sdk/src/forms.ts`), so a wired form needs no helper, no import and no snapshot:
86
+ * it posts to a URL. `status` is carried because a DRAFT form 403s that endpoint — wiring one is
87
+ * correct and incomplete, and the receipt says so rather than leaving a live form that silently
88
+ * rejects every visitor.
89
+ */
90
+ interface BriefForm {
91
+ id: string;
92
+ name: string;
93
+ status: "draft" | "published";
94
+ /** `/api/v1/forms/public/<id>/submissions`, absolute or origin-relative as the brief states it. */
95
+ submitUrl: string;
96
+ successMessage?: string;
97
+ /** The control this form expects, by `name`. `type` is informative; the key is the match. */
98
+ fields: {
99
+ key: string;
100
+ type: string;
101
+ }[];
102
+ }
75
103
 
76
104
  /**
77
105
  * Where in the source does each of the brief's paths actually appear?
@@ -255,7 +283,59 @@ interface ConversionReceipt {
255
283
  lane: "bridge" | "pending";
256
284
  files?: string[];
257
285
  };
286
+ /**
287
+ * What `--forms` wired, and what it could not. OPTIONAL, and ADDITIVE ON PURPOSE: the server's
288
+ * `receiptInvariantHolds` reads `paths` only, so a receipt carrying this is accepted verbatim by
289
+ * a server that has never heard of the lane. @see FormsReceipt
290
+ */
291
+ forms?: FormsReceipt;
292
+ }
293
+ /**
294
+ * Why one CMS form could not be wired to a `<form>` in the repository.
295
+ *
296
+ * `FORM_NOT_IN_SOURCE` no `<form>` in any file this run read holds any of its fields.
297
+ * `FORM_AMBIGUOUS` two `<form>`s hold its fields, or two CMS forms want the same one.
298
+ * `FIELD_UNMATCHED` a `<form>` is plainly the one, but a field's key names no control in it.
299
+ * `ACTION_IS_EXPRESSION` the `<form>`'s `action` is code; replacing it would rewrite a program.
300
+ * `DIALECT_UNSUPPORTED` the `<form>` is in a svelte/vue file, which this lane does not write.
301
+ * `PARSE_ERROR` the file holding it did not parse.
302
+ */
303
+ type FormPendingReason = "FORM_NOT_IN_SOURCE" | "FORM_AMBIGUOUS" | "FIELD_UNMATCHED" | "ACTION_IS_EXPRESSION" | "DIALECT_UNSUPPORTED" | "PARSE_ERROR";
304
+ interface PendingForm {
305
+ id: string;
306
+ name: string;
307
+ reason: FormPendingReason;
308
+ /** The file the reason is about, when the reason is about one. */
309
+ file?: string;
310
+ /** What exactly could not be done. Informative; the reason is the contract. */
311
+ message?: string;
258
312
  }
313
+ interface FormsReceipt {
314
+ /** A `<form>` this run pointed at the CMS: action, method, id, field markers and the script. */
315
+ wired: number;
316
+ /**
317
+ * Already ours, from a previous run — the `<form>` carries `data-bcms-form`.
318
+ *
319
+ * Its own bucket for the same reason componentize keeps one: a second run over a wired tree has
320
+ * to report `wired: 0, pending: []` AND still account for every form of the brief.
321
+ */
322
+ alreadyWired: number;
323
+ pending: PendingForm[];
324
+ /** Prose a human has to act on — a draft form still 403s every submission. */
325
+ notes?: string[];
326
+ }
327
+ /** Raised when the receipt does not account for every form. Never caught inside this package. */
328
+ declare class FormInvariantError extends Error {
329
+ readonly code = "FORM_INVARIANT";
330
+ constructor(message: string);
331
+ }
332
+ /**
333
+ * The invariant, checked where the receipt is built — the same shape as `assertSections`.
334
+ *
335
+ * A form in none of the buckets is a form the codemod forgot, and a forgotten form is a live site
336
+ * whose submit button does nothing at all.
337
+ */
338
+ declare function assertForms(total: number, forms: FormsReceipt): FormsReceipt;
259
339
  /**
260
340
  * One binding whose `data-bcms-field` is an expression, and where its value is written literally.
261
341
  *
@@ -749,6 +829,32 @@ declare function helperSource(dialect: Dialect): string;
749
829
  */
750
830
  declare function isKnownHelper(content: string, dialect: Dialect): number | null;
751
831
 
832
+ /**
833
+ * The submit runtime, LIFTED VERBATIM from `src/lib/sites/render-page.ts` `FORM_SCRIPT`.
834
+ *
835
+ * 🔴 THE SAME BYTES, AND A TEST THAT SAYS SO. The hosted renderer and this codemod ship the same
836
+ * form contract to two different kinds of site, and the `cf-turnstile-response` hoist in the
837
+ * middle is the part that cannot drift: the token must travel as a TOP-LEVEL key and never inside
838
+ * `data`, or the ingest route reads `token === undefined` and — once any Turnstile secret resolves
839
+ * — answers 403 to every single submission. A copy that fell behind would turn enabling Turnstile
840
+ * into a kill switch for the customer's live form, on their own repository, where nothing here can
841
+ * see it. `src/__tests__/content/form-script-pin.test.ts` reads THIS FILE as text and asserts the
842
+ * renderer's constant occurs in it byte for byte, which is the only thing that keeps the copy a
843
+ * copy. Change it there first, then paste it here.
844
+ */
845
+ declare const FORM_SCRIPT = "<script>\ndocument.querySelectorAll('form[data-bcms-form]').forEach(function(f){\n f.addEventListener('submit',function(e){\n e.preventDefault();\n var msg=f.querySelector('.bcms-form-msg');\n var data={};\n // A checkboxes group posts one entry per checked box under the SAME name; the old\n // one-at-a-time assignment kept only the last, so \"pick many\" delivered one answer.\n // One checked box still arrives as a scalar \u2014 FormData cannot tell a group from a\n // lone field \u2014 which is exactly why lib/forms/validate.ts's chosenValues() accepts\n // both an array and a comma string.\n new FormData(f).forEach(function(v,k){\n if(k==='cf-turnstile-response')return;\n if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);\n else data[k]=v;\n });\n var body={data:data};\n var tok=f.querySelector('[name=\"cf-turnstile-response\"]');\n if(tok&&tok.value)body['cf-turnstile-response']=tok.value;\n fetch(f.action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})\n .then(function(r){return r.ok;})\n .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();})\n .catch(function(){if(msg){msg.hidden=false;msg.textContent='Sorry, something went wrong.';}});\n });\n});\n</script>";
846
+ /**
847
+ * Wire every form the brief lists, and say what could not be.
848
+ *
849
+ * PURE, like the rest of this package: files in as values, files out as values. The receipt is a
850
+ * `ConversionReceipt` with an empty path arithmetic and the lane's own account under `forms`, so
851
+ * `submit_conversion_receipt` takes it unchanged — the server's invariant reads `paths` only.
852
+ */
853
+ declare function wireForms(briefIn: Brief, sources: SourceFile[]): Promise<{
854
+ files: PlanFile[];
855
+ receipt: ConversionReceipt;
856
+ }>;
857
+
752
858
  /**
753
859
  * Copy that lives in a DATA LITERAL beside the markup, rendered through `.map`.
754
860
  *
@@ -1538,4 +1644,4 @@ declare function convertSources(briefIn: Brief, sources: SourceFile[], options?:
1538
1644
  receipt: ConversionReceipt;
1539
1645
  }>;
1540
1646
 
1541
- export { ASTRO_LANE_PENDING, type AstNode, type AttrBinding, type Brief, type BriefPage, type BriefPath, COMPONENT_DIR, CONTENT_DIR, type CanvasResult, type CanvasSource, type Carried, type ComponentizeOptions, type ComponentizePlan, type ComponentizePlanComponent, type ComponentizePlanField, type ComponentizePlanPage, type ComponentizePlanSection, type ComponentizeReceipt, type ConversionReceipt, ConvertError, type ConvertErrorCode, type ConvertOptions, DIALECT_RULES, type DataMember, type DataResult, type DeclaredPath, type Dialect, type DynamicBinding, type FileDeclaration, type FindSitesResult, HELPER_PATH, HELPER_VERSION, type ImageResult, type ImageTarget, type ImportedFrom, type LlmFallback, type LocatedPath, type Node, PARSE_FILE, type ParsedFile, type ParserError, type PathLocator, type PendingPath, type PendingReason, type PendingSection, type PlanFile, REGISTRY_MARKER, type ReceiptFile, ReceiptInvariantError, type ReceiptNote, type Rewrite, SECTIONS_LIB, SECTION_MARKER, SKIP_DIRS, SKIP_FILES, SOURCE_EXTENSIONS, SSR_DRAFT_RECIPE, STUB_CONTENT, SectionInvariantError, type SectionPendingReason, type SectionsReceipt, type Site, type SiteWhere, type SourceFile, type Splice, type TargetIdentity, type UnlocatedPath, type ValidationCode, ValidationError, aliasesFrom, assertReceipt, assertSections, assetCandidates, assetImport, attrsFor, bindDataLiterals, bindImages, briefDigest, canvasBridge, carryFor, componentizeSources, convertSources, convertedHere, coverageOf, declaringElements, dialectOf, exportedFunction, extendedConfigs, findSites, findSitesTolerant, flat, helperSource, importsIn, isDynamicRoute, isKnownHelper, isSourceCandidate, locate, overlapping, pageFilesFor, parseFile, propsAttribute, readBrief, readComponentizePlan, readDeclarations, readExpr, readPlan, relativeImport, resolveSpecifier, rewriteFile, routeOfFile, stripTags, unreadableConfigs, walkAst };
1647
+ export { ASTRO_LANE_PENDING, type AstNode, type AttrBinding, type Brief, type BriefForm, type BriefPage, type BriefPath, COMPONENT_DIR, CONTENT_DIR, type CanvasResult, type CanvasSource, type Carried, type ComponentizeOptions, type ComponentizePlan, type ComponentizePlanComponent, type ComponentizePlanField, type ComponentizePlanPage, type ComponentizePlanSection, type ComponentizeReceipt, type ConversionReceipt, ConvertError, type ConvertErrorCode, type ConvertOptions, DIALECT_RULES, type DataMember, type DataResult, type DeclaredPath, type Dialect, type DynamicBinding, FORM_SCRIPT, type FileDeclaration, type FindSitesResult, FormInvariantError, type FormPendingReason, type FormsReceipt, HELPER_PATH, HELPER_VERSION, type ImageResult, type ImageTarget, type ImportedFrom, type LlmFallback, type LocatedPath, type Node, PARSE_FILE, type ParsedFile, type ParserError, type PathLocator, type PendingForm, type PendingPath, type PendingReason, type PendingSection, type PlanFile, REGISTRY_MARKER, type ReceiptFile, ReceiptInvariantError, type ReceiptNote, type Rewrite, SECTIONS_LIB, SECTION_MARKER, SKIP_DIRS, SKIP_FILES, SOURCE_EXTENSIONS, SSR_DRAFT_RECIPE, STUB_CONTENT, SectionInvariantError, type SectionPendingReason, type SectionsReceipt, type Site, type SiteWhere, type SourceFile, type Splice, type TargetIdentity, type UnlocatedPath, type ValidationCode, ValidationError, aliasesFrom, assertForms, assertReceipt, assertSections, assetCandidates, assetImport, attrsFor, bindDataLiterals, bindImages, briefDigest, canvasBridge, carryFor, componentizeSources, convertSources, convertedHere, coverageOf, declaringElements, dialectOf, exportedFunction, extendedConfigs, findSites, findSitesTolerant, flat, helperSource, importsIn, isDynamicRoute, isKnownHelper, isSourceCandidate, locate, overlapping, pageFilesFor, parseFile, propsAttribute, readBrief, readComponentizePlan, readDeclarations, readExpr, readPlan, relativeImport, resolveSpecifier, rewriteFile, routeOfFile, stripTags, unreadableConfigs, walkAst, wireForms };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import MagicString5 from "magic-string";
2
+ import MagicString6 from "magic-string";
3
3
 
4
4
  // src/brief.ts
5
5
  import { createHash } from "crypto";
@@ -2561,7 +2561,16 @@ var IMAGE_SOURCES = {
2561
2561
  };
2562
2562
  var isImageSource = (site) => site.where === "attr" && (IMAGE_SOURCES[site.tag.toLowerCase()] ?? []).includes(site.attr ?? "");
2563
2563
  var norm = (value) => decodeEntities(value.normalize("NFC")).replace(/\s+/g, " ").trim();
2564
- var BCMS_ATTRS = ["data-bcms-field", "data-bcms-kind", "data-bcms-props", "data-bcms-layout-field"];
2564
+ var BCMS_ATTRS = [
2565
+ "data-bcms-field",
2566
+ "data-bcms-kind",
2567
+ "data-bcms-props",
2568
+ "data-bcms-layout-field",
2569
+ // The forms lane's two. Listed here so `collectSites` never matches a brief literal against a
2570
+ // marker this package itself wrote — a form id or a field key is not the page's copy.
2571
+ "data-bcms-form",
2572
+ "data-bcms-form-field"
2573
+ ];
2565
2574
  var attrInsertAfter = (attrs, start, tag) => attrs.length > 0 ? Math.max(...attrs.map((a) => a.range.end)) : start + 1 + tag.length;
2566
2575
  function openTagEnd(content, start) {
2567
2576
  let quote = null;
@@ -5160,6 +5169,22 @@ function importLines(imports) {
5160
5169
 
5161
5170
  // src/receipt.ts
5162
5171
  var CANVAS_REASONS = /* @__PURE__ */ new Set(["CANVAS_BRIDGE_MANUAL", "SSR_DRAFT_ROUTE"]);
5172
+ var FormInvariantError = class extends Error {
5173
+ code = "FORM_INVARIANT";
5174
+ constructor(message) {
5175
+ super(message);
5176
+ this.name = "FormInvariantError";
5177
+ }
5178
+ };
5179
+ function assertForms(total, forms) {
5180
+ const sum = forms.wired + forms.alreadyWired + forms.pending.length;
5181
+ if (sum !== total) {
5182
+ throw new FormInvariantError(
5183
+ `The receipt does not account for every form: ${total} listed but ${forms.wired} wired + ${forms.alreadyWired} already wired + ${forms.pending.length} pending = ${sum}.`
5184
+ );
5185
+ }
5186
+ return forms;
5187
+ }
5163
5188
  var ReceiptInvariantError = class extends Error {
5164
5189
  code = "RECEIPT_INVARIANT";
5165
5190
  constructor(message) {
@@ -6011,6 +6036,17 @@ function readBrief(value) {
6011
6036
  }
6012
6037
  }
6013
6038
  }
6039
+ for (const [at, entry] of check.array(brief.forms ?? [], `the brief's "forms"`).entries()) {
6040
+ const where = `brief.forms[${at}]`;
6041
+ const form = check.record(entry, where);
6042
+ check.segment(form.id, `${where}.id`);
6043
+ check.string(form.name, `${where}.name`);
6044
+ check.string(form.submitUrl, `${where}.submitUrl`);
6045
+ check.optionalString(form.successMessage, `${where}.successMessage`);
6046
+ for (const [n, held] of check.array(form.fields, `${where}.fields`).entries()) {
6047
+ check.segment(check.record(held, `${where}.fields[${n}]`).key, `${where}.fields[${n}].key`);
6048
+ }
6049
+ }
6014
6050
  return brief;
6015
6051
  }
6016
6052
  function readPlan(value) {
@@ -6057,6 +6093,236 @@ function readPlan(value) {
6057
6093
  return plan2;
6058
6094
  }
6059
6095
 
6096
+ // src/forms.ts
6097
+ import MagicString4 from "magic-string";
6098
+ var WIRABLE = ["html", "astro", "jsx"];
6099
+ var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6100
+ var FORM_SCRIPT = `<script>
6101
+ document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
6102
+ f.addEventListener('submit',function(e){
6103
+ e.preventDefault();
6104
+ var msg=f.querySelector('.bcms-form-msg');
6105
+ var data={};
6106
+ // A checkboxes group posts one entry per checked box under the SAME name; the old
6107
+ // one-at-a-time assignment kept only the last, so "pick many" delivered one answer.
6108
+ // One checked box still arrives as a scalar \u2014 FormData cannot tell a group from a
6109
+ // lone field \u2014 which is exactly why lib/forms/validate.ts's chosenValues() accepts
6110
+ // both an array and a comma string.
6111
+ new FormData(f).forEach(function(v,k){
6112
+ if(k==='cf-turnstile-response')return;
6113
+ if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);
6114
+ else data[k]=v;
6115
+ });
6116
+ var body={data:data};
6117
+ var tok=f.querySelector('[name="cf-turnstile-response"]');
6118
+ if(tok&&tok.value)body['cf-turnstile-response']=tok.value;
6119
+ fetch(f.action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
6120
+ .then(function(r){return r.ok;})
6121
+ .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();})
6122
+ .catch(function(){if(msg){msg.hidden=false;msg.textContent='Sorry, something went wrong.';}});
6123
+ });
6124
+ });
6125
+ </script>`;
6126
+ var SCRIPT_BODY = FORM_SCRIPT.slice("<script>".length, -"</script>".length);
6127
+ function scriptFor(dialect) {
6128
+ if (dialect === "jsx") return `<script dangerouslySetInnerHTML={{ __html: \`${SCRIPT_BODY}\` }} />`;
6129
+ if (dialect === "astro") return `<script is:inline>${SCRIPT_BODY}</script>`;
6130
+ return FORM_SCRIPT;
6131
+ }
6132
+ var messageFor = (dialect) => dialect === "jsx" ? `<p className="bcms-form-msg" hidden></p>` : `<p class="bcms-form-msg" hidden></p>`;
6133
+ var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
6134
+ var attrOf = (el, name) => el.attrs.find((a) => a.name.toLowerCase() === name);
6135
+ function* elementsIn(nodes) {
6136
+ for (const node of nodes) {
6137
+ if (node.type === "expr") {
6138
+ yield* elementsIn(node.children);
6139
+ continue;
6140
+ }
6141
+ if (node.type !== "element") continue;
6142
+ yield node;
6143
+ yield* elementsIn(node.children);
6144
+ }
6145
+ }
6146
+ var childElements = (node) => node.children.flatMap(
6147
+ (child) => child.type === "element" ? [child] : child.type === "expr" ? childElements(child) : []
6148
+ );
6149
+ function candidateOf(file, dialect, form) {
6150
+ const controls = /* @__PURE__ */ new Map();
6151
+ const wrapper = /* @__PURE__ */ new Map();
6152
+ const all = [];
6153
+ const walk = (parent) => {
6154
+ const own = [];
6155
+ for (const child of childElements(parent)) {
6156
+ if (CONTROLS.has(child.tag.toLowerCase())) {
6157
+ own.push(child);
6158
+ all.push(child);
6159
+ }
6160
+ walk(child);
6161
+ }
6162
+ if (own.length === 1 && parent !== form) wrapper.set(own[0], parent);
6163
+ };
6164
+ walk(form);
6165
+ for (const control of all) {
6166
+ const name = attrOf(control, "name")?.value;
6167
+ if (name && !controls.has(name)) controls.set(name, control);
6168
+ }
6169
+ for (const control of all) {
6170
+ const id = attrOf(control, "id")?.value;
6171
+ if (id && !controls.has(id)) controls.set(id, control);
6172
+ }
6173
+ return { file, dialect, el: form, controls, wrapper };
6174
+ }
6175
+ var hasMessage = (form) => [...elementsIn([form])].some((el) => (attrOf(el, "class") ?? attrOf(el, "classname"))?.value.includes("bcms-form-msg") ?? false);
6176
+ function wire(source, candidate, form) {
6177
+ const { el, dialect } = candidate;
6178
+ const inserts = [];
6179
+ const set = (name, value) => {
6180
+ const attr = attrOf(el, name);
6181
+ if (!attr) inserts.push(`${name}="${escapeAttr(value)}"`);
6182
+ else if (attr.valueRange) source.overwrite(attr.valueRange.start, attr.valueRange.end, escapeAttr(value));
6183
+ else source.overwrite(attr.range.start, attr.range.end, `${name}="${escapeAttr(value)}"`);
6184
+ };
6185
+ set("action", form.submitUrl);
6186
+ set("method", "post");
6187
+ inserts.push(`data-bcms-form="${escapeAttr(form.id)}"`);
6188
+ if (form.successMessage) inserts.push(`data-bcms-form-success="${escapeAttr(form.successMessage)}"`);
6189
+ source.appendLeft(el.attrInsertAt, ` ${inserts.join(" ")}`);
6190
+ for (const field of form.fields) {
6191
+ const control = candidate.controls.get(field.key);
6192
+ const host = candidate.wrapper.get(control) ?? control;
6193
+ source.appendLeft(host.attrInsertAt, ` data-bcms-form-field="${escapeAttr(field.key)}"`);
6194
+ const name = attrOf(control, "name");
6195
+ if (!name) source.appendLeft(control.attrInsertAt, ` name="${escapeAttr(field.key)}"`);
6196
+ else if (!name.expression && name.value !== field.key && name.valueRange) {
6197
+ source.overwrite(name.valueRange.start, name.valueRange.end, escapeAttr(field.key));
6198
+ }
6199
+ }
6200
+ if (el.inner && !hasMessage(el)) {
6201
+ const tail = /\s*$/.exec(source.original.slice(el.inner.start, el.inner.end))[0];
6202
+ source.appendLeft(el.inner.end - tail.length, `${tail || "\n"}${messageFor(dialect)}`);
6203
+ }
6204
+ }
6205
+ async function wireForms(briefIn, sources) {
6206
+ const brief = readBrief(briefIn);
6207
+ const forms = brief.forms ?? [];
6208
+ const candidates = [];
6209
+ const unreadable = [];
6210
+ for (const source of sources) {
6211
+ if (!/<form[\s/>]/i.test(source.content)) continue;
6212
+ const dialect = dialectOf(source.path, source.content);
6213
+ if (!dialect || !WIRABLE.includes(dialect)) {
6214
+ unreadable.push({
6215
+ file: source.path,
6216
+ reason: "DIALECT_UNSUPPORTED",
6217
+ message: `${source.path} is ${dialect ?? "not a template"}.`
6218
+ });
6219
+ continue;
6220
+ }
6221
+ const parsed = await parseFile4(dialect, source.content);
6222
+ if (parsed.error) {
6223
+ unreadable.push({
6224
+ file: source.path,
6225
+ reason: parsed.error.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR",
6226
+ message: parsed.error.message
6227
+ });
6228
+ continue;
6229
+ }
6230
+ for (const el of elementsIn(parsed.roots)) {
6231
+ if (el.tag.toLowerCase() === "form") candidates.push(candidateOf(source.path, dialect, el));
6232
+ }
6233
+ }
6234
+ const holds = (c, f) => f.fields.filter((field) => c.controls.has(field.key)).length;
6235
+ const covers2 = (c, f) => f.fields.length > 0 && holds(c, f) === f.fields.length;
6236
+ const options = new Map(forms.map((f) => [f.id, candidates.filter((c) => covers2(c, f))]));
6237
+ const wanted = /* @__PURE__ */ new Map();
6238
+ for (const list of options.values()) for (const c of list) wanted.set(c, (wanted.get(c) ?? 0) + 1);
6239
+ const matched = [];
6240
+ const pending = [];
6241
+ const onSite = [];
6242
+ let alreadyWired = 0;
6243
+ const pend2 = (f, reason, file, message) => void pending.push({ id: f.id, name: f.name, reason, ...file ? { file } : {}, ...message ? { message } : {} });
6244
+ for (const form of forms) {
6245
+ const list = options.get(form.id) ?? [];
6246
+ if (list.length > 1) {
6247
+ pend2(form, "FORM_AMBIGUOUS", void 0, `${list.length} forms post every field of "${form.name}": ${list.map((c) => c.file).join(", ")}.`);
6248
+ continue;
6249
+ }
6250
+ if (list.length === 1 && (wanted.get(list[0]) ?? 0) > 1) {
6251
+ pend2(form, "FORM_AMBIGUOUS", list[0].file, "more than one CMS form's fields are posted by this <form>.");
6252
+ continue;
6253
+ }
6254
+ if (list.length === 0) {
6255
+ const [best] = candidates.map((c) => ({ c, n: holds(c, form) })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n);
6256
+ if (best) {
6257
+ const missing = form.fields.filter((f) => !best.c.controls.has(f.key)).map((f) => f.key);
6258
+ pend2(form, "FIELD_UNMATCHED", best.c.file, `no control posts ${missing.join(", ")} in the <form> that posts its other fields.`);
6259
+ } else if (unreadable[0]) {
6260
+ pend2(form, unreadable[0].reason, unreadable[0].file, unreadable[0].message);
6261
+ } else {
6262
+ pend2(form, "FORM_NOT_IN_SOURCE", void 0, `no <form> posts ${form.fields.map((f) => f.key).join(", ") || "any field of this form"}.`);
6263
+ }
6264
+ continue;
6265
+ }
6266
+ const candidate = list[0];
6267
+ if (attrOf(candidate.el, "action")?.expression) {
6268
+ pend2(form, "ACTION_IS_EXPRESSION", candidate.file, "its action is an expression, so this run will not replace it.");
6269
+ continue;
6270
+ }
6271
+ const misnamed = form.fields.find((field) => {
6272
+ const attr = attrOf(candidate.controls.get(field.key), "name");
6273
+ return !!attr && attr.value !== field.key && (attr.expression || !attr.valueRange);
6274
+ });
6275
+ if (misnamed) {
6276
+ 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.`);
6277
+ continue;
6278
+ }
6279
+ if (attrOf(candidate.el, "data-bcms-form")) {
6280
+ alreadyWired++;
6281
+ onSite.push(form);
6282
+ continue;
6283
+ }
6284
+ matched.push({ form, candidate });
6285
+ onSite.push(form);
6286
+ }
6287
+ const contents = new Map(sources.map((s) => [s.path, s.content]));
6288
+ const byFile = /* @__PURE__ */ new Map();
6289
+ for (const m of matched) byFile.set(m.candidate.file, [...byFile.get(m.candidate.file) ?? [], m]);
6290
+ const files = [];
6291
+ for (const [path, list] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) {
6292
+ const content = contents.get(path);
6293
+ const source = new MagicString4(content);
6294
+ for (const { form, candidate } of list) wire(source, candidate, form);
6295
+ if (!content.includes("form[data-bcms-form]")) {
6296
+ const last = list.reduce((a, b) => b.candidate.el.end > a.candidate.el.end ? b : a);
6297
+ const indent = /[^\S\n]*$/.exec(content.slice(0, last.candidate.el.start))[0];
6298
+ source.appendRight(last.candidate.el.end, `
6299
+ ${indent}${scriptFor(last.candidate.dialect)}`);
6300
+ }
6301
+ files.push({ path, operation: "modify", content: source.toString() });
6302
+ }
6303
+ 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.`);
6304
+ return {
6305
+ files,
6306
+ receipt: {
6307
+ briefDigest: briefDigest(brief),
6308
+ // This lane binds no brief PATH, and says so rather than leaving the arithmetic to a reader:
6309
+ // a receipt claiming zero of zero paths is exactly what a forms-only run did.
6310
+ paths: { declared: 0, rewritten: 0, alreadyDeclared: 0, pending: [] },
6311
+ occurrences: { located: 0, rewritten: 0 },
6312
+ files: [],
6313
+ helpers: [],
6314
+ helperUpgraded: false,
6315
+ dynamicBindings: [],
6316
+ forms: assertForms(forms.length, {
6317
+ wired: matched.length,
6318
+ alreadyWired,
6319
+ pending,
6320
+ ...notes.length > 0 ? { notes } : {}
6321
+ })
6322
+ }
6323
+ };
6324
+ }
6325
+
6060
6326
  // src/scan.ts
6061
6327
  var SKIP_DIRS = [
6062
6328
  "node_modules/",
@@ -6425,7 +6691,7 @@ function moduleScope(parsed, content) {
6425
6691
  }
6426
6692
 
6427
6693
  // src/componentize.ts
6428
- import MagicString4 from "magic-string";
6694
+ import MagicString5 from "magic-string";
6429
6695
  var SectionInvariantError = class extends Error {
6430
6696
  code = "SECTION_INVARIANT";
6431
6697
  constructor(message) {
@@ -7351,7 +7617,7 @@ export default function ${resolved.name}({ blockId, bind, overrides, page }: Bcm
7351
7617
  ` };
7352
7618
  }
7353
7619
  function sectionMarkup(content, root, fieldName) {
7354
- const slice = new MagicString4(content.slice(root.start, root.end));
7620
+ const slice = new MagicString5(content.slice(root.start, root.end));
7355
7621
  const at = (offset) => offset - root.start;
7356
7622
  const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
7357
7623
  if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
@@ -7550,7 +7816,7 @@ async function migrateSections(plan2, contents, written, fileSet, aliases) {
7550
7816
  );
7551
7817
  continue;
7552
7818
  }
7553
- const source = new MagicString4(content);
7819
+ const source = new MagicString5(content);
7554
7820
  const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
7555
7821
  if (duplicates.length > 0) {
7556
7822
  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.`);
@@ -7827,7 +8093,7 @@ export default function Sections({ page }: { page: string }) {
7827
8093
  function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
7828
8094
  const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
7829
8095
  const local = names.get("Sections");
7830
- const source = new MagicString4(content);
8096
+ const source = new MagicString5(content);
7831
8097
  const first = placeable[0].root;
7832
8098
  const last = placeable[placeable.length - 1].root;
7833
8099
  source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
@@ -7847,7 +8113,7 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
7847
8113
  { key: "pageSnapshot", name: "pageSnapshot" }
7848
8114
  ]);
7849
8115
  const snapshot = names.get("pageSnapshot");
7850
- const source = new MagicString4(content);
8116
+ const source = new MagicString5(content);
7851
8117
  for (const resolved of placeable) {
7852
8118
  const component = written.get(resolved.slug);
7853
8119
  const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
@@ -8234,7 +8500,7 @@ async function convertSources(briefIn, sources, options) {
8234
8500
  } else rewritten++;
8235
8501
  }
8236
8502
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
8237
- const source = new MagicString5(contents.get(file));
8503
+ const source = new MagicString6(contents.get(file));
8238
8504
  const seen = /* @__PURE__ */ new Set();
8239
8505
  for (const splice of splices) {
8240
8506
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -8942,6 +9208,8 @@ export {
8942
9208
  CONTENT_DIR,
8943
9209
  ConvertError,
8944
9210
  DIALECT_RULES,
9211
+ FORM_SCRIPT,
9212
+ FormInvariantError,
8945
9213
  HELPER_PATH,
8946
9214
  HELPER_VERSION,
8947
9215
  PARSE_FILE,
@@ -8957,6 +9225,7 @@ export {
8957
9225
  SectionInvariantError,
8958
9226
  ValidationError,
8959
9227
  aliasesFrom,
9228
+ assertForms,
8960
9229
  assertReceipt,
8961
9230
  assertSections,
8962
9231
  assetCandidates,
@@ -8999,6 +9268,7 @@ export {
8999
9268
  routeOfFile,
9000
9269
  stripTags,
9001
9270
  unreadableConfigs,
9002
- walkAst
9271
+ walkAst,
9272
+ wireForms
9003
9273
  };
9004
9274
  //# sourceMappingURL=index.js.map