@bettercms-ai/convert 0.2.1 → 0.4.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
@@ -134,7 +134,7 @@ function propsAttribute(props) {
134
134
 
135
135
  // src/helper.ts
136
136
  import { createHash as createHash2 } from "crypto";
137
- var HELPER_VERSION = 3;
137
+ var HELPER_VERSION = 4;
138
138
  var HEADER = "// @bettercms-ai/convert helper v";
139
139
  var HELPER_PATH = Object.fromEntries(
140
140
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -217,32 +217,97 @@ export function bcmsRows<T>(page: unknown, path: string, fallback: T[], slug?: s
217
217
  /**
218
218
  * ONE SECTION's copy, for a component extracted by \`--componentize\`.
219
219
  *
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.
220
+ * OVERRIDES FIRST, PER KEY: \`overrides[key] ?? group[key]\`. An instance that owns its copy
221
+ * (Phase B) carries an override for every prop it declares, so the group underneath it is never
222
+ * read; a placement still bound to a page field group (Phase A) carries none, so the group is all
223
+ * there is. The interesting case is the one in between \u2014 a section half moved \u2014 where a key the
224
+ * instance does not hold has to keep rendering the page's value rather than nothing.
225
+ *
226
+ * \u{1F534} \`??\`, NOT \`||\`. An empty string is a value an editor typed, and treating it as absent put
227
+ * the page's old copy back into a heading somebody had deliberately cleared.
228
+ *
229
+ * With neither an override nor a group, the answer is \`{}\` and every \`bcms(...)\` below renders
230
+ * the fallback the template shipped with \u2014 which is why a section renders correctly against the
231
+ * committed \`{}\` stub.
224
232
  */
225
233
  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
234
  const group = bind ? readPath(page, bind) : undefined;
230
- return isRecord(group) ? group : {};
235
+ const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
236
+ if (!isRecord(overrides)) return base;
237
+ for (const key of Object.keys(overrides)) {
238
+ const value = overrides[key];
239
+ if (value === undefined || value === null) continue;
240
+ /**
241
+ * \u{1F534} A TABLE PROP IS STORED AS \`{ rows: [...] }\` AND READ AS A LIST. That is the shape the
242
+ * dashboard edits a repeater in; handing it to \`bcmsRows\` unchanged is not an array, so the
243
+ * helper fell back to the rows the template shipped with and the editor's work never showed.
244
+ */
245
+ base[key] = isRecord(value) && Array.isArray(value.rows) ? value.rows : value;
246
+ }
247
+ return base;
248
+ }
249
+
250
+ /**
251
+ * WHICH ADDRESS one bound element declares \u2014 decided at RENDER time, from the placement.
252
+ *
253
+ * An element carries exactly ONE \`data-bcms-field\`, and which lane it names depends on where its
254
+ * copy lives. A placement that owns its copy declares the BLOCK lane \u2014
255
+ * \`<blockId>__overrides.<propKey>\` \u2014 because that is the address the editor commits an instance's
256
+ * own value to. A placement bound to a page field group declares the PAGE path, which is what the
257
+ * page form and the coverage meter already know.
258
+ *
259
+ * \u{1F534} THE BOUND ADDRESS IS RE-BASED ONTO **THIS** PLACEMENT'S GROUP. The markup was cut from ONE
260
+ * page and carries that page's paths (\`hero.title\`); the whole point of \`shapeHash\` is that a
261
+ * second page renders the same component bound to a group of its own (\`bind="landingHero"\`).
262
+ * Handing the embedded path through unchanged declared \`hero.title\` on a page whose field is
263
+ * \`landingHero.title\` \u2014 an address that page does not have, so the editor wrote into nothing and
264
+ * the meter counted a binding that was never there. The prefix the markup was cut with comes out,
265
+ * the placement's own group goes in.
266
+ *
267
+ * \u{1F534} THE SAME EXTRACTED FILE SERVES BOTH LANES. The choice is a function of the props, never of
268
+ * how the codemod ran: one component renders a Phase A placement on one page and a Phase B
269
+ * instance on the next, and a repository does not have to be re-cut when its copy moves.
270
+ */
271
+ export function bcmsFields(
272
+ placement: { blockId?: string; bind?: string; overrides?: unknown },
273
+ pathPrefix: string,
274
+ ): (path: string) => string {
275
+ const id = placement.blockId;
276
+ const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
277
+ /**
278
+ * What replaces the prefix the markup was cut with, or null for "leave every path alone".
279
+ *
280
+ * Null covers a placement with neither a bind nor an instance \u2014 a shipped fallback, or a page
281
+ * nobody has componentized yet \u2014 and a bound placement in a component whose prefix is EMPTY,
282
+ * where the plan said the prop key IS the whole page path: there is no group segment to swap,
283
+ * so prepending one would invent an address rather than correct it.
284
+ */
285
+ const lane = own ? \`\${id}__overrides.\` : placement.bind && pathPrefix ? \`\${placement.bind}.\` : null;
286
+ return (path: string): string => {
287
+ // A path outside the group this section was cut from is not one this placement re-addresses:
288
+ // it names a key the instance does not hold and a group this placement is not bound to.
289
+ // (An EMPTY prefix is a plan saying the prop key IS the page path, and matches everything.)
290
+ if (lane === null || !path.startsWith(pathPrefix)) return path;
291
+ return lane + path.slice(pathPrefix.length);
292
+ };
231
293
  }
232
294
  `;
233
295
  }
234
296
  var HISTORICAL = {
235
297
  "src/bcms-content.ts": {
236
298
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
237
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
299
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
300
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
238
301
  },
239
302
  "src/lib/bcms-content.ts": {
240
303
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
241
- 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002"
304
+ 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
305
+ 3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e"
242
306
  },
243
307
  "composables/bcms-content.ts": {
244
308
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
245
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
309
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
310
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
246
311
  }
247
312
  };
248
313
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -2628,6 +2693,71 @@ function locate(brief, sources, options) {
2628
2693
  // src/loops.ts
2629
2694
  import MagicString from "magic-string";
2630
2695
 
2696
+ // src/declarations.ts
2697
+ var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
2698
+ var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
2699
+ var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
2700
+ var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
2701
+ var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
2702
+ const [address, kind] = entry.split("|");
2703
+ if (!address) return [];
2704
+ const scope = address.startsWith("layout:") ? "layout" : "page";
2705
+ return [{ scope, path: stripLayoutPrefix(address), kind: kind ?? "", at, via: "props" }];
2706
+ });
2707
+ var propsPaths = (value) => propsDeclarations(value, 0).map((d) => ({ scope: d.scope, path: d.path }));
2708
+ var TEMPLATE_BINDING = /^:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")$/;
2709
+ var shapeOfPath = (path) => path.replace(/\[(?:\d+|\$\{[^}]*\})\]/g, "[*]");
2710
+ function declarationsIn(roots, content) {
2711
+ const out = [];
2712
+ const visit = (node) => {
2713
+ if (node.type === "expr") {
2714
+ for (const child of node.children) visit(child);
2715
+ return;
2716
+ }
2717
+ if (node.type !== "element") return;
2718
+ const kind = node.attrs.find((a) => a.name === "data-bcms-kind")?.value ?? "";
2719
+ const at = node.attrInsertAt;
2720
+ for (const attr of node.attrs) {
2721
+ const name = attr.name.replace(/^:/, "");
2722
+ if (name === "data-bcms-field" || name === "data-bcms-layout-field") {
2723
+ const scope = name === "data-bcms-layout-field" ? "layout" : "page";
2724
+ const template = content.slice(attr.range.start, attr.range.end).match(TEMPLATE_BINDING);
2725
+ const templated = template?.[1] ?? template?.[2];
2726
+ if (templated) out.push({ scope, path: shapeOfPath(stripLayoutPrefix(templated)), kind, at, via: "field" });
2727
+ else if (attr.value) out.push({ scope, path: stripLayoutPrefix(attr.value), kind, at, via: "field" });
2728
+ } else if (attr.name === "data-bcms-props") {
2729
+ out.push(...propsDeclarations(attr.value, at));
2730
+ } else if (attr.name === "bcmsBindings") {
2731
+ for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
2732
+ out.push({ scope: "page", path, kind: "", at, via: "prop" });
2733
+ }
2734
+ }
2735
+ }
2736
+ for (const child of node.children) visit(child);
2737
+ };
2738
+ for (const root of roots) visit(root);
2739
+ return out;
2740
+ }
2741
+ var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
2742
+ var scopedPath = (scope, path) => `${scope}::${path}`;
2743
+ var covers = (declared, target) => declared !== void 0 && (declared.has(scopedPath(target.scope, target.path)) || declared.has(scopedPath(target.scope, shapeOfPath(target.path))));
2744
+ var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
2745
+ function declaredByScan(content) {
2746
+ const out = /* @__PURE__ */ new Set();
2747
+ const add = (scope, path) => void out.add(scopedPath(scope, path));
2748
+ for (const match of content.matchAll(BINDING_ATTR)) {
2749
+ add(match[0].includes("layout-field") ? "layout" : "page", stripLayoutPrefix(match[1]));
2750
+ }
2751
+ for (const match of content.matchAll(TEMPLATE_SCAN)) {
2752
+ add(match[0].includes("layout-field") ? "layout" : "page", shapeOfPath(stripLayoutPrefix(match[1] ?? match[2])));
2753
+ }
2754
+ for (const match of content.matchAll(BINDINGS_SCAN)) for (const path of bindingsPaths(match[0])) add("page", path);
2755
+ for (const match of content.matchAll(PROPS_ATTR))
2756
+ for (const entry of propsPaths(match[1])) add(entry.scope, entry.path);
2757
+ return out;
2758
+ }
2759
+ var stripLayoutPrefix = (value) => value.startsWith("layout:") ? value.slice("layout:".length) : value;
2760
+
2631
2761
  // src/scope.ts
2632
2762
  function patternNames(node, out) {
2633
2763
  if (!node || typeof node !== "object") return;
@@ -2914,7 +3044,10 @@ function wordBounded(haystack, needle) {
2914
3044
  }
2915
3045
 
2916
3046
  // src/loops.ts
2917
- var shapeOf = (arrayPath, leaf) => `${arrayPath}[*].${leaf}`;
3047
+ function splitNested(leaf) {
3048
+ const match = leaf.match(/^([^[\]]+)\[(\d+)\]\.(.+)$/);
3049
+ return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
3050
+ }
2918
3051
  var elementEnd = (node) => node.inner ? node.inner.end + node.tag.length + 3 : null;
2919
3052
  function findRows(roots, members) {
2920
3053
  const wanted = new Set(members.map((m) => m.index));
@@ -2986,6 +3119,69 @@ function indentOf(content, at) {
2986
3119
  const prefix = content.slice(lineStart, at);
2987
3120
  return prefix.trim() === "" ? prefix : "";
2988
3121
  }
3122
+ function writeLeaf(source, shift, member, rule, read, row, literal) {
3123
+ const site = member.site;
3124
+ const expression = `${read}(${row}, ${JSON.stringify(member.leaf)}, "")`;
3125
+ if (member.kind === "richtext" && rule.richtext) {
3126
+ source.remove(site.inner.start + shift, site.inner.end + shift);
3127
+ source.appendLeft(site.attrInsertAt + shift, ` ${rule.richtext(expression)}`);
3128
+ } else {
3129
+ source.overwrite(site.inner.start + shift, site.inner.end + shift, rule.text(expression));
3130
+ }
3131
+ const kind = member.kind ? ` data-bcms-kind="${member.kind}"` : "";
3132
+ source.appendLeft(site.attrInsertAt + shift, ` ${rule.attr("data-bcms-field", `\`${literal}\``)}${kind}`);
3133
+ }
3134
+ function emitNested(rule, array, members, prefix, outer, parent, ctx) {
3135
+ if (!rule.loop || !rule.text) return null;
3136
+ const inner = [];
3137
+ for (const member of members) {
3138
+ const split = splitNested(member.leaf);
3139
+ if (!split || splitNested(split.leaf) !== null) return null;
3140
+ inner.push({ ...member, index: split.index, leaf: split.leaf });
3141
+ }
3142
+ const rows = findRows([parent.node], inner);
3143
+ if (!rows || rows.length === 0) return null;
3144
+ if (!consecutive(rows, ctx.content)) return null;
3145
+ if (!sameShape(rows, ctx.content)) return null;
3146
+ const template = rows[0];
3147
+ const used = /* @__PURE__ */ new Set([
3148
+ ...ctx.content.slice(template.range.start, template.range.end).match(/[A-Za-z_$][\w$]*/g) ?? [],
3149
+ outer.row,
3150
+ outer.index
3151
+ ]);
3152
+ const allocated = allocateNames(used, [
3153
+ { key: "row", name: "item" },
3154
+ { key: "index", name: "j" }
3155
+ ]);
3156
+ const names = { row: allocated.get("row"), index: allocated.get("index") };
3157
+ const fallback = [];
3158
+ const writable = inner.every((member) => setLeaf(fallback[member.index] ??= emptyRow(), member.leaf, member.fallback));
3159
+ if (!writable) return null;
3160
+ const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
3161
+ const shift = -template.range.start;
3162
+ const declarations = [];
3163
+ for (const member of template.members) {
3164
+ const literal = `${prefix}.${array}[\${${names.index}}].${member.leaf}`;
3165
+ writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
3166
+ declarations.push({ path: shapeOfPath(literal), literal });
3167
+ }
3168
+ const rowsExpr = `${ctx.rows}(${outer.row}, ${JSON.stringify(array)}, ${JSON.stringify(fallback.filter(Boolean))})`;
3169
+ if (rule.loop.kind === "attr") {
3170
+ source.appendRight(template.attrInsertAt + shift, ` ${rule.loop.attr(rowsExpr, names.row, names.index)}`);
3171
+ }
3172
+ if (rule.loopKey) {
3173
+ const key = rule.loopKey(names.index);
3174
+ if (!template.keyAttr) source.appendRight(template.attrInsertAt + shift, ` ${key}`);
3175
+ else if (template.keyAttr.end > template.keyAttr.start)
3176
+ source.overwrite(template.keyAttr.start + shift, template.keyAttr.end + shift, key);
3177
+ else return null;
3178
+ }
3179
+ const indent = indentOf(ctx.content, template.range.start);
3180
+ const text = rule.loop.kind === "wrap" ? `${rule.loop.open(rowsExpr, names.row, names.index)}
3181
+ ${indented(source.toString(), indent)}
3182
+ ${indent}${rule.loop.close}` : source.toString();
3183
+ return { range: { start: template.range.start, end: rows[rows.length - 1].range.end }, text, declarations };
3184
+ }
2989
3185
  function emitRepeater(dialect, group, arrayPath, fallbackRows, ctx) {
2990
3186
  const rule = DIALECT_RULES[dialect];
2991
3187
  if (!rule.loop || !rule.text || group.length === 0) return null;
@@ -3003,20 +3199,25 @@ function emitRepeater(dialect, group, arrayPath, fallbackRows, ctx) {
3003
3199
  const names = { row: allocated.get("row"), index: allocated.get("index") };
3004
3200
  const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
3005
3201
  const shift = -template.range.start;
3006
- const bindings = [];
3202
+ const declarations = [];
3203
+ const nested = /* @__PURE__ */ new Map();
3204
+ const direct = [];
3007
3205
  for (const member of template.members) {
3008
- const site = member.site;
3009
- const expression = `${ctx.read}(${names.row}, ${JSON.stringify(member.leaf)}, "")`;
3010
- if (member.kind === "richtext" && rule.richtext) {
3011
- source.remove(site.inner.start + shift, site.inner.end + shift);
3012
- source.appendLeft(site.attrInsertAt + shift, ` ${rule.richtext(expression)}`);
3013
- } else {
3014
- source.overwrite(site.inner.start + shift, site.inner.end + shift, rule.text(expression));
3015
- }
3016
- const kind = member.kind ? ` data-bcms-kind="${member.kind}"` : "";
3017
- const path = `\`${arrayPath}[\${${names.index}}].${member.leaf}\``;
3018
- source.appendLeft(site.attrInsertAt + shift, ` ${rule.attr("data-bcms-field", path)}${kind}`);
3019
- bindings.push(shapeOf(arrayPath, member.leaf));
3206
+ const inner = splitNested(member.leaf);
3207
+ if (inner === null) direct.push(member);
3208
+ else nested.set(inner.array, [...nested.get(inner.array) ?? [], member]);
3209
+ }
3210
+ for (const [array, members] of nested) {
3211
+ const plan2 = emitNested(rule, array, members, `${arrayPath}[\${${names.index}}]`, names, template, ctx);
3212
+ if (!plan2) return null;
3213
+ if (direct.some((m) => m.site.range.start >= plan2.range.start && m.site.range.end <= plan2.range.end)) return null;
3214
+ source.overwrite(plan2.range.start + shift, plan2.range.end + shift, plan2.text);
3215
+ declarations.push(...plan2.declarations);
3216
+ }
3217
+ for (const member of direct) {
3218
+ const literal = `${arrayPath}[\${${names.index}}].${member.leaf}`;
3219
+ writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
3220
+ declarations.push({ path: shapeOfPath(literal), literal });
3020
3221
  }
3021
3222
  const args = [ctx.snapshot, JSON.stringify(arrayPath), JSON.stringify(fallbackRows), ...ctx.slug ? [ctx.slug] : []];
3022
3223
  const rowsExpr = `${ctx.rows}(${args.join(", ")})`;
@@ -3037,7 +3238,8 @@ ${indent}${rule.loop.close}` : source.toString();
3037
3238
  return {
3038
3239
  range: { start: template.range.start, end: rows[rows.length - 1].range.end },
3039
3240
  text,
3040
- bindings: [...new Set(bindings)],
3241
+ declarations,
3242
+ bindings: [...new Set(declarations.map((d) => d.path))],
3041
3243
  names
3042
3244
  };
3043
3245
  }
@@ -3067,9 +3269,9 @@ function setLeaf(row, leaf, value) {
3067
3269
  var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3068
3270
  var emptyRow = () => /* @__PURE__ */ Object.create(null);
3069
3271
  function splitIndexed(arrayPath, path) {
3070
- const match = path.match(/^(.*)\[(\d+)\]\.(.+)$/);
3071
- if (!match || match[1] !== arrayPath) return null;
3072
- return { index: Number(match[2]), leaf: match[3] };
3272
+ if (!path.startsWith(`${arrayPath}[`)) return null;
3273
+ const match = path.slice(arrayPath.length).match(/^\[(\d+)\]\.(.+)$/);
3274
+ return match ? { index: Number(match[1]), leaf: match[2] } : null;
3073
3275
  }
3074
3276
 
3075
3277
  // src/sites/jsx.ts
@@ -3562,22 +3764,68 @@ var firstPresent = (base, files) => [base, ...EXTENSIONS.map((ext) => `${base}${
3562
3764
  function aliasesFrom(sources) {
3563
3765
  const out = /* @__PURE__ */ new Map();
3564
3766
  for (const source of sources) {
3565
- if (!source.path.endsWith("tsconfig.json") && !source.path.endsWith("jsconfig.json")) continue;
3566
- try {
3567
- const parsed = JSON.parse(source.content.replace(/^\s*\/\/.*$/gm, ""));
3568
- const base = parsed.compilerOptions?.baseUrl?.replace(/^\.\/?/, "").replace(/\/$/, "") ?? "";
3569
- const root = source.path.split("/").slice(0, -1).join("/");
3570
- for (const [pattern, targets] of Object.entries(parsed.compilerOptions?.paths ?? {})) {
3571
- out.set(
3572
- pattern,
3573
- targets.map((target) => [root, base, target.replace(/^\.\/?/, "")].filter(Boolean).join("/"))
3574
- );
3575
- }
3576
- } catch {
3767
+ if (!isProjectConfig(source.path)) continue;
3768
+ const parsed = readJsonc(source.content);
3769
+ if (parsed === null) continue;
3770
+ const base = parsed.compilerOptions?.baseUrl?.replace(/^\.\/?/, "").replace(/\/$/, "") ?? "";
3771
+ const root = source.path.split("/").slice(0, -1).join("/");
3772
+ for (const [pattern, targets] of Object.entries(parsed.compilerOptions?.paths ?? {})) {
3773
+ out.set(
3774
+ pattern,
3775
+ targets.map((target) => [root, base, target.replace(/^\.\/?/, "")].filter(Boolean).join("/"))
3776
+ );
3577
3777
  }
3578
3778
  }
3579
3779
  return out;
3580
3780
  }
3781
+ var isProjectConfig = (path) => path.endsWith("tsconfig.json") || path.endsWith("jsconfig.json");
3782
+ function readJsonc(content) {
3783
+ let out = "";
3784
+ let at = 0;
3785
+ while (at < content.length) {
3786
+ const char = content[at];
3787
+ if (char === '"') {
3788
+ const start = at++;
3789
+ while (at < content.length && content[at] !== '"') at += content[at] === "\\" ? 2 : 1;
3790
+ out += content.slice(start, Math.min(at + 1, content.length));
3791
+ at++;
3792
+ continue;
3793
+ }
3794
+ if (char === "/" && content[at + 1] === "/") {
3795
+ while (at < content.length && content[at] !== "\n") at++;
3796
+ continue;
3797
+ }
3798
+ if (char === "/" && content[at + 1] === "*") {
3799
+ const close = content.indexOf("*/", at + 2);
3800
+ at = close < 0 ? content.length : close + 2;
3801
+ continue;
3802
+ }
3803
+ out += char;
3804
+ at++;
3805
+ }
3806
+ try {
3807
+ const parsed = JSON.parse(out.replace(/,(?=\s*[}\]])/g, ""));
3808
+ return isAliasShaped(parsed) ? parsed : null;
3809
+ } catch {
3810
+ return null;
3811
+ }
3812
+ }
3813
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3814
+ function isAliasShaped(value) {
3815
+ if (!isRecord(value)) return false;
3816
+ const options = value.compilerOptions;
3817
+ if (options === void 0) return true;
3818
+ if (!isRecord(options)) return false;
3819
+ if (options.baseUrl !== void 0 && typeof options.baseUrl !== "string") return false;
3820
+ if (options.paths === void 0) return true;
3821
+ if (!isRecord(options.paths)) return false;
3822
+ return Object.values(options.paths).every(
3823
+ (targets) => Array.isArray(targets) && targets.every((target) => typeof target === "string")
3824
+ );
3825
+ }
3826
+ function unreadableConfigs(sources) {
3827
+ return sources.filter((source) => isProjectConfig(source.path) && readJsonc(source.content) === null).map((source) => source.path);
3828
+ }
3581
3829
  function expand(specifier, aliases) {
3582
3830
  const out = [];
3583
3831
  for (const [pattern, targets] of aliases) {
@@ -3746,70 +3994,6 @@ function typeMember(content, literal) {
3746
3994
  return { start: at, end: at, text: `${terminated ? "" : ";"} ${text}` };
3747
3995
  }
3748
3996
 
3749
- // src/declarations.ts
3750
- var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
3751
- var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
3752
- var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
3753
- var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
3754
- var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
3755
- const [address, kind] = entry.split("|");
3756
- if (!address) return [];
3757
- const scope = address.startsWith("layout:") ? "layout" : "page";
3758
- return [{ scope, path: stripLayoutPrefix(address), kind: kind ?? "", at, via: "props" }];
3759
- });
3760
- var propsPaths = (value) => propsDeclarations(value, 0).map((d) => ({ scope: d.scope, path: d.path }));
3761
- var TEMPLATE_BINDING = /^:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")$/;
3762
- var shapeOfPath = (path) => path.replace(/\[(?:\d+|\$\{[^}]*\})\]/g, "[*]");
3763
- function declarationsIn(roots, content) {
3764
- const out = [];
3765
- const visit = (node) => {
3766
- if (node.type === "expr") {
3767
- for (const child of node.children) visit(child);
3768
- return;
3769
- }
3770
- if (node.type !== "element") return;
3771
- const kind = node.attrs.find((a) => a.name === "data-bcms-kind")?.value ?? "";
3772
- const at = node.attrInsertAt;
3773
- for (const attr of node.attrs) {
3774
- const name = attr.name.replace(/^:/, "");
3775
- if (name === "data-bcms-field" || name === "data-bcms-layout-field") {
3776
- const scope = name === "data-bcms-layout-field" ? "layout" : "page";
3777
- const template = content.slice(attr.range.start, attr.range.end).match(TEMPLATE_BINDING);
3778
- const templated = template?.[1] ?? template?.[2];
3779
- if (templated) out.push({ scope, path: shapeOfPath(stripLayoutPrefix(templated)), kind, at, via: "field" });
3780
- else if (attr.value) out.push({ scope, path: stripLayoutPrefix(attr.value), kind, at, via: "field" });
3781
- } else if (attr.name === "data-bcms-props") {
3782
- out.push(...propsDeclarations(attr.value, at));
3783
- } else if (attr.name === "bcmsBindings") {
3784
- for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
3785
- out.push({ scope: "page", path, kind: "", at, via: "prop" });
3786
- }
3787
- }
3788
- }
3789
- for (const child of node.children) visit(child);
3790
- };
3791
- for (const root of roots) visit(root);
3792
- return out;
3793
- }
3794
- var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
3795
- var scopedPath = (scope, path) => `${scope}::${path}`;
3796
- var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
3797
- function declaredByScan(content) {
3798
- const out = /* @__PURE__ */ new Set();
3799
- const add = (scope, path) => void out.add(scopedPath(scope, path));
3800
- for (const match of content.matchAll(BINDING_ATTR)) {
3801
- add(match[0].includes("layout-field") ? "layout" : "page", stripLayoutPrefix(match[1]));
3802
- }
3803
- for (const match of content.matchAll(TEMPLATE_SCAN)) {
3804
- add(match[0].includes("layout-field") ? "layout" : "page", shapeOfPath(stripLayoutPrefix(match[1] ?? match[2])));
3805
- }
3806
- for (const match of content.matchAll(BINDINGS_SCAN)) for (const path of bindingsPaths(match[0])) add("page", path);
3807
- for (const match of content.matchAll(PROPS_ATTR))
3808
- for (const entry of propsPaths(match[1])) add(entry.scope, entry.path);
3809
- return out;
3810
- }
3811
- var stripLayoutPrefix = (value) => value.startsWith("layout:") ? value.slice("layout:".length) : value;
3812
-
3813
3997
  // src/converted.ts
3814
3998
  var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3815
3999
  function splitArrayPath(path) {
@@ -4643,7 +4827,7 @@ var ValidationError = class extends Error {
4643
4827
  }
4644
4828
  code;
4645
4829
  };
4646
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4830
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4647
4831
  var Check = class {
4648
4832
  constructor(code) {
4649
4833
  this.code = code;
@@ -4653,7 +4837,7 @@ var Check = class {
4653
4837
  throw new ValidationError(this.code, `${where} ${why}.`);
4654
4838
  }
4655
4839
  record(value, where) {
4656
- if (!isRecord(value)) this.fail(where, "is not an object");
4840
+ if (!isRecord2(value)) this.fail(where, "is not an object");
4657
4841
  return value;
4658
4842
  }
4659
4843
  array(value, where) {
@@ -4720,6 +4904,7 @@ function readPlan(value) {
4720
4904
  const section = check.record(held, at2);
4721
4905
  check.segment(section.groupKey, `${at2}.groupKey`);
4722
4906
  check.optionalString(section.sectionType, `${at2}.sectionType`);
4907
+ check.optionalString(section.copy, `${at2}.copy`);
4723
4908
  if (section.fields !== void 0) {
4724
4909
  for (const [f, field] of check.array(section.fields, `${at2}.fields`).entries()) {
4725
4910
  const shape = check.record(field, `${at2}.fields[${f}]`);
@@ -5101,30 +5286,84 @@ var SectionInvariantError = class extends Error {
5101
5286
  }
5102
5287
  };
5103
5288
  function assertSections(total, sections) {
5104
- const sum = sections.extracted + sections.inlineOnly + sections.alreadyExtracted + sections.pending.length;
5289
+ const sum = sections.extracted + sections.inlineOnly + sections.alreadyExtracted + sections.registered + sections.pending.length;
5105
5290
  if (sum !== total) {
5106
5291
  throw new SectionInvariantError(
5107
- `The receipt does not account for every section: ${total} planned but ${sections.extracted} extracted + ${sections.inlineOnly} inline-only + ${sections.alreadyExtracted} already extracted + ${sections.pending.length} pending = ${sum}.`
5292
+ `The receipt does not account for every section: ${total} planned but ${sections.extracted} extracted + ${sections.inlineOnly} inline-only + ${sections.alreadyExtracted} already extracted + ${sections.registered} registered + ${sections.pending.length} pending = ${sum}.`
5108
5293
  );
5109
5294
  }
5110
5295
  return sections;
5111
5296
  }
5112
- var SECTION_MARKER = "// @bettercms-ai/convert section v1";
5297
+ var SECTION_MARKER = "// @bettercms-ai/convert section v2";
5298
+ var SECTION_MARKERS = [SECTION_MARKER, "// @bettercms-ai/convert section v1"];
5113
5299
  var REGISTRY_MARKER = "// @bettercms-ai/convert sections v1";
5114
5300
  var COMPONENT_DIR = "src/components/bcms";
5115
5301
  var SECTIONS_LIB = "src/lib/bcms-sections.ts";
5116
5302
  var EXTRACTABLE = ["astro", "jsx"];
5303
+ var registryValue = (entry) => entry.member ? `${entry.name}.${entry.member}` : entry.name;
5304
+ function importLine(name, specifier, entry) {
5305
+ if (entry.kind === "namespace") return `import * as ${name} from "${specifier}";`;
5306
+ if (entry.kind === "named") {
5307
+ const exported = entry.imported ?? name;
5308
+ return `import { ${exported === name ? name : `${exported} as ${name}`} } from "${specifier}";`;
5309
+ }
5310
+ return `import ${name} from "${specifier}";`;
5311
+ }
5117
5312
  async function componentizeSources(briefIn, planIn, sources, options) {
5118
5313
  const pending = [];
5119
5314
  let extracted = 0;
5120
5315
  let inlineOnly = 0;
5121
5316
  let alreadyExtracted = 0;
5317
+ let registered = 0;
5122
5318
  let planPending = 0;
5123
5319
  let total = 0;
5320
+ const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5124
5321
  const brief = readBrief(briefIn);
5125
5322
  const plan2 = readPlan(planIn);
5323
+ const copy = options?.copy ?? "bind";
5324
+ const instanceOwned = (section) => section.copy === "instance";
5325
+ const notes = unreadableConfigs(sources).map(
5326
+ (path) => ({
5327
+ code: "TSCONFIG_UNREADABLE",
5328
+ file: path,
5329
+ message: `${path} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
5330
+ })
5331
+ );
5332
+ const refuseRun = (reason, message) => {
5333
+ for (const page of plan2.pages ?? []) {
5334
+ for (const section of page.sections ?? []) {
5335
+ if (section.pending) {
5336
+ planPending++;
5337
+ continue;
5338
+ }
5339
+ total++;
5340
+ pend(page.route, section.groupKey, reason, message);
5341
+ }
5342
+ }
5343
+ return {
5344
+ files: [],
5345
+ receipt: {
5346
+ planDigest: plan2.digest ?? "",
5347
+ copy,
5348
+ sections: assertSections(total, { extracted: 0, inlineOnly: 0, alreadyExtracted: 0, registered: 0, pending }),
5349
+ components: [],
5350
+ planPending,
5351
+ notes
5352
+ }
5353
+ };
5354
+ };
5355
+ const movesCopy = (plan2.pages ?? []).some(
5356
+ (page) => (page.sections ?? []).some((section) => !section.pending && instanceOwned(section))
5357
+ );
5358
+ if (copy === "instance" && !movesCopy) {
5359
+ return refuseRun(
5360
+ "COPY_MODE_MISMATCH",
5361
+ '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.'
5362
+ );
5363
+ }
5126
5364
  const contents = new Map(sources.map((s) => [s.path, s.content]));
5127
- const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5365
+ const fileSet = new Set(contents.keys());
5366
+ const aliases = aliasesFrom(sources);
5128
5367
  const trees = /* @__PURE__ */ new Map();
5129
5368
  const treeOf = async (file) => {
5130
5369
  if (trees.has(file)) return trees.get(file);
@@ -5138,7 +5377,9 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5138
5377
  const helperPath = HELPER_PATH.astro;
5139
5378
  const helperExisting = contents.get(helperPath);
5140
5379
  const helperVersion = helperExisting === void 0 ? null : isKnownHelper(helperExisting, "astro");
5141
- const helperBlocked = helperExisting !== void 0 && helperVersion === null && options?.overwriteHelper !== true;
5380
+ if (helperExisting !== void 0 && helperVersion === null && options?.overwriteHelper !== true) {
5381
+ return refuseRun("HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5382
+ }
5142
5383
  const outFiles = /* @__PURE__ */ new Map();
5143
5384
  const written = /* @__PURE__ */ new Map();
5144
5385
  const shipped = /* @__PURE__ */ new Map();
@@ -5148,7 +5389,31 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5148
5389
  return held;
5149
5390
  };
5150
5391
  const fresh = /* @__PURE__ */ new Set();
5392
+ const bindOf = (section) => instanceOwned(section) ? void 0 : section.groupKey;
5393
+ const placementOf = (slug, section) => {
5394
+ const bind = bindOf(section);
5395
+ return bind === void 0 ? { slug } : { slug, bind };
5396
+ };
5397
+ const rebind = (dialect, pageSlug, all, usable) => {
5398
+ if (dialect === null || !EXTRACTABLE.includes(dialect)) return;
5399
+ const order = shipped.get(dialect);
5400
+ const held = order?.get(pageSlug);
5401
+ if (!order || !held) return;
5402
+ const queues = /* @__PURE__ */ new Map();
5403
+ for (const section of all) {
5404
+ const slug = resolveComponent(plan2, section).slug;
5405
+ queues.set(slug, [...queues.get(slug) ?? [], section]);
5406
+ }
5407
+ order.set(
5408
+ pageSlug,
5409
+ held.map((placement) => {
5410
+ const section = queues.get(placement.slug)?.shift();
5411
+ return section && usable.has(section) ? placementOf(placement.slug, section) : placement;
5412
+ })
5413
+ );
5414
+ };
5151
5415
  const registryConflicts = [];
5416
+ const registryUnreadable = [];
5152
5417
  for (const dialect of EXTRACTABLE) {
5153
5418
  const path = `${COMPONENT_DIR}/Sections.${dialect === "astro" ? "astro" : "tsx"}`;
5154
5419
  const existing = contents.get(path);
@@ -5157,7 +5422,11 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5157
5422
  registryConflicts.push(path);
5158
5423
  continue;
5159
5424
  }
5160
- const held = readRegistry(existing, dialect);
5425
+ const held = await readRegistry(existing, dialect, path, fileSet, aliases);
5426
+ if (held === null) {
5427
+ registryUnreadable.push(path);
5428
+ continue;
5429
+ }
5161
5430
  componentsOf(dialect);
5162
5431
  for (const [slug, component] of held.components) written.get(dialect).set(slug, component);
5163
5432
  const order = shipped.get(dialect) ?? /* @__PURE__ */ new Map();
@@ -5167,6 +5436,12 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5167
5436
  const lib = contents.get(SECTIONS_LIB);
5168
5437
  if (lib !== void 0 && !isGenerated(lib)) registryConflicts.push(SECTIONS_LIB);
5169
5438
  const registryBlocked = registryConflicts.length > 0 && options?.overwriteHelper !== true;
5439
+ const registryUnread = registryUnreadable.length > 0;
5440
+ const migrated = registryBlocked || registryUnread ? { files: [], refused: /* @__PURE__ */ new Map() } : await migrateSections(plan2, contents, written, fileSet, aliases);
5441
+ for (const file of migrated.files) {
5442
+ addFile(outFiles, contents, file);
5443
+ contents.set(file.path, file.content);
5444
+ }
5170
5445
  for (const page of plan2.pages ?? []) {
5171
5446
  const sections2 = (page.sections ?? []).filter((section) => {
5172
5447
  if (section.pending) {
@@ -5177,46 +5452,81 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5177
5452
  });
5178
5453
  total += sections2.length;
5179
5454
  if (sections2.length === 0) continue;
5455
+ const prefixes = /* @__PURE__ */ new Map();
5456
+ for (const section of sections2) {
5457
+ const prefix = propPrefix(section);
5458
+ if (prefix === null) {
5459
+ pend(
5460
+ page.route,
5461
+ section.groupKey,
5462
+ "PROP_MAP_INCONSISTENT",
5463
+ "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."
5464
+ );
5465
+ continue;
5466
+ }
5467
+ prefixes.set(section, prefix);
5468
+ }
5469
+ const usable = sections2.filter((section) => prefixes.has(section));
5470
+ if (usable.length === 0) continue;
5471
+ const pendingBefore = pending.length;
5180
5472
  const briefPage = brief.pages.find((p) => p.route === page.route);
5181
5473
  const pageSlug = briefPage?.slug ?? slugify(page.route === "/" ? "home" : page.route);
5182
- if (helperBlocked) {
5183
- for (const section of sections2) {
5184
- pend(page.route, section.groupKey, "HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5474
+ if (registryBlocked) {
5475
+ for (const section of usable) {
5476
+ pend(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5185
5477
  }
5186
5478
  continue;
5187
5479
  }
5188
- if (registryBlocked) {
5189
- for (const section of sections2) {
5190
- pend(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5480
+ if (registryUnread) {
5481
+ for (const section of usable) {
5482
+ pend(
5483
+ page.route,
5484
+ section.groupKey,
5485
+ "REGISTRY_UNREADABLE",
5486
+ `${registryUnreadable.join(", ")} carries our marker but its REGISTRY/SHIPPED tables cannot be read back \u2014 rebuilding it would drop the components it names.`
5487
+ );
5191
5488
  }
5192
5489
  continue;
5193
5490
  }
5194
5491
  const snapshotPath = `${CONTENT_DIR}/${pageSlug}.json`;
5195
5492
  if (!snapshotUsable(contents.get(snapshotPath))) {
5196
- for (const section of sections2) {
5493
+ for (const section of usable) {
5197
5494
  pend(page.route, section.groupKey, "SNAPSHOT_INVALID", `${snapshotPath} is not a JSON object.`);
5198
5495
  }
5199
5496
  continue;
5200
5497
  }
5201
5498
  const pageFiles = pageFilesFor(page.route, contents.keys());
5202
5499
  if (pageFiles.length > 1) {
5203
- for (const section of sections2) {
5500
+ for (const section of usable) {
5204
5501
  pend(page.route, section.groupKey, "ROUTE_FILE_AMBIGUOUS", `${pageFiles.join(" and ")} both serve ${page.route}.`);
5205
5502
  }
5206
5503
  continue;
5207
5504
  }
5208
5505
  const file = pageFiles[0];
5209
5506
  if (file === void 0) {
5210
- for (const section of sections2) {
5507
+ for (const section of usable) {
5211
5508
  pend(page.route, section.groupKey, "NOT_IN_SOURCE", `no page file found for ${page.route}.`);
5212
5509
  }
5213
5510
  continue;
5214
5511
  }
5215
- const done = new Set(alreadyOn(contents.get(file) ?? "", pageSlug, sections2));
5512
+ const pageDialect = dialectOf(file, contents.get(file) ?? "");
5513
+ const done = new Set(alreadyOn(contents.get(file) ?? "", pageSlug, usable));
5514
+ let stuck = 0;
5515
+ for (const section of usable) {
5516
+ if (!done.has(section.groupKey)) continue;
5517
+ const why = pageDialect ? migrated.refused.get(refusalKey(pageDialect, resolveComponent(plan2, section).slug)) : void 0;
5518
+ if (why === void 0) continue;
5519
+ done.delete(section.groupKey);
5520
+ stuck++;
5521
+ pend(page.route, section.groupKey, "SECTION_UNMIGRATABLE", why);
5522
+ }
5216
5523
  alreadyExtracted += done.size;
5217
- if (done.size === sections2.length) continue;
5524
+ if (done.size + stuck === usable.length) {
5525
+ rebind(pageDialect, pageSlug, page.sections ?? [], new Set(usable));
5526
+ continue;
5527
+ }
5218
5528
  const found = await locateSections(
5219
- sections2.filter((section) => !done.has(section.groupKey)),
5529
+ usable.filter((section) => !done.has(section.groupKey)),
5220
5530
  briefPage?.paths ?? [],
5221
5531
  sources,
5222
5532
  treeOf
@@ -5226,37 +5536,115 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5226
5536
  }
5227
5537
  const live = found.placed;
5228
5538
  if (live.length === 0) continue;
5539
+ const content = contents.get(file);
5540
+ const dialect = dialectOf(file, content);
5541
+ if (!dialect || !EXTRACTABLE.includes(dialect)) {
5542
+ for (const placed of live) {
5543
+ pend(page.route, placed.section.groupKey, "DIALECT_UNSUPPORTED", `${file} is ${dialect ?? "not a template"}.`);
5544
+ }
5545
+ continue;
5546
+ }
5547
+ const tree = await treeOf(file);
5548
+ if (!tree) {
5549
+ for (const placed of live) {
5550
+ pend(page.route, placed.section.groupKey, "NOT_IN_SOURCE", `${file} did not parse.`);
5551
+ }
5552
+ continue;
5553
+ }
5554
+ const parsed = tree.parsed;
5555
+ const importedFiles = /* @__PURE__ */ new Map();
5556
+ for (const [local, from] of importsIn(parsed, dialect)) {
5557
+ const resolved = resolveSpecifier(file, from.specifier, fileSet, aliases);
5558
+ if (resolved === null) continue;
5559
+ importedFiles.set(resolved, [
5560
+ ...importedFiles.get(resolved) ?? [],
5561
+ { local, kind: from.kind, imported: from.imported }
5562
+ ]);
5563
+ }
5564
+ const bindingOf = (held) => {
5565
+ const bindings = importedFiles.get(held) ?? [];
5566
+ return bindings.find((binding) => calledIn(tree, binding)) ?? bindings[0];
5567
+ };
5229
5568
  const placedHere = [];
5569
+ const importedSections = [];
5230
5570
  for (const placed of live) {
5231
5571
  const elements = placed.byFile.get(file);
5572
+ const alsoRendered = [...placed.byFile.keys()].filter((held2) => held2 !== file && importedFiles.has(held2));
5232
5573
  if (elements && elements.length > 0) {
5574
+ if (alsoRendered.length > 0) {
5575
+ pend(
5576
+ page.route,
5577
+ placed.section.groupKey,
5578
+ "SECTION_ROOT_AMBIGUOUS",
5579
+ `rendered inline by ${file} AND by ${alsoRendered.join(", ")}, which it imports.`
5580
+ );
5581
+ continue;
5582
+ }
5233
5583
  placedHere.push({ section: placed.section, elements });
5234
5584
  continue;
5235
5585
  }
5586
+ if (alsoRendered.length > 1) {
5587
+ pend(
5588
+ page.route,
5589
+ placed.section.groupKey,
5590
+ "SECTION_ROOT_AMBIGUOUS",
5591
+ `rendered by ${alsoRendered.join(" and ")}, both imported by ${file}.`
5592
+ );
5593
+ continue;
5594
+ }
5595
+ const only = alsoRendered[0] ?? [...placed.byFile.keys()][0];
5596
+ const held = bindingOf(only);
5597
+ if (held !== void 0 && dialectOf(only, contents.get(only) ?? "") === dialect) {
5598
+ importedSections.push({ section: placed.section, path: only, call: held.local });
5599
+ continue;
5600
+ }
5236
5601
  pend(
5237
5602
  page.route,
5238
5603
  placed.section.groupKey,
5239
5604
  "SECTION_ROOT_AMBIGUOUS",
5240
- `rendered by ${[...placed.byFile.keys()].join(", ")}, not ${file}.`
5605
+ held === void 0 ? `rendered by ${[...placed.byFile.keys()].join(", ")}, which ${file} does not import.` : `rendered by ${only}, which is ${dialectOf(only, contents.get(only) ?? "") ?? "not a template"}, not ${dialect} like ${file}.`
5241
5606
  );
5242
5607
  }
5243
- if (placedHere.length === 0) continue;
5244
- const content = contents.get(file);
5245
- const dialect = dialectOf(file, content);
5246
- if (!dialect || !EXTRACTABLE.includes(dialect)) {
5247
- for (const placed of placedHere) {
5248
- pend(page.route, placed.section.groupKey, "DIALECT_UNSUPPORTED", `${file} is ${dialect ?? "not a template"}.`);
5608
+ if (placedHere.length === 0 && importedSections.length === 0) continue;
5609
+ const extractable = componentsOf(dialect);
5610
+ const registeredHere = [];
5611
+ for (const held of importedSections) {
5612
+ const component = resolveComponent(plan2, held.section);
5613
+ const already = extractable.get(component.slug);
5614
+ if (already !== void 0 && already.path !== held.path) {
5615
+ pend(
5616
+ page.route,
5617
+ held.section.groupKey,
5618
+ "COMPONENT_CONFLICT",
5619
+ `the slug "${component.slug}" already names ${already.path}, not ${held.path}.`
5620
+ );
5621
+ continue;
5249
5622
  }
5250
- continue;
5251
- }
5252
- const tree = await treeOf(file);
5253
- if (!tree) {
5254
- for (const placed of placedHere) {
5255
- pend(page.route, placed.section.groupKey, "NOT_IN_SOURCE", `${file} did not parse.`);
5623
+ const binding = bindingOf(held.path);
5624
+ const member = binding.kind === "namespace" ? namespaceMember(tree, binding.local) : void 0;
5625
+ if (binding.kind === "namespace" && member === void 0) {
5626
+ pend(
5627
+ page.route,
5628
+ held.section.groupKey,
5629
+ "SECTION_ROOT_AMBIGUOUS",
5630
+ `${held.path} is imported as a namespace and ${file} never calls one of its components, so nothing names the export that renders it.`
5631
+ );
5632
+ continue;
5256
5633
  }
5257
- continue;
5634
+ extractable.set(component.slug, {
5635
+ name: component.name,
5636
+ path: held.path,
5637
+ kind: binding.kind,
5638
+ imported: binding.imported,
5639
+ ...member ? { member } : {}
5640
+ });
5641
+ registered++;
5642
+ registeredHere.push({
5643
+ slug: component.slug,
5644
+ section: held.section,
5645
+ call: callSiteOf(tree, member ? `${binding.local}.${member}` : held.call)
5646
+ });
5258
5647
  }
5259
- const parsed = tree.parsed;
5260
5648
  const roots = [];
5261
5649
  for (const placed of placedHere) {
5262
5650
  const root = lowestCommonAncestor(placed.elements);
@@ -5265,7 +5653,13 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5265
5653
  continue;
5266
5654
  }
5267
5655
  const component = resolveComponent(plan2, placed.section);
5268
- roots.push({ section: placed.section, route: page.route, root, ...component });
5656
+ roots.push({
5657
+ section: placed.section,
5658
+ route: page.route,
5659
+ root,
5660
+ pathPrefix: prefixes.get(placed.section),
5661
+ ...component
5662
+ });
5269
5663
  }
5270
5664
  const nested = /* @__PURE__ */ new Set();
5271
5665
  for (const a of roots) {
@@ -5280,8 +5674,6 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5280
5674
  pend(page.route, resolved.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its root contains another section's.");
5281
5675
  }
5282
5676
  const candidates = roots.filter((r) => !nested.has(r)).sort((a, b) => a.root.start - b.root.start);
5283
- if (candidates.length === 0) continue;
5284
- const extractable = componentsOf(dialect);
5285
5677
  const placeable = [];
5286
5678
  for (const resolved of candidates) {
5287
5679
  const path = `${COMPONENT_DIR}/${resolved.name}.${dialect === "astro" ? "astro" : "tsx"}`;
@@ -5294,7 +5686,25 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5294
5686
  placeable.push(resolved);
5295
5687
  continue;
5296
5688
  }
5297
- const built = componentFile(dialect, path, file, content, parsed, resolved);
5689
+ const built = componentFile(dialect, path, file, content, parsed, resolved, resolved.pathPrefix);
5690
+ if (built.unconvertible.length > 0) {
5691
+ pend(
5692
+ page.route,
5693
+ resolved.section.groupKey,
5694
+ "PROP_MAP_INCONSISTENT",
5695
+ `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.`
5696
+ );
5697
+ continue;
5698
+ }
5699
+ if (built.duplicates.length > 0) {
5700
+ pend(
5701
+ page.route,
5702
+ resolved.section.groupKey,
5703
+ "DUPLICATE_FIELD_BINDING",
5704
+ `${built.duplicates.join(", ")} carries more than one data-bcms-field, so there is no single address to rewrite.`
5705
+ );
5706
+ continue;
5707
+ }
5298
5708
  if (built.free.length > 0) {
5299
5709
  pend(
5300
5710
  page.route,
@@ -5304,7 +5714,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5304
5714
  );
5305
5715
  continue;
5306
5716
  }
5307
- extractable.set(resolved.slug, { name: resolved.name, path });
5717
+ extractable.set(resolved.slug, { name: resolved.name, path, kind: "default", imported: null });
5308
5718
  fresh.add(resolved.slug);
5309
5719
  addFile(outFiles, contents, {
5310
5720
  path,
@@ -5313,24 +5723,54 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5313
5723
  });
5314
5724
  placeable.push(resolved);
5315
5725
  }
5316
- if (placeable.length === 0) continue;
5317
- const layout = runOf(placeable, parsed.roots);
5726
+ if (placeable.length === 0 && registeredHere.length === 0) continue;
5727
+ const spans = [
5728
+ ...placeable.map((r) => ({ slug: r.slug, bind: bindOf(r.section), root: r.root })),
5729
+ ...registeredHere.map((r) => ({ slug: r.slug, bind: bindOf(r.section), root: r.call }))
5730
+ ].sort((a, b) => (a.root?.start ?? Number.MAX_SAFE_INTEGER) - (b.root?.start ?? Number.MAX_SAFE_INTEGER));
5731
+ const known = spans.filter((s) => s.root !== null);
5732
+ const layout = runOf(known, parsed.roots, content);
5318
5733
  if (layout === null) {
5319
5734
  for (const resolved of placeable) {
5320
5735
  pend(page.route, resolved.section.groupKey, "SECTION_NOT_CONTIGUOUS", "its roots share no container.");
5321
5736
  }
5322
5737
  continue;
5323
5738
  }
5739
+ const wholePage = registeredHere.length === 0 || known.length === spans.length && pending.length === pendingBefore;
5740
+ const useRun = layout.contiguous && wholePage;
5324
5741
  const order = shipped.get(dialect) ?? /* @__PURE__ */ new Map();
5325
5742
  shipped.set(dialect, order);
5326
5743
  order.set(
5327
5744
  pageSlug,
5328
- placeable.map((r) => ({ slug: r.slug, bind: r.section.groupKey }))
5745
+ spans.map((s) => s.bind === void 0 ? { slug: s.slug } : { slug: s.slug, bind: s.bind })
5329
5746
  );
5330
- const rewritten = layout.contiguous ? splicePageRun(file, content, dialect, parsed, placeable, pageSlug) : splicePageInline(file, content, dialect, parsed, placeable, pageSlug, extractable);
5331
- addFile(outFiles, contents, { path: file, operation: "modify", content: rewritten });
5332
- if (layout.contiguous) extracted += placeable.length;
5333
- else inlineOnly += placeable.length;
5747
+ if (useRun) {
5748
+ addFile(outFiles, contents, {
5749
+ path: file,
5750
+ operation: "modify",
5751
+ content: splicePageRun(file, content, dialect, parsed, known, pageSlug)
5752
+ });
5753
+ extracted += placeable.length;
5754
+ } else {
5755
+ const inline = placeable.filter((resolved) => !instanceOwned(resolved.section));
5756
+ for (const resolved of placeable) {
5757
+ if (inline.includes(resolved)) continue;
5758
+ pend(
5759
+ page.route,
5760
+ resolved.section.groupKey,
5761
+ "INLINE_NEEDS_INSTANCE",
5762
+ `${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.`
5763
+ );
5764
+ }
5765
+ if (inline.length > 0) {
5766
+ addFile(outFiles, contents, {
5767
+ path: file,
5768
+ operation: "modify",
5769
+ content: splicePageInline(file, content, dialect, parsed, inline, pageSlug, extractable)
5770
+ });
5771
+ }
5772
+ inlineOnly += inline.length;
5773
+ }
5334
5774
  }
5335
5775
  const componentSlugs = [...written.values()].flatMap((held) => [...held.keys()]);
5336
5776
  if (componentSlugs.length > 0) {
@@ -5352,15 +5792,17 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5352
5792
  emitHelper(outFiles, contents, helperVersion, options?.overwriteHelper);
5353
5793
  emitStubs(outFiles, contents, pageSlugs);
5354
5794
  }
5355
- const sections = assertSections(total, { extracted, inlineOnly, alreadyExtracted, pending });
5795
+ const sections = assertSections(total, { extracted, inlineOnly, alreadyExtracted, registered, pending });
5356
5796
  return {
5357
5797
  files: [...outFiles.values()].sort((a, b) => a.path.localeCompare(b.path)),
5358
5798
  receipt: {
5359
5799
  planDigest: plan2.digest ?? "",
5800
+ copy,
5360
5801
  sections,
5361
5802
  // What THIS run wrote a file for. A component an earlier run left is reused, not re-reported.
5362
5803
  components: [...fresh].sort(),
5363
- planPending
5804
+ planPending,
5805
+ notes
5364
5806
  }
5365
5807
  };
5366
5808
  }
@@ -5373,29 +5815,152 @@ function alreadyOn(content, pageSlug, sections) {
5373
5815
  }
5374
5816
  return sections.map((section) => section.groupKey).filter((groupKey) => new RegExp(`bind=\\s*["']${groupKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content));
5375
5817
  }
5376
- function readRegistry(content, dialect) {
5818
+ async function readRegistry(content, dialect, path, files, aliases) {
5377
5819
  const components = /* @__PURE__ */ new Map();
5378
5820
  const shipped = /* @__PURE__ */ new Map();
5379
- const paths = /* @__PURE__ */ new Map();
5380
- for (const match of content.matchAll(/^import\s+(\w+)\s+from\s+"\.\/([^"]+)";$/gm)) {
5381
- const [, name, file] = match;
5382
- paths.set(name, `${COMPONENT_DIR}/${file}${dialect === "astro" ? "" : ".tsx"}`);
5383
- }
5384
- const registry = content.match(/const REGISTRY[^=]*=\s*\{([\s\S]*?)\n\};/);
5385
- for (const match of (registry?.[1] ?? "").matchAll(/^\s*"([^"]+)":\s*(\w+),$/gm)) {
5386
- const [, slug, name] = match;
5387
- const path = paths.get(name);
5388
- if (path) components.set(slug, { name, path });
5389
- }
5390
- const table = content.match(/const SHIPPED[^=]*=\s*\{([\s\S]*?)\n\};/);
5391
- for (const match of (table?.[1] ?? "").matchAll(/^\s*"([^"]+)":\s*(\[.*\]),$/gm)) {
5821
+ const parsed = await parseFile4(dialect, content);
5822
+ if (parsed.error) return null;
5823
+ const imports = /* @__PURE__ */ new Map();
5824
+ for (const [local, from] of importsIn(parsed, dialect)) {
5825
+ if (!from.specifier.startsWith(".")) continue;
5826
+ const resolved = resolveSpecifier(path, from.specifier, files, aliases);
5827
+ if (resolved !== null) {
5828
+ imports.set(local, { name: local, path: preferDialect(resolved, dialect, files), kind: from.kind, imported: from.imported });
5829
+ }
5830
+ }
5831
+ const registry = blockOf(content, "REGISTRY");
5832
+ const table = blockOf(content, "SHIPPED");
5833
+ if (registry === null || table === null) return null;
5834
+ const registryRows = propertiesOf(registry);
5835
+ const shippedRows = propertiesOf(table);
5836
+ if (registryRows === null || shippedRows === null) return null;
5837
+ if (registryRows.length === 0) return null;
5838
+ for (const row of registryRows) {
5839
+ const named = row.value.match(/^([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?$/);
5840
+ const entry = named ? imports.get(named[1]) : void 0;
5841
+ if (!named || !entry) return null;
5842
+ components.set(row.key, named[2] === void 0 ? entry : { ...entry, member: named[2] });
5843
+ }
5844
+ for (const row of shippedRows) {
5392
5845
  try {
5393
- shipped.set(match[1], JSON.parse(match[2]));
5846
+ const placements = JSON.parse(row.value);
5847
+ if (!Array.isArray(placements) || placements.length === 0 || !placements.every(isPlacement)) return null;
5848
+ if (placements.some((placement) => !components.has(placement.slug))) return null;
5849
+ shipped.set(row.key, placements);
5394
5850
  } catch {
5851
+ return null;
5395
5852
  }
5396
5853
  }
5397
5854
  return { components, shipped };
5398
5855
  }
5856
+ function preferDialect(resolved, dialect, files) {
5857
+ const wanted = dialect === "astro" ? [".astro"] : [".tsx", ".jsx"];
5858
+ if (wanted.some((extension) => resolved.endsWith(extension))) return resolved;
5859
+ const base = resolved.replace(/\.[^./]+$/, "");
5860
+ return wanted.map((extension) => `${base}${extension}`).find((candidate) => files.has(candidate)) ?? resolved;
5861
+ }
5862
+ function isPlacement(value) {
5863
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
5864
+ const held = value;
5865
+ if (typeof held.slug !== "string") return false;
5866
+ return ["bind", "blockId"].every((key) => held[key] === void 0 || typeof held[key] === "string");
5867
+ }
5868
+ function blockOf(content, name) {
5869
+ const declaration2 = content.indexOf(`const ${name}`);
5870
+ if (declaration2 < 0) return null;
5871
+ const open = content.indexOf("{", declaration2);
5872
+ if (open < 0) return null;
5873
+ let depth = 0;
5874
+ let at = open;
5875
+ while (at < content.length) {
5876
+ const skip = skipTrivia(content, at);
5877
+ if (skip >= 0) {
5878
+ at = skip;
5879
+ continue;
5880
+ }
5881
+ if (content[at] === "{") depth++;
5882
+ else if (content[at] === "}" && --depth === 0) return content.slice(open + 1, at);
5883
+ at++;
5884
+ }
5885
+ return null;
5886
+ }
5887
+ function skipTrivia(source, at) {
5888
+ const char = source[at];
5889
+ if (char === '"' || char === "'" || char === "`") {
5890
+ let cursor = at + 1;
5891
+ while (cursor < source.length && source[cursor] !== char) cursor += source[cursor] === "\\" ? 2 : 1;
5892
+ return Math.min(cursor + 1, source.length);
5893
+ }
5894
+ if (char === "/" && source[at + 1] === "/") {
5895
+ const line = source.indexOf("\n", at);
5896
+ return line < 0 ? source.length : line;
5897
+ }
5898
+ if (char === "/" && source[at + 1] === "*") {
5899
+ const close = source.indexOf("*/", at + 2);
5900
+ return close < 0 ? source.length : close + 2;
5901
+ }
5902
+ return -1;
5903
+ }
5904
+ function withoutComments(text) {
5905
+ let out = "";
5906
+ let at = 0;
5907
+ while (at < text.length) {
5908
+ const skip = skipTrivia(text, at);
5909
+ if (skip < 0) {
5910
+ out += text[at];
5911
+ at++;
5912
+ continue;
5913
+ }
5914
+ out += text[at] === '"' || text[at] === "'" || text[at] === "`" ? text.slice(at, skip) : "";
5915
+ at = skip;
5916
+ }
5917
+ return out;
5918
+ }
5919
+ function propertiesOf(block) {
5920
+ const out = [];
5921
+ const push = (text) => {
5922
+ const held = text.trim();
5923
+ if (held === "") return true;
5924
+ let depth2 = 0;
5925
+ for (let at = 0; at < held.length; ) {
5926
+ const skip = skipTrivia(held, at);
5927
+ if (skip >= 0) {
5928
+ at = skip;
5929
+ continue;
5930
+ }
5931
+ const char = held[at];
5932
+ if ("{[(".includes(char)) depth2++;
5933
+ else if ("}])".includes(char)) depth2--;
5934
+ else if (char === ":" && depth2 === 0) {
5935
+ const key = withoutComments(held.slice(0, at)).trim().replace(/^["'`]|["'`]$/g, "");
5936
+ const value = withoutComments(held.slice(at + 1)).trim();
5937
+ if (key === "" || value === "") return false;
5938
+ out.push({ key, value });
5939
+ return true;
5940
+ }
5941
+ at++;
5942
+ }
5943
+ return false;
5944
+ };
5945
+ let depth = 0;
5946
+ let start = 0;
5947
+ for (let at = 0; at < block.length; ) {
5948
+ const skip = skipTrivia(block, at);
5949
+ if (skip >= 0) {
5950
+ at = skip;
5951
+ continue;
5952
+ }
5953
+ const char = block[at];
5954
+ if ("{[(".includes(char)) depth++;
5955
+ else if ("}])".includes(char)) depth--;
5956
+ else if (char === "," && depth === 0) {
5957
+ if (!push(block.slice(start, at))) return null;
5958
+ start = at + 1;
5959
+ }
5960
+ at++;
5961
+ }
5962
+ return push(block.slice(start)) ? out : null;
5963
+ }
5399
5964
  function snapshotUsable(existing) {
5400
5965
  if (existing === void 0) return true;
5401
5966
  try {
@@ -5480,7 +6045,22 @@ function lowestCommonAncestor(elements) {
5480
6045
  }
5481
6046
  return common[common.length - 1] ?? null;
5482
6047
  }
5483
- function runOf(placeable, roots) {
6048
+ var calledIn = (tree, binding) => [...tree.elements.values()].some(
6049
+ (placed) => binding.kind === "namespace" ? placed.node.tag.startsWith(`${binding.local}.`) : placed.node.tag === binding.local
6050
+ );
6051
+ function namespaceMember(tree, local) {
6052
+ const members = new Set(
6053
+ [...tree.elements.values()].map((placed) => placed.node.tag).filter((tag) => tag.startsWith(`${local}.`)).map((tag) => tag.slice(local.length + 1))
6054
+ );
6055
+ return members.size === 1 ? [...members][0] : void 0;
6056
+ }
6057
+ function callSiteOf(tree, local) {
6058
+ const calls = [...tree.elements.values()].map((placed) => placed.node).filter((node) => node.tag === local);
6059
+ const only = calls.length === 1 ? calls[0] : null;
6060
+ return only !== null && only.attrs.length === 0 ? only : null;
6061
+ }
6062
+ function runOf(placeable, roots, content) {
6063
+ if (placeable.length === 0) return null;
5484
6064
  const parents = placeable.map((r) => parentOf(r.root, roots));
5485
6065
  if (parents.some((p) => p === void 0)) return null;
5486
6066
  const [first, ...rest] = parents;
@@ -5490,9 +6070,14 @@ function runOf(placeable, roots) {
5490
6070
  );
5491
6071
  const indices = placeable.map((r) => siblings.indexOf(r.root)).sort((a, b) => a - b);
5492
6072
  if (indices.some((at) => at < 0)) return null;
5493
- const contiguous = indices.every((at, i) => i === 0 || at === indices[i - 1] + 1);
5494
- return { contiguous };
6073
+ const consecutive2 = indices.every((at, i) => i === 0 || at === indices[i - 1] + 1);
6074
+ const ordered = [...placeable].sort((a, b) => a.root.start - b.root.start);
6075
+ const clean = ordered.every(
6076
+ (span, at) => at === 0 || isTrivia(content.slice(ordered[at - 1].root.end, span.root.start))
6077
+ );
6078
+ return { contiguous: consecutive2 && clean };
5495
6079
  }
6080
+ var isTrivia = (between) => between.replace(/<!--[\s\S]*?-->/g, "").replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, "").trim() === "";
5496
6081
  function parentOf(target, roots) {
5497
6082
  if (roots.some((node) => node === target)) return null;
5498
6083
  const search = (nodes, parent) => {
@@ -5526,14 +6111,35 @@ function pascal(value) {
5526
6111
  return /^[A-Za-z]/.test(name) ? name : `Section${name}`;
5527
6112
  }
5528
6113
  var slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
5529
- function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5530
- const markup = sliceWithBlockId(content, resolved.root);
6114
+ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathPrefix) {
6115
+ const addresses = fieldAddresses(content, elementsUnder(resolved.root));
6116
+ const unconvertible = [
6117
+ ...addresses.heads.filter((address) => !address.startsWith(pathPrefix)),
6118
+ ...addresses.opaque
6119
+ ];
6120
+ if (unconvertible.length > 0) return { content: "", free: [], duplicates: [], unconvertible };
6121
+ const cut = content.slice(resolved.root.start, resolved.root.end);
6122
+ const reserved = /* @__PURE__ */ new Set([...topLevelBindings(dialect, parsed, content), ...referencedNames2(cut)]);
6123
+ const introduced = allocateNames(reserved, [
6124
+ { key: "bcmsFields", name: "bcmsFields" },
6125
+ { key: "bcmsField", name: "bcmsField" }
6126
+ ]);
6127
+ const fieldsName = introduced.get("bcmsFields");
6128
+ const fieldName = introduced.get("bcmsField");
6129
+ const { markup, bound, duplicates } = sectionMarkup(content, resolved.root, fieldName);
6130
+ if (duplicates.length > 0) return { content: "", free: [], duplicates, unconvertible: [] };
5531
6131
  const groupKey = resolved.section.groupKey;
5532
6132
  const locals = helperLocals(parsed, dialect, fromFile);
5533
6133
  const helperNames = ["bcms", "bcmsRows", "bcmsLayout"].flatMap(
5534
6134
  (name) => locals[name].filter((local) => mentions(markup, local)).map((local) => ({ name, local }))
5535
6135
  );
5536
- const helpers = [.../* @__PURE__ */ new Set([...helperNames.map((h) => alias(h.name, h.local)), "bcmsSection"])].sort();
6136
+ const helpers = [
6137
+ .../* @__PURE__ */ new Set([
6138
+ ...helperNames.map((h) => alias(h.name, h.local)),
6139
+ "bcmsSection",
6140
+ ...bound > 0 ? [alias("bcmsFields", fieldsName)] : []
6141
+ ])
6142
+ ].sort();
5537
6143
  const helperSpecifier = relativeImport(path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
5538
6144
  const snapshot = snapshotOf(parsed, dialect, fromFile, markup);
5539
6145
  const carry = carryFor({
@@ -5548,20 +6154,23 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5548
6154
  "bind",
5549
6155
  "overrides",
5550
6156
  "page",
6157
+ fieldName,
5551
6158
  ...helperNames.map((h) => h.local),
5552
6159
  ...snapshot ? [snapshot] : []
5553
6160
  ],
5554
6161
  // The helper and the snapshots are what the component declares for itself.
5555
6162
  skipSpecifier: (repoPath, specifier) => specifier.includes(`${CONTENT_DIR}/`) || repoPath.startsWith(HELPER_PATH[dialect].replace(/\.tsx?$/, ""))
5556
6163
  });
5557
- if (carry.free.length > 0) return { content: "", free: carry.free };
6164
+ if (carry.free.length > 0) return { content: "", free: carry.free, duplicates: [], unconvertible: [] };
5558
6165
  const carried = [...carry.imports];
5559
6166
  const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
5560
6167
  const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
5561
6168
  ` : "";
5562
- const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind ?? ${JSON.stringify(groupKey)}, overrides) };` : "";
6169
+ const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides) };` : "";
6170
+ const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)});` : "";
6171
+ const declared = [copy, field].filter(Boolean).join("\n");
5563
6172
  if (dialect === "astro") {
5564
- return { free: [], content: `---
6173
+ return { free: [], duplicates: [], unconvertible: [], content: `---
5565
6174
  ${SECTION_MARKER}
5566
6175
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`].join("\n")}
5567
6176
 
@@ -5576,27 +6185,297 @@ interface Props {
5576
6185
  page?: unknown;
5577
6186
  }
5578
6187
  const { blockId, bind, overrides, page } = Astro.props;
5579
- ${carriedDeclarations}${copy}
6188
+ ${carriedDeclarations}${declared}
5580
6189
  ---
5581
6190
  ${markup}
5582
6191
  ` };
5583
6192
  }
5584
- return { free: [], content: `${SECTION_MARKER}
6193
+ return { free: [], duplicates: [], unconvertible: [], content: `${SECTION_MARKER}
5585
6194
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
5586
6195
  ${carriedDeclarations === "" ? "" : `
5587
6196
  ${carriedDeclarations}`}
5588
6197
  export default function ${resolved.name}({ blockId, bind, overrides, page }: BcmsSectionProps) {
5589
- ${copy}
6198
+ ${declared.split("\n").join("\n ")}
5590
6199
  return (
5591
6200
  ${markup}
5592
6201
  );
5593
6202
  }
5594
6203
  ` };
5595
6204
  }
5596
- function sliceWithBlockId(content, root) {
6205
+ function sectionMarkup(content, root, fieldName) {
5597
6206
  const slice = new MagicString3(content.slice(root.start, root.end));
5598
- slice.appendLeft(root.attrInsertAt - root.start, " data-bcms-block={blockId}");
5599
- return slice.toString();
6207
+ const at = (offset) => offset - root.start;
6208
+ const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
6209
+ if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
6210
+ else {
6211
+ slice.overwrite(at(stamps[0].range.start), at(stamps[0].range.end), "data-bcms-block={blockId}");
6212
+ for (const extra of stamps.slice(1)) {
6213
+ let from = extra.range.start;
6214
+ while (from > root.start && /\s/.test(content[from - 1])) from--;
6215
+ slice.remove(at(from), at(extra.range.end));
6216
+ }
6217
+ }
6218
+ const wrapped = wrapFieldAttrs(slice, content, elementsUnder(root), at, fieldName);
6219
+ return { markup: slice.toString(), ...wrapped };
6220
+ }
6221
+ function wrapFieldAttrs(slice, content, elements, at, fieldName) {
6222
+ let bound = 0;
6223
+ const duplicates = [];
6224
+ for (const element of elements) {
6225
+ const fields = element.attrs.filter((attr) => attr.name === "data-bcms-field");
6226
+ if (fields.length > 1) {
6227
+ duplicates.push(`<${element.tag}>`);
6228
+ continue;
6229
+ }
6230
+ for (const attr of fields) {
6231
+ bound++;
6232
+ if (attr.expressionRange) {
6233
+ slice.appendRight(at(attr.expressionRange.start) + 1, `${fieldName}(`);
6234
+ slice.appendLeft(at(attr.expressionRange.end) - 1, ")");
6235
+ continue;
6236
+ }
6237
+ const value = attr.valueRange ? content.slice(attr.valueRange.start, attr.valueRange.end) : "";
6238
+ slice.overwrite(
6239
+ at(attr.range.start),
6240
+ at(attr.range.end),
6241
+ `data-bcms-field={${fieldName}(${JSON.stringify(value)})}`
6242
+ );
6243
+ }
6244
+ }
6245
+ return { bound, duplicates };
6246
+ }
6247
+ function provableAddress(expression) {
6248
+ const first = expression[0];
6249
+ if (first === '"' || first === "'") {
6250
+ for (let at = 1; at < expression.length; at++) {
6251
+ if (expression[at] === "\\") {
6252
+ at++;
6253
+ continue;
6254
+ }
6255
+ if (expression[at] === first) return at === expression.length - 1 ? expression.slice(1, at) : null;
6256
+ }
6257
+ return null;
6258
+ }
6259
+ if (first !== "`" || expression.length < 2 || !expression.endsWith("`")) return null;
6260
+ const body = expression.slice(1, -1);
6261
+ if (body.includes("`") || body.includes("\\")) return null;
6262
+ let head = null;
6263
+ for (let at = 0; at < body.length; at++) {
6264
+ if (body[at] !== "$" || body[at + 1] !== "{") continue;
6265
+ const close = body.indexOf("}", at + 2);
6266
+ if (close < 0) return null;
6267
+ if (!/^[A-Za-z_$][\w$]*$/.test(body.slice(at + 2, close).trim())) return null;
6268
+ if (body[at - 1] !== "[" || body[close + 1] !== "]") return null;
6269
+ head ??= body.slice(0, at);
6270
+ at = close;
6271
+ }
6272
+ return head ?? body;
6273
+ }
6274
+ function fieldAddresses(content, elements) {
6275
+ const heads = [];
6276
+ const opaque = [];
6277
+ for (const element of elements) {
6278
+ for (const attr of element.attrs) {
6279
+ if (attr.name !== "data-bcms-field") continue;
6280
+ if (!attr.expression && attr.valueRange) {
6281
+ heads.push(content.slice(attr.valueRange.start, attr.valueRange.end));
6282
+ continue;
6283
+ }
6284
+ const inner = attr.expressionRange ? content.slice(attr.expressionRange.start + 1, attr.expressionRange.end - 1).trim() : "";
6285
+ const head = provableAddress(inner);
6286
+ if (head === null) opaque.push(`data-bcms-field={${inner}}`);
6287
+ else heads.push(head);
6288
+ }
6289
+ }
6290
+ return { heads, opaque };
6291
+ }
6292
+ function elementsUnder(...roots) {
6293
+ const out = [];
6294
+ const walk = (node) => {
6295
+ if (node.type === "element") {
6296
+ out.push(node);
6297
+ for (const child of node.children) walk(child);
6298
+ return;
6299
+ }
6300
+ if (node.type === "expr") for (const child of node.children) walk(child);
6301
+ };
6302
+ for (const root of roots) walk(root);
6303
+ return out;
6304
+ }
6305
+ var V1_ANCHOR = {
6306
+ astro: "const { blockId, bind, overrides, page } = Astro.props;",
6307
+ jsx: "}: BcmsSectionProps) {"
6308
+ };
6309
+ var refusalKey = (dialect, slug) => `${dialect}\0${slug}`;
6310
+ function generatedBindings(parsed) {
6311
+ const program = parsed.ast?.program;
6312
+ const body = program?.body ?? [];
6313
+ const scopes = [body];
6314
+ for (const statement of body) {
6315
+ const declaration2 = statement.type === "ExportDefaultDeclaration" ? statement.declaration : void 0;
6316
+ const inner = declaration2?.type === "FunctionDeclaration" ? declaration2.body?.body : void 0;
6317
+ if (inner) scopes.push(inner);
6318
+ }
6319
+ const out = /* @__PURE__ */ new Map();
6320
+ for (const scope of scopes) {
6321
+ for (const statement of scope) {
6322
+ if (statement.type !== "VariableDeclaration") continue;
6323
+ for (const declarator of statement.declarations ?? []) {
6324
+ const id = declarator.id;
6325
+ const name = id?.type === "Identifier" ? id.name : void 0;
6326
+ if (name !== void 0 && !out.has(name)) out.set(name, declarator.init);
6327
+ }
6328
+ }
6329
+ }
6330
+ return out;
6331
+ }
6332
+ function stripBindFallback(parsed, source, content, locals) {
6333
+ if (!parsed.ast?.program) return "unreadable";
6334
+ const offset = parsed.script?.offset ?? 0;
6335
+ let rewritten = 0;
6336
+ let unproven = 0;
6337
+ walkAst(parsed.ast, (node) => {
6338
+ if (node.type !== "CallExpression") return;
6339
+ const argument = (node.arguments ?? [])[1];
6340
+ if (argument?.type !== "LogicalExpression" || argument.operator !== "??") return;
6341
+ const callee = node.callee;
6342
+ if (callee?.type !== "Identifier" || !locals.has(callee.name)) {
6343
+ unproven++;
6344
+ return;
6345
+ }
6346
+ const left = argument.left;
6347
+ source.overwrite(
6348
+ offset + argument.start,
6349
+ offset + argument.end,
6350
+ content.slice(offset + left.start, offset + left.end)
6351
+ );
6352
+ rewritten++;
6353
+ });
6354
+ return rewritten === 0 && unproven > 0 ? "unproven" : "ok";
6355
+ }
6356
+ var isCallOf = (init, callee) => {
6357
+ const fn = init?.type === "CallExpression" ? init.callee : void 0;
6358
+ return fn?.type === "Identifier" && fn.name === callee;
6359
+ };
6360
+ async function migrateSections(plan2, contents, written, fileSet, aliases) {
6361
+ const files = [];
6362
+ const refused = /* @__PURE__ */ new Map();
6363
+ const prefixesFor = (slug) => {
6364
+ const out = /* @__PURE__ */ new Set();
6365
+ for (const page of plan2.pages ?? []) {
6366
+ for (const section of page.sections ?? []) {
6367
+ if (resolveComponent(plan2, section).slug !== slug) continue;
6368
+ const prefix = propPrefix(section);
6369
+ if (prefix !== null) out.add(prefix);
6370
+ }
6371
+ }
6372
+ return [...out];
6373
+ };
6374
+ for (const [dialect, components] of written) {
6375
+ for (const [slug, entry] of components) {
6376
+ const content = contents.get(entry.path);
6377
+ if (content === void 0 || content.includes(SECTION_MARKER)) continue;
6378
+ const older = SECTION_MARKERS.find((marker) => content.includes(marker));
6379
+ if (older === void 0) continue;
6380
+ const candidates = prefixesFor(slug);
6381
+ if (candidates.length === 0) continue;
6382
+ const parsed = await parseFile4(dialect, content);
6383
+ if (parsed.error) {
6384
+ refused.set(refusalKey(dialect, slug), `${entry.path} carries our marker but does not parse (${parsed.error.message}).`);
6385
+ continue;
6386
+ }
6387
+ const elements = elementsUnder(...parsed.roots);
6388
+ const imports = [...importsIn(parsed, dialect)];
6389
+ const helperModule = HELPER_PATH[dialect];
6390
+ const helperLocalsFor = (exported) => new Set(
6391
+ imports.filter(
6392
+ ([, from]) => from.imported === exported && resolveSpecifier(entry.path, from.specifier, fileSet, aliases) === helperModule
6393
+ ).map(([local]) => local)
6394
+ );
6395
+ const { heads: evidence, opaque } = fieldAddresses(content, elements);
6396
+ const pathPrefix = evidence.length + opaque.length === 0 ? "" : opaque.length > 0 ? null : onlyOne(candidates.filter((prefix) => evidence.every((head) => head.startsWith(prefix)))) ?? null;
6397
+ if (pathPrefix === null) {
6398
+ const matching = candidates.filter((prefix) => evidence.every((head) => head.startsWith(prefix)));
6399
+ refused.set(
6400
+ refusalKey(dialect, slug),
6401
+ 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.`
6402
+ );
6403
+ continue;
6404
+ }
6405
+ const source = new MagicString3(content);
6406
+ const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
6407
+ if (duplicates.length > 0) {
6408
+ 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.`);
6409
+ continue;
6410
+ }
6411
+ const markerEnd = content.indexOf(older) + older.length;
6412
+ source.overwrite(markerEnd - older.length, markerEnd, SECTION_MARKER);
6413
+ const stripped = stripBindFallback(parsed, source, content, helperLocalsFor("bcmsSection"));
6414
+ if (stripped !== "ok") {
6415
+ refused.set(
6416
+ refusalKey(dialect, slug),
6417
+ 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.`
6418
+ );
6419
+ continue;
6420
+ }
6421
+ if (bound > 0) {
6422
+ const locals = generatedBindings(parsed);
6423
+ const names = /* @__PURE__ */ new Set([...topLevelBindings(dialect, parsed, content), ...locals.keys()]);
6424
+ const fieldsLocal = [...helperLocalsFor("bcmsFields")][0];
6425
+ if (fieldsLocal === void 0 && names.has("bcmsFields")) {
6426
+ refused.set(
6427
+ refusalKey(dialect, slug),
6428
+ `${entry.path} already binds "bcmsFields" to something other than ${helperModule}'s export.`
6429
+ );
6430
+ continue;
6431
+ }
6432
+ const declared = names.has("bcmsField");
6433
+ if (declared && (fieldsLocal === void 0 || !isCallOf(locals.get("bcmsField"), fieldsLocal))) {
6434
+ refused.set(
6435
+ refusalKey(dialect, slug),
6436
+ `${entry.path} already binds "bcmsField" to something other than a call of ${helperModule}'s bcmsFields.`
6437
+ );
6438
+ continue;
6439
+ }
6440
+ if (!declared) {
6441
+ const anchor = V1_ANCHOR[dialect];
6442
+ const at = anchor ? content.indexOf(anchor) : -1;
6443
+ if (anchor === void 0 || at < 0) {
6444
+ 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.`);
6445
+ continue;
6446
+ }
6447
+ source.appendLeft(
6448
+ at + anchor.length,
6449
+ `
6450
+ ${dialect === "astro" ? "" : " "}const bcmsField = ${fieldsLocal ?? "bcmsFields"}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)});`
6451
+ );
6452
+ }
6453
+ if (fieldsLocal === void 0) {
6454
+ const helper = imports.find(([, from]) => from.imported === "bcmsSection")?.[1].specifier ?? relativeImport(entry.path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
6455
+ source.appendLeft(markerEnd, `
6456
+ import { bcmsFields } from "${helper}";`);
6457
+ }
6458
+ }
6459
+ files.push({ path: entry.path, operation: "modify", content: source.toString() });
6460
+ }
6461
+ }
6462
+ return { files, refused };
6463
+ }
6464
+ function propPrefix(section) {
6465
+ const prefixes = /* @__PURE__ */ new Set();
6466
+ for (const field of section.fields ?? []) {
6467
+ const { path, key } = field;
6468
+ if (typeof path !== "string" || typeof key !== "string") continue;
6469
+ if (!path.endsWith(key)) return null;
6470
+ prefixes.add(path.slice(0, path.length - key.length));
6471
+ }
6472
+ if (prefixes.size > 1) return null;
6473
+ return prefixes.size === 1 ? [...prefixes][0] : `${section.groupKey}.`;
6474
+ }
6475
+ function referencedNames2(markup) {
6476
+ const out = /* @__PURE__ */ new Set();
6477
+ for (const [name] of markup.matchAll(/[A-Za-z_$][\w$]*/g)) out.add(name);
6478
+ return out;
5600
6479
  }
5601
6480
  var alias = (name, local) => name === local ? name : `${name} as ${local}`;
5602
6481
  var mentions = (markup, name) => new RegExp(`(?<![\\w$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w$])`).test(markup);
@@ -5712,9 +6591,9 @@ function registryFile(dialect, path, written, shipped) {
5712
6591
  const entries = [...written].sort(([a], [b]) => a.localeCompare(b));
5713
6592
  const imports = entries.map(([, component]) => {
5714
6593
  const specifier = relativeImport(path, component.path);
5715
- return `import ${component.name} from "${dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, "")}";`;
6594
+ return importLine(component.name, dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, ""), component);
5716
6595
  }).join("\n");
5717
- const registry = entries.map(([slug, component]) => ` ${JSON.stringify(slug)}: ${component.name},`).join("\n");
6596
+ const registry = entries.map(([slug, component]) => ` ${JSON.stringify(slug)}: ${registryValue(component)},`).join("\n");
5718
6597
  const shippedTable = [...shipped].sort(([a], [b]) => a.localeCompare(b)).map(([page, list]) => ` ${JSON.stringify(page)}: ${JSON.stringify(list)},`).join("\n");
5719
6598
  const lib = relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "");
5720
6599
  const registryType = dialect === "astro" ? "Record<string, unknown>" : "Record<string, (props: BcmsSectionProps) => JSX.Element | null>";
@@ -5822,17 +6701,24 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
5822
6701
  const snapshot = names.get("pageSnapshot");
5823
6702
  const source = new MagicString3(content);
5824
6703
  for (const resolved of placeable) {
6704
+ const component = written.get(resolved.slug);
6705
+ const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
5825
6706
  source.overwrite(
5826
6707
  resolved.root.start,
5827
6708
  resolved.root.end,
5828
- `<${names.get(resolved.slug)} bind=${JSON.stringify(resolved.section.groupKey)} page={${snapshot}(${JSON.stringify(pageSlug)})} />`
6709
+ `<${tag} bind=${JSON.stringify(resolved.section.groupKey)} page={${snapshot}(${JSON.stringify(pageSlug)})} />`
5829
6710
  );
5830
6711
  }
5831
6712
  const lib = relativeImport(file, SECTIONS_LIB).replace(/\.tsx?$/, "");
5832
6713
  const imports = [
5833
6714
  ...wanted.map(({ key }) => {
5834
- const specifier = relativeImport(file, written.get(key).path);
5835
- return `import ${names.get(key)} from "${dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, "")}";`;
6715
+ const component = written.get(key);
6716
+ const specifier = relativeImport(file, component.path);
6717
+ return importLine(
6718
+ names.get(key),
6719
+ dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, ""),
6720
+ component
6721
+ );
5836
6722
  }),
5837
6723
  `import { pageSnapshot${snapshot === "pageSnapshot" ? "" : ` as ${snapshot}`} } from "${lib}";`
5838
6724
  ];
@@ -5848,7 +6734,7 @@ function addImports(source, parsed, lines) {
5848
6734
  else source.appendLeft(at.at, `
5849
6735
  ${lines.join("\n")}`);
5850
6736
  }
5851
- var isGenerated = (content) => content.includes(SECTION_MARKER) || content.includes(REGISTRY_MARKER);
6737
+ var isGenerated = (content) => SECTION_MARKERS.some((marker) => content.includes(marker)) || content.includes(REGISTRY_MARKER);
5852
6738
  function emitHelper(out, contents, version, overwrite) {
5853
6739
  const path = HELPER_PATH.astro;
5854
6740
  const source = helperSource("astro");
@@ -6005,28 +6891,78 @@ async function convertSources(briefIn, sources, options) {
6005
6891
  );
6006
6892
  }
6007
6893
  let declaredOnly = 0;
6894
+ const declaredRows = [];
6895
+ const briefRoutes = new Set(brief.pages.map((page) => page.route));
6896
+ const declaredRow = (target, readsFromCms, file) => {
6897
+ declaredRows.push({
6898
+ route: target.route,
6899
+ scope: target.scope,
6900
+ path: target.path,
6901
+ kind: target.kind,
6902
+ ...file ? { file } : {},
6903
+ readsFromCms
6904
+ });
6905
+ };
6008
6906
  const witnessed = async (target) => {
6009
6907
  for (const source of sources) {
6010
6908
  const parsed = await parseOf(source.path);
6011
6909
  const dialect = dialectOf(source.path, source.content);
6012
6910
  if (!parsed || !dialect) continue;
6013
- if (convertedHere(source.content, parsed, dialect, target, source.path)) return true;
6911
+ if (convertedHere(source.content, parsed, dialect, target, source.path) && attributable(target, source.path)) {
6912
+ return source.path;
6913
+ }
6014
6914
  }
6015
- return false;
6915
+ return null;
6016
6916
  };
6017
6917
  const locatedByKey = /* @__PURE__ */ new Map();
6018
6918
  for (const hit of located) {
6019
6919
  const key = keyOf(hit.route, hit.scope ?? "page", hit.path);
6020
6920
  if (!locatedByKey.has(key)) locatedByKey.set(key, hit);
6021
6921
  }
6922
+ const sharingPath = /* @__PURE__ */ new Map();
6923
+ for (const [key, hit] of locatedByKey) {
6924
+ const held = targets.get(key);
6925
+ if (!held) continue;
6926
+ const id = scopedPath(held.scope, held.path);
6927
+ sharingPath.set(id, [...sharingPath.get(id) ?? [], { key, files: hit.files }]);
6928
+ }
6929
+ const claimedByAnother = (target, file) => (sharingPath.get(scopedPath(target.scope, target.path)) ?? []).some(
6930
+ (other) => other.key !== target.key && other.files.includes(file)
6931
+ );
6932
+ const attributable = (target, file, claimed) => {
6933
+ if (target.scope === "layout") return true;
6934
+ const serves = routeOfFile(file);
6935
+ if (serves !== null && serves !== target.route && briefRoutes.has(serves)) return false;
6936
+ if (serves === target.route && pageFilesFor(target.route, contents.keys()).length !== 1) return false;
6937
+ return claimed !== true;
6938
+ };
6939
+ const declaredByHand = (target, within) => {
6940
+ const candidates = within ?? (target.scope === "layout" ? sources.map((s) => s.path) : pageFilesFor(target.route, contents.keys()));
6941
+ for (const file of candidates) {
6942
+ if (!covers(declarations.get(file), target)) continue;
6943
+ if (attributable(target, file, within !== void 0 && claimedByAnother(target, file))) return file;
6944
+ }
6945
+ return null;
6946
+ };
6022
6947
  const missed = /* @__PURE__ */ new Set();
6023
6948
  for (const miss of unlocated) {
6024
6949
  const key = keyOf(miss.route, miss.scope ?? "page", miss.path);
6025
6950
  const target = targets.get(key);
6026
6951
  if (!target || locatedByKey.has(key) || missed.has(key)) continue;
6027
6952
  missed.add(key);
6028
- if (await witnessed(target)) declaredOnly++;
6029
- else pend(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
6953
+ const witness = await witnessed(target);
6954
+ if (witness !== null) {
6955
+ declaredOnly++;
6956
+ declaredRow(target, true, witness);
6957
+ continue;
6958
+ }
6959
+ const byHand = declaredByHand(target);
6960
+ if (byHand !== null) {
6961
+ declaredOnly++;
6962
+ declaredRow(target, false, byHand);
6963
+ continue;
6964
+ }
6965
+ pend(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
6030
6966
  }
6031
6967
  const byFile = /* @__PURE__ */ new Map();
6032
6968
  for (const [key, hit] of locatedByKey) {
@@ -6084,8 +7020,21 @@ async function convertSources(briefIn, sources, options) {
6084
7020
  if (worst) {
6085
7021
  const file = hit.files[outcomes.indexOf(worst)];
6086
7022
  pend(target, worst, file);
6087
- } else if (outcomes.every((o) => o === "declared")) alreadyDeclared++;
6088
- else rewritten++;
7023
+ } else if (outcomes.every((o) => o === "declared")) {
7024
+ const witness = await witnessed(target);
7025
+ const byHand = witness === null ? declaredByHand(target, hit.files) : null;
7026
+ if (witness === null && byHand === null) {
7027
+ pend(
7028
+ target,
7029
+ "AMBIGUOUS_LITERAL",
7030
+ hit.files[0],
7031
+ `${hit.files.join(", ")} already declares ${target.path}, and nothing there says it is ${target.route}'s.`
7032
+ );
7033
+ continue;
7034
+ }
7035
+ alreadyDeclared++;
7036
+ declaredRow(target, witness !== null, witness ?? byHand);
7037
+ } else rewritten++;
6089
7038
  }
6090
7039
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
6091
7040
  const source = new MagicString4(contents.get(file));
@@ -6111,12 +7060,27 @@ async function convertSources(briefIn, sources, options) {
6111
7060
  emitStubs2(dialects, snapshotSlugs, contents, outFiles);
6112
7061
  const receipt = assertReceipt({
6113
7062
  briefDigest: digest,
6114
- paths: { declared: targets.size, rewritten, alreadyDeclared, pending },
7063
+ paths: {
7064
+ declared: targets.size,
7065
+ rewritten,
7066
+ alreadyDeclared,
7067
+ declarations: declaredRows.sort(
7068
+ (a, b) => a.route.localeCompare(b.route) || a.scope.localeCompare(b.scope) || a.path.localeCompare(b.path)
7069
+ ),
7070
+ pending
7071
+ },
6115
7072
  occurrences: { located: occurrencesLocated, rewritten: occurrencesRewritten },
6116
7073
  files: receiptFiles,
6117
7074
  helpers,
6118
7075
  helperUpgraded,
6119
- dynamicBindings
7076
+ dynamicBindings,
7077
+ notes: unreadableConfigs(sources).map(
7078
+ (file) => ({
7079
+ code: "TSCONFIG_UNREADABLE",
7080
+ file,
7081
+ message: `${file} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
7082
+ })
7083
+ )
6120
7084
  });
6121
7085
  return { files: outFiles.sort((a, b) => a.path.localeCompare(b.path)), receipt };
6122
7086
  }
@@ -6384,14 +7348,12 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
6384
7348
  looped.add(member.key);
6385
7349
  outcomes.set(member.key, "rewritten");
6386
7350
  }
6387
- for (const leaf of [...new Set(members.map((m) => m.leaf))]) {
7351
+ for (const declaration2 of plan2.declarations) {
6388
7352
  dynamic.push({
6389
7353
  file,
6390
7354
  prop: null,
6391
- path: shapeOf(arrayPath, leaf),
6392
- // The loop's OWN index name — `i_1` when the row already bound `i`. Hardcoding `i` here
6393
- // described a binding the file does not contain, which is what the proposer looks for.
6394
- callSites: [{ file, literal: `${arrayPath}[\${${plan2.names.index}}].${leaf}` }]
7355
+ path: declaration2.path,
7356
+ callSites: [{ file, literal: declaration2.literal }]
6395
7357
  });
6396
7358
  }
6397
7359
  rewrites.push({
@@ -6625,7 +7587,7 @@ function emitStubs2(dialects, slugs, contents, out) {
6625
7587
  }
6626
7588
 
6627
7589
  // src/cli.ts
6628
- var VERSION = "0.2.0";
7590
+ var VERSION = "0.4.0";
6629
7591
  var MAX_FILE_BYTES = 512 * 1024;
6630
7592
  var CliError = class extends Error {
6631
7593
  };
@@ -6634,6 +7596,7 @@ function parseArgs(argv) {
6634
7596
  brief: void 0,
6635
7597
  componentize: false,
6636
7598
  plan: void 0,
7599
+ copy: "bind",
6637
7600
  root: ".",
6638
7601
  dryRun: false,
6639
7602
  receipt: void 0,
@@ -6647,7 +7610,13 @@ function parseArgs(argv) {
6647
7610
  if (arg === "--brief") args.brief = argv[++i];
6648
7611
  else if (arg === "--componentize") args.componentize = true;
6649
7612
  else if (arg === "--plan") args.plan = argv[++i];
6650
- else if (arg === "--root") args.root = argv[++i] ?? ".";
7613
+ else if (arg === "--copy") {
7614
+ const mode = argv[++i];
7615
+ if (mode !== "bind" && mode !== "instance") {
7616
+ throw new CliError(`--copy takes "bind" or "instance", not ${JSON.stringify(mode ?? "")}. See --help.`);
7617
+ }
7618
+ args.copy = mode;
7619
+ } else if (arg === "--root") args.root = argv[++i] ?? ".";
6651
7620
  else if (arg === "--receipt") args.receipt = argv[++i];
6652
7621
  else if (arg === "--dry-run") args.dryRun = true;
6653
7622
  else if (arg === "--strict") args.strict = true;
@@ -6671,6 +7640,10 @@ var HELP = `bettercms-convert ${VERSION}
6671
7640
  --componentize Lift each planned SECTION into a component the CMS can reorder.
6672
7641
  --plan <file> The componentize plan, as get_componentize_plan returns it.
6673
7642
  Required with --componentize.
7643
+ --copy bind|instance Which copy model the plan is expected to describe. Default: bind
7644
+ (the copy stays in the page's field groups). "instance" is refused
7645
+ \u2014 COPY_MODE_MISMATCH \u2014 unless the plan's placements say the server
7646
+ has already moved it onto them.
6674
7647
  --root <dir> The repository to convert. Default: the working directory.
6675
7648
  --dry-run Print what would change; write nothing.
6676
7649
  --receipt <file> Write the receipt as JSON.
@@ -6748,14 +7721,14 @@ ${rewritten} of ${declared} paths bound (${alreadyDeclared} already declared, ${
6748
7721
  }
6749
7722
  }
6750
7723
  function reportSections(files, receipt, dryRun) {
6751
- const { extracted, inlineOnly, alreadyExtracted, pending } = receipt.sections;
7724
+ const { extracted, inlineOnly, alreadyExtracted, registered, pending } = receipt.sections;
6752
7725
  for (const file of files) {
6753
7726
  process.stdout.write(`${dryRun ? "would " : ""}${file.operation} ${file.path}
6754
7727
  `);
6755
7728
  }
6756
7729
  process.stdout.write(
6757
7730
  `
6758
- ${extracted} sections extracted (${inlineOnly} inline-only, ${alreadyExtracted} already extracted, ${pending.length} pending)
7731
+ ${extracted} sections extracted (${registered} registered, ${inlineOnly} inline-only, ${alreadyExtracted} already extracted, ${pending.length} pending), copy: ${receipt.copy}
6759
7732
  `
6760
7733
  );
6761
7734
  for (const section of pending) {
@@ -6775,6 +7748,9 @@ async function main() {
6775
7748
  if (args.plan && !args.componentize) {
6776
7749
  throw new CliError("--plan is only read with --componentize. See --help.");
6777
7750
  }
7751
+ if (args.copy === "instance" && !args.componentize) {
7752
+ throw new CliError("--copy is only read with --componentize. See --help.");
7753
+ }
6778
7754
  const root = resolve(args.root);
6779
7755
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
6780
7756
  if (!Array.isArray(brief.pages)) {
@@ -6808,7 +7784,7 @@ async function main() {
6808
7784
  brief,
6809
7785
  plan2,
6810
7786
  [...sources, ...await readSnapshots(root)],
6811
- { overwriteHelper: args.overwriteHelper }
7787
+ { overwriteHelper: args.overwriteHelper, copy: args.copy }
6812
7788
  );
6813
7789
  await written(files2);
6814
7790
  await saveReceipt(receipt2);