@bettercms-ai/convert 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -951,7 +951,7 @@ declare function attrsFor(path: string, kind: string, props?: AttrBinding[], sco
951
951
  declare function propsAttribute(props: AttrBinding[]): string;
952
952
 
953
953
  /** Bumped whenever the emitted source changes. `isKnownHelper` upgrades anything older. */
954
- declare const HELPER_VERSION = 7;
954
+ declare const HELPER_VERSION = 8;
955
955
  /** Where each dialect's helper lives. Null = this dialect reads nothing at build time. */
956
956
  declare const HELPER_PATH: Record<Dialect, string | null>;
957
957
  /** The directory the snapshots live in, at the repository root. */
@@ -1196,6 +1196,14 @@ interface ImageContext {
1196
1196
  snapshotName: (slug: string) => string;
1197
1197
  /** How this file reads its route parameter, on a dynamic route. Null off one. */
1198
1198
  slug: string | null;
1199
+ /**
1200
+ * Follow a prop into the component that renders it. @see drillProp
1201
+ *
1202
+ * Most Astro pages import an image and hand it to a section component (`<Hero src={hero} />`),
1203
+ * which renders the `<Image>`. Without this, every such image was `IMAGE_ASSET_UNRESOLVED` and a
1204
+ * converted site had no editable images at all.
1205
+ */
1206
+ drill?: (component: string, prop: string, kind: string) => Promise<DrillOutcome>;
1199
1207
  }
1200
1208
  interface ImageResult {
1201
1209
  splices: ImageSplice[];
@@ -1205,6 +1213,18 @@ interface ImageResult {
1205
1213
  key: string;
1206
1214
  reason: "IMAGE_ASSET_UNRESOLVED";
1207
1215
  }[];
1216
+ /** Edits to the components an image prop was drilled into. */
1217
+ componentEdits: {
1218
+ file: string;
1219
+ splices: ImageSplice[];
1220
+ }[];
1221
+ /** Each drilled image: the component file, the prop, the path, and the call-site literal. */
1222
+ props: {
1223
+ file: string;
1224
+ prop: string;
1225
+ path: string;
1226
+ literal: string;
1227
+ }[];
1208
1228
  }
1209
1229
  /**
1210
1230
  * Which local binding this built url came from, or null when nothing here says.
@@ -1231,7 +1251,7 @@ declare function assetImport(parsed: ParsedFile, dialect: Dialect, original: str
1231
1251
  * one, the import the bundler already optimised when there is not — so a build against the
1232
1252
  * committed `{}` stub renders exactly the image the repository renders today.
1233
1253
  */
1234
- declare function bindImages(ctx: ImageContext, targets: ImageTarget[]): ImageResult;
1254
+ declare function bindImages(ctx: ImageContext, targets: ImageTarget[]): Promise<ImageResult>;
1235
1255
 
1236
1256
  /** The recipe an Astro site needs instead — never auto-migrate a site to SSR. */
1237
1257
  declare const SSR_DRAFT_RECIPE: string;
package/dist/index.js CHANGED
@@ -3185,6 +3185,7 @@ function soleChild(node) {
3185
3185
  const kept = node.children.filter((c) => !(c.type === "text" && c.value.trim() === ""));
3186
3186
  return kept.length === 1 ? kept[0] : null;
3187
3187
  }
3188
+ var ASSET_TAGS = /* @__PURE__ */ new Set(["Image", "Picture"]);
3188
3189
  function findPropTargets(content, parsed, propName, dialect, imported = null) {
3189
3190
  const empty = {
3190
3191
  targets: [],
@@ -3201,6 +3202,7 @@ function findPropTargets(content, parsed, propName, dialect, imported = null) {
3201
3202
  const reception = dialect === "astro" ? astroReception(ast, propName, offset, content) : receptionIn(component, propName, offset, content);
3202
3203
  if (!reception.receivable) return empty;
3203
3204
  const names = new Set(reception.names.size > 0 ? reception.names : [propName]);
3205
+ const assetComponents = /from\s+["']astro:assets["']/.test(content);
3204
3206
  const within = (node) => component === null || node.start >= (component.start ?? 0) + offset && node.start <= (component.end ?? 0) + offset;
3205
3207
  const targets = [];
3206
3208
  const forwards = [];
@@ -3210,6 +3212,15 @@ function findPropTargets(content, parsed, propName, dialect, imported = null) {
3210
3212
  return;
3211
3213
  }
3212
3214
  if (node.type !== "element") return;
3215
+ const sources = IMAGE_SOURCES[node.tag.toLowerCase()] ?? (assetComponents && ASSET_TAGS.has(node.tag) ? ["src"] : []);
3216
+ const readsImage = node.attrs.some((attr) => {
3217
+ if (!sources.includes(attr.name) || !attr.expressionRange) return false;
3218
+ return readsProp(content.slice(attr.expressionRange.start, attr.expressionRange.end), propName, names);
3219
+ });
3220
+ if (readsImage) {
3221
+ if (within(node)) targets.push({ attrInsertAt: node.attrInsertAt, inner: null, tag: node.tag });
3222
+ return;
3223
+ }
3213
3224
  if (isIntrinsic(node.tag)) {
3214
3225
  const child = soleChild(node);
3215
3226
  if (within(node) && child?.type === "expr" && readsProp(child.value, propName, names)) {
@@ -4067,6 +4078,27 @@ function baseName(url) {
4067
4078
  const base = file.split(".")[0] ?? "";
4068
4079
  return base === "" ? null : base.toLowerCase();
4069
4080
  }
4081
+ function passedTo(content, parsed, binding) {
4082
+ const out = [];
4083
+ const visit = (node) => {
4084
+ if (node.type === "expr") {
4085
+ for (const child of node.children) visit(child);
4086
+ return;
4087
+ }
4088
+ if (node.type !== "element" || node.raw) return;
4089
+ const isComponent = /^[A-Z]/.test(node.tag) && !ASSET_COMPONENTS.has(node.tag);
4090
+ if (isComponent && !node.attrs.some((a) => a.name === "bcmsBindings")) {
4091
+ for (const attr of node.attrs) {
4092
+ if (!attr.expressionRange) continue;
4093
+ const inner = content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/^\{|\}$/g, "").trim();
4094
+ if (inner === binding) out.push({ node, prop: attr.name, attr });
4095
+ }
4096
+ }
4097
+ for (const child of node.children) visit(child);
4098
+ };
4099
+ for (const root of parsed.roots) visit(root);
4100
+ return out;
4101
+ }
4070
4102
  function assetCandidates(parsed, dialect, original) {
4071
4103
  const wanted = baseName(original);
4072
4104
  if (wanted === null || parsed.error) return [];
@@ -4104,8 +4136,8 @@ function renderedBy(content, parsed, dialect, binding) {
4104
4136
  for (const root of parsed.roots) visit(root);
4105
4137
  return out;
4106
4138
  }
4107
- function bindImages(ctx, targets) {
4108
- const out = { splices: [], slugs: [], bound: [], refused: [] };
4139
+ async function bindImages(ctx, targets) {
4140
+ const out = { splices: [], slugs: [], bound: [], refused: [], componentEdits: [], props: [] };
4109
4141
  const rule = DIALECT_RULES[ctx.dialect];
4110
4142
  if (!rule.attr || !rule.text || ctx.parsed.error) return out;
4111
4143
  const claimed = /* @__PURE__ */ new Set();
@@ -4113,6 +4145,23 @@ function bindImages(ctx, targets) {
4113
4145
  const binding = assetImport(ctx.parsed, ctx.dialect, target.original);
4114
4146
  const rendered = binding === null ? [] : renderedBy(ctx.content, ctx.parsed, ctx.dialect, binding);
4115
4147
  const free = rendered.filter((hit2) => !claimed.has(hit2.node.attrInsertAt));
4148
+ const args = (b) => [ctx.snapshotName(target.slug), JSON.stringify(target.path), b, ...ctx.slug ? [ctx.slug] : []];
4149
+ if (free.length === 0 && binding !== null && ctx.drill) {
4150
+ const calls = passedTo(ctx.content, ctx.parsed, binding).filter((c) => !claimed.has(c.node.attrInsertAt));
4151
+ const call = calls.length === 1 ? calls[0] : null;
4152
+ const drilled = call ? await ctx.drill(call.node.tag, call.prop, target.kind || "image") : null;
4153
+ if (call && drilled?.ok) {
4154
+ claimed.add(call.node.attrInsertAt);
4155
+ const literal = `${call.prop}: ${JSON.stringify(target.path)}`;
4156
+ out.splices.push({ start: call.attr.range.start, end: call.attr.range.end, text: rule.attr(call.prop, `${ctx.image}(${args(binding).join(", ")})`) });
4157
+ out.splices.push({ start: call.node.attrInsertAt, end: call.node.attrInsertAt, text: ` bcmsBindings={{ ${literal} }}` });
4158
+ out.componentEdits.push(...drilled.edits);
4159
+ out.props.push({ file: drilled.edits[0].file, prop: call.prop, path: target.path, literal });
4160
+ out.slugs.push(target.slug);
4161
+ out.bound.push(target.key);
4162
+ continue;
4163
+ }
4164
+ }
4116
4165
  if (free.length !== 1) {
4117
4166
  out.refused.push({ key: target.key, reason: "IMAGE_ASSET_UNRESOLVED" });
4118
4167
  continue;
@@ -4120,16 +4169,10 @@ function bindImages(ctx, targets) {
4120
4169
  const hit = free[0];
4121
4170
  claimed.add(hit.node.attrInsertAt);
4122
4171
  const attr = hit.node.attrs.find((a) => a.name === hit.attr);
4123
- const args = [
4124
- ctx.snapshotName(target.slug),
4125
- JSON.stringify(target.path),
4126
- binding,
4127
- ...ctx.slug ? [ctx.slug] : []
4128
- ];
4129
4172
  out.splices.push({
4130
4173
  start: attr.range.start,
4131
4174
  end: attr.range.end,
4132
- text: rule.attr(hit.attr, `${ctx.image}(${args.join(", ")})`)
4175
+ text: rule.attr(hit.attr, `${ctx.image}(${args(binding).join(", ")})`)
4133
4176
  });
4134
4177
  out.splices.push({
4135
4178
  start: hit.node.attrInsertAt,
@@ -4144,7 +4187,7 @@ function bindImages(ctx, targets) {
4144
4187
 
4145
4188
  // src/helper.ts
4146
4189
  import { createHash as createHash2 } from "crypto";
4147
- var HELPER_VERSION = 7;
4190
+ var HELPER_VERSION = 8;
4148
4191
  var HEADER = "// @bettercms-ai/convert helper v";
4149
4192
  var HELPER_PATH = Object.fromEntries(
4150
4193
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4276,8 +4319,18 @@ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][
4276
4319
  return paths.map((row, index) => bcmsList(page, row, fallback[index] ?? [], slug));
4277
4320
  }
4278
4321
 
4279
- /** An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED */
4322
+ /**
4323
+ * An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED
4324
+ *
4325
+ * \u{1F534} AN IMPORTED ASSET IS KEPT AT BUILD. \`<Image src={\u2026}>\` from \`astro:assets\` refuses a remote
4326
+ * url string without width, height and an allowed domain, so returning the CMS url here broke the
4327
+ * build the moment an editor replaced the image. The build renders the optimised import; the
4328
+ * release write (\`writeImage\`) puts the editor's image on the \`data-bcms-field\` element, which
4329
+ * is where every published value lands. A plain string fallback (\`<img src="/hero.png">\`) has
4330
+ * no such rule and takes the CMS url directly.
4331
+ */
4280
4332
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
4333
+ if (fallback !== null && typeof fallback === "object") return fallback;
4281
4334
  const entries = isRecord(page) ? page.entries : undefined;
4282
4335
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4283
4336
  return render(readPath(root, path)) ?? fallback;
@@ -4443,7 +4496,8 @@ var HISTORICAL = {
4443
4496
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
4444
4497
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4445
4498
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4446
- 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649"
4499
+ 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4500
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4447
4501
  },
4448
4502
  "src/lib/bcms-content.ts": {
4449
4503
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
@@ -4451,7 +4505,8 @@ var HISTORICAL = {
4451
4505
  3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e",
4452
4506
  4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4453
4507
  5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba",
4454
- 6: "63f1fa29e47e70c5193b81ee24addb238ba100247124d55bc17713102d587df5"
4508
+ 6: "63f1fa29e47e70c5193b81ee24addb238ba100247124d55bc17713102d587df5",
4509
+ 7: "ed92fb88de953d9951abbe6a1edae1d048f4050eaf6fb607ac08167f94d8bcc7"
4455
4510
  },
4456
4511
  "composables/bcms-content.ts": {
4457
4512
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
@@ -4459,7 +4514,8 @@ var HISTORICAL = {
4459
4514
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
4460
4515
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4461
4516
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4462
- 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649"
4517
+ 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4518
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4463
4519
  }
4464
4520
  };
4465
4521
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -9582,8 +9638,21 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
9582
9638
  callSites: [{ file, literal: drilledProp.literal }]
9583
9639
  });
9584
9640
  }
9585
- const images = bindImages(
9586
- { content, parsed, dialect: writing, image: helperName("bcmsImage"), snapshotName, slug: params?.slug ?? null },
9641
+ const images = await bindImages(
9642
+ {
9643
+ content,
9644
+ parsed,
9645
+ dialect: writing,
9646
+ image: helperName("bcmsImage"),
9647
+ snapshotName,
9648
+ slug: params?.slug ?? null,
9649
+ // An image handed to a section component is bound in that component, by the same drill a
9650
+ // text prop takes. @see images.ts
9651
+ drill: async (component, prop, kind) => {
9652
+ const drilled = await drillProp(drill, file, component, prop, kind, 1);
9653
+ return drilled.ok ? { ok: true, edits: drilled.edits } : { ok: false, reason: drilled.reason ?? "PROP_TARGET_NOT_FOUND" };
9654
+ }
9655
+ },
9587
9656
  targets.filter((t) => {
9588
9657
  const outcome = outcomes.get(t.key);
9589
9658
  return t.kind === "image" && t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
@@ -9591,6 +9660,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
9591
9660
  );
9592
9661
  for (const key of images.bound) outcomes.set(key, "rewritten");
9593
9662
  for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
9663
+ componentEdits.push(...images.componentEdits);
9664
+ for (const drilledProp of images.props) {
9665
+ dynamic.push({ file: drilledProp.file, prop: drilledProp.prop, path: drilledProp.path, callSites: [{ file, literal: drilledProp.literal }] });
9666
+ }
9594
9667
  const lanes = [...data.splices, ...images.splices];
9595
9668
  if (applied.length === 0 && lanes.length === 0) {
9596
9669
  return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [], notes: [] };