@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/index.js CHANGED
@@ -128,7 +128,7 @@ function propsAttribute(props) {
128
128
 
129
129
  // src/helper.ts
130
130
  import { createHash as createHash2 } from "crypto";
131
- var HELPER_VERSION = 3;
131
+ var HELPER_VERSION = 4;
132
132
  var HEADER = "// @bettercms-ai/convert helper v";
133
133
  var HELPER_PATH = Object.fromEntries(
134
134
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -211,32 +211,97 @@ export function bcmsRows<T>(page: unknown, path: string, fallback: T[], slug?: s
211
211
  /**
212
212
  * ONE SECTION's copy, for a component extracted by \`--componentize\`.
213
213
  *
214
- * \`overrides ?? pageGroup(bind) ?? defaults\` \u2014 the instance's own values when the editor gave it
215
- * any, otherwise the page field group the placement is bound to, otherwise nothing at all, in
216
- * which case every \`bcms(...)\` below renders the fallback the template shipped with. That last
217
- * step is why a section renders correctly against the committed \`{}\` stub.
214
+ * OVERRIDES FIRST, PER KEY: \`overrides[key] ?? group[key]\`. An instance that owns its copy
215
+ * (Phase B) carries an override for every prop it declares, so the group underneath it is never
216
+ * read; a placement still bound to a page field group (Phase A) carries none, so the group is all
217
+ * there is. The interesting case is the one in between \u2014 a section half moved \u2014 where a key the
218
+ * instance does not hold has to keep rendering the page's value rather than nothing.
219
+ *
220
+ * \u{1F534} \`??\`, NOT \`||\`. An empty string is a value an editor typed, and treating it as absent put
221
+ * the page's old copy back into a heading somebody had deliberately cleared.
222
+ *
223
+ * With neither an override nor a group, the answer is \`{}\` and every \`bcms(...)\` below renders
224
+ * the fallback the template shipped with \u2014 which is why a section renders correctly against the
225
+ * committed \`{}\` stub.
218
226
  */
219
227
  export function bcmsSection(page: unknown, bind?: string, overrides?: unknown): Record<string, unknown> {
220
- // \u{1F534} AN EMPTY OBJECT IS AN ANSWER. \`overrides: {}\` is an instance an editor cleared, and
221
- // "\`??\` unless it happens to be empty" quietly put the page's copy back into it.
222
- if (isRecord(overrides)) return overrides;
223
228
  const group = bind ? readPath(page, bind) : undefined;
224
- return isRecord(group) ? group : {};
229
+ const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
230
+ if (!isRecord(overrides)) return base;
231
+ for (const key of Object.keys(overrides)) {
232
+ const value = overrides[key];
233
+ if (value === undefined || value === null) continue;
234
+ /**
235
+ * \u{1F534} A TABLE PROP IS STORED AS \`{ rows: [...] }\` AND READ AS A LIST. That is the shape the
236
+ * dashboard edits a repeater in; handing it to \`bcmsRows\` unchanged is not an array, so the
237
+ * helper fell back to the rows the template shipped with and the editor's work never showed.
238
+ */
239
+ base[key] = isRecord(value) && Array.isArray(value.rows) ? value.rows : value;
240
+ }
241
+ return base;
242
+ }
243
+
244
+ /**
245
+ * WHICH ADDRESS one bound element declares \u2014 decided at RENDER time, from the placement.
246
+ *
247
+ * An element carries exactly ONE \`data-bcms-field\`, and which lane it names depends on where its
248
+ * copy lives. A placement that owns its copy declares the BLOCK lane \u2014
249
+ * \`<blockId>__overrides.<propKey>\` \u2014 because that is the address the editor commits an instance's
250
+ * own value to. A placement bound to a page field group declares the PAGE path, which is what the
251
+ * page form and the coverage meter already know.
252
+ *
253
+ * \u{1F534} THE BOUND ADDRESS IS RE-BASED ONTO **THIS** PLACEMENT'S GROUP. The markup was cut from ONE
254
+ * page and carries that page's paths (\`hero.title\`); the whole point of \`shapeHash\` is that a
255
+ * second page renders the same component bound to a group of its own (\`bind="landingHero"\`).
256
+ * Handing the embedded path through unchanged declared \`hero.title\` on a page whose field is
257
+ * \`landingHero.title\` \u2014 an address that page does not have, so the editor wrote into nothing and
258
+ * the meter counted a binding that was never there. The prefix the markup was cut with comes out,
259
+ * the placement's own group goes in.
260
+ *
261
+ * \u{1F534} THE SAME EXTRACTED FILE SERVES BOTH LANES. The choice is a function of the props, never of
262
+ * how the codemod ran: one component renders a Phase A placement on one page and a Phase B
263
+ * instance on the next, and a repository does not have to be re-cut when its copy moves.
264
+ */
265
+ export function bcmsFields(
266
+ placement: { blockId?: string; bind?: string; overrides?: unknown },
267
+ pathPrefix: string,
268
+ ): (path: string) => string {
269
+ const id = placement.blockId;
270
+ const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
271
+ /**
272
+ * What replaces the prefix the markup was cut with, or null for "leave every path alone".
273
+ *
274
+ * Null covers a placement with neither a bind nor an instance \u2014 a shipped fallback, or a page
275
+ * nobody has componentized yet \u2014 and a bound placement in a component whose prefix is EMPTY,
276
+ * where the plan said the prop key IS the whole page path: there is no group segment to swap,
277
+ * so prepending one would invent an address rather than correct it.
278
+ */
279
+ const lane = own ? \`\${id}__overrides.\` : placement.bind && pathPrefix ? \`\${placement.bind}.\` : null;
280
+ return (path: string): string => {
281
+ // A path outside the group this section was cut from is not one this placement re-addresses:
282
+ // it names a key the instance does not hold and a group this placement is not bound to.
283
+ // (An EMPTY prefix is a plan saying the prop key IS the page path, and matches everything.)
284
+ if (lane === null || !path.startsWith(pathPrefix)) return path;
285
+ return lane + path.slice(pathPrefix.length);
286
+ };
225
287
  }
226
288
  `;
227
289
  }
228
290
  var HISTORICAL = {
229
291
  "src/bcms-content.ts": {
230
292
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
231
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
293
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
294
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
232
295
  },
233
296
  "src/lib/bcms-content.ts": {
234
297
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
235
- 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002"
298
+ 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
299
+ 3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e"
236
300
  },
237
301
  "composables/bcms-content.ts": {
238
302
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
239
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
303
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
304
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
240
305
  }
241
306
  };
242
307
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -2622,6 +2687,75 @@ function locate(brief, sources, options) {
2622
2687
  // src/loops.ts
2623
2688
  import MagicString from "magic-string";
2624
2689
 
2690
+ // src/declarations.ts
2691
+ var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
2692
+ var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
2693
+ var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
2694
+ var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
2695
+ var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
2696
+ const [address, kind] = entry.split("|");
2697
+ if (!address) return [];
2698
+ const scope = address.startsWith("layout:") ? "layout" : "page";
2699
+ return [{ scope, path: stripLayoutPrefix(address), kind: kind ?? "", at, via: "props" }];
2700
+ });
2701
+ var propsPaths = (value) => propsDeclarations(value, 0).map((d) => ({ scope: d.scope, path: d.path }));
2702
+ var TEMPLATE_BINDING = /^:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")$/;
2703
+ var shapeOfPath = (path) => path.replace(/\[(?:\d+|\$\{[^}]*\})\]/g, "[*]");
2704
+ function declarationsIn(roots, content) {
2705
+ const out = [];
2706
+ const visit = (node) => {
2707
+ if (node.type === "expr") {
2708
+ for (const child of node.children) visit(child);
2709
+ return;
2710
+ }
2711
+ if (node.type !== "element") return;
2712
+ const kind = node.attrs.find((a) => a.name === "data-bcms-kind")?.value ?? "";
2713
+ const at = node.attrInsertAt;
2714
+ for (const attr of node.attrs) {
2715
+ const name = attr.name.replace(/^:/, "");
2716
+ if (name === "data-bcms-field" || name === "data-bcms-layout-field") {
2717
+ const scope = name === "data-bcms-layout-field" ? "layout" : "page";
2718
+ const template = content.slice(attr.range.start, attr.range.end).match(TEMPLATE_BINDING);
2719
+ const templated = template?.[1] ?? template?.[2];
2720
+ if (templated) out.push({ scope, path: shapeOfPath(stripLayoutPrefix(templated)), kind, at, via: "field" });
2721
+ else if (attr.value) out.push({ scope, path: stripLayoutPrefix(attr.value), kind, at, via: "field" });
2722
+ } else if (attr.name === "data-bcms-props") {
2723
+ out.push(...propsDeclarations(attr.value, at));
2724
+ } else if (attr.name === "bcmsBindings") {
2725
+ for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
2726
+ out.push({ scope: "page", path, kind: "", at, via: "prop" });
2727
+ }
2728
+ }
2729
+ }
2730
+ for (const child of node.children) visit(child);
2731
+ };
2732
+ for (const root of roots) visit(root);
2733
+ return out;
2734
+ }
2735
+ var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
2736
+ var scopedPath = (scope, path) => `${scope}::${path}`;
2737
+ var covers = (declared, target) => declared !== void 0 && (declared.has(scopedPath(target.scope, target.path)) || declared.has(scopedPath(target.scope, shapeOfPath(target.path))));
2738
+ var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
2739
+ var scannedDeclarations = (content) => [...declaredByScan(content)].map((entry) => {
2740
+ const [scope, ...rest] = entry.split("::");
2741
+ return { scope, path: rest.join("::") };
2742
+ });
2743
+ function declaredByScan(content) {
2744
+ const out = /* @__PURE__ */ new Set();
2745
+ const add = (scope, path) => void out.add(scopedPath(scope, path));
2746
+ for (const match of content.matchAll(BINDING_ATTR)) {
2747
+ add(match[0].includes("layout-field") ? "layout" : "page", stripLayoutPrefix(match[1]));
2748
+ }
2749
+ for (const match of content.matchAll(TEMPLATE_SCAN)) {
2750
+ add(match[0].includes("layout-field") ? "layout" : "page", shapeOfPath(stripLayoutPrefix(match[1] ?? match[2])));
2751
+ }
2752
+ for (const match of content.matchAll(BINDINGS_SCAN)) for (const path of bindingsPaths(match[0])) add("page", path);
2753
+ for (const match of content.matchAll(PROPS_ATTR))
2754
+ for (const entry of propsPaths(match[1])) add(entry.scope, entry.path);
2755
+ return out;
2756
+ }
2757
+ var stripLayoutPrefix = (value) => value.startsWith("layout:") ? value.slice("layout:".length) : value;
2758
+
2625
2759
  // src/scope.ts
2626
2760
  function patternNames(node, out) {
2627
2761
  if (!node || typeof node !== "object") return;
@@ -2908,7 +3042,10 @@ function wordBounded(haystack, needle) {
2908
3042
  }
2909
3043
 
2910
3044
  // src/loops.ts
2911
- var shapeOf = (arrayPath, leaf) => `${arrayPath}[*].${leaf}`;
3045
+ function splitNested(leaf) {
3046
+ const match = leaf.match(/^([^[\]]+)\[(\d+)\]\.(.+)$/);
3047
+ return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
3048
+ }
2912
3049
  var elementEnd = (node) => node.inner ? node.inner.end + node.tag.length + 3 : null;
2913
3050
  function findRows(roots, members) {
2914
3051
  const wanted = new Set(members.map((m) => m.index));
@@ -2980,6 +3117,69 @@ function indentOf(content, at) {
2980
3117
  const prefix = content.slice(lineStart, at);
2981
3118
  return prefix.trim() === "" ? prefix : "";
2982
3119
  }
3120
+ function writeLeaf(source, shift, member, rule, read, row, literal) {
3121
+ const site = member.site;
3122
+ const expression = `${read}(${row}, ${JSON.stringify(member.leaf)}, "")`;
3123
+ if (member.kind === "richtext" && rule.richtext) {
3124
+ source.remove(site.inner.start + shift, site.inner.end + shift);
3125
+ source.appendLeft(site.attrInsertAt + shift, ` ${rule.richtext(expression)}`);
3126
+ } else {
3127
+ source.overwrite(site.inner.start + shift, site.inner.end + shift, rule.text(expression));
3128
+ }
3129
+ const kind = member.kind ? ` data-bcms-kind="${member.kind}"` : "";
3130
+ source.appendLeft(site.attrInsertAt + shift, ` ${rule.attr("data-bcms-field", `\`${literal}\``)}${kind}`);
3131
+ }
3132
+ function emitNested(rule, array, members, prefix, outer, parent, ctx) {
3133
+ if (!rule.loop || !rule.text) return null;
3134
+ const inner = [];
3135
+ for (const member of members) {
3136
+ const split = splitNested(member.leaf);
3137
+ if (!split || splitNested(split.leaf) !== null) return null;
3138
+ inner.push({ ...member, index: split.index, leaf: split.leaf });
3139
+ }
3140
+ const rows = findRows([parent.node], inner);
3141
+ if (!rows || rows.length === 0) return null;
3142
+ if (!consecutive(rows, ctx.content)) return null;
3143
+ if (!sameShape(rows, ctx.content)) return null;
3144
+ const template = rows[0];
3145
+ const used = /* @__PURE__ */ new Set([
3146
+ ...ctx.content.slice(template.range.start, template.range.end).match(/[A-Za-z_$][\w$]*/g) ?? [],
3147
+ outer.row,
3148
+ outer.index
3149
+ ]);
3150
+ const allocated = allocateNames(used, [
3151
+ { key: "row", name: "item" },
3152
+ { key: "index", name: "j" }
3153
+ ]);
3154
+ const names = { row: allocated.get("row"), index: allocated.get("index") };
3155
+ const fallback = [];
3156
+ const writable = inner.every((member) => setLeaf(fallback[member.index] ??= emptyRow(), member.leaf, member.fallback));
3157
+ if (!writable) return null;
3158
+ const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
3159
+ const shift = -template.range.start;
3160
+ const declarations = [];
3161
+ for (const member of template.members) {
3162
+ const literal = `${prefix}.${array}[\${${names.index}}].${member.leaf}`;
3163
+ writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
3164
+ declarations.push({ path: shapeOfPath(literal), literal });
3165
+ }
3166
+ const rowsExpr = `${ctx.rows}(${outer.row}, ${JSON.stringify(array)}, ${JSON.stringify(fallback.filter(Boolean))})`;
3167
+ if (rule.loop.kind === "attr") {
3168
+ source.appendRight(template.attrInsertAt + shift, ` ${rule.loop.attr(rowsExpr, names.row, names.index)}`);
3169
+ }
3170
+ if (rule.loopKey) {
3171
+ const key = rule.loopKey(names.index);
3172
+ if (!template.keyAttr) source.appendRight(template.attrInsertAt + shift, ` ${key}`);
3173
+ else if (template.keyAttr.end > template.keyAttr.start)
3174
+ source.overwrite(template.keyAttr.start + shift, template.keyAttr.end + shift, key);
3175
+ else return null;
3176
+ }
3177
+ const indent = indentOf(ctx.content, template.range.start);
3178
+ const text = rule.loop.kind === "wrap" ? `${rule.loop.open(rowsExpr, names.row, names.index)}
3179
+ ${indented(source.toString(), indent)}
3180
+ ${indent}${rule.loop.close}` : source.toString();
3181
+ return { range: { start: template.range.start, end: rows[rows.length - 1].range.end }, text, declarations };
3182
+ }
2983
3183
  function emitRepeater(dialect, group, arrayPath, fallbackRows, ctx) {
2984
3184
  const rule = DIALECT_RULES[dialect];
2985
3185
  if (!rule.loop || !rule.text || group.length === 0) return null;
@@ -2997,20 +3197,25 @@ function emitRepeater(dialect, group, arrayPath, fallbackRows, ctx) {
2997
3197
  const names = { row: allocated.get("row"), index: allocated.get("index") };
2998
3198
  const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
2999
3199
  const shift = -template.range.start;
3000
- const bindings = [];
3200
+ const declarations = [];
3201
+ const nested = /* @__PURE__ */ new Map();
3202
+ const direct = [];
3001
3203
  for (const member of template.members) {
3002
- const site = member.site;
3003
- const expression = `${ctx.read}(${names.row}, ${JSON.stringify(member.leaf)}, "")`;
3004
- if (member.kind === "richtext" && rule.richtext) {
3005
- source.remove(site.inner.start + shift, site.inner.end + shift);
3006
- source.appendLeft(site.attrInsertAt + shift, ` ${rule.richtext(expression)}`);
3007
- } else {
3008
- source.overwrite(site.inner.start + shift, site.inner.end + shift, rule.text(expression));
3009
- }
3010
- const kind = member.kind ? ` data-bcms-kind="${member.kind}"` : "";
3011
- const path = `\`${arrayPath}[\${${names.index}}].${member.leaf}\``;
3012
- source.appendLeft(site.attrInsertAt + shift, ` ${rule.attr("data-bcms-field", path)}${kind}`);
3013
- bindings.push(shapeOf(arrayPath, member.leaf));
3204
+ const inner = splitNested(member.leaf);
3205
+ if (inner === null) direct.push(member);
3206
+ else nested.set(inner.array, [...nested.get(inner.array) ?? [], member]);
3207
+ }
3208
+ for (const [array, members] of nested) {
3209
+ const plan2 = emitNested(rule, array, members, `${arrayPath}[\${${names.index}}]`, names, template, ctx);
3210
+ if (!plan2) return null;
3211
+ if (direct.some((m) => m.site.range.start >= plan2.range.start && m.site.range.end <= plan2.range.end)) return null;
3212
+ source.overwrite(plan2.range.start + shift, plan2.range.end + shift, plan2.text);
3213
+ declarations.push(...plan2.declarations);
3214
+ }
3215
+ for (const member of direct) {
3216
+ const literal = `${arrayPath}[\${${names.index}}].${member.leaf}`;
3217
+ writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
3218
+ declarations.push({ path: shapeOfPath(literal), literal });
3014
3219
  }
3015
3220
  const args = [ctx.snapshot, JSON.stringify(arrayPath), JSON.stringify(fallbackRows), ...ctx.slug ? [ctx.slug] : []];
3016
3221
  const rowsExpr = `${ctx.rows}(${args.join(", ")})`;
@@ -3031,7 +3236,8 @@ ${indent}${rule.loop.close}` : source.toString();
3031
3236
  return {
3032
3237
  range: { start: template.range.start, end: rows[rows.length - 1].range.end },
3033
3238
  text,
3034
- bindings: [...new Set(bindings)],
3239
+ declarations,
3240
+ bindings: [...new Set(declarations.map((d) => d.path))],
3035
3241
  names
3036
3242
  };
3037
3243
  }
@@ -3061,9 +3267,9 @@ function setLeaf(row, leaf, value) {
3061
3267
  var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3062
3268
  var emptyRow = () => /* @__PURE__ */ Object.create(null);
3063
3269
  function splitIndexed(arrayPath, path) {
3064
- const match = path.match(/^(.*)\[(\d+)\]\.(.+)$/);
3065
- if (!match || match[1] !== arrayPath) return null;
3066
- return { index: Number(match[2]), leaf: match[3] };
3270
+ if (!path.startsWith(`${arrayPath}[`)) return null;
3271
+ const match = path.slice(arrayPath.length).match(/^\[(\d+)\]\.(.+)$/);
3272
+ return match ? { index: Number(match[1]), leaf: match[2] } : null;
3067
3273
  }
3068
3274
 
3069
3275
  // src/sites/jsx.ts
@@ -3556,22 +3762,68 @@ var firstPresent = (base, files) => [base, ...EXTENSIONS.map((ext) => `${base}${
3556
3762
  function aliasesFrom(sources) {
3557
3763
  const out = /* @__PURE__ */ new Map();
3558
3764
  for (const source of sources) {
3559
- if (!source.path.endsWith("tsconfig.json") && !source.path.endsWith("jsconfig.json")) continue;
3560
- try {
3561
- const parsed = JSON.parse(source.content.replace(/^\s*\/\/.*$/gm, ""));
3562
- const base = parsed.compilerOptions?.baseUrl?.replace(/^\.\/?/, "").replace(/\/$/, "") ?? "";
3563
- const root = source.path.split("/").slice(0, -1).join("/");
3564
- for (const [pattern, targets] of Object.entries(parsed.compilerOptions?.paths ?? {})) {
3565
- out.set(
3566
- pattern,
3567
- targets.map((target) => [root, base, target.replace(/^\.\/?/, "")].filter(Boolean).join("/"))
3568
- );
3569
- }
3570
- } catch {
3765
+ if (!isProjectConfig(source.path)) continue;
3766
+ const parsed = readJsonc(source.content);
3767
+ if (parsed === null) continue;
3768
+ const base = parsed.compilerOptions?.baseUrl?.replace(/^\.\/?/, "").replace(/\/$/, "") ?? "";
3769
+ const root = source.path.split("/").slice(0, -1).join("/");
3770
+ for (const [pattern, targets] of Object.entries(parsed.compilerOptions?.paths ?? {})) {
3771
+ out.set(
3772
+ pattern,
3773
+ targets.map((target) => [root, base, target.replace(/^\.\/?/, "")].filter(Boolean).join("/"))
3774
+ );
3571
3775
  }
3572
3776
  }
3573
3777
  return out;
3574
3778
  }
3779
+ var isProjectConfig = (path) => path.endsWith("tsconfig.json") || path.endsWith("jsconfig.json");
3780
+ function readJsonc(content) {
3781
+ let out = "";
3782
+ let at = 0;
3783
+ while (at < content.length) {
3784
+ const char = content[at];
3785
+ if (char === '"') {
3786
+ const start = at++;
3787
+ while (at < content.length && content[at] !== '"') at += content[at] === "\\" ? 2 : 1;
3788
+ out += content.slice(start, Math.min(at + 1, content.length));
3789
+ at++;
3790
+ continue;
3791
+ }
3792
+ if (char === "/" && content[at + 1] === "/") {
3793
+ while (at < content.length && content[at] !== "\n") at++;
3794
+ continue;
3795
+ }
3796
+ if (char === "/" && content[at + 1] === "*") {
3797
+ const close = content.indexOf("*/", at + 2);
3798
+ at = close < 0 ? content.length : close + 2;
3799
+ continue;
3800
+ }
3801
+ out += char;
3802
+ at++;
3803
+ }
3804
+ try {
3805
+ const parsed = JSON.parse(out.replace(/,(?=\s*[}\]])/g, ""));
3806
+ return isAliasShaped(parsed) ? parsed : null;
3807
+ } catch {
3808
+ return null;
3809
+ }
3810
+ }
3811
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3812
+ function isAliasShaped(value) {
3813
+ if (!isRecord(value)) return false;
3814
+ const options = value.compilerOptions;
3815
+ if (options === void 0) return true;
3816
+ if (!isRecord(options)) return false;
3817
+ if (options.baseUrl !== void 0 && typeof options.baseUrl !== "string") return false;
3818
+ if (options.paths === void 0) return true;
3819
+ if (!isRecord(options.paths)) return false;
3820
+ return Object.values(options.paths).every(
3821
+ (targets) => Array.isArray(targets) && targets.every((target) => typeof target === "string")
3822
+ );
3823
+ }
3824
+ function unreadableConfigs(sources) {
3825
+ return sources.filter((source) => isProjectConfig(source.path) && readJsonc(source.content) === null).map((source) => source.path);
3826
+ }
3575
3827
  function expand(specifier, aliases) {
3576
3828
  const out = [];
3577
3829
  for (const [pattern, targets] of aliases) {
@@ -3740,74 +3992,6 @@ function typeMember(content, literal) {
3740
3992
  return { start: at, end: at, text: `${terminated ? "" : ";"} ${text}` };
3741
3993
  }
3742
3994
 
3743
- // src/declarations.ts
3744
- var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
3745
- var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
3746
- var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
3747
- var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
3748
- var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
3749
- const [address, kind] = entry.split("|");
3750
- if (!address) return [];
3751
- const scope = address.startsWith("layout:") ? "layout" : "page";
3752
- return [{ scope, path: stripLayoutPrefix(address), kind: kind ?? "", at, via: "props" }];
3753
- });
3754
- var propsPaths = (value) => propsDeclarations(value, 0).map((d) => ({ scope: d.scope, path: d.path }));
3755
- var TEMPLATE_BINDING = /^:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")$/;
3756
- var shapeOfPath = (path) => path.replace(/\[(?:\d+|\$\{[^}]*\})\]/g, "[*]");
3757
- function declarationsIn(roots, content) {
3758
- const out = [];
3759
- const visit = (node) => {
3760
- if (node.type === "expr") {
3761
- for (const child of node.children) visit(child);
3762
- return;
3763
- }
3764
- if (node.type !== "element") return;
3765
- const kind = node.attrs.find((a) => a.name === "data-bcms-kind")?.value ?? "";
3766
- const at = node.attrInsertAt;
3767
- for (const attr of node.attrs) {
3768
- const name = attr.name.replace(/^:/, "");
3769
- if (name === "data-bcms-field" || name === "data-bcms-layout-field") {
3770
- const scope = name === "data-bcms-layout-field" ? "layout" : "page";
3771
- const template = content.slice(attr.range.start, attr.range.end).match(TEMPLATE_BINDING);
3772
- const templated = template?.[1] ?? template?.[2];
3773
- if (templated) out.push({ scope, path: shapeOfPath(stripLayoutPrefix(templated)), kind, at, via: "field" });
3774
- else if (attr.value) out.push({ scope, path: stripLayoutPrefix(attr.value), kind, at, via: "field" });
3775
- } else if (attr.name === "data-bcms-props") {
3776
- out.push(...propsDeclarations(attr.value, at));
3777
- } else if (attr.name === "bcmsBindings") {
3778
- for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
3779
- out.push({ scope: "page", path, kind: "", at, via: "prop" });
3780
- }
3781
- }
3782
- }
3783
- for (const child of node.children) visit(child);
3784
- };
3785
- for (const root of roots) visit(root);
3786
- return out;
3787
- }
3788
- var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
3789
- var scopedPath = (scope, path) => `${scope}::${path}`;
3790
- var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
3791
- var scannedDeclarations = (content) => [...declaredByScan(content)].map((entry) => {
3792
- const [scope, ...rest] = entry.split("::");
3793
- return { scope, path: rest.join("::") };
3794
- });
3795
- function declaredByScan(content) {
3796
- const out = /* @__PURE__ */ new Set();
3797
- const add = (scope, path) => void out.add(scopedPath(scope, path));
3798
- for (const match of content.matchAll(BINDING_ATTR)) {
3799
- add(match[0].includes("layout-field") ? "layout" : "page", stripLayoutPrefix(match[1]));
3800
- }
3801
- for (const match of content.matchAll(TEMPLATE_SCAN)) {
3802
- add(match[0].includes("layout-field") ? "layout" : "page", shapeOfPath(stripLayoutPrefix(match[1] ?? match[2])));
3803
- }
3804
- for (const match of content.matchAll(BINDINGS_SCAN)) for (const path of bindingsPaths(match[0])) add("page", path);
3805
- for (const match of content.matchAll(PROPS_ATTR))
3806
- for (const entry of propsPaths(match[1])) add(entry.scope, entry.path);
3807
- return out;
3808
- }
3809
- var stripLayoutPrefix = (value) => value.startsWith("layout:") ? value.slice("layout:".length) : value;
3810
-
3811
3995
  // src/converted.ts
3812
3996
  var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3813
3997
  function splitArrayPath(path) {
@@ -4653,7 +4837,7 @@ var ValidationError = class extends Error {
4653
4837
  }
4654
4838
  code;
4655
4839
  };
4656
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4840
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4657
4841
  var Check = class {
4658
4842
  constructor(code) {
4659
4843
  this.code = code;
@@ -4663,7 +4847,7 @@ var Check = class {
4663
4847
  throw new ValidationError(this.code, `${where} ${why}.`);
4664
4848
  }
4665
4849
  record(value, where) {
4666
- if (!isRecord(value)) this.fail(where, "is not an object");
4850
+ if (!isRecord2(value)) this.fail(where, "is not an object");
4667
4851
  return value;
4668
4852
  }
4669
4853
  array(value, where) {
@@ -4730,6 +4914,7 @@ function readPlan(value) {
4730
4914
  const section = check.record(held, at2);
4731
4915
  check.segment(section.groupKey, `${at2}.groupKey`);
4732
4916
  check.optionalString(section.sectionType, `${at2}.sectionType`);
4917
+ check.optionalString(section.copy, `${at2}.copy`);
4733
4918
  if (section.fields !== void 0) {
4734
4919
  for (const [f, field] of check.array(section.fields, `${at2}.fields`).entries()) {
4735
4920
  const shape = check.record(field, `${at2}.fields[${f}]`);
@@ -5119,30 +5304,84 @@ var SectionInvariantError = class extends Error {
5119
5304
  }
5120
5305
  };
5121
5306
  function assertSections(total, sections) {
5122
- const sum = sections.extracted + sections.inlineOnly + sections.alreadyExtracted + sections.pending.length;
5307
+ const sum = sections.extracted + sections.inlineOnly + sections.alreadyExtracted + sections.registered + sections.pending.length;
5123
5308
  if (sum !== total) {
5124
5309
  throw new SectionInvariantError(
5125
- `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}.`
5310
+ `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}.`
5126
5311
  );
5127
5312
  }
5128
5313
  return sections;
5129
5314
  }
5130
- var SECTION_MARKER = "// @bettercms-ai/convert section v1";
5315
+ var SECTION_MARKER = "// @bettercms-ai/convert section v2";
5316
+ var SECTION_MARKERS = [SECTION_MARKER, "// @bettercms-ai/convert section v1"];
5131
5317
  var REGISTRY_MARKER = "// @bettercms-ai/convert sections v1";
5132
5318
  var COMPONENT_DIR = "src/components/bcms";
5133
5319
  var SECTIONS_LIB = "src/lib/bcms-sections.ts";
5134
5320
  var EXTRACTABLE = ["astro", "jsx"];
5321
+ var registryValue = (entry) => entry.member ? `${entry.name}.${entry.member}` : entry.name;
5322
+ function importLine(name, specifier, entry) {
5323
+ if (entry.kind === "namespace") return `import * as ${name} from "${specifier}";`;
5324
+ if (entry.kind === "named") {
5325
+ const exported = entry.imported ?? name;
5326
+ return `import { ${exported === name ? name : `${exported} as ${name}`} } from "${specifier}";`;
5327
+ }
5328
+ return `import ${name} from "${specifier}";`;
5329
+ }
5135
5330
  async function componentizeSources(briefIn, planIn, sources, options) {
5136
5331
  const pending = [];
5137
5332
  let extracted = 0;
5138
5333
  let inlineOnly = 0;
5139
5334
  let alreadyExtracted = 0;
5335
+ let registered = 0;
5140
5336
  let planPending = 0;
5141
5337
  let total = 0;
5338
+ const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5142
5339
  const brief = readBrief(briefIn);
5143
5340
  const plan2 = readPlan(planIn);
5341
+ const copy = options?.copy ?? "bind";
5342
+ const instanceOwned = (section) => section.copy === "instance";
5343
+ const notes = unreadableConfigs(sources).map(
5344
+ (path) => ({
5345
+ code: "TSCONFIG_UNREADABLE",
5346
+ file: path,
5347
+ message: `${path} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
5348
+ })
5349
+ );
5350
+ const refuseRun = (reason, message) => {
5351
+ for (const page of plan2.pages ?? []) {
5352
+ for (const section of page.sections ?? []) {
5353
+ if (section.pending) {
5354
+ planPending++;
5355
+ continue;
5356
+ }
5357
+ total++;
5358
+ pend(page.route, section.groupKey, reason, message);
5359
+ }
5360
+ }
5361
+ return {
5362
+ files: [],
5363
+ receipt: {
5364
+ planDigest: plan2.digest ?? "",
5365
+ copy,
5366
+ sections: assertSections(total, { extracted: 0, inlineOnly: 0, alreadyExtracted: 0, registered: 0, pending }),
5367
+ components: [],
5368
+ planPending,
5369
+ notes
5370
+ }
5371
+ };
5372
+ };
5373
+ const movesCopy = (plan2.pages ?? []).some(
5374
+ (page) => (page.sections ?? []).some((section) => !section.pending && instanceOwned(section))
5375
+ );
5376
+ if (copy === "instance" && !movesCopy) {
5377
+ return refuseRun(
5378
+ "COPY_MODE_MISMATCH",
5379
+ '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.'
5380
+ );
5381
+ }
5144
5382
  const contents = new Map(sources.map((s) => [s.path, s.content]));
5145
- const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5383
+ const fileSet = new Set(contents.keys());
5384
+ const aliases = aliasesFrom(sources);
5146
5385
  const trees = /* @__PURE__ */ new Map();
5147
5386
  const treeOf = async (file) => {
5148
5387
  if (trees.has(file)) return trees.get(file);
@@ -5156,7 +5395,9 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5156
5395
  const helperPath = HELPER_PATH.astro;
5157
5396
  const helperExisting = contents.get(helperPath);
5158
5397
  const helperVersion = helperExisting === void 0 ? null : isKnownHelper(helperExisting, "astro");
5159
- const helperBlocked = helperExisting !== void 0 && helperVersion === null && options?.overwriteHelper !== true;
5398
+ if (helperExisting !== void 0 && helperVersion === null && options?.overwriteHelper !== true) {
5399
+ return refuseRun("HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5400
+ }
5160
5401
  const outFiles = /* @__PURE__ */ new Map();
5161
5402
  const written = /* @__PURE__ */ new Map();
5162
5403
  const shipped = /* @__PURE__ */ new Map();
@@ -5166,7 +5407,31 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5166
5407
  return held;
5167
5408
  };
5168
5409
  const fresh = /* @__PURE__ */ new Set();
5410
+ const bindOf = (section) => instanceOwned(section) ? void 0 : section.groupKey;
5411
+ const placementOf = (slug, section) => {
5412
+ const bind = bindOf(section);
5413
+ return bind === void 0 ? { slug } : { slug, bind };
5414
+ };
5415
+ const rebind = (dialect, pageSlug, all, usable) => {
5416
+ if (dialect === null || !EXTRACTABLE.includes(dialect)) return;
5417
+ const order = shipped.get(dialect);
5418
+ const held = order?.get(pageSlug);
5419
+ if (!order || !held) return;
5420
+ const queues = /* @__PURE__ */ new Map();
5421
+ for (const section of all) {
5422
+ const slug = resolveComponent(plan2, section).slug;
5423
+ queues.set(slug, [...queues.get(slug) ?? [], section]);
5424
+ }
5425
+ order.set(
5426
+ pageSlug,
5427
+ held.map((placement) => {
5428
+ const section = queues.get(placement.slug)?.shift();
5429
+ return section && usable.has(section) ? placementOf(placement.slug, section) : placement;
5430
+ })
5431
+ );
5432
+ };
5169
5433
  const registryConflicts = [];
5434
+ const registryUnreadable = [];
5170
5435
  for (const dialect of EXTRACTABLE) {
5171
5436
  const path = `${COMPONENT_DIR}/Sections.${dialect === "astro" ? "astro" : "tsx"}`;
5172
5437
  const existing = contents.get(path);
@@ -5175,7 +5440,11 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5175
5440
  registryConflicts.push(path);
5176
5441
  continue;
5177
5442
  }
5178
- const held = readRegistry(existing, dialect);
5443
+ const held = await readRegistry(existing, dialect, path, fileSet, aliases);
5444
+ if (held === null) {
5445
+ registryUnreadable.push(path);
5446
+ continue;
5447
+ }
5179
5448
  componentsOf(dialect);
5180
5449
  for (const [slug, component] of held.components) written.get(dialect).set(slug, component);
5181
5450
  const order = shipped.get(dialect) ?? /* @__PURE__ */ new Map();
@@ -5185,6 +5454,12 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5185
5454
  const lib = contents.get(SECTIONS_LIB);
5186
5455
  if (lib !== void 0 && !isGenerated(lib)) registryConflicts.push(SECTIONS_LIB);
5187
5456
  const registryBlocked = registryConflicts.length > 0 && options?.overwriteHelper !== true;
5457
+ const registryUnread = registryUnreadable.length > 0;
5458
+ const migrated = registryBlocked || registryUnread ? { files: [], refused: /* @__PURE__ */ new Map() } : await migrateSections(plan2, contents, written, fileSet, aliases);
5459
+ for (const file of migrated.files) {
5460
+ addFile(outFiles, contents, file);
5461
+ contents.set(file.path, file.content);
5462
+ }
5188
5463
  for (const page of plan2.pages ?? []) {
5189
5464
  const sections2 = (page.sections ?? []).filter((section) => {
5190
5465
  if (section.pending) {
@@ -5195,46 +5470,81 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5195
5470
  });
5196
5471
  total += sections2.length;
5197
5472
  if (sections2.length === 0) continue;
5473
+ const prefixes = /* @__PURE__ */ new Map();
5474
+ for (const section of sections2) {
5475
+ const prefix = propPrefix(section);
5476
+ if (prefix === null) {
5477
+ pend(
5478
+ page.route,
5479
+ section.groupKey,
5480
+ "PROP_MAP_INCONSISTENT",
5481
+ "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."
5482
+ );
5483
+ continue;
5484
+ }
5485
+ prefixes.set(section, prefix);
5486
+ }
5487
+ const usable = sections2.filter((section) => prefixes.has(section));
5488
+ if (usable.length === 0) continue;
5489
+ const pendingBefore = pending.length;
5198
5490
  const briefPage = brief.pages.find((p) => p.route === page.route);
5199
5491
  const pageSlug = briefPage?.slug ?? slugify(page.route === "/" ? "home" : page.route);
5200
- if (helperBlocked) {
5201
- for (const section of sections2) {
5202
- pend(page.route, section.groupKey, "HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5492
+ if (registryBlocked) {
5493
+ for (const section of usable) {
5494
+ pend(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5203
5495
  }
5204
5496
  continue;
5205
5497
  }
5206
- if (registryBlocked) {
5207
- for (const section of sections2) {
5208
- pend(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5498
+ if (registryUnread) {
5499
+ for (const section of usable) {
5500
+ pend(
5501
+ page.route,
5502
+ section.groupKey,
5503
+ "REGISTRY_UNREADABLE",
5504
+ `${registryUnreadable.join(", ")} carries our marker but its REGISTRY/SHIPPED tables cannot be read back \u2014 rebuilding it would drop the components it names.`
5505
+ );
5209
5506
  }
5210
5507
  continue;
5211
5508
  }
5212
5509
  const snapshotPath = `${CONTENT_DIR}/${pageSlug}.json`;
5213
5510
  if (!snapshotUsable(contents.get(snapshotPath))) {
5214
- for (const section of sections2) {
5511
+ for (const section of usable) {
5215
5512
  pend(page.route, section.groupKey, "SNAPSHOT_INVALID", `${snapshotPath} is not a JSON object.`);
5216
5513
  }
5217
5514
  continue;
5218
5515
  }
5219
5516
  const pageFiles = pageFilesFor(page.route, contents.keys());
5220
5517
  if (pageFiles.length > 1) {
5221
- for (const section of sections2) {
5518
+ for (const section of usable) {
5222
5519
  pend(page.route, section.groupKey, "ROUTE_FILE_AMBIGUOUS", `${pageFiles.join(" and ")} both serve ${page.route}.`);
5223
5520
  }
5224
5521
  continue;
5225
5522
  }
5226
5523
  const file = pageFiles[0];
5227
5524
  if (file === void 0) {
5228
- for (const section of sections2) {
5525
+ for (const section of usable) {
5229
5526
  pend(page.route, section.groupKey, "NOT_IN_SOURCE", `no page file found for ${page.route}.`);
5230
5527
  }
5231
5528
  continue;
5232
5529
  }
5233
- const done = new Set(alreadyOn(contents.get(file) ?? "", pageSlug, sections2));
5530
+ const pageDialect = dialectOf(file, contents.get(file) ?? "");
5531
+ const done = new Set(alreadyOn(contents.get(file) ?? "", pageSlug, usable));
5532
+ let stuck = 0;
5533
+ for (const section of usable) {
5534
+ if (!done.has(section.groupKey)) continue;
5535
+ const why = pageDialect ? migrated.refused.get(refusalKey(pageDialect, resolveComponent(plan2, section).slug)) : void 0;
5536
+ if (why === void 0) continue;
5537
+ done.delete(section.groupKey);
5538
+ stuck++;
5539
+ pend(page.route, section.groupKey, "SECTION_UNMIGRATABLE", why);
5540
+ }
5234
5541
  alreadyExtracted += done.size;
5235
- if (done.size === sections2.length) continue;
5542
+ if (done.size + stuck === usable.length) {
5543
+ rebind(pageDialect, pageSlug, page.sections ?? [], new Set(usable));
5544
+ continue;
5545
+ }
5236
5546
  const found = await locateSections(
5237
- sections2.filter((section) => !done.has(section.groupKey)),
5547
+ usable.filter((section) => !done.has(section.groupKey)),
5238
5548
  briefPage?.paths ?? [],
5239
5549
  sources,
5240
5550
  treeOf
@@ -5244,37 +5554,115 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5244
5554
  }
5245
5555
  const live = found.placed;
5246
5556
  if (live.length === 0) continue;
5557
+ const content = contents.get(file);
5558
+ const dialect = dialectOf(file, content);
5559
+ if (!dialect || !EXTRACTABLE.includes(dialect)) {
5560
+ for (const placed of live) {
5561
+ pend(page.route, placed.section.groupKey, "DIALECT_UNSUPPORTED", `${file} is ${dialect ?? "not a template"}.`);
5562
+ }
5563
+ continue;
5564
+ }
5565
+ const tree = await treeOf(file);
5566
+ if (!tree) {
5567
+ for (const placed of live) {
5568
+ pend(page.route, placed.section.groupKey, "NOT_IN_SOURCE", `${file} did not parse.`);
5569
+ }
5570
+ continue;
5571
+ }
5572
+ const parsed = tree.parsed;
5573
+ const importedFiles = /* @__PURE__ */ new Map();
5574
+ for (const [local, from] of importsIn(parsed, dialect)) {
5575
+ const resolved = resolveSpecifier(file, from.specifier, fileSet, aliases);
5576
+ if (resolved === null) continue;
5577
+ importedFiles.set(resolved, [
5578
+ ...importedFiles.get(resolved) ?? [],
5579
+ { local, kind: from.kind, imported: from.imported }
5580
+ ]);
5581
+ }
5582
+ const bindingOf = (held) => {
5583
+ const bindings = importedFiles.get(held) ?? [];
5584
+ return bindings.find((binding) => calledIn(tree, binding)) ?? bindings[0];
5585
+ };
5247
5586
  const placedHere = [];
5587
+ const importedSections = [];
5248
5588
  for (const placed of live) {
5249
5589
  const elements = placed.byFile.get(file);
5590
+ const alsoRendered = [...placed.byFile.keys()].filter((held2) => held2 !== file && importedFiles.has(held2));
5250
5591
  if (elements && elements.length > 0) {
5592
+ if (alsoRendered.length > 0) {
5593
+ pend(
5594
+ page.route,
5595
+ placed.section.groupKey,
5596
+ "SECTION_ROOT_AMBIGUOUS",
5597
+ `rendered inline by ${file} AND by ${alsoRendered.join(", ")}, which it imports.`
5598
+ );
5599
+ continue;
5600
+ }
5251
5601
  placedHere.push({ section: placed.section, elements });
5252
5602
  continue;
5253
5603
  }
5604
+ if (alsoRendered.length > 1) {
5605
+ pend(
5606
+ page.route,
5607
+ placed.section.groupKey,
5608
+ "SECTION_ROOT_AMBIGUOUS",
5609
+ `rendered by ${alsoRendered.join(" and ")}, both imported by ${file}.`
5610
+ );
5611
+ continue;
5612
+ }
5613
+ const only = alsoRendered[0] ?? [...placed.byFile.keys()][0];
5614
+ const held = bindingOf(only);
5615
+ if (held !== void 0 && dialectOf(only, contents.get(only) ?? "") === dialect) {
5616
+ importedSections.push({ section: placed.section, path: only, call: held.local });
5617
+ continue;
5618
+ }
5254
5619
  pend(
5255
5620
  page.route,
5256
5621
  placed.section.groupKey,
5257
5622
  "SECTION_ROOT_AMBIGUOUS",
5258
- `rendered by ${[...placed.byFile.keys()].join(", ")}, not ${file}.`
5623
+ 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}.`
5259
5624
  );
5260
5625
  }
5261
- if (placedHere.length === 0) continue;
5262
- const content = contents.get(file);
5263
- const dialect = dialectOf(file, content);
5264
- if (!dialect || !EXTRACTABLE.includes(dialect)) {
5265
- for (const placed of placedHere) {
5266
- pend(page.route, placed.section.groupKey, "DIALECT_UNSUPPORTED", `${file} is ${dialect ?? "not a template"}.`);
5626
+ if (placedHere.length === 0 && importedSections.length === 0) continue;
5627
+ const extractable = componentsOf(dialect);
5628
+ const registeredHere = [];
5629
+ for (const held of importedSections) {
5630
+ const component = resolveComponent(plan2, held.section);
5631
+ const already = extractable.get(component.slug);
5632
+ if (already !== void 0 && already.path !== held.path) {
5633
+ pend(
5634
+ page.route,
5635
+ held.section.groupKey,
5636
+ "COMPONENT_CONFLICT",
5637
+ `the slug "${component.slug}" already names ${already.path}, not ${held.path}.`
5638
+ );
5639
+ continue;
5267
5640
  }
5268
- continue;
5269
- }
5270
- const tree = await treeOf(file);
5271
- if (!tree) {
5272
- for (const placed of placedHere) {
5273
- pend(page.route, placed.section.groupKey, "NOT_IN_SOURCE", `${file} did not parse.`);
5641
+ const binding = bindingOf(held.path);
5642
+ const member = binding.kind === "namespace" ? namespaceMember(tree, binding.local) : void 0;
5643
+ if (binding.kind === "namespace" && member === void 0) {
5644
+ pend(
5645
+ page.route,
5646
+ held.section.groupKey,
5647
+ "SECTION_ROOT_AMBIGUOUS",
5648
+ `${held.path} is imported as a namespace and ${file} never calls one of its components, so nothing names the export that renders it.`
5649
+ );
5650
+ continue;
5274
5651
  }
5275
- continue;
5652
+ extractable.set(component.slug, {
5653
+ name: component.name,
5654
+ path: held.path,
5655
+ kind: binding.kind,
5656
+ imported: binding.imported,
5657
+ ...member ? { member } : {}
5658
+ });
5659
+ registered++;
5660
+ registeredHere.push({
5661
+ slug: component.slug,
5662
+ section: held.section,
5663
+ call: callSiteOf(tree, member ? `${binding.local}.${member}` : held.call)
5664
+ });
5276
5665
  }
5277
- const parsed = tree.parsed;
5278
5666
  const roots = [];
5279
5667
  for (const placed of placedHere) {
5280
5668
  const root = lowestCommonAncestor(placed.elements);
@@ -5283,7 +5671,13 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5283
5671
  continue;
5284
5672
  }
5285
5673
  const component = resolveComponent(plan2, placed.section);
5286
- roots.push({ section: placed.section, route: page.route, root, ...component });
5674
+ roots.push({
5675
+ section: placed.section,
5676
+ route: page.route,
5677
+ root,
5678
+ pathPrefix: prefixes.get(placed.section),
5679
+ ...component
5680
+ });
5287
5681
  }
5288
5682
  const nested = /* @__PURE__ */ new Set();
5289
5683
  for (const a of roots) {
@@ -5298,8 +5692,6 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5298
5692
  pend(page.route, resolved.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its root contains another section's.");
5299
5693
  }
5300
5694
  const candidates = roots.filter((r) => !nested.has(r)).sort((a, b) => a.root.start - b.root.start);
5301
- if (candidates.length === 0) continue;
5302
- const extractable = componentsOf(dialect);
5303
5695
  const placeable = [];
5304
5696
  for (const resolved of candidates) {
5305
5697
  const path = `${COMPONENT_DIR}/${resolved.name}.${dialect === "astro" ? "astro" : "tsx"}`;
@@ -5312,7 +5704,25 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5312
5704
  placeable.push(resolved);
5313
5705
  continue;
5314
5706
  }
5315
- const built = componentFile(dialect, path, file, content, parsed, resolved);
5707
+ const built = componentFile(dialect, path, file, content, parsed, resolved, resolved.pathPrefix);
5708
+ if (built.unconvertible.length > 0) {
5709
+ pend(
5710
+ page.route,
5711
+ resolved.section.groupKey,
5712
+ "PROP_MAP_INCONSISTENT",
5713
+ `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.`
5714
+ );
5715
+ continue;
5716
+ }
5717
+ if (built.duplicates.length > 0) {
5718
+ pend(
5719
+ page.route,
5720
+ resolved.section.groupKey,
5721
+ "DUPLICATE_FIELD_BINDING",
5722
+ `${built.duplicates.join(", ")} carries more than one data-bcms-field, so there is no single address to rewrite.`
5723
+ );
5724
+ continue;
5725
+ }
5316
5726
  if (built.free.length > 0) {
5317
5727
  pend(
5318
5728
  page.route,
@@ -5322,7 +5732,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5322
5732
  );
5323
5733
  continue;
5324
5734
  }
5325
- extractable.set(resolved.slug, { name: resolved.name, path });
5735
+ extractable.set(resolved.slug, { name: resolved.name, path, kind: "default", imported: null });
5326
5736
  fresh.add(resolved.slug);
5327
5737
  addFile(outFiles, contents, {
5328
5738
  path,
@@ -5331,24 +5741,54 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5331
5741
  });
5332
5742
  placeable.push(resolved);
5333
5743
  }
5334
- if (placeable.length === 0) continue;
5335
- const layout = runOf(placeable, parsed.roots);
5744
+ if (placeable.length === 0 && registeredHere.length === 0) continue;
5745
+ const spans = [
5746
+ ...placeable.map((r) => ({ slug: r.slug, bind: bindOf(r.section), root: r.root })),
5747
+ ...registeredHere.map((r) => ({ slug: r.slug, bind: bindOf(r.section), root: r.call }))
5748
+ ].sort((a, b) => (a.root?.start ?? Number.MAX_SAFE_INTEGER) - (b.root?.start ?? Number.MAX_SAFE_INTEGER));
5749
+ const known = spans.filter((s) => s.root !== null);
5750
+ const layout = runOf(known, parsed.roots, content);
5336
5751
  if (layout === null) {
5337
5752
  for (const resolved of placeable) {
5338
5753
  pend(page.route, resolved.section.groupKey, "SECTION_NOT_CONTIGUOUS", "its roots share no container.");
5339
5754
  }
5340
5755
  continue;
5341
5756
  }
5757
+ const wholePage = registeredHere.length === 0 || known.length === spans.length && pending.length === pendingBefore;
5758
+ const useRun = layout.contiguous && wholePage;
5342
5759
  const order = shipped.get(dialect) ?? /* @__PURE__ */ new Map();
5343
5760
  shipped.set(dialect, order);
5344
5761
  order.set(
5345
5762
  pageSlug,
5346
- placeable.map((r) => ({ slug: r.slug, bind: r.section.groupKey }))
5763
+ spans.map((s) => s.bind === void 0 ? { slug: s.slug } : { slug: s.slug, bind: s.bind })
5347
5764
  );
5348
- const rewritten = layout.contiguous ? splicePageRun(file, content, dialect, parsed, placeable, pageSlug) : splicePageInline(file, content, dialect, parsed, placeable, pageSlug, extractable);
5349
- addFile(outFiles, contents, { path: file, operation: "modify", content: rewritten });
5350
- if (layout.contiguous) extracted += placeable.length;
5351
- else inlineOnly += placeable.length;
5765
+ if (useRun) {
5766
+ addFile(outFiles, contents, {
5767
+ path: file,
5768
+ operation: "modify",
5769
+ content: splicePageRun(file, content, dialect, parsed, known, pageSlug)
5770
+ });
5771
+ extracted += placeable.length;
5772
+ } else {
5773
+ const inline = placeable.filter((resolved) => !instanceOwned(resolved.section));
5774
+ for (const resolved of placeable) {
5775
+ if (inline.includes(resolved)) continue;
5776
+ pend(
5777
+ page.route,
5778
+ resolved.section.groupKey,
5779
+ "INLINE_NEEDS_INSTANCE",
5780
+ `${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.`
5781
+ );
5782
+ }
5783
+ if (inline.length > 0) {
5784
+ addFile(outFiles, contents, {
5785
+ path: file,
5786
+ operation: "modify",
5787
+ content: splicePageInline(file, content, dialect, parsed, inline, pageSlug, extractable)
5788
+ });
5789
+ }
5790
+ inlineOnly += inline.length;
5791
+ }
5352
5792
  }
5353
5793
  const componentSlugs = [...written.values()].flatMap((held) => [...held.keys()]);
5354
5794
  if (componentSlugs.length > 0) {
@@ -5370,15 +5810,17 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5370
5810
  emitHelper(outFiles, contents, helperVersion, options?.overwriteHelper);
5371
5811
  emitStubs(outFiles, contents, pageSlugs);
5372
5812
  }
5373
- const sections = assertSections(total, { extracted, inlineOnly, alreadyExtracted, pending });
5813
+ const sections = assertSections(total, { extracted, inlineOnly, alreadyExtracted, registered, pending });
5374
5814
  return {
5375
5815
  files: [...outFiles.values()].sort((a, b) => a.path.localeCompare(b.path)),
5376
5816
  receipt: {
5377
5817
  planDigest: plan2.digest ?? "",
5818
+ copy,
5378
5819
  sections,
5379
5820
  // What THIS run wrote a file for. A component an earlier run left is reused, not re-reported.
5380
5821
  components: [...fresh].sort(),
5381
- planPending
5822
+ planPending,
5823
+ notes
5382
5824
  }
5383
5825
  };
5384
5826
  }
@@ -5391,29 +5833,152 @@ function alreadyOn(content, pageSlug, sections) {
5391
5833
  }
5392
5834
  return sections.map((section) => section.groupKey).filter((groupKey) => new RegExp(`bind=\\s*["']${groupKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content));
5393
5835
  }
5394
- function readRegistry(content, dialect) {
5836
+ async function readRegistry(content, dialect, path, files, aliases) {
5395
5837
  const components = /* @__PURE__ */ new Map();
5396
5838
  const shipped = /* @__PURE__ */ new Map();
5397
- const paths = /* @__PURE__ */ new Map();
5398
- for (const match of content.matchAll(/^import\s+(\w+)\s+from\s+"\.\/([^"]+)";$/gm)) {
5399
- const [, name, file] = match;
5400
- paths.set(name, `${COMPONENT_DIR}/${file}${dialect === "astro" ? "" : ".tsx"}`);
5401
- }
5402
- const registry = content.match(/const REGISTRY[^=]*=\s*\{([\s\S]*?)\n\};/);
5403
- for (const match of (registry?.[1] ?? "").matchAll(/^\s*"([^"]+)":\s*(\w+),$/gm)) {
5404
- const [, slug, name] = match;
5405
- const path = paths.get(name);
5406
- if (path) components.set(slug, { name, path });
5407
- }
5408
- const table = content.match(/const SHIPPED[^=]*=\s*\{([\s\S]*?)\n\};/);
5409
- for (const match of (table?.[1] ?? "").matchAll(/^\s*"([^"]+)":\s*(\[.*\]),$/gm)) {
5839
+ const parsed = await parseFile4(dialect, content);
5840
+ if (parsed.error) return null;
5841
+ const imports = /* @__PURE__ */ new Map();
5842
+ for (const [local, from] of importsIn(parsed, dialect)) {
5843
+ if (!from.specifier.startsWith(".")) continue;
5844
+ const resolved = resolveSpecifier(path, from.specifier, files, aliases);
5845
+ if (resolved !== null) {
5846
+ imports.set(local, { name: local, path: preferDialect(resolved, dialect, files), kind: from.kind, imported: from.imported });
5847
+ }
5848
+ }
5849
+ const registry = blockOf(content, "REGISTRY");
5850
+ const table = blockOf(content, "SHIPPED");
5851
+ if (registry === null || table === null) return null;
5852
+ const registryRows = propertiesOf(registry);
5853
+ const shippedRows = propertiesOf(table);
5854
+ if (registryRows === null || shippedRows === null) return null;
5855
+ if (registryRows.length === 0) return null;
5856
+ for (const row of registryRows) {
5857
+ const named = row.value.match(/^([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?$/);
5858
+ const entry = named ? imports.get(named[1]) : void 0;
5859
+ if (!named || !entry) return null;
5860
+ components.set(row.key, named[2] === void 0 ? entry : { ...entry, member: named[2] });
5861
+ }
5862
+ for (const row of shippedRows) {
5410
5863
  try {
5411
- shipped.set(match[1], JSON.parse(match[2]));
5864
+ const placements = JSON.parse(row.value);
5865
+ if (!Array.isArray(placements) || placements.length === 0 || !placements.every(isPlacement)) return null;
5866
+ if (placements.some((placement) => !components.has(placement.slug))) return null;
5867
+ shipped.set(row.key, placements);
5412
5868
  } catch {
5869
+ return null;
5413
5870
  }
5414
5871
  }
5415
5872
  return { components, shipped };
5416
5873
  }
5874
+ function preferDialect(resolved, dialect, files) {
5875
+ const wanted = dialect === "astro" ? [".astro"] : [".tsx", ".jsx"];
5876
+ if (wanted.some((extension) => resolved.endsWith(extension))) return resolved;
5877
+ const base = resolved.replace(/\.[^./]+$/, "");
5878
+ return wanted.map((extension) => `${base}${extension}`).find((candidate) => files.has(candidate)) ?? resolved;
5879
+ }
5880
+ function isPlacement(value) {
5881
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
5882
+ const held = value;
5883
+ if (typeof held.slug !== "string") return false;
5884
+ return ["bind", "blockId"].every((key) => held[key] === void 0 || typeof held[key] === "string");
5885
+ }
5886
+ function blockOf(content, name) {
5887
+ const declaration2 = content.indexOf(`const ${name}`);
5888
+ if (declaration2 < 0) return null;
5889
+ const open = content.indexOf("{", declaration2);
5890
+ if (open < 0) return null;
5891
+ let depth = 0;
5892
+ let at = open;
5893
+ while (at < content.length) {
5894
+ const skip = skipTrivia(content, at);
5895
+ if (skip >= 0) {
5896
+ at = skip;
5897
+ continue;
5898
+ }
5899
+ if (content[at] === "{") depth++;
5900
+ else if (content[at] === "}" && --depth === 0) return content.slice(open + 1, at);
5901
+ at++;
5902
+ }
5903
+ return null;
5904
+ }
5905
+ function skipTrivia(source, at) {
5906
+ const char = source[at];
5907
+ if (char === '"' || char === "'" || char === "`") {
5908
+ let cursor = at + 1;
5909
+ while (cursor < source.length && source[cursor] !== char) cursor += source[cursor] === "\\" ? 2 : 1;
5910
+ return Math.min(cursor + 1, source.length);
5911
+ }
5912
+ if (char === "/" && source[at + 1] === "/") {
5913
+ const line = source.indexOf("\n", at);
5914
+ return line < 0 ? source.length : line;
5915
+ }
5916
+ if (char === "/" && source[at + 1] === "*") {
5917
+ const close = source.indexOf("*/", at + 2);
5918
+ return close < 0 ? source.length : close + 2;
5919
+ }
5920
+ return -1;
5921
+ }
5922
+ function withoutComments(text) {
5923
+ let out = "";
5924
+ let at = 0;
5925
+ while (at < text.length) {
5926
+ const skip = skipTrivia(text, at);
5927
+ if (skip < 0) {
5928
+ out += text[at];
5929
+ at++;
5930
+ continue;
5931
+ }
5932
+ out += text[at] === '"' || text[at] === "'" || text[at] === "`" ? text.slice(at, skip) : "";
5933
+ at = skip;
5934
+ }
5935
+ return out;
5936
+ }
5937
+ function propertiesOf(block) {
5938
+ const out = [];
5939
+ const push = (text) => {
5940
+ const held = text.trim();
5941
+ if (held === "") return true;
5942
+ let depth2 = 0;
5943
+ for (let at = 0; at < held.length; ) {
5944
+ const skip = skipTrivia(held, at);
5945
+ if (skip >= 0) {
5946
+ at = skip;
5947
+ continue;
5948
+ }
5949
+ const char = held[at];
5950
+ if ("{[(".includes(char)) depth2++;
5951
+ else if ("}])".includes(char)) depth2--;
5952
+ else if (char === ":" && depth2 === 0) {
5953
+ const key = withoutComments(held.slice(0, at)).trim().replace(/^["'`]|["'`]$/g, "");
5954
+ const value = withoutComments(held.slice(at + 1)).trim();
5955
+ if (key === "" || value === "") return false;
5956
+ out.push({ key, value });
5957
+ return true;
5958
+ }
5959
+ at++;
5960
+ }
5961
+ return false;
5962
+ };
5963
+ let depth = 0;
5964
+ let start = 0;
5965
+ for (let at = 0; at < block.length; ) {
5966
+ const skip = skipTrivia(block, at);
5967
+ if (skip >= 0) {
5968
+ at = skip;
5969
+ continue;
5970
+ }
5971
+ const char = block[at];
5972
+ if ("{[(".includes(char)) depth++;
5973
+ else if ("}])".includes(char)) depth--;
5974
+ else if (char === "," && depth === 0) {
5975
+ if (!push(block.slice(start, at))) return null;
5976
+ start = at + 1;
5977
+ }
5978
+ at++;
5979
+ }
5980
+ return push(block.slice(start)) ? out : null;
5981
+ }
5417
5982
  function snapshotUsable(existing) {
5418
5983
  if (existing === void 0) return true;
5419
5984
  try {
@@ -5498,7 +6063,22 @@ function lowestCommonAncestor(elements) {
5498
6063
  }
5499
6064
  return common[common.length - 1] ?? null;
5500
6065
  }
5501
- function runOf(placeable, roots) {
6066
+ var calledIn = (tree, binding) => [...tree.elements.values()].some(
6067
+ (placed) => binding.kind === "namespace" ? placed.node.tag.startsWith(`${binding.local}.`) : placed.node.tag === binding.local
6068
+ );
6069
+ function namespaceMember(tree, local) {
6070
+ const members = new Set(
6071
+ [...tree.elements.values()].map((placed) => placed.node.tag).filter((tag) => tag.startsWith(`${local}.`)).map((tag) => tag.slice(local.length + 1))
6072
+ );
6073
+ return members.size === 1 ? [...members][0] : void 0;
6074
+ }
6075
+ function callSiteOf(tree, local) {
6076
+ const calls = [...tree.elements.values()].map((placed) => placed.node).filter((node) => node.tag === local);
6077
+ const only = calls.length === 1 ? calls[0] : null;
6078
+ return only !== null && only.attrs.length === 0 ? only : null;
6079
+ }
6080
+ function runOf(placeable, roots, content) {
6081
+ if (placeable.length === 0) return null;
5502
6082
  const parents = placeable.map((r) => parentOf(r.root, roots));
5503
6083
  if (parents.some((p) => p === void 0)) return null;
5504
6084
  const [first, ...rest] = parents;
@@ -5508,9 +6088,14 @@ function runOf(placeable, roots) {
5508
6088
  );
5509
6089
  const indices = placeable.map((r) => siblings.indexOf(r.root)).sort((a, b) => a - b);
5510
6090
  if (indices.some((at) => at < 0)) return null;
5511
- const contiguous = indices.every((at, i) => i === 0 || at === indices[i - 1] + 1);
5512
- return { contiguous };
6091
+ const consecutive2 = indices.every((at, i) => i === 0 || at === indices[i - 1] + 1);
6092
+ const ordered = [...placeable].sort((a, b) => a.root.start - b.root.start);
6093
+ const clean = ordered.every(
6094
+ (span, at) => at === 0 || isTrivia(content.slice(ordered[at - 1].root.end, span.root.start))
6095
+ );
6096
+ return { contiguous: consecutive2 && clean };
5513
6097
  }
6098
+ var isTrivia = (between) => between.replace(/<!--[\s\S]*?-->/g, "").replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, "").trim() === "";
5514
6099
  function parentOf(target, roots) {
5515
6100
  if (roots.some((node) => node === target)) return null;
5516
6101
  const search = (nodes, parent) => {
@@ -5544,14 +6129,35 @@ function pascal(value) {
5544
6129
  return /^[A-Za-z]/.test(name) ? name : `Section${name}`;
5545
6130
  }
5546
6131
  var slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
5547
- function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5548
- const markup = sliceWithBlockId(content, resolved.root);
6132
+ function componentFile(dialect, path, fromFile, content, parsed, resolved, pathPrefix) {
6133
+ const addresses = fieldAddresses(content, elementsUnder(resolved.root));
6134
+ const unconvertible = [
6135
+ ...addresses.heads.filter((address) => !address.startsWith(pathPrefix)),
6136
+ ...addresses.opaque
6137
+ ];
6138
+ if (unconvertible.length > 0) return { content: "", free: [], duplicates: [], unconvertible };
6139
+ const cut = content.slice(resolved.root.start, resolved.root.end);
6140
+ const reserved = /* @__PURE__ */ new Set([...topLevelBindings(dialect, parsed, content), ...referencedNames2(cut)]);
6141
+ const introduced = allocateNames(reserved, [
6142
+ { key: "bcmsFields", name: "bcmsFields" },
6143
+ { key: "bcmsField", name: "bcmsField" }
6144
+ ]);
6145
+ const fieldsName = introduced.get("bcmsFields");
6146
+ const fieldName = introduced.get("bcmsField");
6147
+ const { markup, bound, duplicates } = sectionMarkup(content, resolved.root, fieldName);
6148
+ if (duplicates.length > 0) return { content: "", free: [], duplicates, unconvertible: [] };
5549
6149
  const groupKey = resolved.section.groupKey;
5550
6150
  const locals = helperLocals(parsed, dialect, fromFile);
5551
6151
  const helperNames = ["bcms", "bcmsRows", "bcmsLayout"].flatMap(
5552
6152
  (name) => locals[name].filter((local) => mentions(markup, local)).map((local) => ({ name, local }))
5553
6153
  );
5554
- const helpers = [.../* @__PURE__ */ new Set([...helperNames.map((h) => alias(h.name, h.local)), "bcmsSection"])].sort();
6154
+ const helpers = [
6155
+ .../* @__PURE__ */ new Set([
6156
+ ...helperNames.map((h) => alias(h.name, h.local)),
6157
+ "bcmsSection",
6158
+ ...bound > 0 ? [alias("bcmsFields", fieldsName)] : []
6159
+ ])
6160
+ ].sort();
5555
6161
  const helperSpecifier = relativeImport(path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
5556
6162
  const snapshot = snapshotOf(parsed, dialect, fromFile, markup);
5557
6163
  const carry = carryFor({
@@ -5566,20 +6172,23 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5566
6172
  "bind",
5567
6173
  "overrides",
5568
6174
  "page",
6175
+ fieldName,
5569
6176
  ...helperNames.map((h) => h.local),
5570
6177
  ...snapshot ? [snapshot] : []
5571
6178
  ],
5572
6179
  // The helper and the snapshots are what the component declares for itself.
5573
6180
  skipSpecifier: (repoPath, specifier) => specifier.includes(`${CONTENT_DIR}/`) || repoPath.startsWith(HELPER_PATH[dialect].replace(/\.tsx?$/, ""))
5574
6181
  });
5575
- if (carry.free.length > 0) return { content: "", free: carry.free };
6182
+ if (carry.free.length > 0) return { content: "", free: carry.free, duplicates: [], unconvertible: [] };
5576
6183
  const carried = [...carry.imports];
5577
6184
  const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
5578
6185
  const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
5579
6186
  ` : "";
5580
- const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind ?? ${JSON.stringify(groupKey)}, overrides) };` : "";
6187
+ const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind, overrides) };` : "";
6188
+ const field = bound > 0 ? `const ${fieldName} = ${fieldsName}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)});` : "";
6189
+ const declared = [copy, field].filter(Boolean).join("\n");
5581
6190
  if (dialect === "astro") {
5582
- return { free: [], content: `---
6191
+ return { free: [], duplicates: [], unconvertible: [], content: `---
5583
6192
  ${SECTION_MARKER}
5584
6193
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`].join("\n")}
5585
6194
 
@@ -5594,27 +6203,297 @@ interface Props {
5594
6203
  page?: unknown;
5595
6204
  }
5596
6205
  const { blockId, bind, overrides, page } = Astro.props;
5597
- ${carriedDeclarations}${copy}
6206
+ ${carriedDeclarations}${declared}
5598
6207
  ---
5599
6208
  ${markup}
5600
6209
  ` };
5601
6210
  }
5602
- return { free: [], content: `${SECTION_MARKER}
6211
+ return { free: [], duplicates: [], unconvertible: [], content: `${SECTION_MARKER}
5603
6212
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
5604
6213
  ${carriedDeclarations === "" ? "" : `
5605
6214
  ${carriedDeclarations}`}
5606
6215
  export default function ${resolved.name}({ blockId, bind, overrides, page }: BcmsSectionProps) {
5607
- ${copy}
6216
+ ${declared.split("\n").join("\n ")}
5608
6217
  return (
5609
6218
  ${markup}
5610
6219
  );
5611
6220
  }
5612
6221
  ` };
5613
6222
  }
5614
- function sliceWithBlockId(content, root) {
6223
+ function sectionMarkup(content, root, fieldName) {
5615
6224
  const slice = new MagicString3(content.slice(root.start, root.end));
5616
- slice.appendLeft(root.attrInsertAt - root.start, " data-bcms-block={blockId}");
5617
- return slice.toString();
6225
+ const at = (offset) => offset - root.start;
6226
+ const stamps = root.attrs.filter((attr) => attr.name === "data-bcms-block");
6227
+ if (stamps.length === 0) slice.appendLeft(at(root.attrInsertAt), " data-bcms-block={blockId}");
6228
+ else {
6229
+ slice.overwrite(at(stamps[0].range.start), at(stamps[0].range.end), "data-bcms-block={blockId}");
6230
+ for (const extra of stamps.slice(1)) {
6231
+ let from = extra.range.start;
6232
+ while (from > root.start && /\s/.test(content[from - 1])) from--;
6233
+ slice.remove(at(from), at(extra.range.end));
6234
+ }
6235
+ }
6236
+ const wrapped = wrapFieldAttrs(slice, content, elementsUnder(root), at, fieldName);
6237
+ return { markup: slice.toString(), ...wrapped };
6238
+ }
6239
+ function wrapFieldAttrs(slice, content, elements, at, fieldName) {
6240
+ let bound = 0;
6241
+ const duplicates = [];
6242
+ for (const element of elements) {
6243
+ const fields = element.attrs.filter((attr) => attr.name === "data-bcms-field");
6244
+ if (fields.length > 1) {
6245
+ duplicates.push(`<${element.tag}>`);
6246
+ continue;
6247
+ }
6248
+ for (const attr of fields) {
6249
+ bound++;
6250
+ if (attr.expressionRange) {
6251
+ slice.appendRight(at(attr.expressionRange.start) + 1, `${fieldName}(`);
6252
+ slice.appendLeft(at(attr.expressionRange.end) - 1, ")");
6253
+ continue;
6254
+ }
6255
+ const value = attr.valueRange ? content.slice(attr.valueRange.start, attr.valueRange.end) : "";
6256
+ slice.overwrite(
6257
+ at(attr.range.start),
6258
+ at(attr.range.end),
6259
+ `data-bcms-field={${fieldName}(${JSON.stringify(value)})}`
6260
+ );
6261
+ }
6262
+ }
6263
+ return { bound, duplicates };
6264
+ }
6265
+ function provableAddress(expression) {
6266
+ const first = expression[0];
6267
+ if (first === '"' || first === "'") {
6268
+ for (let at = 1; at < expression.length; at++) {
6269
+ if (expression[at] === "\\") {
6270
+ at++;
6271
+ continue;
6272
+ }
6273
+ if (expression[at] === first) return at === expression.length - 1 ? expression.slice(1, at) : null;
6274
+ }
6275
+ return null;
6276
+ }
6277
+ if (first !== "`" || expression.length < 2 || !expression.endsWith("`")) return null;
6278
+ const body = expression.slice(1, -1);
6279
+ if (body.includes("`") || body.includes("\\")) return null;
6280
+ let head = null;
6281
+ for (let at = 0; at < body.length; at++) {
6282
+ if (body[at] !== "$" || body[at + 1] !== "{") continue;
6283
+ const close = body.indexOf("}", at + 2);
6284
+ if (close < 0) return null;
6285
+ if (!/^[A-Za-z_$][\w$]*$/.test(body.slice(at + 2, close).trim())) return null;
6286
+ if (body[at - 1] !== "[" || body[close + 1] !== "]") return null;
6287
+ head ??= body.slice(0, at);
6288
+ at = close;
6289
+ }
6290
+ return head ?? body;
6291
+ }
6292
+ function fieldAddresses(content, elements) {
6293
+ const heads = [];
6294
+ const opaque = [];
6295
+ for (const element of elements) {
6296
+ for (const attr of element.attrs) {
6297
+ if (attr.name !== "data-bcms-field") continue;
6298
+ if (!attr.expression && attr.valueRange) {
6299
+ heads.push(content.slice(attr.valueRange.start, attr.valueRange.end));
6300
+ continue;
6301
+ }
6302
+ const inner = attr.expressionRange ? content.slice(attr.expressionRange.start + 1, attr.expressionRange.end - 1).trim() : "";
6303
+ const head = provableAddress(inner);
6304
+ if (head === null) opaque.push(`data-bcms-field={${inner}}`);
6305
+ else heads.push(head);
6306
+ }
6307
+ }
6308
+ return { heads, opaque };
6309
+ }
6310
+ function elementsUnder(...roots) {
6311
+ const out = [];
6312
+ const walk = (node) => {
6313
+ if (node.type === "element") {
6314
+ out.push(node);
6315
+ for (const child of node.children) walk(child);
6316
+ return;
6317
+ }
6318
+ if (node.type === "expr") for (const child of node.children) walk(child);
6319
+ };
6320
+ for (const root of roots) walk(root);
6321
+ return out;
6322
+ }
6323
+ var V1_ANCHOR = {
6324
+ astro: "const { blockId, bind, overrides, page } = Astro.props;",
6325
+ jsx: "}: BcmsSectionProps) {"
6326
+ };
6327
+ var refusalKey = (dialect, slug) => `${dialect}\0${slug}`;
6328
+ function generatedBindings(parsed) {
6329
+ const program = parsed.ast?.program;
6330
+ const body = program?.body ?? [];
6331
+ const scopes = [body];
6332
+ for (const statement of body) {
6333
+ const declaration2 = statement.type === "ExportDefaultDeclaration" ? statement.declaration : void 0;
6334
+ const inner = declaration2?.type === "FunctionDeclaration" ? declaration2.body?.body : void 0;
6335
+ if (inner) scopes.push(inner);
6336
+ }
6337
+ const out = /* @__PURE__ */ new Map();
6338
+ for (const scope of scopes) {
6339
+ for (const statement of scope) {
6340
+ if (statement.type !== "VariableDeclaration") continue;
6341
+ for (const declarator of statement.declarations ?? []) {
6342
+ const id = declarator.id;
6343
+ const name = id?.type === "Identifier" ? id.name : void 0;
6344
+ if (name !== void 0 && !out.has(name)) out.set(name, declarator.init);
6345
+ }
6346
+ }
6347
+ }
6348
+ return out;
6349
+ }
6350
+ function stripBindFallback(parsed, source, content, locals) {
6351
+ if (!parsed.ast?.program) return "unreadable";
6352
+ const offset = parsed.script?.offset ?? 0;
6353
+ let rewritten = 0;
6354
+ let unproven = 0;
6355
+ walkAst(parsed.ast, (node) => {
6356
+ if (node.type !== "CallExpression") return;
6357
+ const argument = (node.arguments ?? [])[1];
6358
+ if (argument?.type !== "LogicalExpression" || argument.operator !== "??") return;
6359
+ const callee = node.callee;
6360
+ if (callee?.type !== "Identifier" || !locals.has(callee.name)) {
6361
+ unproven++;
6362
+ return;
6363
+ }
6364
+ const left = argument.left;
6365
+ source.overwrite(
6366
+ offset + argument.start,
6367
+ offset + argument.end,
6368
+ content.slice(offset + left.start, offset + left.end)
6369
+ );
6370
+ rewritten++;
6371
+ });
6372
+ return rewritten === 0 && unproven > 0 ? "unproven" : "ok";
6373
+ }
6374
+ var isCallOf = (init, callee) => {
6375
+ const fn = init?.type === "CallExpression" ? init.callee : void 0;
6376
+ return fn?.type === "Identifier" && fn.name === callee;
6377
+ };
6378
+ async function migrateSections(plan2, contents, written, fileSet, aliases) {
6379
+ const files = [];
6380
+ const refused = /* @__PURE__ */ new Map();
6381
+ const prefixesFor = (slug) => {
6382
+ const out = /* @__PURE__ */ new Set();
6383
+ for (const page of plan2.pages ?? []) {
6384
+ for (const section of page.sections ?? []) {
6385
+ if (resolveComponent(plan2, section).slug !== slug) continue;
6386
+ const prefix = propPrefix(section);
6387
+ if (prefix !== null) out.add(prefix);
6388
+ }
6389
+ }
6390
+ return [...out];
6391
+ };
6392
+ for (const [dialect, components] of written) {
6393
+ for (const [slug, entry] of components) {
6394
+ const content = contents.get(entry.path);
6395
+ if (content === void 0 || content.includes(SECTION_MARKER)) continue;
6396
+ const older = SECTION_MARKERS.find((marker) => content.includes(marker));
6397
+ if (older === void 0) continue;
6398
+ const candidates = prefixesFor(slug);
6399
+ if (candidates.length === 0) continue;
6400
+ const parsed = await parseFile4(dialect, content);
6401
+ if (parsed.error) {
6402
+ refused.set(refusalKey(dialect, slug), `${entry.path} carries our marker but does not parse (${parsed.error.message}).`);
6403
+ continue;
6404
+ }
6405
+ const elements = elementsUnder(...parsed.roots);
6406
+ const imports = [...importsIn(parsed, dialect)];
6407
+ const helperModule = HELPER_PATH[dialect];
6408
+ const helperLocalsFor = (exported) => new Set(
6409
+ imports.filter(
6410
+ ([, from]) => from.imported === exported && resolveSpecifier(entry.path, from.specifier, fileSet, aliases) === helperModule
6411
+ ).map(([local]) => local)
6412
+ );
6413
+ const { heads: evidence, opaque } = fieldAddresses(content, elements);
6414
+ const pathPrefix = evidence.length + opaque.length === 0 ? "" : opaque.length > 0 ? null : onlyOne(candidates.filter((prefix) => evidence.every((head) => head.startsWith(prefix)))) ?? null;
6415
+ if (pathPrefix === null) {
6416
+ const matching = candidates.filter((prefix) => evidence.every((head) => head.startsWith(prefix)));
6417
+ refused.set(
6418
+ refusalKey(dialect, slug),
6419
+ 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.`
6420
+ );
6421
+ continue;
6422
+ }
6423
+ const source = new MagicString3(content);
6424
+ const { bound, duplicates } = wrapFieldAttrs(source, content, elements, (at) => at, "bcmsField");
6425
+ if (duplicates.length > 0) {
6426
+ 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.`);
6427
+ continue;
6428
+ }
6429
+ const markerEnd = content.indexOf(older) + older.length;
6430
+ source.overwrite(markerEnd - older.length, markerEnd, SECTION_MARKER);
6431
+ const stripped = stripBindFallback(parsed, source, content, helperLocalsFor("bcmsSection"));
6432
+ if (stripped !== "ok") {
6433
+ refused.set(
6434
+ refusalKey(dialect, slug),
6435
+ 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.`
6436
+ );
6437
+ continue;
6438
+ }
6439
+ if (bound > 0) {
6440
+ const locals = generatedBindings(parsed);
6441
+ const names = /* @__PURE__ */ new Set([...topLevelBindings(dialect, parsed, content), ...locals.keys()]);
6442
+ const fieldsLocal = [...helperLocalsFor("bcmsFields")][0];
6443
+ if (fieldsLocal === void 0 && names.has("bcmsFields")) {
6444
+ refused.set(
6445
+ refusalKey(dialect, slug),
6446
+ `${entry.path} already binds "bcmsFields" to something other than ${helperModule}'s export.`
6447
+ );
6448
+ continue;
6449
+ }
6450
+ const declared = names.has("bcmsField");
6451
+ if (declared && (fieldsLocal === void 0 || !isCallOf(locals.get("bcmsField"), fieldsLocal))) {
6452
+ refused.set(
6453
+ refusalKey(dialect, slug),
6454
+ `${entry.path} already binds "bcmsField" to something other than a call of ${helperModule}'s bcmsFields.`
6455
+ );
6456
+ continue;
6457
+ }
6458
+ if (!declared) {
6459
+ const anchor = V1_ANCHOR[dialect];
6460
+ const at = anchor ? content.indexOf(anchor) : -1;
6461
+ if (anchor === void 0 || at < 0) {
6462
+ 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.`);
6463
+ continue;
6464
+ }
6465
+ source.appendLeft(
6466
+ at + anchor.length,
6467
+ `
6468
+ ${dialect === "astro" ? "" : " "}const bcmsField = ${fieldsLocal ?? "bcmsFields"}({ blockId, bind, overrides }, ${JSON.stringify(pathPrefix)});`
6469
+ );
6470
+ }
6471
+ if (fieldsLocal === void 0) {
6472
+ const helper = imports.find(([, from]) => from.imported === "bcmsSection")?.[1].specifier ?? relativeImport(entry.path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
6473
+ source.appendLeft(markerEnd, `
6474
+ import { bcmsFields } from "${helper}";`);
6475
+ }
6476
+ }
6477
+ files.push({ path: entry.path, operation: "modify", content: source.toString() });
6478
+ }
6479
+ }
6480
+ return { files, refused };
6481
+ }
6482
+ function propPrefix(section) {
6483
+ const prefixes = /* @__PURE__ */ new Set();
6484
+ for (const field of section.fields ?? []) {
6485
+ const { path, key } = field;
6486
+ if (typeof path !== "string" || typeof key !== "string") continue;
6487
+ if (!path.endsWith(key)) return null;
6488
+ prefixes.add(path.slice(0, path.length - key.length));
6489
+ }
6490
+ if (prefixes.size > 1) return null;
6491
+ return prefixes.size === 1 ? [...prefixes][0] : `${section.groupKey}.`;
6492
+ }
6493
+ function referencedNames2(markup) {
6494
+ const out = /* @__PURE__ */ new Set();
6495
+ for (const [name] of markup.matchAll(/[A-Za-z_$][\w$]*/g)) out.add(name);
6496
+ return out;
5618
6497
  }
5619
6498
  var alias = (name, local) => name === local ? name : `${name} as ${local}`;
5620
6499
  var mentions = (markup, name) => new RegExp(`(?<![\\w$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w$])`).test(markup);
@@ -5730,9 +6609,9 @@ function registryFile(dialect, path, written, shipped) {
5730
6609
  const entries = [...written].sort(([a], [b]) => a.localeCompare(b));
5731
6610
  const imports = entries.map(([, component]) => {
5732
6611
  const specifier = relativeImport(path, component.path);
5733
- return `import ${component.name} from "${dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, "")}";`;
6612
+ return importLine(component.name, dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, ""), component);
5734
6613
  }).join("\n");
5735
- const registry = entries.map(([slug, component]) => ` ${JSON.stringify(slug)}: ${component.name},`).join("\n");
6614
+ const registry = entries.map(([slug, component]) => ` ${JSON.stringify(slug)}: ${registryValue(component)},`).join("\n");
5736
6615
  const shippedTable = [...shipped].sort(([a], [b]) => a.localeCompare(b)).map(([page, list]) => ` ${JSON.stringify(page)}: ${JSON.stringify(list)},`).join("\n");
5737
6616
  const lib = relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "");
5738
6617
  const registryType = dialect === "astro" ? "Record<string, unknown>" : "Record<string, (props: BcmsSectionProps) => JSX.Element | null>";
@@ -5840,17 +6719,24 @@ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, w
5840
6719
  const snapshot = names.get("pageSnapshot");
5841
6720
  const source = new MagicString3(content);
5842
6721
  for (const resolved of placeable) {
6722
+ const component = written.get(resolved.slug);
6723
+ const tag = component.member ? `${names.get(resolved.slug)}.${component.member}` : names.get(resolved.slug);
5843
6724
  source.overwrite(
5844
6725
  resolved.root.start,
5845
6726
  resolved.root.end,
5846
- `<${names.get(resolved.slug)} bind=${JSON.stringify(resolved.section.groupKey)} page={${snapshot}(${JSON.stringify(pageSlug)})} />`
6727
+ `<${tag} bind=${JSON.stringify(resolved.section.groupKey)} page={${snapshot}(${JSON.stringify(pageSlug)})} />`
5847
6728
  );
5848
6729
  }
5849
6730
  const lib = relativeImport(file, SECTIONS_LIB).replace(/\.tsx?$/, "");
5850
6731
  const imports = [
5851
6732
  ...wanted.map(({ key }) => {
5852
- const specifier = relativeImport(file, written.get(key).path);
5853
- return `import ${names.get(key)} from "${dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, "")}";`;
6733
+ const component = written.get(key);
6734
+ const specifier = relativeImport(file, component.path);
6735
+ return importLine(
6736
+ names.get(key),
6737
+ dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, ""),
6738
+ component
6739
+ );
5854
6740
  }),
5855
6741
  `import { pageSnapshot${snapshot === "pageSnapshot" ? "" : ` as ${snapshot}`} } from "${lib}";`
5856
6742
  ];
@@ -5866,7 +6752,7 @@ function addImports(source, parsed, lines) {
5866
6752
  else source.appendLeft(at.at, `
5867
6753
  ${lines.join("\n")}`);
5868
6754
  }
5869
- var isGenerated = (content) => content.includes(SECTION_MARKER) || content.includes(REGISTRY_MARKER);
6755
+ var isGenerated = (content) => SECTION_MARKERS.some((marker) => content.includes(marker)) || content.includes(REGISTRY_MARKER);
5870
6756
  function emitHelper(out, contents, version, overwrite) {
5871
6757
  const path = HELPER_PATH.astro;
5872
6758
  const source = helperSource("astro");
@@ -6023,28 +6909,78 @@ async function convertSources(briefIn, sources, options) {
6023
6909
  );
6024
6910
  }
6025
6911
  let declaredOnly = 0;
6912
+ const declaredRows = [];
6913
+ const briefRoutes = new Set(brief.pages.map((page) => page.route));
6914
+ const declaredRow = (target, readsFromCms, file) => {
6915
+ declaredRows.push({
6916
+ route: target.route,
6917
+ scope: target.scope,
6918
+ path: target.path,
6919
+ kind: target.kind,
6920
+ ...file ? { file } : {},
6921
+ readsFromCms
6922
+ });
6923
+ };
6026
6924
  const witnessed = async (target) => {
6027
6925
  for (const source of sources) {
6028
6926
  const parsed = await parseOf(source.path);
6029
6927
  const dialect = dialectOf(source.path, source.content);
6030
6928
  if (!parsed || !dialect) continue;
6031
- if (convertedHere(source.content, parsed, dialect, target, source.path)) return true;
6929
+ if (convertedHere(source.content, parsed, dialect, target, source.path) && attributable(target, source.path)) {
6930
+ return source.path;
6931
+ }
6032
6932
  }
6033
- return false;
6933
+ return null;
6034
6934
  };
6035
6935
  const locatedByKey = /* @__PURE__ */ new Map();
6036
6936
  for (const hit of located) {
6037
6937
  const key = keyOf(hit.route, hit.scope ?? "page", hit.path);
6038
6938
  if (!locatedByKey.has(key)) locatedByKey.set(key, hit);
6039
6939
  }
6940
+ const sharingPath = /* @__PURE__ */ new Map();
6941
+ for (const [key, hit] of locatedByKey) {
6942
+ const held = targets.get(key);
6943
+ if (!held) continue;
6944
+ const id = scopedPath(held.scope, held.path);
6945
+ sharingPath.set(id, [...sharingPath.get(id) ?? [], { key, files: hit.files }]);
6946
+ }
6947
+ const claimedByAnother = (target, file) => (sharingPath.get(scopedPath(target.scope, target.path)) ?? []).some(
6948
+ (other) => other.key !== target.key && other.files.includes(file)
6949
+ );
6950
+ const attributable = (target, file, claimed) => {
6951
+ if (target.scope === "layout") return true;
6952
+ const serves = routeOfFile(file);
6953
+ if (serves !== null && serves !== target.route && briefRoutes.has(serves)) return false;
6954
+ if (serves === target.route && pageFilesFor(target.route, contents.keys()).length !== 1) return false;
6955
+ return claimed !== true;
6956
+ };
6957
+ const declaredByHand = (target, within) => {
6958
+ const candidates = within ?? (target.scope === "layout" ? sources.map((s) => s.path) : pageFilesFor(target.route, contents.keys()));
6959
+ for (const file of candidates) {
6960
+ if (!covers(declarations.get(file), target)) continue;
6961
+ if (attributable(target, file, within !== void 0 && claimedByAnother(target, file))) return file;
6962
+ }
6963
+ return null;
6964
+ };
6040
6965
  const missed = /* @__PURE__ */ new Set();
6041
6966
  for (const miss of unlocated) {
6042
6967
  const key = keyOf(miss.route, miss.scope ?? "page", miss.path);
6043
6968
  const target = targets.get(key);
6044
6969
  if (!target || locatedByKey.has(key) || missed.has(key)) continue;
6045
6970
  missed.add(key);
6046
- if (await witnessed(target)) declaredOnly++;
6047
- else pend(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
6971
+ const witness = await witnessed(target);
6972
+ if (witness !== null) {
6973
+ declaredOnly++;
6974
+ declaredRow(target, true, witness);
6975
+ continue;
6976
+ }
6977
+ const byHand = declaredByHand(target);
6978
+ if (byHand !== null) {
6979
+ declaredOnly++;
6980
+ declaredRow(target, false, byHand);
6981
+ continue;
6982
+ }
6983
+ pend(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
6048
6984
  }
6049
6985
  const byFile = /* @__PURE__ */ new Map();
6050
6986
  for (const [key, hit] of locatedByKey) {
@@ -6102,8 +7038,21 @@ async function convertSources(briefIn, sources, options) {
6102
7038
  if (worst) {
6103
7039
  const file = hit.files[outcomes.indexOf(worst)];
6104
7040
  pend(target, worst, file);
6105
- } else if (outcomes.every((o) => o === "declared")) alreadyDeclared++;
6106
- else rewritten++;
7041
+ } else if (outcomes.every((o) => o === "declared")) {
7042
+ const witness = await witnessed(target);
7043
+ const byHand = witness === null ? declaredByHand(target, hit.files) : null;
7044
+ if (witness === null && byHand === null) {
7045
+ pend(
7046
+ target,
7047
+ "AMBIGUOUS_LITERAL",
7048
+ hit.files[0],
7049
+ `${hit.files.join(", ")} already declares ${target.path}, and nothing there says it is ${target.route}'s.`
7050
+ );
7051
+ continue;
7052
+ }
7053
+ alreadyDeclared++;
7054
+ declaredRow(target, witness !== null, witness ?? byHand);
7055
+ } else rewritten++;
6107
7056
  }
6108
7057
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
6109
7058
  const source = new MagicString4(contents.get(file));
@@ -6129,12 +7078,27 @@ async function convertSources(briefIn, sources, options) {
6129
7078
  emitStubs2(dialects, snapshotSlugs, contents, outFiles);
6130
7079
  const receipt = assertReceipt({
6131
7080
  briefDigest: digest,
6132
- paths: { declared: targets.size, rewritten, alreadyDeclared, pending },
7081
+ paths: {
7082
+ declared: targets.size,
7083
+ rewritten,
7084
+ alreadyDeclared,
7085
+ declarations: declaredRows.sort(
7086
+ (a, b) => a.route.localeCompare(b.route) || a.scope.localeCompare(b.scope) || a.path.localeCompare(b.path)
7087
+ ),
7088
+ pending
7089
+ },
6133
7090
  occurrences: { located: occurrencesLocated, rewritten: occurrencesRewritten },
6134
7091
  files: receiptFiles,
6135
7092
  helpers,
6136
7093
  helperUpgraded,
6137
- dynamicBindings
7094
+ dynamicBindings,
7095
+ notes: unreadableConfigs(sources).map(
7096
+ (file) => ({
7097
+ code: "TSCONFIG_UNREADABLE",
7098
+ file,
7099
+ message: `${file} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
7100
+ })
7101
+ )
6138
7102
  });
6139
7103
  return { files: outFiles.sort((a, b) => a.path.localeCompare(b.path)), receipt };
6140
7104
  }
@@ -6402,14 +7366,12 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
6402
7366
  looped.add(member.key);
6403
7367
  outcomes.set(member.key, "rewritten");
6404
7368
  }
6405
- for (const leaf of [...new Set(members.map((m) => m.leaf))]) {
7369
+ for (const declaration2 of plan2.declarations) {
6406
7370
  dynamic.push({
6407
7371
  file,
6408
7372
  prop: null,
6409
- path: shapeOf(arrayPath, leaf),
6410
- // The loop's OWN index name — `i_1` when the row already bound `i`. Hardcoding `i` here
6411
- // described a binding the file does not contain, which is what the proposer looks for.
6412
- callSites: [{ file, literal: `${arrayPath}[\${${plan2.names.index}}].${leaf}` }]
7373
+ path: declaration2.path,
7374
+ callSites: [{ file, literal: declaration2.literal }]
6413
7375
  });
6414
7376
  }
6415
7377
  rewrites.push({
@@ -6695,6 +7657,7 @@ export {
6695
7657
  rewriteFile,
6696
7658
  routeOfFile,
6697
7659
  stripTags,
7660
+ unreadableConfigs,
6698
7661
  walkAst
6699
7662
  };
6700
7663
  //# sourceMappingURL=index.js.map