@bettercms-ai/convert 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { readdir, readFile, writeFile, mkdir, stat } from "fs/promises";
5
5
  import { dirname, join as join2, relative, resolve, sep } from "path";
6
6
 
7
7
  // src/index.ts
8
- import MagicString3 from "magic-string";
8
+ import MagicString4 from "magic-string";
9
9
 
10
10
  // src/brief.ts
11
11
  import { createHash } from "crypto";
@@ -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 = 2;
137
+ var HELPER_VERSION = 3;
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])
@@ -213,12 +213,37 @@ export function bcmsRows<T>(page: unknown, path: string, fallback: T[], slug?: s
213
213
  const value = readPath(root, path);
214
214
  return Array.isArray(value) ? (value as T[]) : fallback;
215
215
  }
216
+
217
+ /**
218
+ * ONE SECTION's copy, for a component extracted by \`--componentize\`.
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.
224
+ */
225
+ 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
+ const group = bind ? readPath(page, bind) : undefined;
230
+ return isRecord(group) ? group : {};
231
+ }
216
232
  `;
217
233
  }
218
234
  var HISTORICAL = {
219
- "src/bcms-content.ts": { 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407" },
220
- "src/lib/bcms-content.ts": { 1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2" },
221
- "composables/bcms-content.ts": { 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407" }
235
+ "src/bcms-content.ts": {
236
+ 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
237
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
238
+ },
239
+ "src/lib/bcms-content.ts": {
240
+ 1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
241
+ 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002"
242
+ },
243
+ "composables/bcms-content.ts": {
244
+ 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
245
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0"
246
+ }
222
247
  };
223
248
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
224
249
  function isKnownHelper(content, dialect) {
@@ -3069,7 +3094,9 @@ function attrsOf2(open, content) {
3069
3094
  value: content.slice(argument?.start ?? 0, argument?.end ?? 0),
3070
3095
  valueRange: null,
3071
3096
  expression: true,
3072
- range: { start: attr.start ?? 0, end: attr.end ?? 0 }
3097
+ range: { start: attr.start ?? 0, end: attr.end ?? 0 },
3098
+ // `{...props}` — the braces ARE the attribute, so the expression is its whole span.
3099
+ expressionRange: { start: attr.start ?? 0, end: attr.end ?? 0 }
3073
3100
  }
3074
3101
  ];
3075
3102
  }
@@ -3084,7 +3111,8 @@ function attrsOf2(open, content) {
3084
3111
  // The quotes are one character each on a StringLiteral; babel's range covers them.
3085
3112
  valueRange: literal ? { start: (value.start ?? 0) + 1, end: (value.end ?? 0) - 1 } : null,
3086
3113
  expression: !literal,
3087
- range
3114
+ range,
3115
+ expressionRange: value?.type === "JSXExpressionContainer" ? { start: value.start ?? 0, end: value.end ?? 0 } : null
3088
3116
  }
3089
3117
  ];
3090
3118
  });
@@ -3149,6 +3177,7 @@ function convert(node, content) {
3149
3177
  type: "element",
3150
3178
  tag,
3151
3179
  start,
3180
+ end: node.end ?? start,
3152
3181
  // Babel's own offsets: the last attribute's `end`, or the end of the element's NAME. Both
3153
3182
  // come out of the parse, so an escaped quote inside an attribute cannot move them.
3154
3183
  attrInsertAt: node.type === "JSXFragment" ? start + 1 : attrInsertAfter(attrs, open?.start ?? start, tag),
@@ -3584,8 +3613,9 @@ function importsIn(parsed, _dialect) {
3584
3613
  if (entry.importKind === "type") continue;
3585
3614
  const local = entry.local?.name;
3586
3615
  if (typeof local !== "string") continue;
3587
- const imported = entry.type === "ImportDefaultSpecifier" ? null : entry.imported?.name ?? local;
3588
- out.set(local, { specifier, imported });
3616
+ const kind = entry.type === "ImportDefaultSpecifier" ? "default" : entry.type === "ImportNamespaceSpecifier" ? "namespace" : "named";
3617
+ const imported = kind === "named" ? entry.imported?.name ?? local : null;
3618
+ out.set(local, { specifier, imported, kind });
3589
3619
  }
3590
3620
  });
3591
3621
  return out;
@@ -3593,6 +3623,33 @@ function importsIn(parsed, _dialect) {
3593
3623
 
3594
3624
  // src/routes.ts
3595
3625
  var isDynamicRoute = (file) => file.split("/").some((segment) => /^\[.+\]/.test(segment) || /\[.+\]\.[a-z]+$/i.test(segment));
3626
+ var PAGE_EXTENSIONS = /* @__PURE__ */ new Set(["astro", "tsx", "jsx", "ts", "js", "svelte", "vue"]);
3627
+ var isOrganisational = (segment) => segment.startsWith("(") && segment.endsWith(")") || segment.startsWith("@");
3628
+ function routeOfFile(file) {
3629
+ const extension = file.slice(file.lastIndexOf(".") + 1);
3630
+ if (!PAGE_EXTENSIONS.has(extension)) return null;
3631
+ let parts = file.split("/");
3632
+ if (parts[0] === "src") parts = parts.slice(1);
3633
+ if (parts[0] === "app" && parts[1] === "pages") parts = parts.slice(1);
3634
+ const router = parts[0];
3635
+ if (router !== "pages" && router !== "app" && router !== "routes") return null;
3636
+ let segments = parts.slice(1);
3637
+ const last = segments[segments.length - 1];
3638
+ if (last === void 0) return null;
3639
+ const base = last.slice(0, last.lastIndexOf("."));
3640
+ if (router === "pages") {
3641
+ segments = [...segments.slice(0, -1), ...base === "index" ? [] : [base]];
3642
+ } else {
3643
+ if (base !== "page" && base !== "+page") return null;
3644
+ segments = segments.slice(0, -1);
3645
+ }
3646
+ const url = segments.filter((segment) => !isOrganisational(segment));
3647
+ return `/${url.join("/")}`.replace(/\/{2,}/g, "/");
3648
+ }
3649
+ function pageFilesFor(route, files) {
3650
+ const wanted = route === "" ? "/" : route.replace(/\/+$/, "") || "/";
3651
+ return [...files].filter((file) => routeOfFile(file) === wanted).sort();
3652
+ }
3596
3653
  var FUNCTIONS2 = /* @__PURE__ */ new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
3597
3654
  function defaultComponent(ast) {
3598
3655
  let found = null;
@@ -3854,7 +3911,7 @@ function namesFile(fromFile, specifier, target) {
3854
3911
  function snapshotLocal(parsed, dialect, slug, file) {
3855
3912
  const target = `${CONTENT_DIR}/${slug}.json`;
3856
3913
  for (const [local, from] of importsIn(parsed, dialect)) {
3857
- if (from.imported !== null) continue;
3914
+ if (from.kind !== "default") continue;
3858
3915
  if (namesFile(file, from.specifier, target)) return local;
3859
3916
  }
3860
3917
  return null;
@@ -4064,6 +4121,8 @@ var attrsOf3 = (el, content) => {
4064
4121
  value: attr.value,
4065
4122
  valueRange,
4066
4123
  expression: false,
4124
+ // parse5 reads HTML, which has no expressions at all.
4125
+ expressionRange: null,
4067
4126
  range: range ?? { start: 0, end: 0 }
4068
4127
  };
4069
4128
  });
@@ -4094,6 +4153,7 @@ function convert2(node, content) {
4094
4153
  type: "element",
4095
4154
  tag: el.tagName,
4096
4155
  start,
4156
+ end: at.endOffset,
4097
4157
  attrInsertAt: insertAt,
4098
4158
  inner,
4099
4159
  attrs,
@@ -4114,6 +4174,18 @@ function parse2(content) {
4114
4174
  // src/sites/astro.ts
4115
4175
  import { parse as parse3 } from "@astrojs/compiler";
4116
4176
  import { parse as parseScript } from "@babel/parser";
4177
+ function byteToChar(content) {
4178
+ if (!/[^\u0000-\u007f]/.test(content)) return null;
4179
+ const bytes = [];
4180
+ for (let at = 0; at < content.length; ) {
4181
+ const code = content.codePointAt(at);
4182
+ const width = code < 128 ? 1 : code < 2048 ? 2 : code < 65536 ? 3 : 4;
4183
+ for (let n = 0; n < width; n++) bytes.push(at);
4184
+ at += code < 65536 ? 1 : 2;
4185
+ }
4186
+ bytes.push(content.length);
4187
+ return (offset) => bytes[offset] ?? content.length;
4188
+ }
4117
4189
  var RAW_TAGS2 = /* @__PURE__ */ new Set(["script", "style"]);
4118
4190
  var backOverSpace = (content, at) => {
4119
4191
  let end = at;
@@ -4160,6 +4232,8 @@ function attrsOf4(node, content) {
4160
4232
  const raw = attr.raw ?? "";
4161
4233
  const end = attrEnd(content, attr, start, attributes[at + 1]?.position?.start?.offset);
4162
4234
  const range = { start, end };
4235
+ const open = attr.kind === "spread" || attr.kind === "shorthand" ? content.lastIndexOf("{", start) : content.indexOf("{", start + attr.name.length);
4236
+ const close = content.lastIndexOf("}", end);
4163
4237
  const quoted = attr.kind === "quoted";
4164
4238
  const rawStart = quoted ? content.indexOf(raw, start) : -1;
4165
4239
  const unquoted = raw.length === attr.value.length;
@@ -4169,7 +4243,8 @@ function attrsOf4(node, content) {
4169
4243
  value: attr.value,
4170
4244
  valueRange: quoted && rawStart >= 0 && attr.value !== "" ? { start: rawStart + (unquoted ? 0 : 1), end: rawStart + raw.length - (unquoted ? 0 : 1) } : null,
4171
4245
  expression: !quoted,
4172
- range
4246
+ range,
4247
+ expressionRange: !quoted && open >= 0 && close > open ? { start: open, end: close + 1 } : null
4173
4248
  }
4174
4249
  ];
4175
4250
  });
@@ -4208,11 +4283,13 @@ function convert3(node, content) {
4208
4283
  const reportedEnd = node.position?.end?.offset;
4209
4284
  const closes = reportedEnd !== void 0 && content.slice(reportedEnd - closeTag.length, reportedEnd) === closeTag;
4210
4285
  const inner = closes ? { start: content.indexOf(">", insertAt) + 1, end: reportedEnd - closeTag.length } : null;
4286
+ const end = closes ? reportedEnd : content.indexOf(">", insertAt) + 1;
4211
4287
  return [
4212
4288
  {
4213
4289
  type: "element",
4214
4290
  tag,
4215
4291
  start,
4292
+ end,
4216
4293
  attrInsertAt: insertAt,
4217
4294
  inner,
4218
4295
  attrs,
@@ -4221,11 +4298,26 @@ function convert3(node, content) {
4221
4298
  }
4222
4299
  ];
4223
4300
  }
4301
+ function remap(nodes, at) {
4302
+ for (const node of nodes) {
4303
+ const start = node.position?.start;
4304
+ if (start?.offset !== void 0) start.offset = at(start.offset);
4305
+ const end = node.position?.end;
4306
+ if (end?.offset !== void 0) end.offset = at(end.offset);
4307
+ for (const attr of node.attributes ?? []) {
4308
+ const attrStart = attr.position?.start;
4309
+ if (attrStart?.offset !== void 0) attrStart.offset = at(attrStart.offset);
4310
+ }
4311
+ remap(node.children ?? [], at);
4312
+ }
4313
+ }
4224
4314
  async function parseFile(content) {
4225
4315
  let top;
4226
4316
  try {
4227
4317
  const result = await parse3(content, { position: true });
4228
4318
  top = result.ast.children ?? [];
4319
+ const at = byteToChar(content);
4320
+ if (at) remap(top, at);
4229
4321
  } catch (error) {
4230
4322
  return { roots: [], error: { code: "PARSE_ERROR", message: error.message } };
4231
4323
  }
@@ -4267,7 +4359,7 @@ function attrsOf5(node) {
4267
4359
  const name = typeof attr.name === "string" ? attr.name : attr.type;
4268
4360
  const range = { start: attr.start ?? 0, end: attr.end ?? 0 };
4269
4361
  if (attr.type !== "Attribute") {
4270
- return [{ name: `\0${name}`, value: "", valueRange: null, expression: true, range }];
4362
+ return [{ name: `\0${name}`, value: "", valueRange: null, expression: true, expressionRange: null, range }];
4271
4363
  }
4272
4364
  const literal = literalValue(attr);
4273
4365
  return [
@@ -4276,6 +4368,8 @@ function attrsOf5(node) {
4276
4368
  value: literal?.value ?? "",
4277
4369
  valueRange: literal && literal.value !== "" ? literal.range : null,
4278
4370
  expression: literal === null,
4371
+ // Not read: svelte sections are DIALECT_UNSUPPORTED. @see componentize.ts
4372
+ expressionRange: null,
4279
4373
  range
4280
4374
  }
4281
4375
  ];
@@ -4321,6 +4415,7 @@ function convert4(node, content) {
4321
4415
  type: "element",
4322
4416
  tag,
4323
4417
  start,
4418
+ end,
4324
4419
  attrInsertAt: insertAt,
4325
4420
  inner: closes ? { start: content.indexOf(">", insertAt) + 1, end: end - close.length } : null,
4326
4421
  attrs,
@@ -4385,6 +4480,7 @@ function attrsOf6(node) {
4385
4480
  value: "",
4386
4481
  valueRange: null,
4387
4482
  expression: true,
4483
+ expressionRange: null,
4388
4484
  range: spanOf(prop.loc)
4389
4485
  }
4390
4486
  ];
@@ -4396,6 +4492,7 @@ function attrsOf6(node) {
4396
4492
  value: "",
4397
4493
  valueRange: null,
4398
4494
  expression: true,
4495
+ expressionRange: null,
4399
4496
  range: spanOf(prop.loc)
4400
4497
  }
4401
4498
  ];
@@ -4409,6 +4506,7 @@ function attrsOf6(node) {
4409
4506
  value,
4410
4507
  valueRange: quoted && value !== "" ? { start: quoted.start + 1, end: quoted.end - 1 } : null,
4411
4508
  expression: false,
4509
+ expressionRange: null,
4412
4510
  range
4413
4511
  }
4414
4512
  ];
@@ -4438,6 +4536,7 @@ function convert5(node, content) {
4438
4536
  type: "element",
4439
4537
  tag,
4440
4538
  start,
4539
+ end,
4441
4540
  attrInsertAt: insertAt,
4442
4541
  inner: closes ? { start: content.indexOf(">", insertAt) + 1, end: end - close.length } : null,
4443
4542
  attrs,
@@ -4533,6 +4632,123 @@ function findSitesTolerant(content, literals) {
4533
4632
  return sites;
4534
4633
  }
4535
4634
 
4635
+ // src/validate.ts
4636
+ var SAFE_SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
4637
+ var ROUTE = /^\/(?:[A-Za-z0-9_\-.~%$@+[\]()!'*,:=&]+(?:\/[A-Za-z0-9_\-.~%$@+[\]()!'*,:=&]+)*\/?)?$/;
4638
+ var ValidationError = class extends Error {
4639
+ constructor(code, message) {
4640
+ super(message);
4641
+ this.code = code;
4642
+ this.name = "ValidationError";
4643
+ }
4644
+ code;
4645
+ };
4646
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4647
+ var Check = class {
4648
+ constructor(code) {
4649
+ this.code = code;
4650
+ }
4651
+ code;
4652
+ fail(where, why) {
4653
+ throw new ValidationError(this.code, `${where} ${why}.`);
4654
+ }
4655
+ record(value, where) {
4656
+ if (!isRecord(value)) this.fail(where, "is not an object");
4657
+ return value;
4658
+ }
4659
+ array(value, where) {
4660
+ if (!Array.isArray(value)) this.fail(where, "is not an array");
4661
+ return value;
4662
+ }
4663
+ string(value, where) {
4664
+ if (typeof value !== "string") this.fail(where, "is not a string");
4665
+ return value;
4666
+ }
4667
+ optionalString(value, where) {
4668
+ if (value === void 0 || value === null) return void 0;
4669
+ return this.string(value, where);
4670
+ }
4671
+ /** A value that becomes a FILE NAME or an object key in generated source. @see SAFE_SEGMENT */
4672
+ segment(value, where) {
4673
+ const held = this.string(value, where);
4674
+ if (!SAFE_SEGMENT.test(held)) {
4675
+ this.fail(where, `is not a safe path segment (${JSON.stringify(held)}); expected ${SAFE_SEGMENT.source}`);
4676
+ }
4677
+ return held;
4678
+ }
4679
+ route(value, where) {
4680
+ const held = this.string(value, where);
4681
+ if (!ROUTE.test(held) || held.includes("..")) this.fail(where, `is not an absolute URL path (${JSON.stringify(held)})`);
4682
+ return held;
4683
+ }
4684
+ };
4685
+ function readBrief(value) {
4686
+ const check = new Check("BRIEF_INVALID");
4687
+ const brief = check.record(value, "the brief");
4688
+ const pages = check.array(brief.pages, `the brief's "pages"`);
4689
+ for (const [at, entry] of pages.entries()) {
4690
+ const where = `brief.pages[${at}]`;
4691
+ const page = check.record(entry, where);
4692
+ check.segment(page.slug, `${where}.slug`);
4693
+ check.route(page.route, `${where}.route`);
4694
+ for (const [n, held] of check.array(page.paths, `${where}.paths`).entries()) {
4695
+ const path = check.record(held, `${where}.paths[${n}]`);
4696
+ check.string(path.path, `${where}.paths[${n}].path`);
4697
+ check.string(path.kind, `${where}.paths[${n}].kind`);
4698
+ const scope = check.optionalString(path.scope, `${where}.paths[${n}].scope`);
4699
+ if (scope !== void 0 && scope !== "page" && scope !== "layout") {
4700
+ check.fail(`${where}.paths[${n}].scope`, 'is neither "page" nor "layout"');
4701
+ }
4702
+ if (path.original !== null && path.original !== void 0 && typeof path.original !== "string") {
4703
+ check.fail(`${where}.paths[${n}].original`, "is not a string or null");
4704
+ }
4705
+ }
4706
+ }
4707
+ return brief;
4708
+ }
4709
+ function readPlan(value) {
4710
+ const check = new Check("PLAN_INVALID");
4711
+ const plan2 = check.record(value, "the plan");
4712
+ if (!Array.isArray(plan2.pages)) check.fail(`the plan's "pages"`, "is missing or not an array");
4713
+ for (const [at, entry] of plan2.pages.entries()) {
4714
+ const where = `plan.pages[${at}]`;
4715
+ const page = check.record(entry, where);
4716
+ check.route(page.route, `${where}.route`);
4717
+ if (page.sections === void 0) continue;
4718
+ for (const [n, held] of check.array(page.sections, `${where}.sections`).entries()) {
4719
+ const at2 = `${where}.sections[${n}]`;
4720
+ const section = check.record(held, at2);
4721
+ check.segment(section.groupKey, `${at2}.groupKey`);
4722
+ check.optionalString(section.sectionType, `${at2}.sectionType`);
4723
+ if (section.fields !== void 0) {
4724
+ for (const [f, field] of check.array(section.fields, `${at2}.fields`).entries()) {
4725
+ const shape = check.record(field, `${at2}.fields[${f}]`);
4726
+ check.optionalString(shape.key, `${at2}.fields[${f}].key`);
4727
+ check.optionalString(shape.path, `${at2}.fields[${f}].path`);
4728
+ }
4729
+ }
4730
+ if (section.reuse !== void 0) {
4731
+ const reuse = check.record(section.reuse, `${at2}.reuse`);
4732
+ check.optionalString(reuse.componentId, `${at2}.reuse.componentId`);
4733
+ if (reuse.proposedSlug !== void 0) check.segment(reuse.proposedSlug, `${at2}.reuse.proposedSlug`);
4734
+ }
4735
+ if (section.pending !== void 0) check.record(section.pending, `${at2}.pending`);
4736
+ }
4737
+ }
4738
+ if (plan2.components !== void 0) {
4739
+ for (const [at, entry] of check.array(plan2.components, `the plan's "components"`).entries()) {
4740
+ const where = `plan.components[${at}]`;
4741
+ const component = check.record(entry, where);
4742
+ check.segment(component.proposedSlug, `${where}.proposedSlug`);
4743
+ check.optionalString(component.name, `${where}.name`);
4744
+ check.optionalString(component.sectionType, `${where}.sectionType`);
4745
+ check.optionalString(component.componentId, `${where}.componentId`);
4746
+ }
4747
+ }
4748
+ check.optionalString(plan2.digest, `the plan's "digest"`);
4749
+ return plan2;
4750
+ }
4751
+
4536
4752
  // src/scan.ts
4537
4753
  var SKIP_DIRS = [
4538
4754
  "node_modules/",
@@ -4579,6 +4795,1104 @@ function isSourceCandidate(path) {
4579
4795
  return SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext));
4580
4796
  }
4581
4797
 
4798
+ // src/carry.ts
4799
+ var GLOBALS = /* @__PURE__ */ new Set([
4800
+ "Astro",
4801
+ "Fragment",
4802
+ "React",
4803
+ "JSX",
4804
+ "globalThis",
4805
+ "undefined",
4806
+ "null",
4807
+ "true",
4808
+ "false",
4809
+ "NaN",
4810
+ "Infinity",
4811
+ "Math",
4812
+ "JSON",
4813
+ "Object",
4814
+ "Array",
4815
+ "String",
4816
+ "Number",
4817
+ "Boolean",
4818
+ "Date",
4819
+ "RegExp",
4820
+ "Map",
4821
+ "Set",
4822
+ "WeakMap",
4823
+ "WeakSet",
4824
+ "Promise",
4825
+ "Symbol",
4826
+ "BigInt",
4827
+ "Error",
4828
+ "Intl",
4829
+ "URL",
4830
+ "URLSearchParams",
4831
+ "console",
4832
+ "process",
4833
+ "window",
4834
+ "document",
4835
+ "navigator",
4836
+ "fetch",
4837
+ "isNaN",
4838
+ "isFinite",
4839
+ "parseInt",
4840
+ "parseFloat",
4841
+ "encodeURIComponent",
4842
+ "decodeURIComponent",
4843
+ "structuredClone",
4844
+ "import",
4845
+ "arguments",
4846
+ "this",
4847
+ "void",
4848
+ "typeof"
4849
+ ]);
4850
+ function carryFor(options) {
4851
+ const { dialect, fromFile, toFile, content, parsed, root } = options;
4852
+ const wanted = referencedNames(root, content);
4853
+ if (wanted === null) {
4854
+ return { imports: [], declarations: [], free: ["<an expression this version cannot read>"] };
4855
+ }
4856
+ const provided = new Set(options.provided);
4857
+ const imports = importsIn(parsed, dialect);
4858
+ const declarations = moduleScope(parsed, content);
4859
+ const carriedImports = /* @__PURE__ */ new Map();
4860
+ const carriedDeclarations = /* @__PURE__ */ new Map();
4861
+ const free = /* @__PURE__ */ new Set();
4862
+ const seen = /* @__PURE__ */ new Set();
4863
+ const queue = [...wanted];
4864
+ while (queue.length > 0) {
4865
+ const name = queue.shift();
4866
+ if (seen.has(name) || provided.has(name) || GLOBALS.has(name)) continue;
4867
+ seen.add(name);
4868
+ const imported = imports.get(name);
4869
+ if (imported) {
4870
+ if (skipImport(imported.specifier, fromFile, options.skipSpecifier)) continue;
4871
+ const specifier = imported.specifier.startsWith(".") ? relativeImport(toFile, joinFrom(fromFile, imported.specifier)) : imported.specifier;
4872
+ carriedImports.set(
4873
+ name,
4874
+ imported.kind === "namespace" ? `import * as ${name} from "${specifier}";` : imported.kind === "default" ? `import ${name} from "${specifier}";` : `import { ${imported.imported === name ? name : `${imported.imported} as ${name}`} } from "${specifier}";`
4875
+ );
4876
+ continue;
4877
+ }
4878
+ const declared = declarations.get(name);
4879
+ if (declared) {
4880
+ carriedDeclarations.set(declared.at, declared.text);
4881
+ queue.push(...declared.uses);
4882
+ continue;
4883
+ }
4884
+ free.add(name);
4885
+ }
4886
+ return {
4887
+ imports: [...new Set(carriedImports.values())].sort(),
4888
+ declarations: [...carriedDeclarations].sort(([a], [b]) => a - b).map(([, text]) => text),
4889
+ free: [...free].sort()
4890
+ };
4891
+ }
4892
+ function skipImport(specifier, fromFile, skip) {
4893
+ return skip(specifier.startsWith(".") ? joinFrom(fromFile, specifier) : "", specifier);
4894
+ }
4895
+ function joinFrom(fromFile, specifier) {
4896
+ const parts = fromFile.split("/").slice(0, -1);
4897
+ for (const segment of specifier.split("/")) {
4898
+ if (segment === "." || segment === "") continue;
4899
+ if (segment === "..") parts.pop();
4900
+ else parts.push(segment);
4901
+ }
4902
+ return parts.join("/");
4903
+ }
4904
+ function referencedNames(root, content) {
4905
+ const used = /* @__PURE__ */ new Set();
4906
+ const add = (code, scope) => {
4907
+ const scanned = scanExpression(code);
4908
+ if (!scanned) return null;
4909
+ for (const name of scanned.free) {
4910
+ if (!scope.some((held) => held.has(name))) used.add(name);
4911
+ }
4912
+ return scanned.bound;
4913
+ };
4914
+ const visit = (node, bounds, scope) => {
4915
+ if (node.type === "expr") {
4916
+ const start = Math.max(node.range.start, bounds.start);
4917
+ const end = Math.min(node.range.end, bounds.end);
4918
+ const holes = node.children.flatMap(
4919
+ (child) => child.type === "element" ? [{ start: child.start, end: child.end }] : []
4920
+ );
4921
+ let text = "";
4922
+ let at = start;
4923
+ for (const hole of [...holes].sort((a, b) => a.start - b.start)) {
4924
+ if (hole.start < at || hole.end > end) continue;
4925
+ text += `${content.slice(at, hole.start)}null`;
4926
+ at = hole.end;
4927
+ }
4928
+ text += content.slice(at, end);
4929
+ const open = text.indexOf("{");
4930
+ const close = text.lastIndexOf("}");
4931
+ const bound = add(open >= 0 && close > open ? text.slice(open + 1, close) : text, scope);
4932
+ if (!bound) return false;
4933
+ const inner2 = [...scope, bound];
4934
+ for (const child of node.children) if (!visit(child, bounds, inner2)) return false;
4935
+ return true;
4936
+ }
4937
+ if (node.type !== "element") return true;
4938
+ if (!isIntrinsic(node.tag) && node.tag !== "") {
4939
+ const tag = node.tag.split(".")[0];
4940
+ if (!scope.some((held) => held.has(tag))) used.add(tag);
4941
+ }
4942
+ for (const attr of node.attrs) {
4943
+ if (!attr.expression) continue;
4944
+ const span = attr.expressionRange;
4945
+ if (!span) continue;
4946
+ const raw = content.slice(span.start, span.end);
4947
+ if (add(stripBraces(raw), scope) === null) return false;
4948
+ }
4949
+ const inner = node.inner ?? bounds;
4950
+ const kids = node.children;
4951
+ for (const [at, child] of kids.entries()) {
4952
+ const next = kids[at + 1];
4953
+ const stop = next ? next.type === "element" ? next.start : next.range.start : inner.end;
4954
+ if (!visit(child, { start: inner.start, end: Math.min(inner.end, stop) }, scope)) return false;
4955
+ }
4956
+ return true;
4957
+ };
4958
+ return visit(root, { start: root.start, end: root.end }, []) ? used : null;
4959
+ }
4960
+ function stripBraces(text) {
4961
+ const trimmed = text.trim();
4962
+ const inner = trimmed.startsWith("{") && trimmed.endsWith("}") ? trimmed.slice(1, -1) : trimmed;
4963
+ return inner.trim().startsWith("...") ? inner.trim().slice(3) : inner;
4964
+ }
4965
+ function scanExpression(code) {
4966
+ if (code.trim() === "") return { free: /* @__PURE__ */ new Set(), bound: /* @__PURE__ */ new Set() };
4967
+ let ast;
4968
+ try {
4969
+ ast = parseProgram(`(${code}
4970
+ )`);
4971
+ } catch {
4972
+ try {
4973
+ ast = parseProgram(code);
4974
+ } catch {
4975
+ return null;
4976
+ }
4977
+ }
4978
+ const candidates = /* @__PURE__ */ new Set();
4979
+ const skip = /* @__PURE__ */ new Set();
4980
+ const bound = /* @__PURE__ */ new Set();
4981
+ walkAst(ast, (node) => {
4982
+ switch (node.type) {
4983
+ case "Identifier":
4984
+ case "JSXIdentifier":
4985
+ if (typeof node.name === "string") candidates.add(node.name);
4986
+ return;
4987
+ case "MemberExpression":
4988
+ case "OptionalMemberExpression":
4989
+ case "JSXMemberExpression":
4990
+ if (!node.computed) nameOf2(node.property, skip);
4991
+ return;
4992
+ case "ObjectProperty":
4993
+ case "ObjectMethod":
4994
+ case "ClassProperty":
4995
+ case "ClassMethod":
4996
+ if (!node.computed) nameOf2(node.key, skip);
4997
+ return;
4998
+ case "JSXAttribute":
4999
+ nameOf2(node.name, skip);
5000
+ return;
5001
+ case "JSXOpeningElement":
5002
+ case "JSXClosingElement": {
5003
+ const name = node.name;
5004
+ if (name?.type === "JSXIdentifier" && typeof name.name === "string") skip.add(name.name);
5005
+ return;
5006
+ }
5007
+ case "TSTypeReference":
5008
+ case "TSQualifiedName":
5009
+ case "TSInterfaceDeclaration":
5010
+ case "TSTypeAliasDeclaration":
5011
+ for (const key of ["typeName", "left", "right", "id"]) nameOf2(node[key], skip);
5012
+ return;
5013
+ case "VariableDeclarator":
5014
+ patternNames2(node.id, bound);
5015
+ return;
5016
+ case "CatchClause":
5017
+ patternNames2(node.param, bound);
5018
+ return;
5019
+ default:
5020
+ return;
5021
+ }
5022
+ });
5023
+ walkAst(ast, (node) => {
5024
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration" || node.type === "ObjectMethod" || node.type === "ClassMethod") {
5025
+ for (const param of node.params ?? []) patternNames2(param, bound);
5026
+ if (node.id) patternNames2(node.id, bound);
5027
+ }
5028
+ });
5029
+ const free = /* @__PURE__ */ new Set();
5030
+ for (const name of candidates) if (!skip.has(name) && !bound.has(name)) free.add(name);
5031
+ return { free, bound };
5032
+ }
5033
+ function nameOf2(node, out) {
5034
+ if (!node) return;
5035
+ if (typeof node.name === "string") out.add(node.name);
5036
+ if (node.type === "JSXNamespacedName") {
5037
+ nameOf2(node.namespace, out);
5038
+ nameOf2(node.name, out);
5039
+ }
5040
+ }
5041
+ function patternNames2(node, out) {
5042
+ if (!node || typeof node !== "object") return;
5043
+ const typed = node;
5044
+ switch (typed.type) {
5045
+ case "Identifier":
5046
+ out.add(String(typed.name));
5047
+ return;
5048
+ case "ObjectPattern":
5049
+ for (const property of typed.properties ?? []) {
5050
+ patternNames2(property.type === "RestElement" ? property.argument : property.value, out);
5051
+ }
5052
+ return;
5053
+ case "ArrayPattern":
5054
+ for (const element of typed.elements ?? []) patternNames2(element, out);
5055
+ return;
5056
+ case "AssignmentPattern":
5057
+ patternNames2(typed.left, out);
5058
+ return;
5059
+ case "RestElement":
5060
+ patternNames2(typed.argument, out);
5061
+ return;
5062
+ default:
5063
+ return;
5064
+ }
5065
+ }
5066
+ function moduleScope(parsed, content) {
5067
+ const out = /* @__PURE__ */ new Map();
5068
+ const program = parsed.ast?.program;
5069
+ if (!program) return out;
5070
+ const source = parsed.script?.code ?? content;
5071
+ for (const statement of program.body ?? []) {
5072
+ const kind = statement.type;
5073
+ if (kind !== "VariableDeclaration" && kind !== "FunctionDeclaration" && kind !== "ClassDeclaration" && kind !== "TSTypeAliasDeclaration" && kind !== "TSInterfaceDeclaration" && kind !== "TSEnumDeclaration") {
5074
+ continue;
5075
+ }
5076
+ const start = statement.start;
5077
+ const end = statement.end;
5078
+ if (start === void 0 || end === void 0) continue;
5079
+ const text = source.slice(start, end);
5080
+ const names = /* @__PURE__ */ new Set();
5081
+ if (kind === "VariableDeclaration") {
5082
+ for (const declarator of statement.declarations ?? []) patternNames2(declarator.id, names);
5083
+ } else {
5084
+ patternNames2(statement.id, names);
5085
+ }
5086
+ if (names.size === 0) continue;
5087
+ const scanned = scanExpression(text.replace(/^(?:export\s+)?/, ""));
5088
+ const uses = scanned ? [...scanned.free].filter((name) => !names.has(name)) : [];
5089
+ for (const name of names) out.set(name, { at: start, text, uses });
5090
+ }
5091
+ return out;
5092
+ }
5093
+
5094
+ // src/componentize.ts
5095
+ import MagicString3 from "magic-string";
5096
+ var SectionInvariantError = class extends Error {
5097
+ code = "SECTION_INVARIANT";
5098
+ constructor(message) {
5099
+ super(message);
5100
+ this.name = "SectionInvariantError";
5101
+ }
5102
+ };
5103
+ function assertSections(total, sections) {
5104
+ const sum = sections.extracted + sections.inlineOnly + sections.alreadyExtracted + sections.pending.length;
5105
+ if (sum !== total) {
5106
+ throw new SectionInvariantError(
5107
+ `The receipt does not account for every section: ${total} planned but ${sections.extracted} extracted + ${sections.inlineOnly} inline-only + ${sections.alreadyExtracted} already extracted + ${sections.pending.length} pending = ${sum}.`
5108
+ );
5109
+ }
5110
+ return sections;
5111
+ }
5112
+ var SECTION_MARKER = "// @bettercms-ai/convert section v1";
5113
+ var REGISTRY_MARKER = "// @bettercms-ai/convert sections v1";
5114
+ var COMPONENT_DIR = "src/components/bcms";
5115
+ var SECTIONS_LIB = "src/lib/bcms-sections.ts";
5116
+ var EXTRACTABLE = ["astro", "jsx"];
5117
+ async function componentizeSources(briefIn, planIn, sources, options) {
5118
+ const pending = [];
5119
+ let extracted = 0;
5120
+ let inlineOnly = 0;
5121
+ let alreadyExtracted = 0;
5122
+ let planPending = 0;
5123
+ let total = 0;
5124
+ const brief = readBrief(briefIn);
5125
+ const plan2 = readPlan(planIn);
5126
+ const contents = new Map(sources.map((s) => [s.path, s.content]));
5127
+ const pend = (route, groupKey, reason, message) => void pending.push({ route, groupKey, reason, ...message ? { message } : {} });
5128
+ const trees = /* @__PURE__ */ new Map();
5129
+ const treeOf = async (file) => {
5130
+ if (trees.has(file)) return trees.get(file);
5131
+ const content = contents.get(file) ?? "";
5132
+ const dialect = dialectOf(file, content);
5133
+ const parsed = dialect ? await parseFile4(dialect, content) : null;
5134
+ const tree = parsed && !parsed.error ? { parsed, elements: elementIndex(parsed.roots) } : null;
5135
+ trees.set(file, tree);
5136
+ return tree;
5137
+ };
5138
+ const helperPath = HELPER_PATH.astro;
5139
+ const helperExisting = contents.get(helperPath);
5140
+ const helperVersion = helperExisting === void 0 ? null : isKnownHelper(helperExisting, "astro");
5141
+ const helperBlocked = helperExisting !== void 0 && helperVersion === null && options?.overwriteHelper !== true;
5142
+ const outFiles = /* @__PURE__ */ new Map();
5143
+ const written = /* @__PURE__ */ new Map();
5144
+ const shipped = /* @__PURE__ */ new Map();
5145
+ const componentsOf = (dialect) => {
5146
+ const held = written.get(dialect) ?? /* @__PURE__ */ new Map();
5147
+ written.set(dialect, held);
5148
+ return held;
5149
+ };
5150
+ const fresh = /* @__PURE__ */ new Set();
5151
+ const registryConflicts = [];
5152
+ for (const dialect of EXTRACTABLE) {
5153
+ const path = `${COMPONENT_DIR}/Sections.${dialect === "astro" ? "astro" : "tsx"}`;
5154
+ const existing = contents.get(path);
5155
+ if (existing === void 0) continue;
5156
+ if (!isGenerated(existing)) {
5157
+ registryConflicts.push(path);
5158
+ continue;
5159
+ }
5160
+ const held = readRegistry(existing, dialect);
5161
+ componentsOf(dialect);
5162
+ for (const [slug, component] of held.components) written.get(dialect).set(slug, component);
5163
+ const order = shipped.get(dialect) ?? /* @__PURE__ */ new Map();
5164
+ shipped.set(dialect, order);
5165
+ for (const [page, list] of held.shipped) order.set(page, list);
5166
+ }
5167
+ const lib = contents.get(SECTIONS_LIB);
5168
+ if (lib !== void 0 && !isGenerated(lib)) registryConflicts.push(SECTIONS_LIB);
5169
+ const registryBlocked = registryConflicts.length > 0 && options?.overwriteHelper !== true;
5170
+ for (const page of plan2.pages ?? []) {
5171
+ const sections2 = (page.sections ?? []).filter((section) => {
5172
+ if (section.pending) {
5173
+ planPending++;
5174
+ return false;
5175
+ }
5176
+ return true;
5177
+ });
5178
+ total += sections2.length;
5179
+ if (sections2.length === 0) continue;
5180
+ const briefPage = brief.pages.find((p) => p.route === page.route);
5181
+ const pageSlug = briefPage?.slug ?? slugify(page.route === "/" ? "home" : page.route);
5182
+ if (helperBlocked) {
5183
+ for (const section of sections2) {
5184
+ pend(page.route, section.groupKey, "HELPER_CONFLICT", `${helperPath} is not a module this package wrote.`);
5185
+ }
5186
+ continue;
5187
+ }
5188
+ if (registryBlocked) {
5189
+ for (const section of sections2) {
5190
+ pend(page.route, section.groupKey, "REGISTRY_CONFLICT", `${registryConflicts.join(", ")} is not a file this package wrote.`);
5191
+ }
5192
+ continue;
5193
+ }
5194
+ const snapshotPath = `${CONTENT_DIR}/${pageSlug}.json`;
5195
+ if (!snapshotUsable(contents.get(snapshotPath))) {
5196
+ for (const section of sections2) {
5197
+ pend(page.route, section.groupKey, "SNAPSHOT_INVALID", `${snapshotPath} is not a JSON object.`);
5198
+ }
5199
+ continue;
5200
+ }
5201
+ const pageFiles = pageFilesFor(page.route, contents.keys());
5202
+ if (pageFiles.length > 1) {
5203
+ for (const section of sections2) {
5204
+ pend(page.route, section.groupKey, "ROUTE_FILE_AMBIGUOUS", `${pageFiles.join(" and ")} both serve ${page.route}.`);
5205
+ }
5206
+ continue;
5207
+ }
5208
+ const file = pageFiles[0];
5209
+ if (file === void 0) {
5210
+ for (const section of sections2) {
5211
+ pend(page.route, section.groupKey, "NOT_IN_SOURCE", `no page file found for ${page.route}.`);
5212
+ }
5213
+ continue;
5214
+ }
5215
+ const done = new Set(alreadyOn(contents.get(file) ?? "", pageSlug, sections2));
5216
+ alreadyExtracted += done.size;
5217
+ if (done.size === sections2.length) continue;
5218
+ const found = await locateSections(
5219
+ sections2.filter((section) => !done.has(section.groupKey)),
5220
+ briefPage?.paths ?? [],
5221
+ sources,
5222
+ treeOf
5223
+ );
5224
+ for (const [groupKey, reason] of found.misses) {
5225
+ pend(page.route, groupKey, reason.code, reason.message);
5226
+ }
5227
+ const live = found.placed;
5228
+ if (live.length === 0) continue;
5229
+ const placedHere = [];
5230
+ for (const placed of live) {
5231
+ const elements = placed.byFile.get(file);
5232
+ if (elements && elements.length > 0) {
5233
+ placedHere.push({ section: placed.section, elements });
5234
+ continue;
5235
+ }
5236
+ pend(
5237
+ page.route,
5238
+ placed.section.groupKey,
5239
+ "SECTION_ROOT_AMBIGUOUS",
5240
+ `rendered by ${[...placed.byFile.keys()].join(", ")}, not ${file}.`
5241
+ );
5242
+ }
5243
+ if (placedHere.length === 0) continue;
5244
+ const content = contents.get(file);
5245
+ const dialect = dialectOf(file, content);
5246
+ if (!dialect || !EXTRACTABLE.includes(dialect)) {
5247
+ for (const placed of placedHere) {
5248
+ pend(page.route, placed.section.groupKey, "DIALECT_UNSUPPORTED", `${file} is ${dialect ?? "not a template"}.`);
5249
+ }
5250
+ continue;
5251
+ }
5252
+ const tree = await treeOf(file);
5253
+ if (!tree) {
5254
+ for (const placed of placedHere) {
5255
+ pend(page.route, placed.section.groupKey, "NOT_IN_SOURCE", `${file} did not parse.`);
5256
+ }
5257
+ continue;
5258
+ }
5259
+ const parsed = tree.parsed;
5260
+ const roots = [];
5261
+ for (const placed of placedHere) {
5262
+ const root = lowestCommonAncestor(placed.elements);
5263
+ if (!root) {
5264
+ pend(page.route, placed.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its fields sit under no single element.");
5265
+ continue;
5266
+ }
5267
+ const component = resolveComponent(plan2, placed.section);
5268
+ roots.push({ section: placed.section, route: page.route, root, ...component });
5269
+ }
5270
+ const nested = /* @__PURE__ */ new Set();
5271
+ for (const a of roots) {
5272
+ for (const b of roots) {
5273
+ if (a !== b && a.root.start <= b.root.start && b.root.end <= a.root.end) {
5274
+ nested.add(a);
5275
+ nested.add(b);
5276
+ }
5277
+ }
5278
+ }
5279
+ for (const resolved of nested) {
5280
+ pend(page.route, resolved.section.groupKey, "SECTION_ROOT_AMBIGUOUS", "its root contains another section's.");
5281
+ }
5282
+ const candidates = roots.filter((r) => !nested.has(r)).sort((a, b) => a.root.start - b.root.start);
5283
+ if (candidates.length === 0) continue;
5284
+ const extractable = componentsOf(dialect);
5285
+ const placeable = [];
5286
+ for (const resolved of candidates) {
5287
+ const path = `${COMPONENT_DIR}/${resolved.name}.${dialect === "astro" ? "astro" : "tsx"}`;
5288
+ const existing = contents.get(path);
5289
+ if (existing !== void 0 && !isGenerated(existing) && !extractable.has(resolved.slug)) {
5290
+ pend(page.route, resolved.section.groupKey, "COMPONENT_CONFLICT", `${path} is not a file this package wrote.`);
5291
+ continue;
5292
+ }
5293
+ if (extractable.has(resolved.slug)) {
5294
+ placeable.push(resolved);
5295
+ continue;
5296
+ }
5297
+ const built = componentFile(dialect, path, file, content, parsed, resolved);
5298
+ if (built.free.length > 0) {
5299
+ pend(
5300
+ page.route,
5301
+ resolved.section.groupKey,
5302
+ "SECTION_FREE_IDENTIFIERS",
5303
+ `its markup uses ${built.free.join(", ")}, which the component file cannot be given.`
5304
+ );
5305
+ continue;
5306
+ }
5307
+ extractable.set(resolved.slug, { name: resolved.name, path });
5308
+ fresh.add(resolved.slug);
5309
+ addFile(outFiles, contents, {
5310
+ path,
5311
+ operation: existing === void 0 ? "add" : "modify",
5312
+ content: built.content
5313
+ });
5314
+ placeable.push(resolved);
5315
+ }
5316
+ if (placeable.length === 0) continue;
5317
+ const layout = runOf(placeable, parsed.roots);
5318
+ if (layout === null) {
5319
+ for (const resolved of placeable) {
5320
+ pend(page.route, resolved.section.groupKey, "SECTION_NOT_CONTIGUOUS", "its roots share no container.");
5321
+ }
5322
+ continue;
5323
+ }
5324
+ const order = shipped.get(dialect) ?? /* @__PURE__ */ new Map();
5325
+ shipped.set(dialect, order);
5326
+ order.set(
5327
+ pageSlug,
5328
+ placeable.map((r) => ({ slug: r.slug, bind: r.section.groupKey }))
5329
+ );
5330
+ const rewritten = layout.contiguous ? splicePageRun(file, content, dialect, parsed, placeable, pageSlug) : splicePageInline(file, content, dialect, parsed, placeable, pageSlug, extractable);
5331
+ addFile(outFiles, contents, { path: file, operation: "modify", content: rewritten });
5332
+ if (layout.contiguous) extracted += placeable.length;
5333
+ else inlineOnly += placeable.length;
5334
+ }
5335
+ const componentSlugs = [...written.values()].flatMap((held) => [...held.keys()]);
5336
+ if (componentSlugs.length > 0) {
5337
+ const pageSlugs = [...new Set([...shipped.values()].flatMap((order) => [...order.keys()]))].sort();
5338
+ addFile(outFiles, contents, {
5339
+ path: SECTIONS_LIB,
5340
+ operation: contents.has(SECTIONS_LIB) ? "modify" : "add",
5341
+ content: sectionsLib(pageSlugs)
5342
+ });
5343
+ for (const [dialect, held] of [...written].sort(([a], [b]) => a.localeCompare(b))) {
5344
+ if (held.size === 0) continue;
5345
+ const registryPath = `${COMPONENT_DIR}/Sections.${dialect === "astro" ? "astro" : "tsx"}`;
5346
+ addFile(outFiles, contents, {
5347
+ path: registryPath,
5348
+ operation: contents.has(registryPath) ? "modify" : "add",
5349
+ content: registryFile(dialect, registryPath, held, shipped.get(dialect) ?? /* @__PURE__ */ new Map())
5350
+ });
5351
+ }
5352
+ emitHelper(outFiles, contents, helperVersion, options?.overwriteHelper);
5353
+ emitStubs(outFiles, contents, pageSlugs);
5354
+ }
5355
+ const sections = assertSections(total, { extracted, inlineOnly, alreadyExtracted, pending });
5356
+ return {
5357
+ files: [...outFiles.values()].sort((a, b) => a.path.localeCompare(b.path)),
5358
+ receipt: {
5359
+ planDigest: plan2.digest ?? "",
5360
+ sections,
5361
+ // What THIS run wrote a file for. A component an earlier run left is reused, not re-reported.
5362
+ components: [...fresh].sort(),
5363
+ planPending
5364
+ }
5365
+ };
5366
+ }
5367
+ function alreadyOn(content, pageSlug, sections) {
5368
+ const ours = COMPONENT_DIR.split("/").slice(-2).join("/");
5369
+ const importsOurs = new RegExp(`from\\s*["'][^"']*${ours}/`).test(content);
5370
+ if (!importsOurs) return [];
5371
+ if (new RegExp(`page=\\s*["']${pageSlug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content)) {
5372
+ return sections.map((section) => section.groupKey);
5373
+ }
5374
+ return sections.map((section) => section.groupKey).filter((groupKey) => new RegExp(`bind=\\s*["']${groupKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content));
5375
+ }
5376
+ function readRegistry(content, dialect) {
5377
+ const components = /* @__PURE__ */ new Map();
5378
+ const shipped = /* @__PURE__ */ new Map();
5379
+ const paths = /* @__PURE__ */ new Map();
5380
+ for (const match of content.matchAll(/^import\s+(\w+)\s+from\s+"\.\/([^"]+)";$/gm)) {
5381
+ const [, name, file] = match;
5382
+ paths.set(name, `${COMPONENT_DIR}/${file}${dialect === "astro" ? "" : ".tsx"}`);
5383
+ }
5384
+ const registry = content.match(/const REGISTRY[^=]*=\s*\{([\s\S]*?)\n\};/);
5385
+ for (const match of (registry?.[1] ?? "").matchAll(/^\s*"([^"]+)":\s*(\w+),$/gm)) {
5386
+ const [, slug, name] = match;
5387
+ const path = paths.get(name);
5388
+ if (path) components.set(slug, { name, path });
5389
+ }
5390
+ const table = content.match(/const SHIPPED[^=]*=\s*\{([\s\S]*?)\n\};/);
5391
+ for (const match of (table?.[1] ?? "").matchAll(/^\s*"([^"]+)":\s*(\[.*\]),$/gm)) {
5392
+ try {
5393
+ shipped.set(match[1], JSON.parse(match[2]));
5394
+ } catch {
5395
+ }
5396
+ }
5397
+ return { components, shipped };
5398
+ }
5399
+ function snapshotUsable(existing) {
5400
+ if (existing === void 0) return true;
5401
+ try {
5402
+ const parsed = JSON.parse(existing);
5403
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
5404
+ } catch {
5405
+ return false;
5406
+ }
5407
+ }
5408
+ function addFile(out, contents, file) {
5409
+ if (contents.get(file.path) === file.content) return;
5410
+ out.set(file.path, file);
5411
+ }
5412
+ async function locateSections(sections, briefPaths, sources, treeOf) {
5413
+ const placed = [];
5414
+ const misses = [];
5415
+ for (const section of sections) {
5416
+ const paths = fieldPaths(section);
5417
+ const literals = new Set(
5418
+ briefPaths.filter((p) => (p.scope ?? "page") === "page" && groupOf(p.path) === section.groupKey && p.original).map((p) => flat(stripTags(p.original))).filter(Boolean)
5419
+ );
5420
+ const hits = /* @__PURE__ */ new Map();
5421
+ for (const source of sources) {
5422
+ const tree = await treeOf(source.path);
5423
+ if (!tree) continue;
5424
+ const parsed = tree.parsed;
5425
+ const elements = tree.elements;
5426
+ const found = /* @__PURE__ */ new Map();
5427
+ for (const declaration2 of declarationsIn(parsed.roots, source.content)) {
5428
+ if (declaration2.scope !== "page") continue;
5429
+ if (groupOf(declaration2.path) !== section.groupKey) continue;
5430
+ const placed2 = elements.get(declaration2.at);
5431
+ if (placed2) found.set(declaration2.at, placed2);
5432
+ }
5433
+ if (found.size === 0 && literals.size > 0) {
5434
+ for (const site of collectSites(parsed.roots, [...literals])) {
5435
+ if (site.partial || site.where === "script" || site.where === "comment") continue;
5436
+ const placed2 = elements.get(site.attrInsertAt);
5437
+ if (placed2) found.set(site.attrInsertAt, placed2);
5438
+ }
5439
+ }
5440
+ if (found.size > 0) hits.set(source.path, [...found.values()]);
5441
+ }
5442
+ if (hits.size === 0) {
5443
+ misses.push([
5444
+ section.groupKey,
5445
+ { code: "NOT_IN_SOURCE", message: `no file renders ${paths.join(", ") || section.groupKey}.` }
5446
+ ]);
5447
+ continue;
5448
+ }
5449
+ placed.push({ section, byFile: hits });
5450
+ }
5451
+ return { placed, misses };
5452
+ }
5453
+ var groupOf = (path) => path.split(/[.[]/)[0] ?? "";
5454
+ var fieldPaths = (section) => (section.fields ?? []).map((field) => field.path ?? `${section.groupKey}.${field.key ?? ""}`);
5455
+ function elementIndex(roots) {
5456
+ const out = /* @__PURE__ */ new Map();
5457
+ const visit = (node, chain) => {
5458
+ if (node.type === "expr") {
5459
+ for (const child of node.children) visit(child, chain);
5460
+ return;
5461
+ }
5462
+ if (node.type !== "element") return;
5463
+ const mine = [...chain, node];
5464
+ out.set(node.attrInsertAt, { node, chain: mine });
5465
+ for (const child of node.children) visit(child, mine);
5466
+ };
5467
+ for (const root of roots) visit(root, []);
5468
+ return out;
5469
+ }
5470
+ function lowestCommonAncestor(elements) {
5471
+ if (elements.length === 0) return null;
5472
+ let common = elements[0].chain;
5473
+ for (const placed of elements.slice(1)) {
5474
+ const shared = [];
5475
+ for (let i = 0; i < Math.min(common.length, placed.chain.length); i++) {
5476
+ if (common[i] !== placed.chain[i]) break;
5477
+ shared.push(common[i]);
5478
+ }
5479
+ common = shared;
5480
+ }
5481
+ return common[common.length - 1] ?? null;
5482
+ }
5483
+ function runOf(placeable, roots) {
5484
+ const parents = placeable.map((r) => parentOf(r.root, roots));
5485
+ if (parents.some((p) => p === void 0)) return null;
5486
+ const [first, ...rest] = parents;
5487
+ if (rest.some((p) => p !== first)) return null;
5488
+ const siblings = (first === null ? roots : first.children).filter(
5489
+ (node) => node.type === "element"
5490
+ );
5491
+ const indices = placeable.map((r) => siblings.indexOf(r.root)).sort((a, b) => a - b);
5492
+ if (indices.some((at) => at < 0)) return null;
5493
+ const contiguous = indices.every((at, i) => i === 0 || at === indices[i - 1] + 1);
5494
+ return { contiguous };
5495
+ }
5496
+ function parentOf(target, roots) {
5497
+ if (roots.some((node) => node === target)) return null;
5498
+ const search = (nodes, parent) => {
5499
+ for (const node of nodes) {
5500
+ if (node.type === "expr") {
5501
+ const inside = search(node.children, parent);
5502
+ if (inside !== void 0) return inside;
5503
+ continue;
5504
+ }
5505
+ if (node.type !== "element") continue;
5506
+ if (node.children.some((child) => child === target)) return node;
5507
+ const deeper = search(node.children, node);
5508
+ if (deeper !== void 0) return deeper;
5509
+ }
5510
+ return void 0;
5511
+ };
5512
+ return search(roots, null);
5513
+ }
5514
+ function resolveComponent(plan2, section) {
5515
+ const components = plan2.components ?? [];
5516
+ const wanted = section.reuse?.proposedSlug;
5517
+ const byId = section.reuse?.componentId;
5518
+ const entry = (wanted ? components.find((c) => c.proposedSlug === wanted) : void 0) ?? (byId ? components.find((c) => c.componentId === byId) : void 0) ?? onlyOne(components.filter((c) => c.sectionType && c.sectionType === section.sectionType));
5519
+ const family = entry?.name ?? section.sectionType ?? section.groupKey;
5520
+ return { slug: entry?.proposedSlug ?? wanted ?? slugify(family), name: pascal(family) };
5521
+ }
5522
+ var onlyOne = (all) => all.length === 1 ? all[0] : void 0;
5523
+ function pascal(value) {
5524
+ const parts = value.split(/[^A-Za-z0-9]+/).filter(Boolean);
5525
+ const name = parts.map((part) => part[0].toUpperCase() + part.slice(1)).join("");
5526
+ return /^[A-Za-z]/.test(name) ? name : `Section${name}`;
5527
+ }
5528
+ var slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
5529
+ function componentFile(dialect, path, fromFile, content, parsed, resolved) {
5530
+ const markup = sliceWithBlockId(content, resolved.root);
5531
+ const groupKey = resolved.section.groupKey;
5532
+ const locals = helperLocals(parsed, dialect, fromFile);
5533
+ const helperNames = ["bcms", "bcmsRows", "bcmsLayout"].flatMap(
5534
+ (name) => locals[name].filter((local) => mentions(markup, local)).map((local) => ({ name, local }))
5535
+ );
5536
+ const helpers = [.../* @__PURE__ */ new Set([...helperNames.map((h) => alias(h.name, h.local)), "bcmsSection"])].sort();
5537
+ const helperSpecifier = relativeImport(path, HELPER_PATH[dialect]).replace(/\.tsx?$/, "");
5538
+ const snapshot = snapshotOf(parsed, dialect, fromFile, markup);
5539
+ const carry = carryFor({
5540
+ dialect,
5541
+ fromFile,
5542
+ toFile: path,
5543
+ content,
5544
+ parsed,
5545
+ root: resolved.root,
5546
+ provided: [
5547
+ "blockId",
5548
+ "bind",
5549
+ "overrides",
5550
+ "page",
5551
+ ...helperNames.map((h) => h.local),
5552
+ ...snapshot ? [snapshot] : []
5553
+ ],
5554
+ // The helper and the snapshots are what the component declares for itself.
5555
+ skipSpecifier: (repoPath, specifier) => specifier.includes(`${CONTENT_DIR}/`) || repoPath.startsWith(HELPER_PATH[dialect].replace(/\.tsx?$/, ""))
5556
+ });
5557
+ if (carry.free.length > 0) return { content: "", free: carry.free };
5558
+ const carried = [...carry.imports];
5559
+ const key = /^[A-Za-z_$][\w$]*$/.test(groupKey) ? groupKey : JSON.stringify(groupKey);
5560
+ const carriedDeclarations = carry.declarations.length > 0 ? `${carry.declarations.join("\n")}
5561
+ ` : "";
5562
+ const copy = snapshot ? `const ${snapshot} = { ${key}: bcmsSection(page, bind ?? ${JSON.stringify(groupKey)}, overrides) };` : "";
5563
+ if (dialect === "astro") {
5564
+ return { free: [], content: `---
5565
+ ${SECTION_MARKER}
5566
+ ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`].join("\n")}
5567
+
5568
+ interface Props {
5569
+ /** The placement's block id. The editor hit-tests \`[data-bcms-block]\` for section chrome. */
5570
+ blockId?: string;
5571
+ /** The page field group this placement renders \u2014 Phase A keeps the copy where P2 put it. */
5572
+ bind?: string;
5573
+ /** The instance's own copy, when an editor gave it any. Wins over the bound group. */
5574
+ overrides?: Record<string, unknown>;
5575
+ /** The page snapshot, so one component can serve every page whose group has this shape. */
5576
+ page?: unknown;
5577
+ }
5578
+ const { blockId, bind, overrides, page } = Astro.props;
5579
+ ${carriedDeclarations}${copy}
5580
+ ---
5581
+ ${markup}
5582
+ ` };
5583
+ }
5584
+ return { free: [], content: `${SECTION_MARKER}
5585
+ ${[...carried, `import { ${helpers.join(", ")} } from "${helperSpecifier}";`, `import type { BcmsSectionProps } from "${relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "")}";`].join("\n")}
5586
+ ${carriedDeclarations === "" ? "" : `
5587
+ ${carriedDeclarations}`}
5588
+ export default function ${resolved.name}({ blockId, bind, overrides, page }: BcmsSectionProps) {
5589
+ ${copy}
5590
+ return (
5591
+ ${markup}
5592
+ );
5593
+ }
5594
+ ` };
5595
+ }
5596
+ function sliceWithBlockId(content, root) {
5597
+ const slice = new MagicString3(content.slice(root.start, root.end));
5598
+ slice.appendLeft(root.attrInsertAt - root.start, " data-bcms-block={blockId}");
5599
+ return slice.toString();
5600
+ }
5601
+ var alias = (name, local) => name === local ? name : `${name} as ${local}`;
5602
+ var mentions = (markup, name) => new RegExp(`(?<![\\w$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w$])`).test(markup);
5603
+ function snapshotOf(parsed, dialect, fromFile, markup) {
5604
+ for (const [local, from] of importsIn(parsed, dialect)) {
5605
+ if (from.imported !== null) continue;
5606
+ if (!from.specifier.includes(`${CONTENT_DIR}/`) || !from.specifier.endsWith(".json")) continue;
5607
+ if (mentions(markup, local)) return local;
5608
+ }
5609
+ return null;
5610
+ }
5611
+ function sectionsLib(pageSlugs) {
5612
+ const imports = pageSlugs.map(
5613
+ (slug) => `import ${identifierFor(slug)} from "${relativeImport(SECTIONS_LIB, `${CONTENT_DIR}/${slug}.json`)}";`
5614
+ );
5615
+ const table = pageSlugs.map((slug) => ` ${JSON.stringify(slug)}: ${identifierFor(slug)},`).join("\n");
5616
+ return `${REGISTRY_MARKER}
5617
+ // Structure comes from bcms-content/*.json, written into the tree before the build: each page's
5618
+ // \`blocks\` are its ordered section instances, and components.json names which component each one
5619
+ // is. The committed stubs are empty \u2014 a build with them renders the order the repository ships.
5620
+ import bcmsComponents from "${relativeImport(SECTIONS_LIB, `${CONTENT_DIR}/components.json`)}";
5621
+ ${imports.join("\n")}
5622
+
5623
+ const SNAPSHOTS: Record<string, unknown> = {
5624
+ ${table}
5625
+ };
5626
+
5627
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
5628
+ typeof v === "object" && v !== null && !Array.isArray(v);
5629
+
5630
+ /** What every extracted section component is handed. */
5631
+ export interface BcmsSectionProps {
5632
+ blockId?: string;
5633
+ bind?: string;
5634
+ overrides?: Record<string, unknown>;
5635
+ page?: unknown;
5636
+ }
5637
+
5638
+ /** One placement on one page: which component, bound to which page field group. */
5639
+ export interface BcmsPlacement {
5640
+ blockId?: string;
5641
+ slug: string;
5642
+ bind?: string;
5643
+ overrides?: Record<string, unknown>;
5644
+ }
5645
+
5646
+ /** The page's own snapshot \u2014 the copy every placement on it reads through \`bind\`. */
5647
+ export function pageSnapshot(pageSlug: string): unknown {
5648
+ return SNAPSHOTS[pageSlug];
5649
+ }
5650
+
5651
+ /** The page's \`component\` blocks, before any of them is matched to a component this repo has. */
5652
+ function componentBlocks(pageSlug: string): Record<string, unknown>[] {
5653
+ const snapshot = SNAPSHOTS[pageSlug];
5654
+ const blocks = isRecord(snapshot) ? snapshot.blocks : undefined;
5655
+ if (!Array.isArray(blocks)) return [];
5656
+ return (blocks as unknown[]).filter(
5657
+ (block): block is Record<string, unknown> => isRecord(block) && block.type === "component",
5658
+ );
5659
+ }
5660
+
5661
+ /**
5662
+ * Has the CMS said anything about this page's structure?
5663
+ *
5664
+ * \u{1F534} THE RAW LIST, NOT THE RESOLVED ONE. "No sections came back" has two causes that must not be
5665
+ * treated alike: the CMS holds nothing yet (fall back to the order this repository ships), or the
5666
+ * CMS holds a list whose components this repository does not have (render nothing \u2014 the page HAS
5667
+ * been arranged, and re-showing the old bands would silently ignore an editor's work).
5668
+ */
5669
+ export function hasCmsSections(pageSlug: string): boolean {
5670
+ return componentBlocks(pageSlug).length > 0;
5671
+ }
5672
+
5673
+ /**
5674
+ * The page's section instances, in the order the CMS holds them.
5675
+ *
5676
+ * An instance naming a component this repository does not have is SKIPPED, never rendered as a
5677
+ * hole: the CMS is free to add a section type the repo has not shipped yet, and a half-built page
5678
+ * is worse than a page missing one band.
5679
+ */
5680
+ export function pageSections(pageSlug: string): BcmsPlacement[] {
5681
+ const blocks = componentBlocks(pageSlug);
5682
+ if (blocks.length === 0) return [];
5683
+
5684
+ const slugs = new Map<string, string>();
5685
+ if (Array.isArray(bcmsComponents)) {
5686
+ for (const component of bcmsComponents as unknown[]) {
5687
+ if (!isRecord(component)) continue;
5688
+ const id = component.id ?? component.componentId;
5689
+ const slug = component.slug ?? component.proposedSlug;
5690
+ if (typeof id === "string" && typeof slug === "string") slugs.set(id, slug);
5691
+ }
5692
+ }
5693
+
5694
+ const out: BcmsPlacement[] = [];
5695
+ for (const block of blocks) {
5696
+ const props = isRecord(block.props) ? block.props : {};
5697
+ const slug = typeof props.componentId === "string" ? slugs.get(props.componentId) : undefined;
5698
+ if (!slug) continue;
5699
+ out.push({
5700
+ blockId: typeof block.id === "string" ? block.id : undefined,
5701
+ slug,
5702
+ bind: typeof props.bind === "string" ? props.bind : undefined,
5703
+ overrides: isRecord(props.overrides) ? props.overrides : undefined,
5704
+ });
5705
+ }
5706
+ return out;
5707
+ }
5708
+ `;
5709
+ }
5710
+ var identifierFor = (slug) => `bcms${slug.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^./, (c) => c.toUpperCase())}`;
5711
+ function registryFile(dialect, path, written, shipped) {
5712
+ const entries = [...written].sort(([a], [b]) => a.localeCompare(b));
5713
+ const imports = entries.map(([, component]) => {
5714
+ const specifier = relativeImport(path, component.path);
5715
+ return `import ${component.name} from "${dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, "")}";`;
5716
+ }).join("\n");
5717
+ const registry = entries.map(([slug, component]) => ` ${JSON.stringify(slug)}: ${component.name},`).join("\n");
5718
+ const shippedTable = [...shipped].sort(([a], [b]) => a.localeCompare(b)).map(([page, list]) => ` ${JSON.stringify(page)}: ${JSON.stringify(list)},`).join("\n");
5719
+ const lib = relativeImport(path, SECTIONS_LIB).replace(/\.tsx?$/, "");
5720
+ const registryType = dialect === "astro" ? "Record<string, unknown>" : "Record<string, (props: BcmsSectionProps) => JSX.Element | null>";
5721
+ const shared = `${imports}
5722
+ import { hasCmsSections, pageSections, pageSnapshot, type BcmsPlacement${dialect === "astro" ? "" : ", type BcmsSectionProps"} } from "${lib}";
5723
+
5724
+ /** slug \u2192 the component that renders it. An unknown slug is skipped, never a hole. */
5725
+ const REGISTRY: ${registryType} = {
5726
+ ${registry}
5727
+ };
5728
+
5729
+ /**
5730
+ * The order the REPOSITORY ships, per page \u2014 the fallback when the CMS holds no blocks yet.
5731
+ *
5732
+ * \u{1F534} LOAD-BEARING. An empty \`blocks\` means "nothing has been said about this page", not "this
5733
+ * page is empty": it is what a fresh clone, the committed stub and every not-yet-componentised
5734
+ * page all look like. Rendering nothing there would blank the site on the first build after the
5735
+ * codemod ran, with nothing in the diff to explain it.
5736
+ */
5737
+ const SHIPPED: Record<string, BcmsPlacement[]> = {
5738
+ ${shippedTable}
5739
+ };
5740
+
5741
+ /**
5742
+ * \u{1F534} THE FALLBACK IS DECIDED ON THE RAW LIST. A page whose CMS blocks all name components this
5743
+ * repository does not have has still been ARRANGED \u2014 falling back there would put the shipped
5744
+ * bands back on a page an editor deliberately emptied, and no amount of reading the diff would
5745
+ * explain it. Only "the CMS holds nothing" takes the shipped order.
5746
+ */
5747
+ const placementsFor = (page: string): BcmsPlacement[] =>
5748
+ hasCmsSections(page) ? pageSections(page) : (SHIPPED[page] ?? []);`;
5749
+ if (dialect === "astro") {
5750
+ return `---
5751
+ ${REGISTRY_MARKER}
5752
+ ${shared}
5753
+
5754
+ interface Props {
5755
+ /** The page slug, as \`bcms-content/<slug>.json\` spells it. */
5756
+ page: string;
5757
+ }
5758
+ const { page } = Astro.props;
5759
+ const placements = placementsFor(page);
5760
+ const snapshot = pageSnapshot(page);
5761
+ ---
5762
+ {placements.map((placement) => {
5763
+ const Component = REGISTRY[placement.slug] as never;
5764
+ return Component ? (
5765
+ <Component
5766
+ blockId={placement.blockId}
5767
+ bind={placement.bind}
5768
+ overrides={placement.overrides}
5769
+ page={snapshot}
5770
+ />
5771
+ ) : null;
5772
+ })}
5773
+ `;
5774
+ }
5775
+ return `${REGISTRY_MARKER}
5776
+ ${shared}
5777
+
5778
+ export default function Sections({ page }: { page: string }) {
5779
+ const placements = placementsFor(page);
5780
+ const snapshot = pageSnapshot(page);
5781
+ return (
5782
+ <>
5783
+ {placements.map((placement, i) => {
5784
+ const Component = REGISTRY[placement.slug];
5785
+ return Component ? (
5786
+ <Component
5787
+ key={placement.blockId ?? \`\${placement.slug}-\${i}\`}
5788
+ blockId={placement.blockId}
5789
+ bind={placement.bind}
5790
+ overrides={placement.overrides}
5791
+ page={snapshot}
5792
+ />
5793
+ ) : null;
5794
+ })}
5795
+ </>
5796
+ );
5797
+ }
5798
+ `;
5799
+ }
5800
+ function splicePageRun(file, content, dialect, parsed, placeable, pageSlug) {
5801
+ const names = allocateNames(topLevelBindings(dialect, parsed, content), [{ key: "Sections", name: "Sections" }]);
5802
+ const local = names.get("Sections");
5803
+ const source = new MagicString3(content);
5804
+ const first = placeable[0].root;
5805
+ const last = placeable[placeable.length - 1].root;
5806
+ source.overwrite(first.start, last.end, `<${local} page="${pageSlug}" />`);
5807
+ const registry = `${COMPONENT_DIR}/Sections.${dialect === "astro" ? "astro" : "tsx"}`;
5808
+ const specifier = relativeImport(file, registry);
5809
+ addImports(source, parsed, [
5810
+ `import ${local} from "${dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, "")}";`
5811
+ ]);
5812
+ return source.toString();
5813
+ }
5814
+ function splicePageInline(file, content, dialect, parsed, placeable, pageSlug, written) {
5815
+ const wanted = [
5816
+ ...new Set(placeable.map((r) => r.slug))
5817
+ ].map((slug) => ({ key: slug, name: written.get(slug).name }));
5818
+ const names = allocateNames(topLevelBindings(dialect, parsed, content), [
5819
+ ...wanted,
5820
+ { key: "pageSnapshot", name: "pageSnapshot" }
5821
+ ]);
5822
+ const snapshot = names.get("pageSnapshot");
5823
+ const source = new MagicString3(content);
5824
+ for (const resolved of placeable) {
5825
+ source.overwrite(
5826
+ resolved.root.start,
5827
+ resolved.root.end,
5828
+ `<${names.get(resolved.slug)} bind=${JSON.stringify(resolved.section.groupKey)} page={${snapshot}(${JSON.stringify(pageSlug)})} />`
5829
+ );
5830
+ }
5831
+ const lib = relativeImport(file, SECTIONS_LIB).replace(/\.tsx?$/, "");
5832
+ const imports = [
5833
+ ...wanted.map(({ key }) => {
5834
+ const specifier = relativeImport(file, written.get(key).path);
5835
+ return `import ${names.get(key)} from "${dialect === "astro" ? specifier : specifier.replace(/\.tsx?$/, "")}";`;
5836
+ }),
5837
+ `import { pageSnapshot${snapshot === "pageSnapshot" ? "" : ` as ${snapshot}`} } from "${lib}";`
5838
+ ];
5839
+ addImports(source, parsed, imports);
5840
+ return source.toString();
5841
+ }
5842
+ function addImports(source, parsed, lines) {
5843
+ const at = parsed.importAt;
5844
+ if (!at || lines.length === 0) return;
5845
+ if (at.wrap) source.appendLeft(at.at, `${at.wrap.before}${lines.join("\n")}${at.wrap.after}`);
5846
+ else if (at.at === 0) source.appendLeft(0, `${lines.join("\n")}
5847
+ `);
5848
+ else source.appendLeft(at.at, `
5849
+ ${lines.join("\n")}`);
5850
+ }
5851
+ var isGenerated = (content) => content.includes(SECTION_MARKER) || content.includes(REGISTRY_MARKER);
5852
+ function emitHelper(out, contents, version, overwrite) {
5853
+ const path = HELPER_PATH.astro;
5854
+ const source = helperSource("astro");
5855
+ const existing = contents.get(path);
5856
+ if (existing === void 0) {
5857
+ addFile(out, contents, { path, operation: "add", content: source });
5858
+ return;
5859
+ }
5860
+ if (version === null && overwrite !== true) return;
5861
+ if (version === null || version < HELPER_VERSION) {
5862
+ addFile(out, contents, { path, operation: "modify", content: source });
5863
+ }
5864
+ }
5865
+ function emitStubs(out, contents, pageSlugs) {
5866
+ const components = `${CONTENT_DIR}/components.json`;
5867
+ if (!contents.has(components)) {
5868
+ addFile(out, contents, { path: components, operation: "add", content: "[]" });
5869
+ }
5870
+ for (const slug of pageSlugs) {
5871
+ const path = `${CONTENT_DIR}/${slug}.json`;
5872
+ const existing = contents.get(path);
5873
+ let held = {};
5874
+ if (existing !== void 0) {
5875
+ try {
5876
+ const parsed = JSON.parse(existing);
5877
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
5878
+ held = parsed;
5879
+ } catch {
5880
+ continue;
5881
+ }
5882
+ }
5883
+ if (Array.isArray(held.blocks)) continue;
5884
+ addFile(out, contents, {
5885
+ path,
5886
+ operation: existing === void 0 ? "add" : "modify",
5887
+ content: `${JSON.stringify({ ...held, blocks: [] }, null, 2)}
5888
+ `
5889
+ });
5890
+ }
5891
+ }
5892
+ function readComponentizePlan(value) {
5893
+ return readPlan(value);
5894
+ }
5895
+
4582
5896
  // src/index.ts
4583
5897
  var ConvertError = class extends Error {
4584
5898
  constructor(code, message) {
@@ -4590,7 +5904,7 @@ var ConvertError = class extends Error {
4590
5904
  };
4591
5905
  var keyOf = (route, scope, path) => scope === "layout" ? `layout::${path}` : `${route}::page::${path}`;
4592
5906
  var escapeRegExp2 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4593
- var identifierFor = (slug) => `bcms${slug.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^./, (c) => c.toUpperCase())}`;
5907
+ var identifierFor2 = (slug) => `bcms${slug.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^./, (c) => c.toUpperCase())}`;
4594
5908
  async function drillProp(ctx, fromFile, component, prop, kind, hop) {
4595
5909
  const miss = { ok: false, reason: "PROP_TARGET_NOT_FOUND", edits: [] };
4596
5910
  if (hop > MAX_HOPS) return { ok: false, reason: "PROP_DRILLED_DEEP", edits: [] };
@@ -4638,7 +5952,8 @@ async function drillProp(ctx, fromFile, component, prop, kind, hop) {
4638
5952
  if (found.propsType) at(found.propsType.at, found.propsType.text);
4639
5953
  return { ok: true, edits: [{ file, splices }] };
4640
5954
  }
4641
- async function convertSources(brief, sources, options) {
5955
+ async function convertSources(briefIn, sources, options) {
5956
+ const brief = readBrief(briefIn);
4642
5957
  const digest = briefDigest(brief);
4643
5958
  const targets = /* @__PURE__ */ new Map();
4644
5959
  for (const page of brief.pages) {
@@ -4773,7 +6088,7 @@ async function convertSources(brief, sources, options) {
4773
6088
  else rewritten++;
4774
6089
  }
4775
6090
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
4776
- const source = new MagicString3(contents.get(file));
6091
+ const source = new MagicString4(contents.get(file));
4777
6092
  const seen = /* @__PURE__ */ new Set();
4778
6093
  for (const splice of splices) {
4779
6094
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -4793,7 +6108,7 @@ async function convertSources(brief, sources, options) {
4793
6108
  }
4794
6109
  }
4795
6110
  const { helpers, helperUpgraded } = emitHelpers(dialects, contents, outFiles, options?.overwriteHelper);
4796
- emitStubs(dialects, snapshotSlugs, contents, outFiles);
6111
+ emitStubs2(dialects, snapshotSlugs, contents, outFiles);
4797
6112
  const receipt = assertReceipt({
4798
6113
  briefDigest: digest,
4799
6114
  paths: { declared: targets.size, rewritten, alreadyDeclared, pending },
@@ -4908,7 +6223,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
4908
6223
  { key: "helper:bcmsRows", name: "bcmsRows" },
4909
6224
  { key: "helper:bcmsLayout", name: "bcmsLayout" },
4910
6225
  // Keyed by the SLUG: two slugs that spell one identifier are still two pages. @see scope.ts
4911
- ...slugs.map((slug) => ({ key: `slug:${slug}`, name: identifierFor(slug) }))
6226
+ ...slugs.map((slug) => ({ key: `slug:${slug}`, name: identifierFor2(slug) }))
4912
6227
  ].filter((entry) => !preset.has(entry.key));
4913
6228
  const names = new Map([...preset, ...allocateNames(taken, wanted)]);
4914
6229
  const snapshotName = (slug) => names.get(`slug:${slug}`);
@@ -5300,7 +6615,7 @@ function emitHelpers(dialects, contents, out, overwrite) {
5300
6615
  }
5301
6616
  return { helpers, helperUpgraded };
5302
6617
  }
5303
- function emitStubs(dialects, slugs, contents, out) {
6618
+ function emitStubs2(dialects, slugs, contents, out) {
5304
6619
  if (![...dialects].some((d) => DIALECT_RULES[d].text)) return;
5305
6620
  const paths = [...[...slugs].map((slug) => `${CONTENT_DIR}/${slug}.json`), `${CONTENT_DIR}/layout.json`];
5306
6621
  for (const path of paths.sort()) {
@@ -5310,13 +6625,15 @@ function emitStubs(dialects, slugs, contents, out) {
5310
6625
  }
5311
6626
 
5312
6627
  // src/cli.ts
5313
- var VERSION = "0.1.0";
6628
+ var VERSION = "0.2.0";
5314
6629
  var MAX_FILE_BYTES = 512 * 1024;
5315
6630
  var CliError = class extends Error {
5316
6631
  };
5317
6632
  function parseArgs(argv) {
5318
6633
  const args = {
5319
6634
  brief: void 0,
6635
+ componentize: false,
6636
+ plan: void 0,
5320
6637
  root: ".",
5321
6638
  dryRun: false,
5322
6639
  receipt: void 0,
@@ -5328,6 +6645,8 @@ function parseArgs(argv) {
5328
6645
  for (let i = 0; i < argv.length; i++) {
5329
6646
  const arg = argv[i];
5330
6647
  if (arg === "--brief") args.brief = argv[++i];
6648
+ else if (arg === "--componentize") args.componentize = true;
6649
+ else if (arg === "--plan") args.plan = argv[++i];
5331
6650
  else if (arg === "--root") args.root = argv[++i] ?? ".";
5332
6651
  else if (arg === "--receipt") args.receipt = argv[++i];
5333
6652
  else if (arg === "--dry-run") args.dryRun = true;
@@ -5346,16 +6665,20 @@ function parseArgs(argv) {
5346
6665
  var HELP = `bettercms-convert ${VERSION}
5347
6666
 
5348
6667
  bettercms-convert --brief <json> --root . [options]
6668
+ bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
5349
6669
 
5350
6670
  --brief <file> The conversion brief, as get_conversion_brief returns it. Required.
6671
+ --componentize Lift each planned SECTION into a component the CMS can reorder.
6672
+ --plan <file> The componentize plan, as get_componentize_plan returns it.
6673
+ Required with --componentize.
5351
6674
  --root <dir> The repository to convert. Default: the working directory.
5352
6675
  --dry-run Print what would change; write nothing.
5353
6676
  --receipt <file> Write the receipt as JSON.
5354
- --strict Exit 2 when any path is left pending (for CI).
6677
+ --strict Exit 2 when anything is left pending (for CI).
5355
6678
  --overwrite-helper Replace a bcms-content helper this tool did not write.
5356
6679
  --verbose Print stack traces and every file considered.
5357
6680
 
5358
- Exit: 0 done (pending paths are listed), 1 refused, 2 --strict with pending paths.
6681
+ Exit: 0 done (pending items are listed), 1 refused, 2 --strict with pending items.
5359
6682
  `;
5360
6683
  async function readTree(root, verbose) {
5361
6684
  const files = [];
@@ -5381,6 +6704,26 @@ async function readTree(root, verbose) {
5381
6704
  await walk(root);
5382
6705
  return files;
5383
6706
  }
6707
+ async function readSnapshots(root) {
6708
+ const dir = join2(root, CONTENT_DIR);
6709
+ let entries;
6710
+ try {
6711
+ entries = await readdir(dir, { withFileTypes: true });
6712
+ } catch (error) {
6713
+ if (error.code !== "ENOENT") {
6714
+ throw new CliError(
6715
+ `SNAPSHOT_UNREADABLE: ${CONTENT_DIR} could not be read (${error.message}). Componentize writes into those snapshots, so it will not proceed while it cannot see them.`
6716
+ );
6717
+ }
6718
+ return [];
6719
+ }
6720
+ const out = [];
6721
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
6722
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
6723
+ out.push({ path: `${CONTENT_DIR}/${entry.name}`, content: await readFile(join2(dir, entry.name), "utf-8") });
6724
+ }
6725
+ return out;
6726
+ }
5384
6727
  function under(root, path) {
5385
6728
  const target = resolve(root, path);
5386
6729
  if (target !== root && !target.startsWith(root + sep)) {
@@ -5404,6 +6747,24 @@ ${rewritten} of ${declared} paths bound (${alreadyDeclared} already declared, ${
5404
6747
  `);
5405
6748
  }
5406
6749
  }
6750
+ function reportSections(files, receipt, dryRun) {
6751
+ const { extracted, inlineOnly, alreadyExtracted, pending } = receipt.sections;
6752
+ for (const file of files) {
6753
+ process.stdout.write(`${dryRun ? "would " : ""}${file.operation} ${file.path}
6754
+ `);
6755
+ }
6756
+ process.stdout.write(
6757
+ `
6758
+ ${extracted} sections extracted (${inlineOnly} inline-only, ${alreadyExtracted} already extracted, ${pending.length} pending)
6759
+ `
6760
+ );
6761
+ for (const section of pending) {
6762
+ process.stdout.write(
6763
+ ` pending ${section.route} ${section.groupKey} \u2014 ${section.reason}${section.message ? ` (${section.message})` : ""}
6764
+ `
6765
+ );
6766
+ }
6767
+ }
5407
6768
  async function main() {
5408
6769
  const args = parseArgs(process.argv.slice(2));
5409
6770
  if (args.help) {
@@ -5411,6 +6772,9 @@ async function main() {
5411
6772
  return 0;
5412
6773
  }
5413
6774
  if (!args.brief) throw new CliError("--brief <file> is required. See --help.");
6775
+ if (args.plan && !args.componentize) {
6776
+ throw new CliError("--plan is only read with --componentize. See --help.");
6777
+ }
5414
6778
  const root = resolve(args.root);
5415
6779
  const brief = JSON.parse(await readFile(resolve(args.brief), "utf-8"));
5416
6780
  if (!Array.isArray(brief.pages)) {
@@ -5419,26 +6783,50 @@ async function main() {
5419
6783
  const sources = await readTree(root, args.verbose);
5420
6784
  if (args.verbose) process.stderr.write(`read ${sources.length} candidate files under ${root}
5421
6785
  `);
5422
- const { files, receipt } = await convertSources(brief, sources, {
5423
- overwriteHelper: args.overwriteHelper
5424
- });
5425
- if (!args.dryRun) {
5426
- for (const file of files) {
6786
+ const written = async (files2) => {
6787
+ if (args.dryRun) return;
6788
+ for (const file of files2) {
5427
6789
  const target = under(root, file.path);
5428
6790
  await mkdir(dirname(target), { recursive: true });
5429
6791
  await writeFile(target, file.content, "utf-8");
5430
6792
  }
5431
- }
5432
- if (args.receipt) {
5433
- await writeFile(resolve(args.receipt), `${JSON.stringify(receipt, null, 2)}
6793
+ };
6794
+ const saveReceipt = async (receipt2) => {
6795
+ if (!args.receipt) return;
6796
+ await writeFile(resolve(args.receipt), `${JSON.stringify(receipt2, null, 2)}
5434
6797
  `, "utf-8");
6798
+ };
6799
+ if (args.componentize) {
6800
+ if (!args.plan) throw new CliError("--plan <file> is required with --componentize. See --help.");
6801
+ let plan2;
6802
+ try {
6803
+ plan2 = readComponentizePlan(JSON.parse(await readFile(resolve(args.plan), "utf-8")));
6804
+ } catch (error) {
6805
+ throw new CliError(`${args.plan}: ${error.message}`);
6806
+ }
6807
+ const { files: files2, receipt: receipt2 } = await componentizeSources(
6808
+ brief,
6809
+ plan2,
6810
+ [...sources, ...await readSnapshots(root)],
6811
+ { overwriteHelper: args.overwriteHelper }
6812
+ );
6813
+ await written(files2);
6814
+ await saveReceipt(receipt2);
6815
+ reportSections(files2, receipt2, args.dryRun);
6816
+ return args.strict && receipt2.sections.pending.length > 0 ? 2 : 0;
5435
6817
  }
6818
+ const { files, receipt } = await convertSources(brief, sources, {
6819
+ overwriteHelper: args.overwriteHelper
6820
+ });
6821
+ await written(files);
6822
+ await saveReceipt(receipt);
5436
6823
  report(files, receipt, args.dryRun);
5437
6824
  return args.strict && receipt.paths.pending.length > 0 ? 2 : 0;
5438
6825
  }
5439
6826
  main().then((code) => process.exit(code)).catch((error) => {
5440
6827
  const message = error instanceof Error ? error.message : String(error);
5441
- const code = error instanceof ConvertError ? `${error.code}: ` : "";
6828
+ const named = error;
6829
+ const code = typeof named.code === "string" && /^[A-Z_]+$/.test(named.code) ? `${named.code}: ` : "";
5442
6830
  process.stderr.write(`${code}${message}
5443
6831
  `);
5444
6832
  if (process.argv.includes("--verbose") && error instanceof Error) {