@bettercms-ai/convert 0.25.1 → 0.26.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.26.1
4
+
5
+ ### Patch Changes
6
+
7
+ - c83b686: The codemod routes an image through `bcmsImage` where a call site already declares its field in `bcmsBindings` and still passes the raw import (the element was stamped, and the page kept rendering the file its code imports), and names an image's asset from an optimizer url (`/_image?href=…`). The components skill gains "Read a published value where the app renders the page".
8
+ - Updated dependencies [1a52ee6]
9
+ - @bettercms-ai/types@1.36.0
10
+
11
+ ## 0.26.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 69dfcfa: A server app hosted on BetterCMS shows a publish on its very next request: the converted-site helper (v14) and `readPublishedContent` ask the content file's time on every read instead of once a second. Helper v14 also renders an image an editor replaced on a page the app renders per request: `bcmsImage` returns the local copy BetterCMS puts beside the app, in the shape of an import, where it used to keep the build's picture. A site built with an older helper gets v14 on its next BetterCMS build, with no commit.
16
+
3
17
  ## 0.25.1
4
18
 
5
19
  ### Patch Changes
package/dist/cli.js CHANGED
@@ -4090,6 +4090,13 @@ var IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".svg
4090
4090
  var ASSET_COMPONENTS = /* @__PURE__ */ new Set(["Image", "Picture"]);
4091
4091
  var ASSET_MODULE = "astro:assets";
4092
4092
  function baseName(url) {
4093
+ const inner = /[?&](?:href|url)=([^&#]+)/.exec(url)?.[1];
4094
+ if (inner) {
4095
+ try {
4096
+ url = decodeURIComponent(inner);
4097
+ } catch {
4098
+ }
4099
+ }
4093
4100
  const file = url.split(/[?#]/)[0].split("/").pop() ?? "";
4094
4101
  const base = file.split(".")[0] ?? "";
4095
4102
  return base === "" ? null : base.toLowerCase();
@@ -4115,6 +4122,29 @@ function passedTo(content, parsed, binding) {
4115
4122
  for (const root of parsed.roots) visit(root);
4116
4123
  return out;
4117
4124
  }
4125
+ function declaredAt(content, parsed, binding, path) {
4126
+ const out = [];
4127
+ const names = [`"${path}"`, `'${path}'`];
4128
+ const visit = (node) => {
4129
+ if (node.type === "expr") {
4130
+ for (const child of node.children) visit(child);
4131
+ return;
4132
+ }
4133
+ if (node.type !== "element" || node.raw) return;
4134
+ const bindings = node.attrs.find((a) => a.name === "bcmsBindings");
4135
+ const declared = bindings?.expressionRange ? content.slice(bindings.expressionRange.start, bindings.expressionRange.end) : "";
4136
+ if (/^[A-Z]/.test(node.tag) && !ASSET_COMPONENTS.has(node.tag) && names.some((name) => declared.includes(name))) {
4137
+ for (const attr of node.attrs) {
4138
+ if (!attr.expressionRange) continue;
4139
+ const inner = content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/^\{|\}$/g, "").trim();
4140
+ if (inner === binding) out.push({ node, prop: attr.name, attr });
4141
+ }
4142
+ }
4143
+ for (const child of node.children) visit(child);
4144
+ };
4145
+ for (const root of parsed.roots) visit(root);
4146
+ return out;
4147
+ }
4118
4148
  function assetCandidates(parsed, dialect, original) {
4119
4149
  const wanted = baseName(original);
4120
4150
  if (wanted === null || parsed.error) return [];
@@ -4162,6 +4192,18 @@ async function bindImages(ctx, targets) {
4162
4192
  const rendered = binding === null ? [] : renderedBy(ctx.content, ctx.parsed, ctx.dialect, binding);
4163
4193
  const free = rendered.filter((hit2) => !claimed.has(hit2.node.attrInsertAt));
4164
4194
  const args = (b) => [ctx.snapshotName(target.slug), JSON.stringify(target.path), b, ...ctx.slug ? [ctx.slug] : []];
4195
+ if (free.length === 0 && binding !== null) {
4196
+ const declared = declaredAt(ctx.content, ctx.parsed, binding, target.path).filter((c) => !claimed.has(c.attr.range.start));
4197
+ if (declared.length === 1) {
4198
+ const call = declared[0];
4199
+ claimed.add(call.attr.range.start);
4200
+ out.splices.push({ start: call.attr.range.start, end: call.attr.range.end, text: rule.attr(call.prop, `${ctx.image}(${args(binding).join(", ")})`) });
4201
+ out.slugs.push(target.slug);
4202
+ out.bound.push(target.key);
4203
+ continue;
4204
+ }
4205
+ }
4206
+ if (target.declared) continue;
4165
4207
  if (free.length === 0 && binding !== null && ctx.drill) {
4166
4208
  const calls = passedTo(ctx.content, ctx.parsed, binding).filter((c) => !claimed.has(c.node.attrInsertAt));
4167
4209
  const call = calls.length === 1 ? calls[0] : null;
@@ -4203,7 +4245,7 @@ async function bindImages(ctx, targets) {
4203
4245
 
4204
4246
  // src/helper.ts
4205
4247
  import { createHash as createHash2 } from "crypto";
4206
- var HELPER_VERSION = 13;
4248
+ var HELPER_VERSION = 14;
4207
4249
  var HEADER = "// @bettercms-ai/convert helper v";
4208
4250
  var HELPER_PATH = Object.fromEntries(
4209
4251
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4277,11 +4319,11 @@ function render(value: unknown): string | undefined {
4277
4319
  *
4278
4320
  * Nowhere else does anything change: with no variable (a build, \`astro dev\`, a browser, an edge
4279
4321
  * runtime) every snapshot is the one the bundle imported, exactly as before. A file that cannot be
4280
- * read keeps the last good copy; it is checked at most once a second.
4322
+ * read keeps the last good copy. Its modification time is asked on EVERY read (a stat, microseconds;
4323
+ * the file is parsed again only when it changed), so a publish shows on the very next request.
4281
4324
  */
4282
- type BcmsLiveContent = { stamp: number; bySlug: Map<string, unknown>; layout: unknown };
4325
+ type BcmsLiveContent = { helper: string; stamp: number; bySlug: Map<string, unknown>; entries: Record<string, unknown>; layout: unknown };
4283
4326
  let bcmsLive: BcmsLiveContent | null = null;
4284
- let bcmsLiveCheckedAt = 0;
4285
4327
 
4286
4328
  function liveContent(): BcmsLiveContent | null {
4287
4329
  const proc = (globalThis as {
@@ -4289,9 +4331,6 @@ function liveContent(): BcmsLiveContent | null {
4289
4331
  }).process;
4290
4332
  const file = proc?.env?.BCMS_CONTENT_FILE;
4291
4333
  if (!file || typeof proc?.getBuiltinModule !== "function") return null;
4292
- const now = Date.now();
4293
- if (now - bcmsLiveCheckedAt < 1000) return bcmsLive;
4294
- bcmsLiveCheckedAt = now;
4295
4334
  try {
4296
4335
  const fs = proc.getBuiltinModule("node:fs") as {
4297
4336
  statSync(path: string): { mtimeMs: number };
@@ -4305,13 +4344,30 @@ function liveContent(): BcmsLiveContent | null {
4305
4344
  for (const rows of Object.values(collections)) {
4306
4345
  if (!Array.isArray(rows)) continue;
4307
4346
  for (const row of rows) {
4308
- if (isRecord(row) && typeof row.slug === "string" && !bySlug.has(row.slug)) bySlug.set(row.slug, row);
4347
+ // A published row carries its values under \`data\` (the delivery API's shape); the snapshot a
4348
+ // template reads is the values themselves. Read flat, as before, where a row has none.
4349
+ if (isRecord(row) && typeof row.slug === "string" && !bySlug.has(row.slug)) {
4350
+ bySlug.set(row.slug, isRecord(row.data) ? { ...row.data, slug: row.slug } : row);
4351
+ }
4309
4352
  }
4310
4353
  }
4354
+ // A dynamic route reads \`page.entries[slug]\`: every published row, by its slug.
4355
+ const entries: Record<string, unknown> = Object.fromEntries(bySlug);
4356
+ for (const row of bySlug.values()) if (isRecord(row) && !("entries" in row)) row.entries = entries;
4311
4357
  const pages = isRecord(snapshot) && Array.isArray(snapshot.pages) ? snapshot.pages : [];
4312
4358
  const withLayout = pages.find((page: unknown) => isRecord(page) && isRecord(page.layout));
4313
- const layout = isRecord(withLayout) ? withLayout.layout : isRecord(snapshot) ? snapshot.layout : undefined;
4314
- bcmsLive = { stamp, bySlug, layout };
4359
+ // The site's Layout first; a page's own (its overrides resolved in) only where there is no other.
4360
+ const delivered = isRecord(snapshot) && isRecord(snapshot.layout) ? snapshot.layout : isRecord(withLayout) ? withLayout.layout : undefined;
4361
+ // A template addresses a Layout value as \`<section id>:<field id>\`; the published Layout holds
4362
+ // each Section's values under \`sections.<slug>.fields\`. Both spellings are answered.
4363
+ const layout: Record<string, unknown> | undefined = isRecord(delivered) ? { ...delivered } : undefined;
4364
+ for (const section of layout && isRecord(delivered) && isRecord(delivered.sections) ? Object.values(delivered.sections) : []) {
4365
+ if (!isRecord(section) || typeof section.id !== "string" || !isRecord(section.fields)) continue;
4366
+ for (const [field, value] of Object.entries(section.fields)) layout![section.id + ":" + field] = value;
4367
+ }
4368
+ // \`helper\` names this reader in the built bundle: the platform reads it to know the app shows a
4369
+ // publish on its next request, and an image BetterCMS copied beside it (@see bcmsImage).
4370
+ bcmsLive = { helper: "bcms-helper@${HELPER_VERSION}", stamp, bySlug, entries, layout };
4315
4371
  } catch {
4316
4372
  // Unreadable, or caught mid-write: keep serving the last good copy.
4317
4373
  }
@@ -4323,7 +4379,10 @@ function live(page: unknown): unknown {
4323
4379
  const content = liveContent();
4324
4380
  if (!content) return page;
4325
4381
  if (page === layoutSnapshot) return isRecord(content.layout) ? content.layout : page;
4326
- if (isRecord(page) && typeof page.slug === "string") return content.bySlug.get(page.slug) ?? page;
4382
+ if (isRecord(page) && typeof page.slug === "string") {
4383
+ // A collection's page has no row of its own: its entries are the published ones all the same.
4384
+ return content.bySlug.get(page.slug) ?? ("entries" in page ? page : { ...page, entries: content.entries });
4385
+ }
4327
4386
  return page;
4328
4387
  }
4329
4388
 
@@ -4337,7 +4396,10 @@ export function bcms(page: unknown, path: string, fallback: string, slug?: strin
4337
4396
 
4338
4397
  /** One field of the shared layout \u2014 the header and footer every route renders. */
4339
4398
  export function bcmsLayout(path: string, fallback: string): string {
4340
- return render(readPath(live(layoutSnapshot), path)) ?? fallback;
4399
+ const layout = live(layoutSnapshot);
4400
+ // The whole address first: a field id may carry a dot of its own (\`derived.nav-cta\`).
4401
+ const whole = isRecord(layout) ? layout[path] : undefined;
4402
+ return render(whole !== undefined ? whole : readPath(layout, path)) ?? fallback;
4341
4403
  }
4342
4404
 
4343
4405
  /**
@@ -4414,13 +4476,24 @@ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][
4414
4476
  * release write (\`writeImage\`) puts the editor's image on the \`data-bcms-field\` element, which
4415
4477
  * is where every published value lands. A plain string fallback (\`<img src="/hero.png">\`) has
4416
4478
  * no such rule and takes the CMS url directly.
4479
+ *
4480
+ * \u{1F534} ON A HOSTED SERVER THE EDITOR'S IMAGE IS A LOCAL FILE. A page the app renders per request has no
4481
+ * release write: what this returns is the page. BetterCMS copies a replaced image beside the app and
4482
+ * names the copy on the published value (\`local\`, with its size), and it is returned in the shape
4483
+ * of an import (\`{ src, width, height, format }\`), so \`<Image>\` optimises it like the site's own.
4484
+ * Absent (a build, or the site's own picture) the import stands.
4417
4485
  */
4418
4486
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
4419
4487
  page = live(page);
4420
- if (fallback !== null && typeof fallback === "object") return fallback;
4421
4488
  const entries = isRecord(page) ? page.entries : undefined;
4422
4489
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4423
- return render(readPath(root, path)) ?? fallback;
4490
+ const value = readPath(root, path);
4491
+ if (fallback !== null && typeof fallback === "object") {
4492
+ if (!isRecord(value) || typeof value.local !== "string" || typeof value.width !== "number" || typeof value.height !== "number") return fallback;
4493
+ const format = /\\.([a-z0-9]+)$/i.exec(value.local)?.[1]?.toLowerCase() ?? "";
4494
+ return { src: value.local, width: value.width, height: value.height, format: format === "jpg" ? "jpeg" : format } as unknown as T;
4495
+ }
4496
+ return render(value) ?? fallback;
4424
4497
  }
4425
4498
 
4426
4499
  /**
@@ -4753,7 +4826,8 @@ var HISTORICAL = {
4753
4826
  9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
4754
4827
  10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
4755
4828
  11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e",
4756
- 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528"
4829
+ 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528",
4830
+ 13: "cd8bdd74dbc944f2331503bdf8e995cb7f74f0d5b307d08aee4690e1695ae798"
4757
4831
  },
4758
4832
  "src/lib/bcms-content.ts": {
4759
4833
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
@@ -4767,7 +4841,8 @@ var HISTORICAL = {
4767
4841
  9: "eee9fb1635ae7d6f8f33676a1f390600ca24a62ee992775252273c73a011c494",
4768
4842
  10: "35bb7c0e30f5c41fe68e52259ec4764de6de2734064fa0e1521f3338eef0a09e",
4769
4843
  11: "818784f2088150d64d91b42eac944dc9503f104d32a0e7a18c753d2982a5adc0",
4770
- 12: "79dafc525a541feae55ada5dbe2ea966cac82acd741a4b41d61fe9e0cdfaa208"
4844
+ 12: "79dafc525a541feae55ada5dbe2ea966cac82acd741a4b41d61fe9e0cdfaa208",
4845
+ 13: "06ee70c9f0bb25e03f3930d2250da8dae172ade457f27ebba20382b6482ad3f4"
4771
4846
  },
4772
4847
  "composables/bcms-content.ts": {
4773
4848
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
@@ -4781,7 +4856,8 @@ var HISTORICAL = {
4781
4856
  9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
4782
4857
  10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
4783
4858
  11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e",
4784
- 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528"
4859
+ 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528",
4860
+ 13: "cd8bdd74dbc944f2331503bdf8e995cb7f74f0d5b307d08aee4690e1695ae798"
4785
4861
  }
4786
4862
  };
4787
4863
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -10150,10 +10226,13 @@ async function convertSources(briefIn, sources, options) {
10150
10226
  async function convertFile(file, content, targets, parsed, declared, llmFallback, drill) {
10151
10227
  const outcomes = /* @__PURE__ */ new Map();
10152
10228
  const pendingTargets = [];
10229
+ let unreadImages = false;
10153
10230
  const dialect = dialectOf(file, content);
10154
10231
  for (const target of targets) {
10155
10232
  if (parsed && dialect && convertedHere(content, parsed, dialect, target, file)) {
10156
10233
  outcomes.set(target.key, "declared");
10234
+ if (target.kind === "image" && !content.includes("bcmsImage(")) unreadImages = true;
10235
+ else if (target.kind === "image" && !new RegExp(`bcmsImage\\([^)]*["']${target.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content)) unreadImages = true;
10157
10236
  } else pendingTargets.push(target);
10158
10237
  }
10159
10238
  const empty = {
@@ -10168,7 +10247,7 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
10168
10247
  snapshots: [],
10169
10248
  notes: []
10170
10249
  };
10171
- if (pendingTargets.length === 0) return empty;
10250
+ if (pendingTargets.length === 0 && !unreadImages) return empty;
10172
10251
  if (!dialect) {
10173
10252
  if (isTolerantDialect(file)) {
10174
10253
  const already = declaredByScan(content);
@@ -10369,15 +10448,15 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
10369
10448
  continue;
10370
10449
  }
10371
10450
  const declares = (d) => d.scope === target.scope && (d.path === target.path || d.path === shapeOfPath(target.path));
10372
- const declaredAt = new Set(declarations.filter(declares).map((d) => d.at));
10451
+ const declaredAt2 = new Set(declarations.filter(declares).map((d) => d.at));
10373
10452
  const claimedAt = new Set(declarations.filter((d) => !declares(d) && d.via === "field").map((d) => d.at));
10374
10453
  const writesField = (site) => site.where !== "attr" || target.kind === "image";
10375
10454
  const fresh = eligible.filter(
10376
- (site) => !declaredAt.has(site.attrInsertAt) && !(writesField(site) && claimedAt.has(site.attrInsertAt))
10455
+ (site) => !declaredAt2.has(site.attrInsertAt) && !(writesField(site) && claimedAt.has(site.attrInsertAt))
10377
10456
  );
10378
10457
  located += eligible.length;
10379
10458
  const blocked = eligible.filter(
10380
- (site) => !declaredAt.has(site.attrInsertAt) && writesField(site) && claimedAt.has(site.attrInsertAt)
10459
+ (site) => !declaredAt2.has(site.attrInsertAt) && writesField(site) && claimedAt.has(site.attrInsertAt)
10381
10460
  );
10382
10461
  if (blocked.length > 0) {
10383
10462
  outcomes.set(target.key, "AMBIGUOUS_LITERAL");
@@ -10386,7 +10465,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
10386
10465
  if (fresh.length === 0) {
10387
10466
  outcomes.set(
10388
10467
  target.key,
10389
- eligible.some((site) => declaredAt.has(site.attrInsertAt)) ? "declared" : "AMBIGUOUS_LITERAL"
10468
+ eligible.some((site) => declaredAt2.has(site.attrInsertAt)) ? "declared" : "AMBIGUOUS_LITERAL"
10390
10469
  );
10391
10470
  continue;
10392
10471
  }
@@ -10572,8 +10651,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
10572
10651
  },
10573
10652
  targets.filter((t) => {
10574
10653
  const outcome = outcomes.get(t.key);
10575
- return t.kind === "image" && t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
10576
- }).map((t) => ({ key: t.key, path: t.path, kind: t.kind, original: t.fallback, slug: t.slug }))
10654
+ return t.kind === "image" && t.scope === "page" && (outcome === void 0 || outcome === "declared" || UNPLACED.has(outcome));
10655
+ }).map((t) => ({ key: t.key, path: t.path, kind: t.kind, original: t.fallback, slug: t.slug, declared: outcomes.get(t.key) === "declared" }))
10577
10656
  );
10578
10657
  for (const key of images.bound) outcomes.set(key, "rewritten");
10579
10658
  for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
@@ -10863,7 +10942,7 @@ function styleEdits(before, after) {
10863
10942
  // package.json
10864
10943
  var package_default = {
10865
10944
  name: "@bettercms-ai/convert",
10866
- version: "0.25.1",
10945
+ version: "0.26.1",
10867
10946
  type: "module",
10868
10947
  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.",
10869
10948
  bin: {
@@ -10896,7 +10975,7 @@ var package_default = {
10896
10975
  dependencies: {
10897
10976
  "@astrojs/compiler": "^2.13.1",
10898
10977
  "@babel/parser": "^7.29.2",
10899
- "@bettercms-ai/types": "^1.32.0",
10978
+ "@bettercms-ai/types": "^1.36.0",
10900
10979
  "@vue/compiler-sfc": "^3.5.0",
10901
10980
  "magic-string": "^0.30.21",
10902
10981
  parse5: "^7.3.0",