@bettercms-ai/convert 0.3.0 → 0.5.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/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 MagicString4 from "magic-string";
8
+ import MagicString5 from "magic-string";
9
9
 
10
10
  // src/brief.ts
11
11
  import { createHash } from "crypto";
@@ -27,6 +27,21 @@ var BY_EXTENSION = {
27
27
  ".svelte": "svelte",
28
28
  ".vue": "vue"
29
29
  };
30
+ var TOLERANT_EXTENSIONS = /* @__PURE__ */ new Set([
31
+ ".md",
32
+ ".mdx",
33
+ ".njk",
34
+ ".hbs",
35
+ ".ejs",
36
+ ".liquid",
37
+ ".erb",
38
+ ".twig"
39
+ ]);
40
+ function isTolerantDialect(path) {
41
+ const lower = path.toLowerCase();
42
+ const at = lower.lastIndexOf(".");
43
+ return at >= 0 && TOLERANT_EXTENSIONS.has(lower.slice(at));
44
+ }
30
45
  function dialectOf(path, _content) {
31
46
  const lower = path.toLowerCase();
32
47
  const at = lower.lastIndexOf(".");
@@ -134,7 +149,7 @@ function propsAttribute(props) {
134
149
 
135
150
  // src/helper.ts
136
151
  import { createHash as createHash2 } from "crypto";
137
- var HELPER_VERSION = 3;
152
+ var HELPER_VERSION = 4;
138
153
  var HEADER = "// @bettercms-ai/convert helper v";
139
154
  var HELPER_PATH = Object.fromEntries(
140
155
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -217,32 +232,97 @@ export function bcmsRows<T>(page: unknown, path: string, fallback: T[], slug?: s
217
232
  /**
218
233
  * ONE SECTION's copy, for a component extracted by \`--componentize\`.
219
234
  *
220
- * \`overrides ?? pageGroup(bind) ?? defaults\` \u2014 the instance's own values when the editor gave it
221
- * any, otherwise the page field group the placement is bound to, otherwise nothing at all, in
222
- * which case every \`bcms(...)\` below renders the fallback the template shipped with. That last
223
- * step is why a section renders correctly against the committed \`{}\` stub.
235
+ * OVERRIDES FIRST, PER KEY: \`overrides[key] ?? group[key]\`. An instance that owns its copy
236
+ * (Phase B) carries an override for every prop it declares, so the group underneath it is never
237
+ * read; a placement still bound to a page field group (Phase A) carries none, so the group is all
238
+ * there is. The interesting case is the one in between \u2014 a section half moved \u2014 where a key the
239
+ * instance does not hold has to keep rendering the page's value rather than nothing.
240
+ *
241
+ * \u{1F534} \`??\`, NOT \`||\`. An empty string is a value an editor typed, and treating it as absent put
242
+ * the page's old copy back into a heading somebody had deliberately cleared.
243
+ *
244
+ * With neither an override nor a group, the answer is \`{}\` and every \`bcms(...)\` below renders
245
+ * the fallback the template shipped with \u2014 which is why a section renders correctly against the
246
+ * committed \`{}\` stub.
224
247
  */
225
248
  export function bcmsSection(page: unknown, bind?: string, overrides?: unknown): Record<string, unknown> {
226
- // \u{1F534} AN EMPTY OBJECT IS AN ANSWER. \`overrides: {}\` is an instance an editor cleared, and
227
- // "\`??\` unless it happens to be empty" quietly put the page's copy back into it.
228
- if (isRecord(overrides)) return overrides;
229
249
  const group = bind ? readPath(page, bind) : undefined;
230
- return isRecord(group) ? group : {};
250
+ const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
251
+ if (!isRecord(overrides)) return base;
252
+ for (const key of Object.keys(overrides)) {
253
+ const value = overrides[key];
254
+ if (value === undefined || value === null) continue;
255
+ /**
256
+ * \u{1F534} A TABLE PROP IS STORED AS \`{ rows: [...] }\` AND READ AS A LIST. That is the shape the
257
+ * dashboard edits a repeater in; handing it to \`bcmsRows\` unchanged is not an array, so the
258
+ * helper fell back to the rows the template shipped with and the editor's work never showed.
259
+ */
260
+ base[key] = isRecord(value) && Array.isArray(value.rows) ? value.rows : value;
261
+ }
262
+ return base;
263
+ }
264
+
265
+ /**
266
+ * WHICH ADDRESS one bound element declares \u2014 decided at RENDER time, from the placement.
267
+ *
268
+ * An element carries exactly ONE \`data-bcms-field\`, and which lane it names depends on where its
269
+ * copy lives. A placement that owns its copy declares the BLOCK lane \u2014
270
+ * \`<blockId>__overrides.<propKey>\` \u2014 because that is the address the editor commits an instance's
271
+ * own value to. A placement bound to a page field group declares the PAGE path, which is what the
272
+ * page form and the coverage meter already know.
273
+ *
274
+ * \u{1F534} THE BOUND ADDRESS IS RE-BASED ONTO **THIS** PLACEMENT'S GROUP. The markup was cut from ONE
275
+ * page and carries that page's paths (\`hero.title\`); the whole point of \`shapeHash\` is that a
276
+ * second page renders the same component bound to a group of its own (\`bind="landingHero"\`).
277
+ * Handing the embedded path through unchanged declared \`hero.title\` on a page whose field is
278
+ * \`landingHero.title\` \u2014 an address that page does not have, so the editor wrote into nothing and
279
+ * the meter counted a binding that was never there. The prefix the markup was cut with comes out,
280
+ * the placement's own group goes in.
281
+ *
282
+ * \u{1F534} THE SAME EXTRACTED FILE SERVES BOTH LANES. The choice is a function of the props, never of
283
+ * how the codemod ran: one component renders a Phase A placement on one page and a Phase B
284
+ * instance on the next, and a repository does not have to be re-cut when its copy moves.
285
+ */
286
+ export function bcmsFields(
287
+ placement: { blockId?: string; bind?: string; overrides?: unknown },
288
+ pathPrefix: string,
289
+ ): (path: string) => string {
290
+ const id = placement.blockId;
291
+ const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
292
+ /**
293
+ * What replaces the prefix the markup was cut with, or null for "leave every path alone".
294
+ *
295
+ * Null covers a placement with neither a bind nor an instance \u2014 a shipped fallback, or a page
296
+ * nobody has componentized yet \u2014 and a bound placement in a component whose prefix is EMPTY,
297
+ * where the plan said the prop key IS the whole page path: there is no group segment to swap,
298
+ * so prepending one would invent an address rather than correct it.
299
+ */
300
+ const lane = own ? \`\${id}__overrides.\` : placement.bind && pathPrefix ? \`\${placement.bind}.\` : null;
301
+ return (path: string): string => {
302
+ // A path outside the group this section was cut from is not one this placement re-addresses:
303
+ // it names a key the instance does not hold and a group this placement is not bound to.
304
+ // (An EMPTY prefix is a plan saying the prop key IS the page path, and matches everything.)
305
+ if (lane === null || !path.startsWith(pathPrefix)) return path;
306
+ return lane + path.slice(pathPrefix.length);
307
+ };
231
308
  }
232
309
  `;
233
310
  }
234
311
  var HISTORICAL = {
235
312
  "src/bcms-content.ts": {
236
313
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
237
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
314
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
315
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
238
316
  },
239
317
  "src/lib/bcms-content.ts": {
240
318
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
241
- 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002"
319
+ 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
320
+ 3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e"
242
321
  },
243
322
  "composables/bcms-content.ts": {
244
323
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
245
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
324
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
325
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
246
326
  }
247
327
  };
248
328
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -4198,6 +4278,7 @@ function importLines(imports) {
4198
4278
  }
4199
4279
 
4200
4280
  // src/receipt.ts
4281
+ var CANVAS_REASONS = /* @__PURE__ */ new Set(["CANVAS_BRIDGE_MANUAL", "SSR_DRAFT_ROUTE"]);
4201
4282
  var ReceiptInvariantError = class extends Error {
4202
4283
  code = "RECEIPT_INVARIANT";
4203
4284
  constructor(message) {
@@ -4207,10 +4288,11 @@ var ReceiptInvariantError = class extends Error {
4207
4288
  };
4208
4289
  function assertReceipt(receipt) {
4209
4290
  const { declared, rewritten, alreadyDeclared, pending } = receipt.paths;
4210
- const sum = rewritten + alreadyDeclared + pending.length;
4291
+ const paths = pending.filter((p) => !CANVAS_REASONS.has(p.reason));
4292
+ const sum = rewritten + alreadyDeclared + paths.length;
4211
4293
  if (sum !== declared) {
4212
4294
  throw new ReceiptInvariantError(
4213
- `The receipt does not account for every path: ${declared} declared but ${rewritten} rewritten + ${alreadyDeclared} already declared + ${pending.length} pending = ${sum}.`
4295
+ `The receipt does not account for every path: ${declared} declared but ${rewritten} rewritten + ${alreadyDeclared} already declared + ${paths.length} pending = ${sum}.`
4214
4296
  );
4215
4297
  }
4216
4298
  if (receipt.occurrences.rewritten > receipt.occurrences.located) {
@@ -4710,6 +4792,218 @@ function tryParse3(code) {
4710
4792
  var PARSE_FILE = { html: parse2, astro: parseFile, jsx: parse, svelte: parseFile2, vue: parseFile3 };
4711
4793
  var parseFile4 = (dialect, content) => Promise.resolve(PARSE_FILE[dialect](content));
4712
4794
 
4795
+ // src/canvas.ts
4796
+ import MagicString3 from "magic-string";
4797
+ var SSR_DRAFT_RECIPE = "Astro renders this site at build time, so there is no server to answer the editor's draft request. Either mount <BcmsLiveBlocks client:load> from @bettercms-ai/astro on the pages whose body is a block tree, or switch the preview routes to `output: 'server'` with the node adapter, mount @bettercms-ai/astro's draft routes, set BCMS_DRAFT_SECRET, redeploy, and switch the project to 'Run as a server app'.";
4798
+ var isNode = (value) => !!value && typeof value === "object" && typeof value.type === "string";
4799
+ var FUNCTIONS3 = /* @__PURE__ */ new Set([
4800
+ "FunctionDeclaration",
4801
+ "FunctionExpression",
4802
+ "ArrowFunctionExpression",
4803
+ "ObjectMethod",
4804
+ "ClassMethod"
4805
+ ]);
4806
+ function patternNames2(node, out) {
4807
+ if (!isNode(node)) return;
4808
+ switch (node.type) {
4809
+ case "Identifier":
4810
+ out.add(String(node.name));
4811
+ return;
4812
+ case "ObjectPattern":
4813
+ for (const p of node.properties ?? [])
4814
+ patternNames2(p.type === "RestElement" ? p.argument : p.value, out);
4815
+ return;
4816
+ case "ArrayPattern":
4817
+ for (const el of node.elements ?? []) patternNames2(el, out);
4818
+ return;
4819
+ case "AssignmentPattern":
4820
+ patternNames2(node.left, out);
4821
+ return;
4822
+ case "RestElement":
4823
+ patternNames2(node.argument, out);
4824
+ return;
4825
+ default:
4826
+ return;
4827
+ }
4828
+ }
4829
+ function statementNames2(statements, out) {
4830
+ for (const statement of statements ?? []) {
4831
+ if (!isNode(statement)) continue;
4832
+ const node = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement;
4833
+ if (!isNode(node)) continue;
4834
+ if (node.type === "VariableDeclaration")
4835
+ for (const d of node.declarations ?? []) patternNames2(d.id, out);
4836
+ else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") patternNames2(node.id, out);
4837
+ else if (node.type === "ImportDeclaration")
4838
+ for (const specifier of node.specifiers ?? []) patternNames2(specifier.local, out);
4839
+ }
4840
+ }
4841
+ function functionBindings(fn) {
4842
+ const out = /* @__PURE__ */ new Set();
4843
+ for (const param of fn.params ?? []) patternNames2(param, out);
4844
+ const body = fn.body;
4845
+ if (body?.type === "BlockStatement") statementNames2(body.body, out);
4846
+ return out;
4847
+ }
4848
+ function stringLiterals(node, out) {
4849
+ if (!isNode(node)) {
4850
+ if (Array.isArray(node)) for (const child of node) stringLiterals(child, out);
4851
+ return;
4852
+ }
4853
+ if (FUNCTIONS3.has(node.type)) return;
4854
+ if (node.type === "StringLiteral" && typeof node.value === "string") out.add(node.value);
4855
+ for (const key of Object.keys(node)) {
4856
+ if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue;
4857
+ stringLiterals(node[key], out);
4858
+ }
4859
+ }
4860
+ function usages(ast, local) {
4861
+ const found = [];
4862
+ const moduleScope2 = /* @__PURE__ */ new Set();
4863
+ statementNames2(ast?.program?.body, moduleScope2);
4864
+ const walk = (node, scope, literals) => {
4865
+ if (!isNode(node)) {
4866
+ if (Array.isArray(node)) for (const child of node) walk(child, scope, literals);
4867
+ return;
4868
+ }
4869
+ let nextScope = scope;
4870
+ let nextLiterals = literals;
4871
+ if (FUNCTIONS3.has(node.type)) {
4872
+ nextScope = /* @__PURE__ */ new Set([...scope, ...functionBindings(node)]);
4873
+ nextLiterals = /* @__PURE__ */ new Set();
4874
+ stringLiterals(node.body, nextLiterals);
4875
+ }
4876
+ if (node.type === "JSXElement") {
4877
+ const name = node.openingElement?.name;
4878
+ if (name?.type === "JSXIdentifier" && name.name === local)
4879
+ found.push({ element: node, scope: nextScope, literals: nextLiterals });
4880
+ }
4881
+ for (const key of Object.keys(node)) {
4882
+ if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue;
4883
+ walk(node[key], nextScope, nextLiterals);
4884
+ }
4885
+ };
4886
+ walk(ast?.program ?? ast, moduleScope2, /* @__PURE__ */ new Set());
4887
+ return found;
4888
+ }
4889
+ function bridgedAlready(ast) {
4890
+ const body = ast?.program?.body ?? [];
4891
+ return body.some(
4892
+ (statement) => statement.type === "ImportDeclaration" && String(statement.source?.value ?? "") === "@bettercms-ai/next/draft-bridge" && (statement.specifiers ?? []).some(
4893
+ (specifier) => specifier.imported?.name === "BcmsDraftBridge"
4894
+ )
4895
+ );
4896
+ }
4897
+ function blocksImport(ast) {
4898
+ const body = ast?.program?.body ?? [];
4899
+ for (const statement of body) {
4900
+ if (statement.type !== "ImportDeclaration") continue;
4901
+ const source = String(statement.source?.value ?? "");
4902
+ if (source !== "@bettercms-ai/next" && source !== "@bettercms-ai/next/blocks") continue;
4903
+ for (const specifier of statement.specifiers ?? []) {
4904
+ const imported = specifier.imported;
4905
+ if (specifier.type !== "ImportSpecifier" || imported?.name !== "BcmsBlocks") continue;
4906
+ return { declaration: statement, specifier, local: String(specifier.local.name) };
4907
+ }
4908
+ }
4909
+ return null;
4910
+ }
4911
+ function slugExpression(usage, routes) {
4912
+ if (usage.scope.has("slug")) return "{slug}";
4913
+ if (usage.scope.has("page")) return "{page.slug}";
4914
+ const matched = [...new Set([...usage.literals].map((l) => routes.get(l)).filter((s) => !!s))];
4915
+ return matched.length === 1 ? JSON.stringify(matched[0]) : null;
4916
+ }
4917
+ var lineOf = (content, offset) => content.slice(0, offset).split("\n").length;
4918
+ var pend = (reason, hint, file, line) => ({
4919
+ route: "",
4920
+ scope: "page",
4921
+ path: reason === "SSR_DRAFT_ROUTE" ? "canvas.lane" : "canvas.bridge",
4922
+ kind: "canvas",
4923
+ ...file ? { file } : {},
4924
+ ...line ? { line } : {},
4925
+ reason,
4926
+ hint
4927
+ });
4928
+ var ASTRO_LANE_PENDING = pend("SSR_DRAFT_ROUTE", SSR_DRAFT_RECIPE);
4929
+ function canvasBridge(sources, routes) {
4930
+ if (sources.some((s) => s.path.toLowerCase().endsWith(".astro")))
4931
+ return { lane: "pending", files: [], edits: /* @__PURE__ */ new Map(), pending: [ASTRO_LANE_PENDING] };
4932
+ const files = [];
4933
+ const edits = /* @__PURE__ */ new Map();
4934
+ const pending = [];
4935
+ for (const source of sources) {
4936
+ if (!/\.[jt]sx$/i.test(source.path) || !source.ast) continue;
4937
+ if (bridgedAlready(source.ast)) {
4938
+ files.push(source.path);
4939
+ continue;
4940
+ }
4941
+ const imported = blocksImport(source.ast);
4942
+ if (!imported) continue;
4943
+ const found = usages(source.ast, imported.local);
4944
+ const slugs = found.map((usage) => slugExpression(usage, routes));
4945
+ const unresolved = found.findIndex((_, i) => slugs[i] === null);
4946
+ if (found.length === 0 || unresolved !== -1) {
4947
+ const at = found[unresolved === -1 ? 0 : unresolved]?.element.start;
4948
+ pending.push(
4949
+ pend(
4950
+ "CANVAS_BRIDGE_MANUAL",
4951
+ "Wrap this page's <BcmsBlocks> in <BcmsDraftBridge slug={\u2026}> from @bettercms-ai/next/draft-bridge. No slug expression was derivable in the component's own scope, so the codemod will not guess one: a bridge on the wrong slug never updates in the editor.",
4952
+ source.path,
4953
+ at === void 0 ? void 0 : lineOf(source.content, at)
4954
+ )
4955
+ );
4956
+ continue;
4957
+ }
4958
+ const out = new MagicString3(source.content);
4959
+ rewriteImport(out, imported);
4960
+ for (const [i, usage] of found.entries()) rewriteElement(out, usage, imported.local, slugs[i]);
4961
+ edits.set(source.path, out.toString());
4962
+ files.push(source.path);
4963
+ }
4964
+ return { lane: files.length > 0 ? "bridge" : "pending", files, edits, pending };
4965
+ }
4966
+ function rewriteImport(out, imported) {
4967
+ const { declaration: declaration2, specifier, local } = imported;
4968
+ const line = `import { BcmsDraftBridge${local === "BcmsBlocks" ? "" : ` as ${local}`} } from "@bettercms-ai/next/draft-bridge";`;
4969
+ const specifiers = declaration2.specifiers ?? [];
4970
+ const named = specifiers.filter((s) => s.type === "ImportSpecifier");
4971
+ if (named.length === 1) {
4972
+ if (specifiers.length === 1) {
4973
+ out.overwrite(declaration2.start, declaration2.end, line);
4974
+ return;
4975
+ }
4976
+ const other = specifiers.find((s) => s.type !== "ImportSpecifier");
4977
+ const close = out.original.indexOf("}", named[0].end);
4978
+ out.remove(other.end, close + 1);
4979
+ out.appendRight(declaration2.end, `
4980
+ ${line}`);
4981
+ return;
4982
+ }
4983
+ const index = named.indexOf(specifier);
4984
+ const previous = named[index - 1];
4985
+ const from = previous ? previous.end : specifier.start;
4986
+ const to = previous ? specifier.end : named[index + 1].start;
4987
+ out.remove(from, to);
4988
+ out.appendRight(declaration2.end, `
4989
+ ${line}`);
4990
+ }
4991
+ function rewriteElement(out, usage, local, slug) {
4992
+ const opening = usage.element.openingElement;
4993
+ const name = opening.name;
4994
+ if (local === "BcmsBlocks") {
4995
+ out.overwrite(name.start, name.end, "BcmsDraftBridge");
4996
+ const closing = usage.element.closingElement;
4997
+ if (closing) {
4998
+ const closingName = closing.name;
4999
+ out.overwrite(closingName.start, closingName.end, "BcmsDraftBridge");
5000
+ }
5001
+ }
5002
+ const attributes = opening.attributes ?? [];
5003
+ const hasSlug = attributes.some((a) => a.name?.name === "slug");
5004
+ if (!hasSlug) out.appendLeft(name.end, ` slug=${slug}`);
5005
+ }
5006
+
4713
5007
  // src/tolerant.ts
4714
5008
  var escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4715
5009
  var flexible = (literal) => new RegExp(escape(literal).split(" ").map(escape).join("\\s+"), "gu");
@@ -4839,6 +5133,7 @@ function readPlan(value) {
4839
5133
  const section = check.record(held, at2);
4840
5134
  check.segment(section.groupKey, `${at2}.groupKey`);
4841
5135
  check.optionalString(section.sectionType, `${at2}.sectionType`);
5136
+ check.optionalString(section.copy, `${at2}.copy`);
4842
5137
  if (section.fields !== void 0) {
4843
5138
  for (const [f, field] of check.array(section.fields, `${at2}.fields`).entries()) {
4844
5139
  const shape = check.record(field, `${at2}.fields[${f}]`);
@@ -5130,10 +5425,10 @@ function scanExpression(code) {
5130
5425
  for (const key of ["typeName", "left", "right", "id"]) nameOf2(node[key], skip);
5131
5426
  return;
5132
5427
  case "VariableDeclarator":
5133
- patternNames2(node.id, bound);
5428
+ patternNames3(node.id, bound);
5134
5429
  return;
5135
5430
  case "CatchClause":
5136
- patternNames2(node.param, bound);
5431
+ patternNames3(node.param, bound);
5137
5432
  return;
5138
5433
  default:
5139
5434
  return;
@@ -5141,8 +5436,8 @@ function scanExpression(code) {
5141
5436
  });
5142
5437
  walkAst(ast, (node) => {
5143
5438
  if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration" || node.type === "ObjectMethod" || node.type === "ClassMethod") {
5144
- for (const param of node.params ?? []) patternNames2(param, bound);
5145
- if (node.id) patternNames2(node.id, bound);
5439
+ for (const param of node.params ?? []) patternNames3(param, bound);
5440
+ if (node.id) patternNames3(node.id, bound);
5146
5441
  }
5147
5442
  });
5148
5443
  const free = /* @__PURE__ */ new Set();
@@ -5157,7 +5452,7 @@ function nameOf2(node, out) {
5157
5452
  nameOf2(node.name, out);
5158
5453
  }
5159
5454
  }
5160
- function patternNames2(node, out) {
5455
+ function patternNames3(node, out) {
5161
5456
  if (!node || typeof node !== "object") return;
5162
5457
  const typed = node;
5163
5458
  switch (typed.type) {
@@ -5166,17 +5461,17 @@ function patternNames2(node, out) {
5166
5461
  return;
5167
5462
  case "ObjectPattern":
5168
5463
  for (const property of typed.properties ?? []) {
5169
- patternNames2(property.type === "RestElement" ? property.argument : property.value, out);
5464
+ patternNames3(property.type === "RestElement" ? property.argument : property.value, out);
5170
5465
  }
5171
5466
  return;
5172
5467
  case "ArrayPattern":
5173
- for (const element of typed.elements ?? []) patternNames2(element, out);
5468
+ for (const element of typed.elements ?? []) patternNames3(element, out);
5174
5469
  return;
5175
5470
  case "AssignmentPattern":
5176
- patternNames2(typed.left, out);
5471
+ patternNames3(typed.left, out);
5177
5472
  return;
5178
5473
  case "RestElement":
5179
- patternNames2(typed.argument, out);
5474
+ patternNames3(typed.argument, out);
5180
5475
  return;
5181
5476
  default:
5182
5477
  return;
@@ -5198,9 +5493,9 @@ function moduleScope(parsed, content) {
5198
5493
  const text = source.slice(start, end);
5199
5494
  const names = /* @__PURE__ */ new Set();
5200
5495
  if (kind === "VariableDeclaration") {
5201
- for (const declarator of statement.declarations ?? []) patternNames2(declarator.id, names);
5496
+ for (const declarator of statement.declarations ?? []) patternNames3(declarator.id, names);
5202
5497
  } else {
5203
- patternNames2(statement.id, names);
5498
+ patternNames3(statement.id, names);
5204
5499
  }
5205
5500
  if (names.size === 0) continue;
5206
5501
  const scanned = scanExpression(text.replace(/^(?:export\s+)?/, ""));
@@ -5211,7 +5506,7 @@ function moduleScope(parsed, content) {
5211
5506
  }
5212
5507
 
5213
5508
  // src/componentize.ts
5214
- import MagicString3 from "magic-string";
5509
+ import MagicString4 from "magic-string";
5215
5510
  var SectionInvariantError = class extends Error {
5216
5511
  code = "SECTION_INVARIANT";
5217
5512
  constructor(message) {
@@ -5228,7 +5523,8 @@ function assertSections(total, sections) {
5228
5523
  }
5229
5524
  return sections;
5230
5525
  }
5231
- var SECTION_MARKER = "// @bettercms-ai/convert section v1";
5526
+ var SECTION_MARKER = "// @bettercms-ai/convert section v2";
5527
+ var SECTION_MARKERS = [SECTION_MARKER, "// @bettercms-ai/convert section v1"];
5232
5528
  var REGISTRY_MARKER = "// @bettercms-ai/convert sections v1";
5233
5529
  var COMPONENT_DIR = "src/components/bcms";
5234
5530
  var SECTIONS_LIB = "src/lib/bcms-sections.ts";
@@ -5250,12 +5546,53 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5250
5546
  let registered = 0;
5251
5547
  let planPending = 0;
5252
5548
  let total = 0;
5549
+ const pend2 = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5253
5550
  const brief = readBrief(briefIn);
5254
5551
  const plan2 = readPlan(planIn);
5552
+ const copy = options?.copy ?? "bind";
5553
+ const instanceOwned = (section) => section.copy === "instance";
5554
+ const notes = unreadableConfigs(sources).map(
5555
+ (path) => ({
5556
+ code: "TSCONFIG_UNREADABLE",
5557
+ file: path,
5558
+ message: `${path} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
5559
+ })
5560
+ );
5561
+ const refuseRun = (reason, message) => {
5562
+ for (const page of plan2.pages ?? []) {
5563
+ for (const section of page.sections ?? []) {
5564
+ if (section.pending) {
5565
+ planPending++;
5566
+ continue;
5567
+ }
5568
+ total++;
5569
+ pend2(page.route, section.groupKey, reason, message);
5570
+ }
5571
+ }
5572
+ return {
5573
+ files: [],
5574
+ receipt: {
5575
+ planDigest: plan2.digest ?? "",
5576
+ copy,
5577
+ sections: assertSections(total, { extracted: 0, inlineOnly: 0, alreadyExtracted: 0, registered: 0, pending }),
5578
+ components: [],
5579
+ planPending,
5580
+ notes
5581
+ }
5582
+ };
5583
+ };
5584
+ const movesCopy = (plan2.pages ?? []).some(
5585
+ (page) => (page.sections ?? []).some((section) => !section.pending && instanceOwned(section))
5586
+ );
5587
+ if (copy === "instance" && !movesCopy) {
5588
+ return refuseRun(
5589
+ "COPY_MODE_MISMATCH",
5590
+ 'this run was asked for --copy instance, but no placement in the plan carries copy: "instance" \u2014 run componentize_sections { copy: "instance" } on the server first, then fetch the plan again.'
5591
+ );
5592
+ }
5255
5593
  const contents = new Map(sources.map((s) => [s.path, s.content]));
5256
5594
  const fileSet = new Set(contents.keys());
5257
5595
  const aliases = aliasesFrom(sources);
5258
- const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5259
5596
  const trees = /* @__PURE__ */ new Map();
5260
5597
  const treeOf = async (file) => {
5261
5598
  if (trees.has(file)) return trees.get(file);
@@ -5269,7 +5606,9 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5269
5606
  const helperPath = HELPER_PATH.astro;
5270
5607
  const helperExisting = contents.get(helperPath);
5271
5608
  const helperVersion = helperExisting === void 0 ? null : isKnownHelper(helperExisting, "astro");
5272
- const helperBlocked = helperExisting !== void 0 && helperVersion === null && options?.overwriteHelper !== true;
5609
+ if (helperExisting !== void 0 && helperVersion === null && options?.overwriteHelper !== true) {
5610
+ return refuseRun("HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5611
+ }
5273
5612
  const outFiles = /* @__PURE__ */ new Map();
5274
5613
  const written = /* @__PURE__ */ new Map();
5275
5614
  const shipped = /* @__PURE__ */ new Map();
@@ -5279,6 +5618,29 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5279
5618
  return held;
5280
5619
  };
5281
5620
  const fresh = /* @__PURE__ */ new Set();
5621
+ const bindOf = (section) => instanceOwned(section) ? void 0 : section.groupKey;
5622
+ const placementOf = (slug, section) => {
5623
+ const bind = bindOf(section);
5624
+ return bind === void 0 ? { slug } : { slug, bind };
5625
+ };
5626
+ const rebind = (dialect, pageSlug, all, usable) => {
5627
+ if (dialect === null || !EXTRACTABLE.includes(dialect)) return;
5628
+ const order = shipped.get(dialect);
5629
+ const held = order?.get(pageSlug);
5630
+ if (!order || !held) return;
5631
+ const queues = /* @__PURE__ */ new Map();
5632
+ for (const section of all) {
5633
+ const slug = resolveComponent(plan2, section).slug;
5634
+ queues.set(slug, [...queues.get(slug) ?? [], section]);
5635
+ }
5636
+ order.set(
5637
+ pageSlug,
5638
+ held.map((placement) => {
5639
+ const section = queues.get(placement.slug)?.shift();
5640
+ return section && usable.has(section) ? placementOf(placement.slug, section) : placement;
5641
+ })
5642
+ );
5643
+ };
5282
5644
  const registryConflicts = [];
5283
5645
  const registryUnreadable = [];
5284
5646
  for (const dialect of EXTRACTABLE) {
@@ -5304,6 +5666,11 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5304
5666
  if (lib !== void 0 && !isGenerated(lib)) registryConflicts.push(SECTIONS_LIB);
5305
5667
  const registryBlocked = registryConflicts.length > 0 && options?.overwriteHelper !== true;
5306
5668
  const registryUnread = registryUnreadable.length > 0;
5669
+ const migrated = registryBlocked || registryUnread ? { files: [], refused: /* @__PURE__ */ new Map() } : await migrateSections(plan2, contents, written, fileSet, aliases);
5670
+ for (const file of migrated.files) {
5671
+ addFile(outFiles, contents, file);
5672
+ contents.set(file.path, file.content);
5673
+ }
5307
5674
  for (const page of plan2.pages ?? []) {
5308
5675
  const sections2 = (page.sections ?? []).filter((section) => {
5309
5676
  if (section.pending) {
@@ -5314,24 +5681,34 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5314
5681
  });
5315
5682
  total += sections2.length;
5316
5683
  if (sections2.length === 0) continue;
5684
+ const prefixes = /* @__PURE__ */ new Map();
5685
+ for (const section of sections2) {
5686
+ const prefix = propPrefix(section);
5687
+ if (prefix === null) {
5688
+ pend2(
5689
+ page.route,
5690
+ section.groupKey,
5691
+ "PROP_MAP_INCONSISTENT",
5692
+ "the plan's fields do not spell every path as one common prefix plus its key, so a page path cannot be turned into a prop key."
5693
+ );
5694
+ continue;
5695
+ }
5696
+ prefixes.set(section, prefix);
5697
+ }
5698
+ const usable = sections2.filter((section) => prefixes.has(section));
5699
+ if (usable.length === 0) continue;
5317
5700
  const pendingBefore = pending.length;
5318
5701
  const briefPage = brief.pages.find((p) => p.route === page.route);
5319
5702
  const pageSlug = briefPage?.slug ?? slugify(page.route === "/" ? "home" : page.route);
5320
- if (helperBlocked) {
5321
- for (const section of sections2) {
5322
- pend(page.route, section.groupKey, "HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5323
- }
5324
- continue;
5325
- }
5326
5703
  if (registryBlocked) {
5327
- for (const section of sections2) {
5328
- pend(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5704
+ for (const section of usable) {
5705
+ pend2(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5329
5706
  }
5330
5707
  continue;
5331
5708
  }
5332
5709
  if (registryUnread) {
5333
- for (const section of sections2) {
5334
- pend(
5710
+ for (const section of usable) {
5711
+ pend2(
5335
5712
  page.route,
5336
5713
  section.groupKey,
5337
5714
  "REGISTRY_UNREADABLE",
@@ -5342,36 +5719,49 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5342
5719
  }
5343
5720
  const snapshotPath = `${CONTENT_DIR}/${pageSlug}.json`;
5344
5721
  if (!snapshotUsable(contents.get(snapshotPath))) {
5345
- for (const section of sections2) {
5346
- pend(page.route, section.groupKey, "SNAPSHOT_INVALID", `${snapshotPath} is not a JSON object.`);
5722
+ for (const section of usable) {
5723
+ pend2(page.route, section.groupKey, "SNAPSHOT_INVALID", `${snapshotPath} is not a JSON object.`);
5347
5724
  }
5348
5725
  continue;
5349
5726
  }
5350
5727
  const pageFiles = pageFilesFor(page.route, contents.keys());
5351
5728
  if (pageFiles.length > 1) {
5352
- for (const section of sections2) {
5353
- pend(page.route, section.groupKey, "ROUTE_FILE_AMBIGUOUS", `${pageFiles.join(" and ")} both serve ${page.route}.`);
5729
+ for (const section of usable) {
5730
+ pend2(page.route, section.groupKey, "ROUTE_FILE_AMBIGUOUS", `${pageFiles.join(" and ")} both serve ${page.route}.`);
5354
5731
  }
5355
5732
  continue;
5356
5733
  }
5357
5734
  const file = pageFiles[0];
5358
5735
  if (file === void 0) {
5359
- for (const section of sections2) {
5360
- pend(page.route, section.groupKey, "NOT_IN_SOURCE", `no page file found for ${page.route}.`);
5736
+ for (const section of usable) {
5737
+ pend2(page.route, section.groupKey, "NOT_IN_SOURCE", `no page file found for ${page.route}.`);
5361
5738
  }
5362
5739
  continue;
5363
5740
  }
5364
- const done = new Set(alreadyOn(contents.get(file) ?? "", pageSlug, sections2));
5741
+ const pageDialect = dialectOf(file, contents.get(file) ?? "");
5742
+ const done = new Set(alreadyOn(contents.get(file) ?? "", pageSlug, usable));
5743
+ let stuck = 0;
5744
+ for (const section of usable) {
5745
+ if (!done.has(section.groupKey)) continue;
5746
+ const why = pageDialect ? migrated.refused.get(refusalKey(pageDialect, resolveComponent(plan2, section).slug)) : void 0;
5747
+ if (why === void 0) continue;
5748
+ done.delete(section.groupKey);
5749
+ stuck++;
5750
+ pend2(page.route, section.groupKey, "SECTION_UNMIGRATABLE", why);
5751
+ }
5365
5752
  alreadyExtracted += done.size;
5366
- if (done.size === sections2.length) continue;
5753
+ if (done.size + stuck === usable.length) {
5754
+ rebind(pageDialect, pageSlug, page.sections ?? [], new Set(usable));
5755
+ continue;
5756
+ }
5367
5757
  const found = await locateSections(
5368
- sections2.filter((section) => !done.has(section.groupKey)),
5758
+ usable.filter((section) => !done.has(section.groupKey)),
5369
5759
  briefPage?.paths ?? [],
5370
5760
  sources,
5371
5761
  treeOf
5372
5762
  );
5373
5763
  for (const [groupKey, reason] of found.misses) {
5374
- pend(page.route, groupKey, reason.code, reason.message);
5764
+ pend2(page.route, groupKey, reason.code, reason.message);
5375
5765
  }
5376
5766
  const live = found.placed;
5377
5767
  if (live.length === 0) continue;
@@ -5379,14 +5769,14 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5379
5769
  const dialect = dialectOf(file, content);
5380
5770
  if (!dialect || !EXTRACTABLE.includes(dialect)) {
5381
5771
  for (const placed of live) {
5382
- pend(page.route, placed.section.groupKey, "DIALECT_UNSUPPORTED", `${file} is ${dialect ?? "not a template"}.`);
5772
+ pend2(page.route, placed.section.groupKey, "DIALECT_UNSUPPORTED", `${file} is ${dialect ?? "not a template"}.`);
5383
5773
  }
5384
5774
  continue;
5385
5775
  }
5386
5776
  const tree = await treeOf(file);
5387
5777
  if (!tree) {
5388
5778
  for (const placed of live) {
5389
- pend(page.route, placed.section.groupKey, "NOT_IN_SOURCE", `${file} did not parse.`);
5779
+ pend2(page.route, placed.section.groupKey, "NOT_IN_SOURCE", `${file} did not parse.`);
5390
5780
  }
5391
5781
  continue;
5392
5782
  }
@@ -5411,7 +5801,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5411
5801
  const alsoRendered = [...placed.byFile.keys()].filter((held2) => held2 !== file && importedFiles.has(held2));
5412
5802
  if (elements && elements.length > 0) {
5413
5803
  if (alsoRendered.length > 0) {
5414
- pend(
5804
+ pend2(
5415
5805
  page.route,
5416
5806
  placed.section.groupKey,
5417
5807
  "SECTION_ROOT_AMBIGUOUS",
@@ -5423,7 +5813,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5423
5813
  continue;
5424
5814
  }
5425
5815
  if (alsoRendered.length > 1) {
5426
- pend(
5816
+ pend2(
5427
5817
  page.route,
5428
5818
  placed.section.groupKey,
5429
5819
  "SECTION_ROOT_AMBIGUOUS",
@@ -5437,7 +5827,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5437
5827
  importedSections.push({ section: placed.section, path: only, call: held.local });
5438
5828
  continue;
5439
5829
  }
5440
- pend(
5830
+ pend2(
5441
5831
  page.route,
5442
5832
  placed.section.groupKey,
5443
5833
  "SECTION_ROOT_AMBIGUOUS",
@@ -5451,7 +5841,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5451
5841
  const component = resolveComponent(plan2, held.section);
5452
5842
  const already = extractable.get(component.slug);
5453
5843
  if (already !== void 0 && already.path !== held.path) {
5454
- pend(
5844
+ pend2(
5455
5845
  page.route,
5456
5846
  held.section.groupKey,
5457
5847
  "COMPONENT_CONFLICT",
@@ -5462,7 +5852,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5462
5852
  const binding = bindingOf(held.path);
5463
5853
  const member = binding.kind === "namespace" ? namespaceMember(tree, binding.local) : void 0;
5464
5854
  if (binding.kind === "namespace" && member === void 0) {
5465
- pend(
5855
+ pend2(
5466
5856
  page.route,
5467
5857
  held.section.groupKey,
5468
5858
  "SECTION_ROOT_AMBIGUOUS",
@@ -5480,7 +5870,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5480
5870
  registered++;
5481
5871
  registeredHere.push({
5482
5872
  slug: component.slug,
5483
- bind: held.section.groupKey,
5873
+ section: held.section,
5484
5874
  call: callSiteOf(tree, member ? `${binding.local}.${member}` : held.call)
5485
5875
  });
5486
5876
  }
@@ -5488,11 +5878,17 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5488
5878
  for (const placed of placedHere) {
5489
5879
  const root = lowestCommonAncestor(placed.elements);
5490
5880
  if (!root) {
5491
- pend(page.route, placed.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its fields sit under no single element.");
5881
+ pend2(page.route, placed.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its fields sit under no single element.");
5492
5882
  continue;
5493
5883
  }
5494
5884
  const component = resolveComponent(plan2, placed.section);
5495
- roots.push({ section: placed.section, route: page.route, root, ...component });
5885
+ roots.push({
5886
+ section: placed.section,
5887
+ route: page.route,
5888
+ root,
5889
+ pathPrefix: prefixes.get(placed.section),
5890
+ ...component
5891
+ });
5496
5892
  }
5497
5893
  const nested = /* @__PURE__ */ new Set();
5498
5894
  for (const a of roots) {
@@ -5504,7 +5900,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5504
5900
  }
5505
5901
  }
5506
5902
  for (const resolved of nested) {
5507
- pend(page.route, resolved.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its root contains another section's.");
5903
+ pend2(page.route, resolved.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its root contains another section's.");
5508
5904
  }
5509
5905
  const candidates = roots.filter((r) => !nested.has(r)).sort((a, b) => a.root.start - b.root.start);
5510
5906
  const placeable = [];
@@ -5512,16 +5908,34 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5512
5908
  const path = `${COMPONENT_DIR}/${resolved.name}.${dialect === "astro" ? "astro" : "tsx"}`;
5513
5909
  const existing = contents.get(path);
5514
5910
  if (existing !== void 0 && !isGenerated(existing) && !extractable.has(resolved.slug)) {
5515
- pend(page.route, resolved.section.groupKey, "COMPONENT_CONFLICT", `${path} is not a file this package wrote.`);
5911
+ pend2(page.route, resolved.section.groupKey, "COMPONENT_CONFLICT", `${path} is not a file this package wrote.`);
5516
5912
  continue;
5517
5913
  }
5518
5914
  if (extractable.has(resolved.slug)) {
5519
5915
  placeable.push(resolved);
5520
5916
  continue;
5521
5917
  }
5522
- const built = componentFile(dialect, path, file, content, parsed, resolved);
5918
+ const built = componentFile(dialect, path, file, content, parsed, resolved, resolved.pathPrefix);
5919
+ if (built.unconvertible.length > 0) {
5920
+ pend2(
5921
+ page.route,
5922
+ resolved.section.groupKey,
5923
+ "PROP_MAP_INCONSISTENT",
5924
+ `its fields map to the prefix "${resolved.pathPrefix}", which ${[...new Set(built.unconvertible)].slice(0, 3).join(", ")} cannot be converted through \u2014 so those elements would stay on the page lane whatever the placement says.`
5925
+ );
5926
+ continue;
5927
+ }
5928
+ if (built.duplicates.length > 0) {
5929
+ pend2(
5930
+ page.route,
5931
+ resolved.section.groupKey,
5932
+ "DUPLICATE_FIELD_BINDING",
5933
+ `${built.duplicates.join(", ")} carries more than one data-bcms-field, so there is no single address to rewrite.`
5934
+ );
5935
+ continue;
5936
+ }
5523
5937
  if (built.free.length > 0) {
5524
- pend(
5938
+ pend2(
5525
5939
  page.route,
5526
5940
  resolved.section.groupKey,
5527
5941
  "SECTION_FREE_IDENTIFIERS",
@@ -5540,14 +5954,14 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5540
5954
  }
5541
5955
  if (placeable.length === 0 && registeredHere.length === 0) continue;
5542
5956
  const spans = [
5543
- ...placeable.map((r) => ({ slug: r.slug, bind: r.section.groupKey, root: r.root })),
5544
- ...registeredHere.map((r) => ({ slug: r.slug, bind: r.bind, root: r.call }))
5957
+ ...placeable.map((r) => ({ slug: r.slug, bind: bindOf(r.section), root: r.root })),
5958
+ ...registeredHere.map((r) => ({ slug: r.slug, bind: bindOf(r.section), root: r.call }))
5545
5959
  ].sort((a, b) => (a.root?.start ?? Number.MAX_SAFE_INTEGER) - (b.root?.start ?? Number.MAX_SAFE_INTEGER));
5546
5960
  const known = spans.filter((s) => s.root !== null);
5547
5961
  const layout = runOf(known, parsed.roots, content);
5548
5962
  if (layout === null) {
5549
5963
  for (const resolved of placeable) {
5550
- pend(page.route, resolved.section.groupKey, "SECTION_NOT_CONTIGUOUS", "its roots share no container.");
5964
+ pend2(page.route, resolved.section.groupKey, "SECTION_NOT_CONTIGUOUS", "its roots share no container.");
5551
5965
  }
5552
5966
  continue;
5553
5967
  }
@@ -5557,7 +5971,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5557
5971
  shipped.set(dialect, order);
5558
5972
  order.set(
5559
5973
  pageSlug,
5560
- spans.map((s) => ({ slug: s.slug, bind: s.bind }))
5974
+ spans.map((s) => s.bind === void 0 ? { slug: s.slug } : { slug: s.slug, bind: s.bind })
5561
5975
  );
5562
5976
  if (useRun) {
5563
5977
  addFile(outFiles, contents, {
@@ -5566,13 +5980,25 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5566
5980
  content: splicePageRun(file, content, dialect, parsed, known, pageSlug)
5567
5981
  });
5568
5982
  extracted += placeable.length;
5569
- } else if (placeable.length > 0) {
5570
- addFile(outFiles, contents, {
5571
- path: file,
5572
- operation: "modify",
5573
- content: splicePageInline(file, content, dialect, parsed, placeable, pageSlug, extractable)
5574
- });
5575
- inlineOnly += placeable.length;
5983
+ } else {
5984
+ const inline = placeable.filter((resolved) => !instanceOwned(resolved.section));
5985
+ for (const resolved of placeable) {
5986
+ if (inline.includes(resolved)) continue;
5987
+ pend2(
5988
+ page.route,
5989
+ resolved.section.groupKey,
5990
+ "INLINE_NEEDS_INSTANCE",
5991
+ `${file} cannot be replaced by one <Sections page="${pageSlug}" />, and an in-place call site carries no blockId or overrides \u2014 so this placement's own copy could not be rendered or addressed.`
5992
+ );
5993
+ }
5994
+ if (inline.length > 0) {
5995
+ addFile(outFiles, contents, {
5996
+ path: file,
5997
+ operation: "modify",
5998
+ content: splicePageInline(file, content, dialect, parsed, inline, pageSlug, extractable)
5999
+ });
6000
+ }
6001
+ inlineOnly += inline.length;
5576
6002
  }
5577
6003
  }
5578
6004
  const componentSlugs = [...written.values()].flatMap((held) => [...held.keys()]);
@@ -5600,17 +6026,12 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5600
6026
  files: [...outFiles.values()].sort((a, b) => a.path.localeCompare(b.path)),
5601
6027
  receipt: {
5602
6028
  planDigest: plan2.digest ?? "",
6029
+ copy,
5603
6030
  sections,
5604
6031
  // What THIS run wrote a file for. A component an earlier run left is reused, not re-reported.
5605
6032
  components: [...fresh].sort(),
5606
6033
  planPending,
5607
- notes: unreadableConfigs(sources).map(
5608
- (path) => ({
5609
- code: "TSCONFIG_UNREADABLE",
5610
- file: path,
5611
- message: `${path} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
5612
- })
5613
- )
6034
+ notes
5614
6035
  }
5615
6036
  };
5616
6037
  }
@@ -5633,7 +6054,7 @@ async function readRegistry(content, dialect, path, files, aliases) {
5633
6054
  if (!from.specifier.startsWith(".")) continue;
5634
6055
  const resolved = resolveSpecifier(path, from.specifier, files, aliases);
5635
6056
  if (resolved !== null) {
5636
- imports.set(local, { name: local, path: resolved, kind: from.kind, imported: from.imported });
6057
+ imports.set(local, { name: local, path: preferDialect(resolved, dialect, files), kind: from.kind, imported: from.imported });
5637
6058
  }
5638
6059
  }
5639
6060
  const registry = blockOf(content, "REGISTRY");
@@ -5642,6 +6063,7 @@ async function readRegistry(content, dialect, path, files, aliases) {
5642
6063
  const registryRows = propertiesOf(registry);
5643
6064
  const shippedRows = propertiesOf(table);
5644
6065
  if (registryRows === null || shippedRows === null) return null;
6066
+ if (registryRows.length === 0) return null;
5645
6067
  for (const row of registryRows) {
5646
6068
  const named = row.value.match(/^([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?$/);
5647
6069
  const entry = named ? imports.get(named[1]) : void 0;
@@ -5651,7 +6073,8 @@ async function readRegistry(content, dialect, path, files, aliases) {
5651
6073
  for (const row of shippedRows) {
5652
6074
  try {
5653
6075
  const placements = JSON.parse(row.value);
5654
- if (!Array.isArray(placements)) return null;
6076
+ if (!Array.isArray(placements) || placements.length === 0 || !placements.every(isPlacement)) return null;
6077
+ if (placements.some((placement) => !components.has(placement.slug))) return null;
5655
6078
  shipped.set(row.key, placements);
5656
6079
  } catch {
5657
6080
  return null;
@@ -5659,6 +6082,18 @@ async function readRegistry(content, dialect, path, files, aliases) {
5659
6082
  }
5660
6083
  return { components, shipped };
5661
6084
  }
6085
+ function preferDialect(resolved, dialect, files) {
6086
+ const wanted = dialect === "astro" ? [".astro"] : [".tsx", ".jsx"];
6087
+ if (wanted.some((extension) => resolved.endsWith(extension))) return resolved;
6088
+ const base = resolved.replace(/\.[^./]+$/, "");
6089
+ return wanted.map((extension) => `${base}${extension}`).find((candidate) => files.has(candidate)) ?? resolved;
6090
+ }
6091
+ function isPlacement(value) {
6092
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
6093
+ const held = value;
6094
+ if (typeof held.slug !== "string") return false;
6095
+ return ["bind", "blockId"].every((key) => held[key] === void 0 || typeof held[key] === "string");
6096
+ }
5662
6097
  function blockOf(content, name) {
5663
6098
  const declaration2 = content.indexOf(`const ${name}`);
5664
6099
  if (declaration2 < 0) return null;
@@ -5905,14 +6340,35 @@ function pascal(value) {
5905
6340
  return /^[A-Za-z]/.test(name) ? name : `Section${name}`;
5906
6341
  }
5907
6342
  var slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
5908
- function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5909
- const markup = sliceWithBlockId(content, resolved.root);
6343
+ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathPrefix) {
6344
+ const addresses = fieldAddresses(content, elementsUnder(resolved.root));
6345
+ const unconvertible = [
6346
+ ...addresses.heads.filter((address) => !address.startsWith(pathPrefix)),
6347
+ ...addresses.opaque
6348
+ ];
6349
+ if (unconvertible.length > 0) return { content: "", free: [], duplicates: [], unconvertible };
6350
+ const cut = content.slice(resolved.root.start, resolved.root.end);
6351
+ const reserved = /* @__PURE__ */ new Set([...topLevelBindings(dialect, parsed, content), ...referencedNames2(cut)]);
6352
+ const introduced = allocateNames(reserved, [
6353
+ { key: "bcmsFields", name: "bcmsFields" },
6354
+ { key: "bcmsField", name: "bcmsField" }
6355
+ ]);
6356
+ const fieldsName = introduced.get("bcmsFields");
6357
+ const fieldName = introduced.get("bcmsField");
6358
+ const { markup, bound, duplicates } = sectionMarkup(content, resolved.root, fieldName);
6359
+ if (duplicates.length > 0) return { content: "", free: [], duplicates, unconvertible: [] };
5910
6360
  const groupKey = resolved.section.groupKey;
5911
6361
  const locals = helperLocals(parsed, dialect, fromFile);
5912
6362
  const helperNames = ["bcms", "bcmsRows", "bcmsLayout"].flatMap(
5913
6363
  (name) => locals[name].filter((local) => mentions(markup, local)).map((local) => ({ name, local }))
5914
6364
  );
5915
- const helpers = [.../* @__PURE__ */ new Set([...helperNames.map((h) => alias(h.name, h.local)), "bcmsSection"])].sort();
6365
+ const helpers = [
6366
+ .../* @__PURE__ */ new Set([
6367
+ ...helperNames.map((h) => alias(h.name, h.local)),
6368
+ "bcmsSection",
6369
+ ...bound > 0 ? [alias("bcmsFields", fieldsName)] : []
6370
+ ])
6371
+ ].sort();
5916
6372
  const helperSpecifier = relativeImport(path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
5917
6373
  const snapshot = snapshotOf(parsed, dialect, fromFile, markup);
5918
6374
  const carry = carryFor({
@@ -5927,20 +6383,23 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5927
6383
  "bind",
5928
6384
  "overrides",
5929
6385
  "page",
6386
+ fieldName,
5930
6387
  ...helperNames.map((h) => h.local),
5931
6388
  ...snapshot ? [snapshot] : []
5932
6389
  ],
5933
6390
  // The helper and the snapshots are what the component declares for itself.
5934
6391
  skipSpecifier: (repoPath, specifier) => specifier.includes(`${CONTENT_DIR}/`) || repoPath.startsWith(HELPER_PATH[dialect].replace(/\.tsx?$/, ""))
5935
6392
  });
5936
- if (carry.free.length > 0) return { content: "", free: carry.free };
6393
+ if (carry.free.length > 0) return { content: "", free: carry.free, duplicates: [], unconvertible: [] };
5937
6394
  const carried = [...carry.imports];
5938
6395
  const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
5939
6396
  const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
5940
6397
  ` : "";
5941
- const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind ?? ${JSON.stringify(groupKey)}, overrides) };` : "";
6398
+ const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides) };` : "";
6399
+ const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)});` : "";
6400
+ const declared = [copy, field].filter(Boolean).join("\n");
5942
6401
  if (dialect === "astro") {
5943
- return { free: [], content: `---
6402
+ return { free: [], duplicates: [], unconvertible: [], content: `---
5944
6403
  ${SECTION_MARKER}
5945
6404
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`].join("\n")}
5946
6405
 
@@ -5955,27 +6414,297 @@ interface Props {
5955
6414
  page?: unknown;
5956
6415
  }
5957
6416
  const { blockId, bind, overrides, page } = Astro.props;
5958
- ${carriedDeclarations}${copy}
6417
+ ${carriedDeclarations}${declared}
5959
6418
  ---
5960
6419
  ${markup}
5961
6420
  ` };
5962
6421
  }
5963
- return { free: [], content: `${SECTION_MARKER}
6422
+ return { free: [], duplicates: [], unconvertible: [], content: `${SECTION_MARKER}
5964
6423
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
5965
6424
  ${carriedDeclarations === "" ? "" : `
5966
6425
  ${carriedDeclarations}`}
5967
6426
  export default function ${resolved.name}({ blockId, bind, overrides, page }: BcmsSectionProps) {
5968
- ${copy}
6427
+ ${declared.split("\n").join("\n ")}
5969
6428
  return (
5970
6429
  ${markup}
5971
6430
  );
5972
6431
  }
5973
6432
  ` };
5974
6433
  }
5975
- function sliceWithBlockId(content, root) {
5976
- const slice = new MagicString3(content.slice(root.start, root.end));
5977
- slice.appendLeft(root.attrInsertAt - root.start, " data-bcms-block={blockId}");
5978
- return slice.toString();
6434
+ function sectionMarkup(content, root, fieldName) {
6435
+ const slice = new MagicString4(content.slice(root.start, root.end));
6436
+ const at = (offset) => offset - root.start;
6437
+ const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
6438
+ if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
6439
+ else {
6440
+ slice.overwrite(at(stamps[0].range.start), at(stamps[0].range.end), "data-bcms-block={blockId}");
6441
+ for (const extra of stamps.slice(1)) {
6442
+ let from = extra.range.start;
6443
+ while (from > root.start && /\s/.test(content[from - 1])) from--;
6444
+ slice.remove(at(from), at(extra.range.end));
6445
+ }
6446
+ }
6447
+ const wrapped = wrapFieldAttrs(slice, content, elementsUnder(root), at, fieldName);
6448
+ return { markup: slice.toString(), ...wrapped };
6449
+ }
6450
+ function wrapFieldAttrs(slice, content, elements, at, fieldName) {
6451
+ let bound = 0;
6452
+ const duplicates = [];
6453
+ for (const element of elements) {
6454
+ const fields = element.attrs.filter((attr) => attr.name === "data-bcms-field");
6455
+ if (fields.length > 1) {
6456
+ duplicates.push(`<${element.tag}>`);
6457
+ continue;
6458
+ }
6459
+ for (const attr of fields) {
6460
+ bound++;
6461
+ if (attr.expressionRange) {
6462
+ slice.appendRight(at(attr.expressionRange.start) + 1, `${fieldName}(`);
6463
+ slice.appendLeft(at(attr.expressionRange.end) - 1, ")");
6464
+ continue;
6465
+ }
6466
+ const value = attr.valueRange ? content.slice(attr.valueRange.start, attr.valueRange.end) : "";
6467
+ slice.overwrite(
6468
+ at(attr.range.start),
6469
+ at(attr.range.end),
6470
+ `data-bcms-field={${fieldName}(${JSON.stringify(value)})}`
6471
+ );
6472
+ }
6473
+ }
6474
+ return { bound, duplicates };
6475
+ }
6476
+ function provableAddress(expression) {
6477
+ const first = expression[0];
6478
+ if (first === '"' || first === "'") {
6479
+ for (let at = 1; at < expression.length; at++) {
6480
+ if (expression[at] === "\\") {
6481
+ at++;
6482
+ continue;
6483
+ }
6484
+ if (expression[at] === first) return at === expression.length - 1 ? expression.slice(1, at) : null;
6485
+ }
6486
+ return null;
6487
+ }
6488
+ if (first !== "`" || expression.length < 2 || !expression.endsWith("`")) return null;
6489
+ const body = expression.slice(1, -1);
6490
+ if (body.includes("`") || body.includes("\\")) return null;
6491
+ let head = null;
6492
+ for (let at = 0; at < body.length; at++) {
6493
+ if (body[at] !== "$" || body[at + 1] !== "{") continue;
6494
+ const close = body.indexOf("}", at + 2);
6495
+ if (close < 0) return null;
6496
+ if (!/^[A-Za-z_$][\w$]*$/.test(body.slice(at + 2, close).trim())) return null;
6497
+ if (body[at - 1] !== "[" || body[close + 1] !== "]") return null;
6498
+ head ??= body.slice(0, at);
6499
+ at = close;
6500
+ }
6501
+ return head ?? body;
6502
+ }
6503
+ function fieldAddresses(content, elements) {
6504
+ const heads = [];
6505
+ const opaque = [];
6506
+ for (const element of elements) {
6507
+ for (const attr of element.attrs) {
6508
+ if (attr.name !== "data-bcms-field") continue;
6509
+ if (!attr.expression && attr.valueRange) {
6510
+ heads.push(content.slice(attr.valueRange.start, attr.valueRange.end));
6511
+ continue;
6512
+ }
6513
+ const inner = attr.expressionRange ? content.slice(attr.expressionRange.start + 1, attr.expressionRange.end - 1).trim() : "";
6514
+ const head = provableAddress(inner);
6515
+ if (head === null) opaque.push(`data-bcms-field={${inner}}`);
6516
+ else heads.push(head);
6517
+ }
6518
+ }
6519
+ return { heads, opaque };
6520
+ }
6521
+ function elementsUnder(...roots) {
6522
+ const out = [];
6523
+ const walk = (node) => {
6524
+ if (node.type === "element") {
6525
+ out.push(node);
6526
+ for (const child of node.children) walk(child);
6527
+ return;
6528
+ }
6529
+ if (node.type === "expr") for (const child of node.children) walk(child);
6530
+ };
6531
+ for (const root of roots) walk(root);
6532
+ return out;
6533
+ }
6534
+ var V1_ANCHOR = {
6535
+ astro: "const { blockId, bind, overrides, page } = Astro.props;",
6536
+ jsx: "}: BcmsSectionProps) {"
6537
+ };
6538
+ var refusalKey = (dialect, slug) => `${dialect}\0${slug}`;
6539
+ function generatedBindings(parsed) {
6540
+ const program = parsed.ast?.program;
6541
+ const body = program?.body ?? [];
6542
+ const scopes = [body];
6543
+ for (const statement of body) {
6544
+ const declaration2 = statement.type === "ExportDefaultDeclaration" ? statement.declaration : void 0;
6545
+ const inner = declaration2?.type === "FunctionDeclaration" ? declaration2.body?.body : void 0;
6546
+ if (inner) scopes.push(inner);
6547
+ }
6548
+ const out = /* @__PURE__ */ new Map();
6549
+ for (const scope of scopes) {
6550
+ for (const statement of scope) {
6551
+ if (statement.type !== "VariableDeclaration") continue;
6552
+ for (const declarator of statement.declarations ?? []) {
6553
+ const id = declarator.id;
6554
+ const name = id?.type === "Identifier" ? id.name : void 0;
6555
+ if (name !== void 0 && !out.has(name)) out.set(name, declarator.init);
6556
+ }
6557
+ }
6558
+ }
6559
+ return out;
6560
+ }
6561
+ function stripBindFallback(parsed, source, content, locals) {
6562
+ if (!parsed.ast?.program) return "unreadable";
6563
+ const offset = parsed.script?.offset ?? 0;
6564
+ let rewritten = 0;
6565
+ let unproven = 0;
6566
+ walkAst(parsed.ast, (node) => {
6567
+ if (node.type !== "CallExpression") return;
6568
+ const argument = (node.arguments ?? [])[1];
6569
+ if (argument?.type !== "LogicalExpression" || argument.operator !== "??") return;
6570
+ const callee = node.callee;
6571
+ if (callee?.type !== "Identifier" || !locals.has(callee.name)) {
6572
+ unproven++;
6573
+ return;
6574
+ }
6575
+ const left = argument.left;
6576
+ source.overwrite(
6577
+ offset + argument.start,
6578
+ offset + argument.end,
6579
+ content.slice(offset + left.start, offset + left.end)
6580
+ );
6581
+ rewritten++;
6582
+ });
6583
+ return rewritten === 0 && unproven > 0 ? "unproven" : "ok";
6584
+ }
6585
+ var isCallOf = (init, callee) => {
6586
+ const fn = init?.type === "CallExpression" ? init.callee : void 0;
6587
+ return fn?.type === "Identifier" && fn.name === callee;
6588
+ };
6589
+ async function migrateSections(plan2, contents, written, fileSet, aliases) {
6590
+ const files = [];
6591
+ const refused = /* @__PURE__ */ new Map();
6592
+ const prefixesFor = (slug) => {
6593
+ const out = /* @__PURE__ */ new Set();
6594
+ for (const page of plan2.pages ?? []) {
6595
+ for (const section of page.sections ?? []) {
6596
+ if (resolveComponent(plan2, section).slug !== slug) continue;
6597
+ const prefix = propPrefix(section);
6598
+ if (prefix !== null) out.add(prefix);
6599
+ }
6600
+ }
6601
+ return [...out];
6602
+ };
6603
+ for (const [dialect, components] of written) {
6604
+ for (const [slug, entry] of components) {
6605
+ const content = contents.get(entry.path);
6606
+ if (content === void 0 || content.includes(SECTION_MARKER)) continue;
6607
+ const older = SECTION_MARKERS.find((marker) => content.includes(marker));
6608
+ if (older === void 0) continue;
6609
+ const candidates = prefixesFor(slug);
6610
+ if (candidates.length === 0) continue;
6611
+ const parsed = await parseFile4(dialect, content);
6612
+ if (parsed.error) {
6613
+ refused.set(refusalKey(dialect, slug), `${entry.path} carries our marker but does not parse (${parsed.error.message}).`);
6614
+ continue;
6615
+ }
6616
+ const elements = elementsUnder(...parsed.roots);
6617
+ const imports = [...importsIn(parsed, dialect)];
6618
+ const helperModule = HELPER_PATH[dialect];
6619
+ const helperLocalsFor = (exported) => new Set(
6620
+ imports.filter(
6621
+ ([, from]) => from.imported === exported && resolveSpecifier(entry.path, from.specifier, fileSet, aliases) === helperModule
6622
+ ).map(([local]) => local)
6623
+ );
6624
+ const { heads: evidence, opaque } = fieldAddresses(content, elements);
6625
+ const pathPrefix = evidence.length + opaque.length === 0 ? "" : opaque.length > 0 ? null : onlyOne(candidates.filter((prefix) => evidence.every((head) => head.startsWith(prefix)))) ?? null;
6626
+ if (pathPrefix === null) {
6627
+ const matching = candidates.filter((prefix) => evidence.every((head) => head.startsWith(prefix)));
6628
+ refused.set(
6629
+ refusalKey(dialect, slug),
6630
+ opaque.length > 0 ? `${entry.path} carries ${[...new Set(opaque)].slice(0, 3).join(", ")}, whose address this cannot read \u2014 so there is no prefix it can be shown to convert through.` : matching.length === 0 ? `${entry.path} declares ${evidence.length > 0 ? `addresses (${[...new Set(evidence)].slice(0, 3).join(", ")})` : "addresses"} that match none of this slug's placements (${candidates.join(", ") || "none with a usable prop map"}), so the prefix its markup was cut with cannot be recovered.` : `${entry.path} matches more than one of this slug's placements (${matching.join(", ")}), so the prefix its markup was cut with is ambiguous.`
6631
+ );
6632
+ continue;
6633
+ }
6634
+ const source = new MagicString4(content);
6635
+ const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
6636
+ if (duplicates.length > 0) {
6637
+ 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.`);
6638
+ continue;
6639
+ }
6640
+ const markerEnd = content.indexOf(older) + older.length;
6641
+ source.overwrite(markerEnd - older.length, markerEnd, SECTION_MARKER);
6642
+ const stripped = stripBindFallback(parsed, source, content, helperLocalsFor("bcmsSection"));
6643
+ if (stripped !== "ok") {
6644
+ refused.set(
6645
+ refusalKey(dialect, slug),
6646
+ stripped === "unreadable" ? `${entry.path} carries our marker but its module could not be read as a program, so the Phase A \`bind ?? "\u2026"\` fallback cannot be shown to be gone.` : `${entry.path} carries a \`?? \` fallback in a call this cannot identify as ${helperModule}'s bcmsSection, so the Phase A page-group default cannot be shown to be gone.`
6647
+ );
6648
+ continue;
6649
+ }
6650
+ if (bound > 0) {
6651
+ const locals = generatedBindings(parsed);
6652
+ const names = /* @__PURE__ */ new Set([...topLevelBindings(dialect, parsed, content), ...locals.keys()]);
6653
+ const fieldsLocal = [...helperLocalsFor("bcmsFields")][0];
6654
+ if (fieldsLocal === void 0 && names.has("bcmsFields")) {
6655
+ refused.set(
6656
+ refusalKey(dialect, slug),
6657
+ `${entry.path} already binds "bcmsFields" to something other than ${helperModule}'s export.`
6658
+ );
6659
+ continue;
6660
+ }
6661
+ const declared = names.has("bcmsField");
6662
+ if (declared && (fieldsLocal === void 0 || !isCallOf(locals.get("bcmsField"), fieldsLocal))) {
6663
+ refused.set(
6664
+ refusalKey(dialect, slug),
6665
+ `${entry.path} already binds "bcmsField" to something other than a call of ${helperModule}'s bcmsFields.`
6666
+ );
6667
+ continue;
6668
+ }
6669
+ if (!declared) {
6670
+ const anchor = V1_ANCHOR[dialect];
6671
+ const at = anchor ? content.indexOf(anchor) : -1;
6672
+ if (anchor === void 0 || at < 0) {
6673
+ refused.set(refusalKey(dialect, slug), `${entry.path} carries our marker but no longer declares its placement, so there is nowhere the chooser can be put.`);
6674
+ continue;
6675
+ }
6676
+ source.appendLeft(
6677
+ at + anchor.length,
6678
+ `
6679
+ ${dialect === "astro" ? "" : " "}const bcmsField = ${fieldsLocal ?? "bcmsFields"}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)});`
6680
+ );
6681
+ }
6682
+ if (fieldsLocal === void 0) {
6683
+ const helper = imports.find(([, from]) => from.imported === "bcmsSection")?.[1].specifier ?? relativeImport(entry.path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
6684
+ source.appendLeft(markerEnd, `
6685
+ import { bcmsFields } from "${helper}";`);
6686
+ }
6687
+ }
6688
+ files.push({ path: entry.path, operation: "modify", content: source.toString() });
6689
+ }
6690
+ }
6691
+ return { files, refused };
6692
+ }
6693
+ function propPrefix(section) {
6694
+ const prefixes = /* @__PURE__ */ new Set();
6695
+ for (const field of section.fields ?? []) {
6696
+ const { path, key } = field;
6697
+ if (typeof path !== "string" || typeof key !== "string") continue;
6698
+ if (!path.endsWith(key)) return null;
6699
+ prefixes.add(path.slice(0, path.length - key.length));
6700
+ }
6701
+ if (prefixes.size > 1) return null;
6702
+ return prefixes.size === 1 ? [...prefixes][0] : `${section.groupKey}.`;
6703
+ }
6704
+ function referencedNames2(markup) {
6705
+ const out = /* @__PURE__ */ new Set();
6706
+ for (const [name] of markup.matchAll(/[A-Za-z_$][\w$]*/g)) out.add(name);
6707
+ return out;
5979
6708
  }
5980
6709
  var alias = (name, local) => name === local ? name : `${name} as ${local}`;
5981
6710
  var mentions = (markup, name) => new RegExp(`(?<![\\w$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w$])`).test(markup);
@@ -6179,7 +6908,7 @@ export default function Sections({ page }: { page: string }) {
6179
6908
  function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
6180
6909
  const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
6181
6910
  const local = names.get("Sections");
6182
- const source = new MagicString3(content);
6911
+ const source = new MagicString4(content);
6183
6912
  const first = placeable[0].root;
6184
6913
  const last = placeable[placeable.length - 1].root;
6185
6914
  source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
@@ -6199,7 +6928,7 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
6199
6928
  { key: "pageSnapshot", name: "pageSnapshot" }
6200
6929
  ]);
6201
6930
  const snapshot = names.get("pageSnapshot");
6202
- const source = new MagicString3(content);
6931
+ const source = new MagicString4(content);
6203
6932
  for (const resolved of placeable) {
6204
6933
  const component = written.get(resolved.slug);
6205
6934
  const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
@@ -6234,7 +6963,7 @@ function addImports(source, parsed, lines) {
6234
6963
  else source.appendLeft(at.at, `
6235
6964
  ${lines.join("\n")}`);
6236
6965
  }
6237
- var isGenerated = (content) => content.includes(SECTION_MARKER) || content.includes(REGISTRY_MARKER);
6966
+ var isGenerated = (content) => SECTION_MARKERS.some((marker) => content.includes(marker)) || content.includes(REGISTRY_MARKER);
6238
6967
  function emitHelper(out, contents, version, overwrite) {
6239
6968
  const path = HELPER_PATH.astro;
6240
6969
  const source = helperSource("astro");
@@ -6280,6 +7009,10 @@ function readComponentizePlan(value) {
6280
7009
  }
6281
7010
 
6282
7011
  // src/index.ts
7012
+ var UNPARSEABLE_DIALECT = {
7013
+ roots: [],
7014
+ error: { code: "DIALECT_UNSUPPORTED", message: "no parser for this extension" }
7015
+ };
6283
7016
  var ConvertError = class extends Error {
6284
7017
  constructor(code, message) {
6285
7018
  super(message);
@@ -6361,7 +7094,7 @@ async function convertSources(briefIn, sources, options) {
6361
7094
  }
6362
7095
  const { located, unlocated } = locate(brief, sources, { stripTags: true });
6363
7096
  const pending = [];
6364
- const pend = (target, reason, file, message) => {
7097
+ const pend2 = (target, reason, file, message) => {
6365
7098
  pending.push({
6366
7099
  route: target.route,
6367
7100
  scope: target.scope,
@@ -6462,7 +7195,7 @@ async function convertSources(briefIn, sources, options) {
6462
7195
  declaredRow(target, false, byHand);
6463
7196
  continue;
6464
7197
  }
6465
- pend(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
7198
+ pend2(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
6466
7199
  }
6467
7200
  const byFile = /* @__PURE__ */ new Map();
6468
7201
  for (const [key, hit] of locatedByKey) {
@@ -6519,12 +7252,12 @@ async function convertSources(briefIn, sources, options) {
6519
7252
  const worst = outcomes.find((o) => o !== "rewritten" && o !== "declared");
6520
7253
  if (worst) {
6521
7254
  const file = hit.files[outcomes.indexOf(worst)];
6522
- pend(target, worst, file);
7255
+ pend2(target, worst, file);
6523
7256
  } else if (outcomes.every((o) => o === "declared")) {
6524
7257
  const witness = await witnessed(target);
6525
7258
  const byHand = witness === null ? declaredByHand(target, hit.files) : null;
6526
7259
  if (witness === null && byHand === null) {
6527
- pend(
7260
+ pend2(
6528
7261
  target,
6529
7262
  "AMBIGUOUS_LITERAL",
6530
7263
  hit.files[0],
@@ -6537,7 +7270,7 @@ async function convertSources(briefIn, sources, options) {
6537
7270
  } else rewritten++;
6538
7271
  }
6539
7272
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
6540
- const source = new MagicString4(contents.get(file));
7273
+ const source = new MagicString5(contents.get(file));
6541
7274
  const seen = /* @__PURE__ */ new Set();
6542
7275
  for (const splice of splices) {
6543
7276
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -6558,6 +7291,28 @@ async function convertSources(briefIn, sources, options) {
6558
7291
  }
6559
7292
  const { helpers, helperUpgraded } = emitHelpers(dialects, contents, outFiles, options?.overwriteHelper);
6560
7293
  emitStubs2(dialects, snapshotSlugs, contents, outFiles);
7294
+ const latest = new Map(contents);
7295
+ for (const file of outFiles) if (file.operation === "modify") latest.set(file.path, file.content);
7296
+ const canvasSources = [];
7297
+ for (const source of sources) {
7298
+ const content = latest.get(source.path) ?? source.content;
7299
+ if (!/\.[jt]sx$/i.test(source.path)) {
7300
+ canvasSources.push({ path: source.path, content });
7301
+ continue;
7302
+ }
7303
+ const parsed = content === source.content ? await parseOf(source.path) : await parseFile4("jsx", content);
7304
+ canvasSources.push({ path: source.path, content, ...parsed && !parsed.error ? { ast: parsed.ast } : {} });
7305
+ }
7306
+ const canvas = canvasBridge(
7307
+ canvasSources,
7308
+ new Map(brief.pages.map((page) => [page.route, page.slug]))
7309
+ );
7310
+ for (const [path, content] of canvas.edits) {
7311
+ const existing = outFiles.find((f) => f.path === path);
7312
+ if (existing) existing.content = content;
7313
+ else outFiles.push({ path, operation: "modify", content });
7314
+ }
7315
+ pending.push(...canvas.pending);
6561
7316
  const receipt = assertReceipt({
6562
7317
  briefDigest: digest,
6563
7318
  paths: {
@@ -6574,6 +7329,7 @@ async function convertSources(briefIn, sources, options) {
6574
7329
  helpers,
6575
7330
  helperUpgraded,
6576
7331
  dynamicBindings,
7332
+ canvas: { lane: canvas.lane, ...canvas.files.length > 0 ? { files: canvas.files } : {} },
6577
7333
  notes: unreadableConfigs(sources).map(
6578
7334
  (file) => ({
6579
7335
  code: "TSCONFIG_UNREADABLE",
@@ -6606,6 +7362,30 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
6606
7362
  };
6607
7363
  if (pendingTargets.length === 0) return empty;
6608
7364
  if (!dialect) {
7365
+ if (isTolerantDialect(file)) {
7366
+ const tolerantLiterals = [...new Set(pendingTargets.map((t) => t.literal))];
7367
+ const tolerantSites = findSitesTolerant(content, tolerantLiterals);
7368
+ if (tolerantSites.length > 0) {
7369
+ return plan(
7370
+ file,
7371
+ content,
7372
+ // Never read: `plan` forces `writing = "html"` at tier 2, and every other use of this
7373
+ // argument is behind `!parsed.error` or `tier !== 2`. Passing the writing dialect keeps
7374
+ // the parameter honest rather than widening its type for one caller.
7375
+ "html",
7376
+ pendingTargets,
7377
+ tolerantSites,
7378
+ void 0,
7379
+ // no parse, no import point: tier 2 declares, it does not read
7380
+ outcomes,
7381
+ 2,
7382
+ UNPARSEABLE_DIALECT,
7383
+ drill,
7384
+ []
7385
+ // no parse, so nothing in this file is already declared
7386
+ );
7387
+ }
7388
+ }
6609
7389
  for (const target of pendingTargets) outcomes.set(target.key, "DIALECT_UNSUPPORTED");
6610
7390
  return { ...empty, dialect: null, outcomes };
6611
7391
  }
@@ -6664,7 +7444,7 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
6664
7444
  }
6665
7445
  async function plan(file, content, dialect, targets, sites, importAt, outcomes, tier, parsed, drill, declarations) {
6666
7446
  const writing = tier === 2 ? "html" : dialect;
6667
- const missing = tier === 2 ? "PARSE_ERROR" : "NOT_IN_SOURCE";
7447
+ const missing = tier !== 2 ? "NOT_IN_SOURCE" : parsed.error?.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR";
6668
7448
  const taken = parsed.error ? /* @__PURE__ */ new Set() : topLevelBindings(dialect, parsed, content);
6669
7449
  const slugs = [...new Set(targets.map((t) => t.slug))].sort();
6670
7450
  const preset = /* @__PURE__ */ new Map();
@@ -7087,7 +7867,7 @@ function emitStubs2(dialects, slugs, contents, out) {
7087
7867
  }
7088
7868
 
7089
7869
  // src/cli.ts
7090
- var VERSION = "0.2.0";
7870
+ var VERSION = "0.4.0";
7091
7871
  var MAX_FILE_BYTES = 512 * 1024;
7092
7872
  var CliError = class extends Error {
7093
7873
  };
@@ -7096,6 +7876,7 @@ function parseArgs(argv) {
7096
7876
  brief: void 0,
7097
7877
  componentize: false,
7098
7878
  plan: void 0,
7879
+ copy: "bind",
7099
7880
  root: ".",
7100
7881
  dryRun: false,
7101
7882
  receipt: void 0,
@@ -7109,7 +7890,13 @@ function parseArgs(argv) {
7109
7890
  if (arg === "--brief") args.brief = argv[++i];
7110
7891
  else if (arg === "--componentize") args.componentize = true;
7111
7892
  else if (arg === "--plan") args.plan = argv[++i];
7112
- else if (arg === "--root") args.root = argv[++i] ?? ".";
7893
+ else if (arg === "--copy") {
7894
+ const mode = argv[++i];
7895
+ if (mode !== "bind" && mode !== "instance") {
7896
+ throw new CliError(`--copy takes "bind" or "instance", not ${JSON.stringify(mode ?? "")}. See --help.`);
7897
+ }
7898
+ args.copy = mode;
7899
+ } else if (arg === "--root") args.root = argv[++i] ?? ".";
7113
7900
  else if (arg === "--receipt") args.receipt = argv[++i];
7114
7901
  else if (arg === "--dry-run") args.dryRun = true;
7115
7902
  else if (arg === "--strict") args.strict = true;
@@ -7133,6 +7920,10 @@ var HELP = `bettercms-convert ${VERSION}
7133
7920
  --componentize Lift each planned SECTION into a component the CMS can reorder.
7134
7921
  --plan <file> The componentize plan, as get_componentize_plan returns it.
7135
7922
  Required with --componentize.
7923
+ --copy bind|instance Which copy model the plan is expected to describe. Default: bind
7924
+ (the copy stays in the page's field groups). "instance" is refused
7925
+ \u2014 COPY_MODE_MISMATCH \u2014 unless the plan's placements say the server
7926
+ has already moved it onto them.
7136
7927
  --root <dir> The repository to convert. Default: the working directory.
7137
7928
  --dry-run Print what would change; write nothing.
7138
7929
  --receipt <file> Write the receipt as JSON.
@@ -7217,7 +8008,7 @@ function reportSections(files, receipt, dryRun) {
7217
8008
  }
7218
8009
  process.stdout.write(
7219
8010
  `
7220
- ${extracted} sections extracted (${registered} registered, ${inlineOnly} inline-only, ${alreadyExtracted} already extracted, ${pending.length} pending)
8011
+ ${extracted} sections extracted (${registered} registered, ${inlineOnly} inline-only, ${alreadyExtracted} already extracted, ${pending.length} pending), copy: ${receipt.copy}
7221
8012
  `
7222
8013
  );
7223
8014
  for (const section of pending) {
@@ -7237,6 +8028,9 @@ async function main() {
7237
8028
  if (args.plan && !args.componentize) {
7238
8029
  throw new CliError("--plan is only read with --componentize. See --help.");
7239
8030
  }
8031
+ if (args.copy === "instance" && !args.componentize) {
8032
+ throw new CliError("--copy is only read with --componentize. See --help.");
8033
+ }
7240
8034
  const root = resolve(args.root);
7241
8035
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
7242
8036
  if (!Array.isArray(brief.pages)) {
@@ -7270,7 +8064,7 @@ async function main() {
7270
8064
  brief,
7271
8065
  plan2,
7272
8066
  [...sources, ...await readSnapshots(root)],
7273
- { overwriteHelper: args.overwriteHelper }
8067
+ { overwriteHelper: args.overwriteHelper, copy: args.copy }
7274
8068
  );
7275
8069
  await written(files2);
7276
8070
  await saveReceipt(receipt2);