@bettercms-ai/convert 0.3.0 → 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");
@@ -4839,6 +4904,7 @@ function readPlan(value) {
4839
4904
  const section = check.record(held, at2);
4840
4905
  check.segment(section.groupKey, `${at2}.groupKey`);
4841
4906
  check.optionalString(section.sectionType, `${at2}.sectionType`);
4907
+ check.optionalString(section.copy, `${at2}.copy`);
4842
4908
  if (section.fields !== void 0) {
4843
4909
  for (const [f, field] of check.array(section.fields, `${at2}.fields`).entries()) {
4844
4910
  const shape = check.record(field, `${at2}.fields[${f}]`);
@@ -5228,7 +5294,8 @@ function assertSections(total, sections) {
5228
5294
  }
5229
5295
  return sections;
5230
5296
  }
5231
- 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"];
5232
5299
  var REGISTRY_MARKER = "// @bettercms-ai/convert sections v1";
5233
5300
  var COMPONENT_DIR = "src/components/bcms";
5234
5301
  var SECTIONS_LIB = "src/lib/bcms-sections.ts";
@@ -5250,12 +5317,53 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5250
5317
  let registered = 0;
5251
5318
  let planPending = 0;
5252
5319
  let total = 0;
5320
+ const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5253
5321
  const brief = readBrief(briefIn);
5254
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
+ }
5255
5364
  const contents = new Map(sources.map((s) => [s.path, s.content]));
5256
5365
  const fileSet = new Set(contents.keys());
5257
5366
  const aliases = aliasesFrom(sources);
5258
- const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5259
5367
  const trees = /* @__PURE__ */ new Map();
5260
5368
  const treeOf = async (file) => {
5261
5369
  if (trees.has(file)) return trees.get(file);
@@ -5269,7 +5377,9 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5269
5377
  const helperPath = HELPER_PATH.astro;
5270
5378
  const helperExisting = contents.get(helperPath);
5271
5379
  const helperVersion = helperExisting === void 0 ? null : isKnownHelper(helperExisting, "astro");
5272
- 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
+ }
5273
5383
  const outFiles = /* @__PURE__ */ new Map();
5274
5384
  const written = /* @__PURE__ */ new Map();
5275
5385
  const shipped = /* @__PURE__ */ new Map();
@@ -5279,6 +5389,29 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5279
5389
  return held;
5280
5390
  };
5281
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
+ };
5282
5415
  const registryConflicts = [];
5283
5416
  const registryUnreadable = [];
5284
5417
  for (const dialect of EXTRACTABLE) {
@@ -5304,6 +5437,11 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5304
5437
  if (lib !== void 0 && !isGenerated(lib)) registryConflicts.push(SECTIONS_LIB);
5305
5438
  const registryBlocked = registryConflicts.length > 0 && options?.overwriteHelper !== true;
5306
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
+ }
5307
5445
  for (const page of plan2.pages ?? []) {
5308
5446
  const sections2 = (page.sections ?? []).filter((section) => {
5309
5447
  if (section.pending) {
@@ -5314,23 +5452,33 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5314
5452
  });
5315
5453
  total += sections2.length;
5316
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;
5317
5471
  const pendingBefore = pending.length;
5318
5472
  const briefPage = brief.pages.find((p) => p.route === page.route);
5319
5473
  const pageSlug = briefPage?.slug ?? slugify(page.route === "/" ? "home" : page.route);
5320
- if (helperBlocked) {
5321
- for (const section of sections2) {
5322
- pend(page.route, section.groupKey, "HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5323
- }
5324
- continue;
5325
- }
5326
5474
  if (registryBlocked) {
5327
- for (const section of sections2) {
5475
+ for (const section of usable) {
5328
5476
  pend(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5329
5477
  }
5330
5478
  continue;
5331
5479
  }
5332
5480
  if (registryUnread) {
5333
- for (const section of sections2) {
5481
+ for (const section of usable) {
5334
5482
  pend(
5335
5483
  page.route,
5336
5484
  section.groupKey,
@@ -5342,30 +5490,43 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5342
5490
  }
5343
5491
  const snapshotPath = `${CONTENT_DIR}/${pageSlug}.json`;
5344
5492
  if (!snapshotUsable(contents.get(snapshotPath))) {
5345
- for (const section of sections2) {
5493
+ for (const section of usable) {
5346
5494
  pend(page.route, section.groupKey, "SNAPSHOT_INVALID", `${snapshotPath} is not a JSON object.`);
5347
5495
  }
5348
5496
  continue;
5349
5497
  }
5350
5498
  const pageFiles = pageFilesFor(page.route, contents.keys());
5351
5499
  if (pageFiles.length > 1) {
5352
- for (const section of sections2) {
5500
+ for (const section of usable) {
5353
5501
  pend(page.route, section.groupKey, "ROUTE_FILE_AMBIGUOUS", `${pageFiles.join(" and ")} both serve ${page.route}.`);
5354
5502
  }
5355
5503
  continue;
5356
5504
  }
5357
5505
  const file = pageFiles[0];
5358
5506
  if (file === void 0) {
5359
- for (const section of sections2) {
5507
+ for (const section of usable) {
5360
5508
  pend(page.route, section.groupKey, "NOT_IN_SOURCE", `no page file found for ${page.route}.`);
5361
5509
  }
5362
5510
  continue;
5363
5511
  }
5364
- 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
+ }
5365
5523
  alreadyExtracted += done.size;
5366
- 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
+ }
5367
5528
  const found = await locateSections(
5368
- sections2.filter((section) => !done.has(section.groupKey)),
5529
+ usable.filter((section) => !done.has(section.groupKey)),
5369
5530
  briefPage?.paths ?? [],
5370
5531
  sources,
5371
5532
  treeOf
@@ -5480,7 +5641,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5480
5641
  registered++;
5481
5642
  registeredHere.push({
5482
5643
  slug: component.slug,
5483
- bind: held.section.groupKey,
5644
+ section: held.section,
5484
5645
  call: callSiteOf(tree, member ? `${binding.local}.${member}` : held.call)
5485
5646
  });
5486
5647
  }
@@ -5492,7 +5653,13 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5492
5653
  continue;
5493
5654
  }
5494
5655
  const component = resolveComponent(plan2, placed.section);
5495
- 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
+ });
5496
5663
  }
5497
5664
  const nested = /* @__PURE__ */ new Set();
5498
5665
  for (const a of roots) {
@@ -5519,7 +5686,25 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5519
5686
  placeable.push(resolved);
5520
5687
  continue;
5521
5688
  }
5522
- 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
+ }
5523
5708
  if (built.free.length > 0) {
5524
5709
  pend(
5525
5710
  page.route,
@@ -5540,8 +5725,8 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5540
5725
  }
5541
5726
  if (placeable.length === 0 && registeredHere.length === 0) continue;
5542
5727
  const spans = [
5543
- ...placeable.map((r) => ({ slug: r.slug, bind: r.section.groupKey, root: r.root })),
5544
- ...registeredHere.map((r) => ({ slug: r.slug, bind: r.bind, root: r.call }))
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 }))
5545
5730
  ].sort((a, b) => (a.root?.start ?? Number.MAX_SAFE_INTEGER) - (b.root?.start ?? Number.MAX_SAFE_INTEGER));
5546
5731
  const known = spans.filter((s) => s.root !== null);
5547
5732
  const layout = runOf(known, parsed.roots, content);
@@ -5557,7 +5742,7 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5557
5742
  shipped.set(dialect, order);
5558
5743
  order.set(
5559
5744
  pageSlug,
5560
- spans.map((s) => ({ slug: s.slug, bind: s.bind }))
5745
+ spans.map((s) => s.bind === void 0 ? { slug: s.slug } : { slug: s.slug, bind: s.bind })
5561
5746
  );
5562
5747
  if (useRun) {
5563
5748
  addFile(outFiles, contents, {
@@ -5566,13 +5751,25 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5566
5751
  content: splicePageRun(file, content, dialect, parsed, known, pageSlug)
5567
5752
  });
5568
5753
  extracted += placeable.length;
5569
- } else if (placeable.length > 0) {
5570
- addFile(outFiles, contents, {
5571
- path: file,
5572
- operation: "modify",
5573
- content: splicePageInline(file, content, dialect, parsed, placeable, pageSlug, extractable)
5574
- });
5575
- inlineOnly += placeable.length;
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;
5576
5773
  }
5577
5774
  }
5578
5775
  const componentSlugs = [...written.values()].flatMap((held) => [...held.keys()]);
@@ -5600,17 +5797,12 @@ async function componentizeSources(briefIn, planIn, sources, options) {
5600
5797
  files: [...outFiles.values()].sort((a, b) => a.path.localeCompare(b.path)),
5601
5798
  receipt: {
5602
5799
  planDigest: plan2.digest ?? "",
5800
+ copy,
5603
5801
  sections,
5604
5802
  // What THIS run wrote a file for. A component an earlier run left is reused, not re-reported.
5605
5803
  components: [...fresh].sort(),
5606
5804
  planPending,
5607
- notes: unreadableConfigs(sources).map(
5608
- (path) => ({
5609
- code: "TSCONFIG_UNREADABLE",
5610
- file: path,
5611
- message: `${path} is not JSON this package can read, so its \`paths\` aliases resolved to nothing.`
5612
- })
5613
- )
5805
+ notes
5614
5806
  }
5615
5807
  };
5616
5808
  }
@@ -5633,7 +5825,7 @@ async function readRegistry(content, dialect, path, files, aliases) {
5633
5825
  if (!from.specifier.startsWith(".")) continue;
5634
5826
  const resolved = resolveSpecifier(path, from.specifier, files, aliases);
5635
5827
  if (resolved !== null) {
5636
- imports.set(local, { name: local, path: resolved, kind: from.kind, imported: from.imported });
5828
+ imports.set(local, { name: local, path: preferDialect(resolved, dialect, files), kind: from.kind, imported: from.imported });
5637
5829
  }
5638
5830
  }
5639
5831
  const registry = blockOf(content, "REGISTRY");
@@ -5642,6 +5834,7 @@ async function readRegistry(content, dialect, path, files, aliases) {
5642
5834
  const registryRows = propertiesOf(registry);
5643
5835
  const shippedRows = propertiesOf(table);
5644
5836
  if (registryRows === null || shippedRows === null) return null;
5837
+ if (registryRows.length === 0) return null;
5645
5838
  for (const row of registryRows) {
5646
5839
  const named = row.value.match(/^([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?$/);
5647
5840
  const entry = named ? imports.get(named[1]) : void 0;
@@ -5651,7 +5844,8 @@ async function readRegistry(content, dialect, path, files, aliases) {
5651
5844
  for (const row of shippedRows) {
5652
5845
  try {
5653
5846
  const placements = JSON.parse(row.value);
5654
- if (!Array.isArray(placements)) return null;
5847
+ if (!Array.isArray(placements) || placements.length === 0 || !placements.every(isPlacement)) return null;
5848
+ if (placements.some((placement) => !components.has(placement.slug))) return null;
5655
5849
  shipped.set(row.key, placements);
5656
5850
  } catch {
5657
5851
  return null;
@@ -5659,6 +5853,18 @@ async function readRegistry(content, dialect, path, files, aliases) {
5659
5853
  }
5660
5854
  return { components, shipped };
5661
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
+ }
5662
5868
  function blockOf(content, name) {
5663
5869
  const declaration2 = content.indexOf(`const ${name}`);
5664
5870
  if (declaration2 < 0) return null;
@@ -5905,14 +6111,35 @@ function pascal(value) {
5905
6111
  return /^[A-Za-z]/.test(name) ? name : `Section${name}`;
5906
6112
  }
5907
6113
  var slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
5908
- function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5909
- const markup = sliceWithBlockId(content, resolved.root);
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: [] };
5910
6131
  const groupKey = resolved.section.groupKey;
5911
6132
  const locals = helperLocals(parsed, dialect, fromFile);
5912
6133
  const helperNames = ["bcms", "bcmsRows", "bcmsLayout"].flatMap(
5913
6134
  (name) => locals[name].filter((local) => mentions(markup, local)).map((local) => ({ name, local }))
5914
6135
  );
5915
- 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();
5916
6143
  const helperSpecifier = relativeImport(path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
5917
6144
  const snapshot = snapshotOf(parsed, dialect, fromFile, markup);
5918
6145
  const carry = carryFor({
@@ -5927,20 +6154,23 @@ function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5927
6154
  "bind",
5928
6155
  "overrides",
5929
6156
  "page",
6157
+ fieldName,
5930
6158
  ...helperNames.map((h) => h.local),
5931
6159
  ...snapshot ? [snapshot] : []
5932
6160
  ],
5933
6161
  // The helper and the snapshots are what the component declares for itself.
5934
6162
  skipSpecifier: (repoPath, specifier) => specifier.includes(`${CONTENT_DIR}/`) || repoPath.startsWith(HELPER_PATH[dialect].replace(/\.tsx?$/, ""))
5935
6163
  });
5936
- if (carry.free.length > 0) return { content: "", free: carry.free };
6164
+ if (carry.free.length > 0) return { content: "", free: carry.free, duplicates: [], unconvertible: [] };
5937
6165
  const carried = [...carry.imports];
5938
6166
  const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
5939
6167
  const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
5940
6168
  ` : "";
5941
- 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");
5942
6172
  if (dialect === "astro") {
5943
- return { free: [], content: `---
6173
+ return { free: [], duplicates: [], unconvertible: [], content: `---
5944
6174
  ${SECTION_MARKER}
5945
6175
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`].join("\n")}
5946
6176
 
@@ -5955,27 +6185,297 @@ interface Props {
5955
6185
  page?: unknown;
5956
6186
  }
5957
6187
  const { blockId, bind, overrides, page } = Astro.props;
5958
- ${carriedDeclarations}${copy}
6188
+ ${carriedDeclarations}${declared}
5959
6189
  ---
5960
6190
  ${markup}
5961
6191
  ` };
5962
6192
  }
5963
- return { free: [], content: `${SECTION_MARKER}
6193
+ return { free: [], duplicates: [], unconvertible: [], content: `${SECTION_MARKER}
5964
6194
  ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
5965
6195
  ${carriedDeclarations === "" ? "" : `
5966
6196
  ${carriedDeclarations}`}
5967
6197
  export default function ${resolved.name}({ blockId, bind, overrides, page }: BcmsSectionProps) {
5968
- ${copy}
6198
+ ${declared.split("\n").join("\n ")}
5969
6199
  return (
5970
6200
  ${markup}
5971
6201
  );
5972
6202
  }
5973
6203
  ` };
5974
6204
  }
5975
- function sliceWithBlockId(content, root) {
6205
+ function sectionMarkup(content, root, fieldName) {
5976
6206
  const slice = new MagicString3(content.slice(root.start, root.end));
5977
- slice.appendLeft(root.attrInsertAt - root.start, " data-bcms-block={blockId}");
5978
- return slice.toString();
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;
5979
6479
  }
5980
6480
  var alias = (name, local) => name === local ? name : `${name} as ${local}`;
5981
6481
  var mentions = (markup, name) => new RegExp(`(?<![\\w$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w$])`).test(markup);
@@ -6234,7 +6734,7 @@ function addImports(source, parsed, lines) {
6234
6734
  else source.appendLeft(at.at, `
6235
6735
  ${lines.join("\n")}`);
6236
6736
  }
6237
- 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);
6238
6738
  function emitHelper(out, contents, version, overwrite) {
6239
6739
  const path = HELPER_PATH.astro;
6240
6740
  const source = helperSource("astro");
@@ -7087,7 +7587,7 @@ function emitStubs2(dialects, slugs, contents, out) {
7087
7587
  }
7088
7588
 
7089
7589
  // src/cli.ts
7090
- var VERSION = "0.2.0";
7590
+ var VERSION = "0.4.0";
7091
7591
  var MAX_FILE_BYTES = 512 * 1024;
7092
7592
  var CliError = class extends Error {
7093
7593
  };
@@ -7096,6 +7596,7 @@ function parseArgs(argv) {
7096
7596
  brief: void 0,
7097
7597
  componentize: false,
7098
7598
  plan: void 0,
7599
+ copy: "bind",
7099
7600
  root: ".",
7100
7601
  dryRun: false,
7101
7602
  receipt: void 0,
@@ -7109,7 +7610,13 @@ function parseArgs(argv) {
7109
7610
  if (arg === "--brief") args.brief = argv[++i];
7110
7611
  else if (arg === "--componentize") args.componentize = true;
7111
7612
  else if (arg === "--plan") args.plan = argv[++i];
7112
- 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] ?? ".";
7113
7620
  else if (arg === "--receipt") args.receipt = argv[++i];
7114
7621
  else if (arg === "--dry-run") args.dryRun = true;
7115
7622
  else if (arg === "--strict") args.strict = true;
@@ -7133,6 +7640,10 @@ var HELP = `bettercms-convert ${VERSION}
7133
7640
  --componentize Lift each planned SECTION into a component the CMS can reorder.
7134
7641
  --plan <file> The componentize plan, as get_componentize_plan returns it.
7135
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.
7136
7647
  --root <dir> The repository to convert. Default: the working directory.
7137
7648
  --dry-run Print what would change; write nothing.
7138
7649
  --receipt <file> Write the receipt as JSON.
@@ -7217,7 +7728,7 @@ function reportSections(files, receipt, dryRun) {
7217
7728
  }
7218
7729
  process.stdout.write(
7219
7730
  `
7220
- ${extracted} sections extracted (${registered} registered, ${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}
7221
7732
  `
7222
7733
  );
7223
7734
  for (const section of pending) {
@@ -7237,6 +7748,9 @@ async function main() {
7237
7748
  if (args.plan && !args.componentize) {
7238
7749
  throw new CliError("--plan is only read with --componentize. See --help.");
7239
7750
  }
7751
+ if (args.copy === "instance" && !args.componentize) {
7752
+ throw new CliError("--copy is only read with --componentize. See --help.");
7753
+ }
7240
7754
  const root = resolve(args.root);
7241
7755
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
7242
7756
  if (!Array.isArray(brief.pages)) {
@@ -7270,7 +7784,7 @@ async function main() {
7270
7784
  brief,
7271
7785
  plan2,
7272
7786
  [...sources, ...await readSnapshots(root)],
7273
- { overwriteHelper: args.overwriteHelper }
7787
+ { overwriteHelper: args.overwriteHelper, copy: args.copy }
7274
7788
  );
7275
7789
  await written(files2);
7276
7790
  await saveReceipt(receipt2);