@bettercms-ai/convert 0.22.0 → 0.24.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
@@ -6,7 +6,7 @@ import { readdir, readFile, writeFile, mkdir, stat } from "fs/promises";
6
6
  import { dirname, join as join2, relative, resolve, sep } from "path";
7
7
 
8
8
  // src/index.ts
9
- import MagicString6 from "magic-string";
9
+ import MagicString7 from "magic-string";
10
10
 
11
11
  // src/brief.ts
12
12
  import { createHash } from "crypto";
@@ -3767,8 +3767,8 @@ function bindLists(ctx, members, module, out, indexes) {
3767
3767
  const loops = iterations(ctx.parsed.roots, name);
3768
3768
  if (loops.length > 1) return { refuse: "REPEATER_AMBIGUOUS" };
3769
3769
  if (loops.length === 0) return null;
3770
- const mentions2 = ctx.content.match(new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g")) ?? [];
3771
- if (mentions2.length !== 2 || escapes.has(name)) return { refuse: "IN_EXPRESSION" };
3770
+ const mentions3 = ctx.content.match(new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g")) ?? [];
3771
+ if (mentions3.length !== 2 || escapes.has(name)) return { refuse: "IN_EXPRESSION" };
3772
3772
  return { loop: loops[0], params: callbackParams(loops[0], name) };
3773
3773
  }
3774
3774
  });
@@ -4190,7 +4190,7 @@ async function bindImages(ctx, targets) {
4190
4190
 
4191
4191
  // src/helper.ts
4192
4192
  import { createHash as createHash2 } from "crypto";
4193
- var HELPER_VERSION = 11;
4193
+ var HELPER_VERSION = 12;
4194
4194
  var HEADER = "// @bettercms-ai/convert helper v";
4195
4195
  var HELPER_PATH = Object.fromEntries(
4196
4196
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4247,8 +4247,69 @@ function render(value: unknown): string | undefined {
4247
4247
  return undefined;
4248
4248
  }
4249
4249
 
4250
+ /**
4251
+ * The PUBLISHED copy, on a server BetterCMS hosts \u2014 read at request time, not at build time.
4252
+ *
4253
+ * BetterCMS keeps one file current for a running server app (\`BCMS_CONTENT_FILE\`, the same
4254
+ * bcms-content.json the build was given) and rewrites it on every publish, so a server-rendered
4255
+ * page shows a publish within seconds instead of after a rebuild. Each snapshot this module reads
4256
+ * resolves to its live counterpart: the shared layout by identity, a page by its \`slug\`.
4257
+ *
4258
+ * Nowhere else does anything change: with no variable (a build, \`astro dev\`, a browser, an edge
4259
+ * runtime) every snapshot is the one the bundle imported, exactly as before. A file that cannot be
4260
+ * read keeps the last good copy; it is checked at most once a second.
4261
+ */
4262
+ type BcmsLiveContent = { stamp: number; bySlug: Map<string, unknown>; layout: unknown };
4263
+ let bcmsLive: BcmsLiveContent | null = null;
4264
+ let bcmsLiveCheckedAt = 0;
4265
+
4266
+ function liveContent(): BcmsLiveContent | null {
4267
+ const proc = (globalThis as {
4268
+ process?: { env?: Record<string, string | undefined>; getBuiltinModule?: (id: string) => unknown };
4269
+ }).process;
4270
+ const file = proc?.env?.BCMS_CONTENT_FILE;
4271
+ if (!file || typeof proc?.getBuiltinModule !== "function") return null;
4272
+ const now = Date.now();
4273
+ if (now - bcmsLiveCheckedAt < 1000) return bcmsLive;
4274
+ bcmsLiveCheckedAt = now;
4275
+ try {
4276
+ const fs = proc.getBuiltinModule("node:fs") as {
4277
+ statSync(path: string): { mtimeMs: number };
4278
+ readFileSync(path: string, encoding: "utf8"): string;
4279
+ };
4280
+ const stamp = fs.statSync(file).mtimeMs;
4281
+ if (bcmsLive && bcmsLive.stamp === stamp) return bcmsLive;
4282
+ const snapshot: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
4283
+ const bySlug = new Map<string, unknown>();
4284
+ const collections = isRecord(snapshot) && isRecord(snapshot.collections) ? snapshot.collections : {};
4285
+ for (const rows of Object.values(collections)) {
4286
+ if (!Array.isArray(rows)) continue;
4287
+ for (const row of rows) {
4288
+ if (isRecord(row) && typeof row.slug === "string" && !bySlug.has(row.slug)) bySlug.set(row.slug, row);
4289
+ }
4290
+ }
4291
+ const pages = isRecord(snapshot) && Array.isArray(snapshot.pages) ? snapshot.pages : [];
4292
+ const withLayout = pages.find((page: unknown) => isRecord(page) && isRecord(page.layout));
4293
+ const layout = isRecord(withLayout) ? withLayout.layout : isRecord(snapshot) ? snapshot.layout : undefined;
4294
+ bcmsLive = { stamp, bySlug, layout };
4295
+ } catch {
4296
+ // Unreadable, or caught mid-write: keep serving the last good copy.
4297
+ }
4298
+ return bcmsLive;
4299
+ }
4300
+
4301
+ /** The published counterpart of a snapshot this module was handed, or the snapshot itself. */
4302
+ function live(page: unknown): unknown {
4303
+ const content = liveContent();
4304
+ if (!content) return page;
4305
+ if (page === layoutSnapshot) return isRecord(content.layout) ? content.layout : page;
4306
+ if (isRecord(page) && typeof page.slug === "string") return content.bySlug.get(page.slug) ?? page;
4307
+ return page;
4308
+ }
4309
+
4250
4310
  /** One field of one page. \`slug\` selects the entry on a dynamic route. */
4251
4311
  export function bcms(page: unknown, path: string, fallback: string, slug?: string): string {
4312
+ page = live(page);
4252
4313
  const entries = isRecord(page) ? page.entries : undefined;
4253
4314
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4254
4315
  return render(readPath(root, path)) ?? fallback;
@@ -4256,7 +4317,7 @@ export function bcms(page: unknown, path: string, fallback: string, slug?: strin
4256
4317
 
4257
4318
  /** One field of the shared layout \u2014 the header and footer every route renders. */
4258
4319
  export function bcmsLayout(path: string, fallback: string): string {
4259
- return render(readPath(layoutSnapshot, path)) ?? fallback;
4320
+ return render(readPath(live(layoutSnapshot), path)) ?? fallback;
4260
4321
  }
4261
4322
 
4262
4323
  /**
@@ -4264,6 +4325,7 @@ export function bcmsLayout(path: string, fallback: string): string {
4264
4325
  * an empty snapshot still renders the same number of cards it always did.
4265
4326
  */
4266
4327
  export function bcmsRows<T>(page: unknown, path: string, fallback: T[], slug?: string): T[] {
4328
+ page = live(page);
4267
4329
  const entries = isRecord(page) ? page.entries : undefined;
4268
4330
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4269
4331
  const value = readPath(root, path);
@@ -4309,6 +4371,7 @@ export function bcmsRowsAs<T>(
4309
4371
  * this out loud rather than leaving an editor to discover it.
4310
4372
  */
4311
4373
  export function bcmsList(page: unknown, paths: string[], fallback: string[], slug?: string): string[] {
4374
+ page = live(page);
4312
4375
  const entries = isRecord(page) ? page.entries : undefined;
4313
4376
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4314
4377
  return paths.map((path, index) => render(readPath(root, path)) ?? fallback[index] ?? "");
@@ -4333,6 +4396,7 @@ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][
4333
4396
  * no such rule and takes the CMS url directly.
4334
4397
  */
4335
4398
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
4399
+ page = live(page);
4336
4400
  if (fallback !== null && typeof fallback === "object") return fallback;
4337
4401
  const entries = isRecord(page) ? page.entries : undefined;
4338
4402
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
@@ -4361,6 +4425,7 @@ export function bcmsSection(
4361
4425
  overrides?: unknown,
4362
4426
  bindPaths?: Record<string, string>,
4363
4427
  ): Record<string, unknown> {
4428
+ page = live(page);
4364
4429
  const group = bind ? readPath(page, bind) : undefined;
4365
4430
  const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
4366
4431
  // A locale copy on its twin's component spells its fields its own way: each prop's copy is
@@ -4432,6 +4497,7 @@ export function bcmsSectionPaths(
4432
4497
  map: BcmsPathMap,
4433
4498
  bindPaths?: Record<string, string>,
4434
4499
  ): Record<string, unknown> {
4500
+ page = live(page);
4435
4501
  const out: Record<string, unknown> = isRecord(page) ? { ...page } : {};
4436
4502
  const own = isRecord(overrides) ? overrides : {};
4437
4503
  const separator = prefix.slice(-1);
@@ -4665,7 +4731,8 @@ var HISTORICAL = {
4665
4731
  7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93",
4666
4732
  8: "484e72852d2b496e016a1f2a2b1a77bb993f8a163b290781522d6114b6519bb9",
4667
4733
  9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
4668
- 10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910"
4734
+ 10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
4735
+ 11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e"
4669
4736
  },
4670
4737
  "src/lib/bcms-content.ts": {
4671
4738
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
@@ -4677,7 +4744,8 @@ var HISTORICAL = {
4677
4744
  7: "ed92fb88de953d9951abbe6a1edae1d048f4050eaf6fb607ac08167f94d8bcc7",
4678
4745
  8: "1a6f669d76d42effe77f082436e9d21c0efa6fc499e919e3d116bc3a27a7f3d5",
4679
4746
  9: "eee9fb1635ae7d6f8f33676a1f390600ca24a62ee992775252273c73a011c494",
4680
- 10: "35bb7c0e30f5c41fe68e52259ec4764de6de2734064fa0e1521f3338eef0a09e"
4747
+ 10: "35bb7c0e30f5c41fe68e52259ec4764de6de2734064fa0e1521f3338eef0a09e",
4748
+ 11: "818784f2088150d64d91b42eac944dc9503f104d32a0e7a18c753d2982a5adc0"
4681
4749
  },
4682
4750
  "composables/bcms-content.ts": {
4683
4751
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
@@ -4689,7 +4757,8 @@ var HISTORICAL = {
4689
4757
  7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93",
4690
4758
  8: "484e72852d2b496e016a1f2a2b1a77bb993f8a163b290781522d6114b6519bb9",
4691
4759
  9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
4692
- 10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910"
4760
+ 10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
4761
+ 11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e"
4693
4762
  }
4694
4763
  };
4695
4764
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -9178,6 +9247,371 @@ function readComponentizePlan(value) {
9178
9247
  return readPlan(value);
9179
9248
  }
9180
9249
 
9250
+ // src/upgrade.ts
9251
+ import MagicString6 from "magic-string";
9252
+ var UpgradeError = class extends Error {
9253
+ code = "HELPER_CONFLICT";
9254
+ constructor(message) {
9255
+ super(message);
9256
+ this.name = "UpgradeError";
9257
+ }
9258
+ };
9259
+ var SDK_SINCE = {
9260
+ astro: { pkg: "@bettercms-ai/astro", version: [0, 19, 0] },
9261
+ jsx: { pkg: "@bettercms-ai/next", version: [0, 18, 0] }
9262
+ };
9263
+ function sdkHasSectionRootAttrs(packageJson, dialect) {
9264
+ if (!packageJson) return false;
9265
+ let pkg;
9266
+ try {
9267
+ pkg = JSON.parse(packageJson);
9268
+ } catch {
9269
+ return false;
9270
+ }
9271
+ const { pkg: name, version: since } = SDK_SINCE[dialect];
9272
+ const range = [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies].map((deps) => deps && typeof deps === "object" ? deps[name] : void 0).find((v2) => typeof v2 === "string");
9273
+ if (range === void 0) return false;
9274
+ if (range.trim() === "latest" || range.trim() === "*") return true;
9275
+ const match = /^\s*(?:\^|~|>=|=)?\s*v?(\d+)\.(\d+)\.(\d+)/.exec(range);
9276
+ if (!match) return false;
9277
+ const v = [Number(match[1]), Number(match[2]), Number(match[3])];
9278
+ for (let i = 0; i < 3; i++) {
9279
+ if (v[i] !== since[i]) return v[i] > since[i];
9280
+ }
9281
+ return true;
9282
+ }
9283
+ function elementsIn2(nodes, out = []) {
9284
+ for (const node of nodes) {
9285
+ if (node.type === "element") {
9286
+ out.push(node);
9287
+ elementsIn2(node.children, out);
9288
+ } else if (node.type === "expr") elementsIn2(node.children, out);
9289
+ }
9290
+ return out;
9291
+ }
9292
+ var lineAt = (content, offset) => content.slice(0, offset).split("\n").length;
9293
+ var exprOf2 = (content, range) => content.slice(range.start + 1, range.end - 1).trim();
9294
+ var isSpread = (content, attr) => !!attr.expressionRange && content.slice(attr.expressionRange.start, attr.expressionRange.end).trimStart().startsWith("{...");
9295
+ var mentions2 = (content, name) => new RegExp(`(?<![\\w$.])${name.replace(/[$]/g, "\\$")}(?![\\w$])`).test(content);
9296
+ function importOf(content, matches) {
9297
+ const re = /import\s*\{([^}]*)\}\s*from\s*(["'])([^"']+)\2\s*;?/g;
9298
+ for (const m of content.matchAll(re)) {
9299
+ if (!matches(m[3])) continue;
9300
+ const names = m[1].split(",").map((n) => n.trim()).filter(Boolean);
9301
+ return { start: m.index, end: m.index + m[0].length, names };
9302
+ }
9303
+ return null;
9304
+ }
9305
+ function addNamedImport(source, content, parsed, specifier, sameModule, names) {
9306
+ const existing = importOf(content, sameModule);
9307
+ if (existing) {
9308
+ const missing = names.filter((n) => !existing.names.some((e) => e === n || e.endsWith(` as ${n}`)));
9309
+ if (missing.length === 0) return;
9310
+ const quote = content.slice(existing.start, existing.end).includes("'") ? "'" : '"';
9311
+ const spec = /from\s*["']([^"']+)["']/.exec(content.slice(existing.start, existing.end))[1];
9312
+ const all = [...existing.names, ...missing].sort((a, b) => a.localeCompare(b));
9313
+ const semi = content.slice(existing.start, existing.end).trimEnd().endsWith(";") ? ";" : "";
9314
+ source.overwrite(existing.start, existing.end, `import { ${all.join(", ")} } from ${quote}${spec}${quote}${semi}`);
9315
+ return;
9316
+ }
9317
+ const line = `import { ${[...names].sort().join(", ")} } from "${specifier}";`;
9318
+ const at = parsed.importAt;
9319
+ if (!at) return;
9320
+ if (at.wrap) source.appendLeft(at.at, `${at.wrap.before}${line}${at.wrap.after}`);
9321
+ else if (at.at === 0) source.appendLeft(0, `${line}
9322
+ `);
9323
+ else source.appendLeft(at.at, `
9324
+ ${line}`);
9325
+ }
9326
+ var importedFrom = (content, name, sameModule) => !!importOf(content, sameModule)?.names.some((e) => e === name || e.endsWith(` as ${name}`));
9327
+ var LIB_PROPS_DOC = " /** The placement's `block.style`, which the section root paints (`data-bcms-style`). */\n blockStyle?: Record<string, unknown>;";
9328
+ var LIB_PLACEMENT_DOC = " /** The placement's `block.style` \u2014 what the editor's section spacing and style controls write. */\n style?: Record<string, unknown>;";
9329
+ function insertAfterLine(content, from, to, anchor, addition) {
9330
+ const slice = content.slice(from, to);
9331
+ const m = anchor.exec(slice);
9332
+ if (!m) return null;
9333
+ const eol = content.indexOf("\n", from + m.index);
9334
+ if (eol < 0 || eol > to) return null;
9335
+ const nl = eolOf(content);
9336
+ return content.slice(0, eol + 1) + addition.split("\n").join(nl) + nl + content.slice(eol + 1);
9337
+ }
9338
+ var eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
9339
+ function interfaceBody(content, name) {
9340
+ const at = content.indexOf(`export interface ${name} {`);
9341
+ if (at < 0) return null;
9342
+ const end = content.indexOf("\n}", at);
9343
+ return end < 0 ? null : { start: at, end };
9344
+ }
9345
+ function patchLib(content) {
9346
+ let out = content;
9347
+ if (!/blockStyle\?:/.test(out)) {
9348
+ const body = interfaceBody(out, "BcmsSectionProps");
9349
+ if (!body) return null;
9350
+ const next = insertAfterLine(out, body.start, body.end, /^\s*page\?: unknown;/m, LIB_PROPS_DOC);
9351
+ if (next === null) return null;
9352
+ out = next;
9353
+ }
9354
+ const placement = interfaceBody(out, "BcmsPlacement");
9355
+ if (!placement) return null;
9356
+ if (!/^\s*style\?:/m.test(out.slice(placement.start, placement.end))) {
9357
+ const next = insertAfterLine(out, placement.start, placement.end, /^\s*overrides\?: Record<string, unknown>;/m, LIB_PLACEMENT_DOC);
9358
+ if (next === null) return null;
9359
+ out = next;
9360
+ }
9361
+ if (!/style: isRecord\(block\.style\)/.test(out)) {
9362
+ const push = /^([ \t]*)overrides: isRecord\(props\.overrides\) \? props\.overrides : undefined,\r?$/m.exec(out);
9363
+ if (!push) return null;
9364
+ const eol = out.indexOf("\n", push.index);
9365
+ out = out.slice(0, eol + 1) + `${push[1]}style: isRecord(block.style) ? block.style : undefined,${eolOf(out)}` + out.slice(eol + 1);
9366
+ }
9367
+ return out;
9368
+ }
9369
+ function patchRegistry(content) {
9370
+ if (content.includes("blockStyle={placement.style}")) return content;
9371
+ const lines = content.split("\n");
9372
+ const out = [];
9373
+ let patched = 0;
9374
+ for (const line of lines) {
9375
+ out.push(line);
9376
+ const m = /^([ \t]*)overrides=\{placement\.overrides\}(\r?)$/.exec(line);
9377
+ if (m) {
9378
+ out.push(`${m[1]}blockStyle={placement.style}${m[2]}`);
9379
+ patched++;
9380
+ }
9381
+ }
9382
+ return patched > 0 ? out.join("\n") : null;
9383
+ }
9384
+ var STYLE_READER = { astro: "bcmsSectionStyleText", jsx: "bcmsSectionStyle" };
9385
+ var BLOCK_MEMBER = /^([A-Za-z_$][\w$]*)(\??\.)(id|blockId)$/;
9386
+ var ID_ONLY = /^(?:(?:Astro\.)?props\??\.)?[A-Za-z_$][\w$]*$/;
9387
+ async function upgradeSections(sources, options = {}) {
9388
+ const contents = new Map(sources.map((f) => [f.path, f.content]));
9389
+ const out = /* @__PURE__ */ new Map();
9390
+ const write = (path, content) => {
9391
+ if (contents.get(path) === content) return;
9392
+ out.set(path, { path, operation: contents.has(path) ? "modify" : "add", content });
9393
+ };
9394
+ const receipt = {
9395
+ helpers: [],
9396
+ registry: [],
9397
+ roots: { upgraded: [], alreadyMarked: [], skipped: [] }
9398
+ };
9399
+ let registryOk = true;
9400
+ for (const file of sources) {
9401
+ if (!file.content.includes(REGISTRY_MARKER)) continue;
9402
+ const isLib = file.content.includes("export function pageSections(");
9403
+ const next = isLib ? patchLib(file.content) : patchRegistry(file.content);
9404
+ if (next === null) {
9405
+ registryOk = false;
9406
+ continue;
9407
+ }
9408
+ receipt.registry.push({ path: file.path, operation: next === file.content ? "unchanged" : "modify" });
9409
+ write(file.path, next);
9410
+ }
9411
+ const helperState = /* @__PURE__ */ new Map();
9412
+ const helperDialect = /* @__PURE__ */ new Map();
9413
+ for (const dialect of Object.keys(HELPER_PATH)) {
9414
+ const path = HELPER_PATH[dialect];
9415
+ if (!path || helperDialect.has(path)) continue;
9416
+ helperDialect.set(path, dialect);
9417
+ const existing = contents.get(path);
9418
+ helperState.set(path, existing === void 0 ? void 0 : isKnownHelper(existing, dialect));
9419
+ }
9420
+ const helperNeeded = /* @__PURE__ */ new Set();
9421
+ for (const file of sources) {
9422
+ const dialect = dialectOf(file.path);
9423
+ if (!dialect || !/data-bcms-block|sectionRootAttrs/.test(file.content)) continue;
9424
+ const ref = (el, attr) => ({
9425
+ file: file.path,
9426
+ line: lineAt(file.content, el.start),
9427
+ tag: el.tag,
9428
+ block: attr.expression ? file.content.slice(attr.expressionRange?.start ?? attr.range.start, attr.expressionRange?.end ?? attr.range.end) : JSON.stringify(attr.value)
9429
+ });
9430
+ const skip = (el, attr, reason, message) => {
9431
+ receipt.roots.skipped.push({ ...ref(el, attr), reason, message });
9432
+ };
9433
+ const parsed = await parseFile4(dialect, file.content);
9434
+ if (parsed.error) {
9435
+ receipt.roots.skipped.push({
9436
+ file: file.path,
9437
+ line: 1,
9438
+ tag: "",
9439
+ block: "",
9440
+ reason: "PARSE_ERROR",
9441
+ message: parsed.error.message
9442
+ });
9443
+ continue;
9444
+ }
9445
+ const roots = elementsIn2(parsed.roots).flatMap((el) => {
9446
+ const attr = el.attrs.find((a) => a.name === "data-bcms-block");
9447
+ return attr ? [{ el, attr }] : [];
9448
+ });
9449
+ for (const el of elementsIn2(parsed.roots)) {
9450
+ const spread = el.attrs.find((a) => isSpread(file.content, a) && /sectionRootAttrs\s*\(/.test(file.content.slice(a.expressionRange.start, a.expressionRange.end)));
9451
+ if (spread && !el.attrs.some((a) => a.name === "data-bcms-block")) {
9452
+ receipt.roots.alreadyMarked.push({
9453
+ file: file.path,
9454
+ line: lineAt(file.content, el.start),
9455
+ tag: el.tag,
9456
+ block: file.content.slice(spread.expressionRange.start, spread.expressionRange.end)
9457
+ });
9458
+ }
9459
+ }
9460
+ if (roots.length === 0) continue;
9461
+ if (dialect !== "astro" && dialect !== "jsx") {
9462
+ for (const { el, attr } of roots) {
9463
+ if (el.attrs.some((a) => a.name === "data-bcms-style")) receipt.roots.alreadyMarked.push(ref(el, attr));
9464
+ else skip(el, attr, "DIALECT_UNSUPPORTED", `${dialect} section roots are not rewritten by this pass; add data-bcms-style and the block's style by hand.`);
9465
+ }
9466
+ continue;
9467
+ }
9468
+ const source = new MagicString6(file.content);
9469
+ const reader = STYLE_READER[dialect];
9470
+ const helperPath = HELPER_PATH[dialect];
9471
+ const helperSpecifier = relativeImport(file.path, helperPath).replace(/\.tsx?$/, "");
9472
+ const isHelperModule = (spec) => /(^|\/)bcms-content(\.[jt]sx?)?$/.test(spec);
9473
+ const sdk = SDK_SINCE[dialect].pkg;
9474
+ const isSdk = (spec) => spec === sdk;
9475
+ const useSdk = sdkHasSectionRootAttrs(options.packageJson, dialect);
9476
+ const generated = SECTION_MARKERS.some((m) => file.content.includes(m));
9477
+ const typed = dialect === "astro" || file.path.endsWith(".tsx");
9478
+ const free = (name, from) => importedFrom(file.content, name, from) || !mentions2(file.content, name);
9479
+ let wantReader = false;
9480
+ let wantSdk = false;
9481
+ let sectionProp = false;
9482
+ for (const { el, attr } of roots) {
9483
+ if (el.attrs.some((a) => a.name === "data-bcms-style")) {
9484
+ receipt.roots.alreadyMarked.push(ref(el, attr));
9485
+ continue;
9486
+ }
9487
+ if (!isIntrinsic(el.tag)) {
9488
+ skip(el, attr, "NOT_AN_ELEMENT", `<${el.tag}> is a component: data-bcms-block on it is a prop. Mark the element that component renders.`);
9489
+ continue;
9490
+ }
9491
+ if (el.attrs.some((a) => a.name === "style")) {
9492
+ skip(el, attr, "OWN_STYLE", "The root already sets its own style; a second style attribute would be resolved by the dialect and one of them would vanish. Merge the block's style into it by hand.");
9493
+ continue;
9494
+ }
9495
+ if (el.attrs.some((a) => isSpread(file.content, a))) {
9496
+ skip(el, attr, "SPREAD", "The root spreads props that may carry a style; marking it could promise a style the spread overrides.");
9497
+ continue;
9498
+ }
9499
+ if (!attr.expression || !attr.expressionRange) {
9500
+ skip(el, attr, "BLOCK_LITERAL", "data-bcms-block is a literal id: there is no block object here to read a style from.");
9501
+ continue;
9502
+ }
9503
+ const expr = exprOf2(file.content, attr.expressionRange);
9504
+ if (generated && expr === "blockId") {
9505
+ if (!registryOk) {
9506
+ skip(el, attr, "REGISTRY_UNRECOGNISED", "The generated registry or bcms-sections does not pass block.style and could not be patched; re-run --componentize, then this.");
9507
+ continue;
9508
+ }
9509
+ if (!free(reader, isHelperModule) || /\bblockStyle\b/.test(file.content)) {
9510
+ skip(el, attr, "NAME_TAKEN", `${reader} or blockStyle already means something else in this file.`);
9511
+ continue;
9512
+ }
9513
+ source.appendLeft(attr.range.end, ` data-bcms-style="" style={${reader}(blockStyle)}`);
9514
+ receipt.roots.upgraded.push({ ...ref(el, attr), form: "section-prop" });
9515
+ wantReader = true;
9516
+ sectionProp = true;
9517
+ continue;
9518
+ }
9519
+ const member = BLOCK_MEMBER.exec(expr);
9520
+ if (member) {
9521
+ const [, object, dot, key] = member;
9522
+ if (useSdk && key === "id") {
9523
+ if (!free("sectionRootAttrs", isSdk)) {
9524
+ skip(el, attr, "NAME_TAKEN", "sectionRootAttrs already means something else in this file.");
9525
+ continue;
9526
+ }
9527
+ source.overwrite(attr.range.start, attr.range.end, `{...sectionRootAttrs(${object})}`);
9528
+ receipt.roots.upgraded.push({ ...ref(el, attr), form: "sectionRootAttrs" });
9529
+ wantSdk = true;
9530
+ continue;
9531
+ }
9532
+ if (!free(reader, isHelperModule)) {
9533
+ skip(el, attr, "NAME_TAKEN", `${reader} already means something else in this file.`);
9534
+ continue;
9535
+ }
9536
+ const style = typed && dialect === "jsx" ? `(${object} as { style?: unknown } | null | undefined)?.style` : `${object}${dot}style`;
9537
+ source.appendLeft(attr.range.end, ` data-bcms-style="" style={${reader}(${style})}`);
9538
+ receipt.roots.upgraded.push({ ...ref(el, attr), form: "helper" });
9539
+ wantReader = true;
9540
+ continue;
9541
+ }
9542
+ if (ID_ONLY.test(expr)) {
9543
+ skip(
9544
+ el,
9545
+ attr,
9546
+ "BLOCK_ID_ONLY",
9547
+ `Only a block id reaches this root (${expr}); the block's style is not in scope. Pass the block (or its style) down with the id, then re-run.`
9548
+ );
9549
+ continue;
9550
+ }
9551
+ skip(el, attr, "BLOCK_UNRESOLVABLE", `data-bcms-block={${expr}} does not name one block whose style this root could paint.`);
9552
+ }
9553
+ if (sectionProp) {
9554
+ const destructure = dialect === "astro" ? /const\s*\{([^}]*\bblockId\b[^}]*)\}\s*=\s*Astro\.props\s*;?/.exec(file.content) : /\(\s*\{([^}]*\bblockId\b[^}]*)\}\s*:\s*BcmsSectionProps\s*\)/.exec(file.content);
9555
+ if (destructure) {
9556
+ const close = destructure.index + destructure[0].indexOf("}");
9557
+ let at = close;
9558
+ while (at > 0 && /\s/.test(file.content[at - 1])) at--;
9559
+ source.appendLeft(at, ", blockStyle");
9560
+ }
9561
+ if (dialect === "astro") {
9562
+ const props = /interface Props \{[\s\S]*?\n\}/.exec(file.content);
9563
+ if (props) {
9564
+ const end = props.index + props[0].length - 1;
9565
+ const nl = eolOf(file.content);
9566
+ source.appendLeft(
9567
+ end,
9568
+ ` /** The placement's \`block.style\` \u2014 painted on the root, which says so with \`data-bcms-style\`. */${nl} blockStyle?: Record<string, unknown>;${nl}`
9569
+ );
9570
+ }
9571
+ }
9572
+ }
9573
+ if (wantReader) {
9574
+ addNamedImport(source, file.content, parsed, helperSpecifier, isHelperModule, [reader]);
9575
+ helperNeeded.add(helperPath);
9576
+ }
9577
+ if (wantSdk) addNamedImport(source, file.content, parsed, sdk, isSdk, ["sectionRootAttrs"]);
9578
+ if (source.hasChanged()) write(file.path, source.toString());
9579
+ }
9580
+ for (const [path, version] of helperState) {
9581
+ const dialect = helperDialect.get(path);
9582
+ const needed = helperNeeded.has(path);
9583
+ if (version === void 0) {
9584
+ if (!needed) continue;
9585
+ write(path, helperSource(dialect));
9586
+ receipt.helpers.push({ path, from: null, to: HELPER_VERSION, operation: "add" });
9587
+ const stub = `${CONTENT_DIR}/layout.json`;
9588
+ if (!contents.has(stub)) write(stub, STUB_CONTENT);
9589
+ continue;
9590
+ }
9591
+ if (version === null) {
9592
+ if (needed && !options.overwriteHelper) {
9593
+ throw new UpgradeError(
9594
+ `${path} already exists and its bytes are not ones @bettercms-ai/convert wrote \u2014 someone else's module, or ours with edits in it. The upgraded roots read bcmsSectionStyle from it. Move it, or re-run with --overwrite-helper.`
9595
+ );
9596
+ }
9597
+ if (needed) {
9598
+ write(path, helperSource(dialect));
9599
+ receipt.helpers.push({ path, from: null, to: HELPER_VERSION, operation: "modify" });
9600
+ } else receipt.helpers.push({ path, from: null, to: HELPER_VERSION, operation: "foreign" });
9601
+ continue;
9602
+ }
9603
+ if (version < HELPER_VERSION) {
9604
+ write(path, helperSource(dialect));
9605
+ receipt.helpers.push({ path, from: version, to: HELPER_VERSION, operation: "modify" });
9606
+ } else receipt.helpers.push({ path, from: version, to: HELPER_VERSION, operation: "unchanged" });
9607
+ }
9608
+ const byPlace = (a, b) => a.file.localeCompare(b.file) || a.line - b.line;
9609
+ receipt.roots.upgraded.sort(byPlace);
9610
+ receipt.roots.alreadyMarked.sort(byPlace);
9611
+ receipt.roots.skipped.sort(byPlace);
9612
+ return { files: [...out.values()].sort((a, b) => a.path.localeCompare(b.path)), receipt };
9613
+ }
9614
+
9181
9615
  // src/index.ts
9182
9616
  var UNPARSEABLE_DIALECT = {
9183
9617
  roots: [],
@@ -9515,7 +9949,7 @@ async function convertSources(briefIn, sources, options) {
9515
9949
  rewritten++;
9516
9950
  }
9517
9951
  for (const [file, splices] of [...componentEdits].sort(([a], [b]) => a.localeCompare(b))) {
9518
- const source = new MagicString6(contents.get(file));
9952
+ const source = new MagicString7(contents.get(file));
9519
9953
  const seen = /* @__PURE__ */ new Set();
9520
9954
  for (const splice of splices) {
9521
9955
  const id = `${splice.start}:${splice.end}:${splice.text}`;
@@ -10340,7 +10774,7 @@ function styleEdits(before, after) {
10340
10774
  // package.json
10341
10775
  var package_default = {
10342
10776
  name: "@bettercms-ai/convert",
10343
- version: "0.22.0",
10777
+ version: "0.24.0",
10344
10778
  type: "module",
10345
10779
  description: "Deterministic codemod that turns an imported site's text-matched bindings into declared ones \u2014 parser offsets in, one magic-string pass out, plus a receipt of everything it could not do.",
10346
10780
  bin: {
@@ -10403,6 +10837,7 @@ function parseArgs(argv) {
10403
10837
  componentize: false,
10404
10838
  forms: false,
10405
10839
  pins: {},
10840
+ upgradeSections: false,
10406
10841
  xray: void 0,
10407
10842
  verifyStyles: false,
10408
10843
  base: "HEAD",
@@ -10421,6 +10856,7 @@ function parseArgs(argv) {
10421
10856
  if (arg === "--brief") args.brief = argv[++i];
10422
10857
  else if (arg === "--componentize") args.componentize = true;
10423
10858
  else if (arg === "--forms") args.forms = true;
10859
+ else if (arg === "--upgrade-sections") args.upgradeSections = true;
10424
10860
  else if (arg === "--pin") {
10425
10861
  const raw = argv[++i] ?? "";
10426
10862
  const at = raw.indexOf("=");
@@ -10460,6 +10896,7 @@ var HELP = `bettercms-convert ${VERSION}
10460
10896
  bettercms-convert --brief <json> --root . [options]
10461
10897
  bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
10462
10898
  bettercms-convert --forms --brief <json> --root . [options]
10899
+ bettercms-convert --upgrade-sections --root . [--dry-run]
10463
10900
  bettercms-convert --xray <dist-dir>
10464
10901
  bettercms-convert --verify-styles [--base <ref>] --root .
10465
10902
 
@@ -10472,6 +10909,14 @@ var HELP = `bettercms-convert ${VERSION}
10472
10909
  --pin <form>=<file> With --forms: the file a form's <form> is in, when the run reports
10473
10910
  FORM_AMBIGUOUS (two files post exactly its fields). Form by id or
10474
10911
  name. Repeatable.
10912
+ --upgrade-sections For a site converted before helper v10: give every section root
10913
+ (data-bcms-block) the data-bcms-style marker and the block's style,
10914
+ so the editor's section spacing and style handles turn on. Upgrades
10915
+ the helper, patches a generated registry to pass block.style, and
10916
+ spreads {...sectionRootAttrs(block)} where the SDK has it. A root
10917
+ with its own style or a spread, or whose block is not in scope, is
10918
+ left alone and listed with its reason. Takes no --brief; a second
10919
+ run changes nothing. Exit 2 with --strict when any root was skipped.
10475
10920
  --xray <dist-dir> Measure a LOCAL build instead of converting: how much of each page's
10476
10921
  visible copy no binding owns, and the biggest buckets of it. Run it
10477
10922
  after a build to see what the next conversion still has to reach,
@@ -10603,6 +11048,28 @@ ${extracted} sections extracted (${registered} registered, ${inlineOnly} inline-
10603
11048
  );
10604
11049
  }
10605
11050
  }
11051
+ function reportUpgrade(files, receipt, dryRun) {
11052
+ for (const file of files) {
11053
+ process.stdout.write(`${dryRun ? "would " : ""}${file.operation} ${file.path}
11054
+ `);
11055
+ }
11056
+ for (const h of receipt.helpers) {
11057
+ process.stdout.write(`helper ${h.path} ${h.from === null ? "(none)" : `v${h.from}`} -> v${h.to} (${h.operation})
11058
+ `);
11059
+ }
11060
+ for (const r of receipt.registry) process.stdout.write(`registry ${r.path} (${r.operation})
11061
+ `);
11062
+ const { upgraded, alreadyMarked, skipped } = receipt.roots;
11063
+ process.stdout.write(
11064
+ `
11065
+ ${upgraded.length} section roots upgraded (${alreadyMarked.length} already marked, ${skipped.length} skipped)
11066
+ `
11067
+ );
11068
+ for (const r of upgraded) process.stdout.write(` upgraded ${r.file}:${r.line} <${r.tag}> ${r.block} \u2014 ${r.form}
11069
+ `);
11070
+ for (const r of skipped) process.stdout.write(` skipped ${r.file}:${r.line} <${r.tag}> ${r.block} \u2014 ${r.reason}: ${r.message}
11071
+ `);
11072
+ }
10606
11073
  async function xray(dir, strict) {
10607
11074
  const pages = [];
10608
11075
  const walk = async (at) => {
@@ -10676,6 +11143,31 @@ async function main() {
10676
11143
  return await xray(resolve(args.xray), args.strict);
10677
11144
  }
10678
11145
  if (args.verifyStyles) return await verifyStyles(resolve(args.root), args.base, args.verbose);
11146
+ if (args.upgradeSections) {
11147
+ if (args.brief || args.componentize || args.forms || args.plan) {
11148
+ throw new CliError("--upgrade-sections reads the repository only: no --brief, --plan, --componentize or --forms. See --help.");
11149
+ }
11150
+ const root2 = resolve(args.root);
11151
+ let packageJson;
11152
+ try {
11153
+ packageJson = await readFile(join2(root2, "package.json"), "utf-8");
11154
+ } catch {
11155
+ packageJson = void 0;
11156
+ }
11157
+ const sources2 = [...await readTree(root2, args.verbose), ...await readSnapshots(root2)];
11158
+ const { files: files2, receipt: receipt2 } = await upgradeSections(sources2, { overwriteHelper: args.overwriteHelper, packageJson });
11159
+ if (!args.dryRun) {
11160
+ for (const file of files2) {
11161
+ const target = under(root2, file.path);
11162
+ await mkdir(dirname(target), { recursive: true });
11163
+ await writeFile(target, file.content, "utf-8");
11164
+ }
11165
+ }
11166
+ if (args.receipt) await writeFile(resolve(args.receipt), `${JSON.stringify(receipt2, null, 2)}
11167
+ `, "utf-8");
11168
+ reportUpgrade(files2, receipt2, args.dryRun);
11169
+ return args.strict && receipt2.roots.skipped.length > 0 ? 2 : 0;
11170
+ }
10679
11171
  if (!args.brief) throw new CliError("--brief <file> is required. See --help.");
10680
11172
  if (args.plan && !args.componentize) {
10681
11173
  throw new CliError("--plan is only read with --componentize. See --help.");