@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/dist/index.d.ts CHANGED
@@ -957,7 +957,7 @@ declare function attrsFor(path: string, kind: string, props?: AttrBinding[], sco
957
957
  declare function propsAttribute(props: AttrBinding[]): string;
958
958
 
959
959
  /** Bumped whenever the emitted source changes. `isKnownHelper` upgrades anything older. */
960
- declare const HELPER_VERSION = 13;
960
+ declare const HELPER_VERSION = 14;
961
961
  /** Where each dialect's helper lives. Null = this dialect reads nothing at build time. */
962
962
  declare const HELPER_PATH: Record<Dialect, string | null>;
963
963
  /** The directory the snapshots live in, at the repository root. */
@@ -1203,6 +1203,8 @@ interface ImageTarget {
1203
1203
  /** The brief's `original` — the BUILT url. */
1204
1204
  original: string;
1205
1205
  slug: string;
1206
+ /** The file already declares this path: only a missing READ is added, and nothing is refused. */
1207
+ declared?: boolean;
1206
1208
  }
1207
1209
  interface ImageSplice {
1208
1210
  start: number;
package/dist/index.js CHANGED
@@ -4086,6 +4086,13 @@ var IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".svg
4086
4086
  var ASSET_COMPONENTS = /* @__PURE__ */ new Set(["Image", "Picture"]);
4087
4087
  var ASSET_MODULE = "astro:assets";
4088
4088
  function baseName(url) {
4089
+ const inner = /[?&](?:href|url)=([^&#]+)/.exec(url)?.[1];
4090
+ if (inner) {
4091
+ try {
4092
+ url = decodeURIComponent(inner);
4093
+ } catch {
4094
+ }
4095
+ }
4089
4096
  const file = url.split(/[?#]/)[0].split("/").pop() ?? "";
4090
4097
  const base = file.split(".")[0] ?? "";
4091
4098
  return base === "" ? null : base.toLowerCase();
@@ -4111,6 +4118,29 @@ function passedTo(content, parsed, binding) {
4111
4118
  for (const root of parsed.roots) visit(root);
4112
4119
  return out;
4113
4120
  }
4121
+ function declaredAt(content, parsed, binding, path) {
4122
+ const out = [];
4123
+ const names = [`"${path}"`, `'${path}'`];
4124
+ const visit = (node) => {
4125
+ if (node.type === "expr") {
4126
+ for (const child of node.children) visit(child);
4127
+ return;
4128
+ }
4129
+ if (node.type !== "element" || node.raw) return;
4130
+ const bindings = node.attrs.find((a) => a.name === "bcmsBindings");
4131
+ const declared = bindings?.expressionRange ? content.slice(bindings.expressionRange.start, bindings.expressionRange.end) : "";
4132
+ if (/^[A-Z]/.test(node.tag) && !ASSET_COMPONENTS.has(node.tag) && names.some((name) => declared.includes(name))) {
4133
+ for (const attr of node.attrs) {
4134
+ if (!attr.expressionRange) continue;
4135
+ const inner = content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/^\{|\}$/g, "").trim();
4136
+ if (inner === binding) out.push({ node, prop: attr.name, attr });
4137
+ }
4138
+ }
4139
+ for (const child of node.children) visit(child);
4140
+ };
4141
+ for (const root of parsed.roots) visit(root);
4142
+ return out;
4143
+ }
4114
4144
  function assetCandidates(parsed, dialect, original) {
4115
4145
  const wanted = baseName(original);
4116
4146
  if (wanted === null || parsed.error) return [];
@@ -4158,6 +4188,18 @@ async function bindImages(ctx, targets) {
4158
4188
  const rendered = binding === null ? [] : renderedBy(ctx.content, ctx.parsed, ctx.dialect, binding);
4159
4189
  const free = rendered.filter((hit2) => !claimed.has(hit2.node.attrInsertAt));
4160
4190
  const args = (b) => [ctx.snapshotName(target.slug), JSON.stringify(target.path), b, ...ctx.slug ? [ctx.slug] : []];
4191
+ if (free.length === 0 && binding !== null) {
4192
+ const declared = declaredAt(ctx.content, ctx.parsed, binding, target.path).filter((c) => !claimed.has(c.attr.range.start));
4193
+ if (declared.length === 1) {
4194
+ const call = declared[0];
4195
+ claimed.add(call.attr.range.start);
4196
+ out.splices.push({ start: call.attr.range.start, end: call.attr.range.end, text: rule.attr(call.prop, `${ctx.image}(${args(binding).join(", ")})`) });
4197
+ out.slugs.push(target.slug);
4198
+ out.bound.push(target.key);
4199
+ continue;
4200
+ }
4201
+ }
4202
+ if (target.declared) continue;
4161
4203
  if (free.length === 0 && binding !== null && ctx.drill) {
4162
4204
  const calls = passedTo(ctx.content, ctx.parsed, binding).filter((c) => !claimed.has(c.node.attrInsertAt));
4163
4205
  const call = calls.length === 1 ? calls[0] : null;
@@ -4199,7 +4241,7 @@ async function bindImages(ctx, targets) {
4199
4241
 
4200
4242
  // src/helper.ts
4201
4243
  import { createHash as createHash2 } from "crypto";
4202
- var HELPER_VERSION = 13;
4244
+ var HELPER_VERSION = 14;
4203
4245
  var HEADER = "// @bettercms-ai/convert helper v";
4204
4246
  var HELPER_PATH = Object.fromEntries(
4205
4247
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4273,11 +4315,11 @@ function render(value: unknown): string | undefined {
4273
4315
  *
4274
4316
  * Nowhere else does anything change: with no variable (a build, \`astro dev\`, a browser, an edge
4275
4317
  * runtime) every snapshot is the one the bundle imported, exactly as before. A file that cannot be
4276
- * read keeps the last good copy; it is checked at most once a second.
4318
+ * read keeps the last good copy. Its modification time is asked on EVERY read (a stat, microseconds;
4319
+ * the file is parsed again only when it changed), so a publish shows on the very next request.
4277
4320
  */
4278
- type BcmsLiveContent = { stamp: number; bySlug: Map<string, unknown>; layout: unknown };
4321
+ type BcmsLiveContent = { helper: string; stamp: number; bySlug: Map<string, unknown>; entries: Record<string, unknown>; layout: unknown };
4279
4322
  let bcmsLive: BcmsLiveContent | null = null;
4280
- let bcmsLiveCheckedAt = 0;
4281
4323
 
4282
4324
  function liveContent(): BcmsLiveContent | null {
4283
4325
  const proc = (globalThis as {
@@ -4285,9 +4327,6 @@ function liveContent(): BcmsLiveContent | null {
4285
4327
  }).process;
4286
4328
  const file = proc?.env?.BCMS_CONTENT_FILE;
4287
4329
  if (!file || typeof proc?.getBuiltinModule !== "function") return null;
4288
- const now = Date.now();
4289
- if (now - bcmsLiveCheckedAt < 1000) return bcmsLive;
4290
- bcmsLiveCheckedAt = now;
4291
4330
  try {
4292
4331
  const fs = proc.getBuiltinModule("node:fs") as {
4293
4332
  statSync(path: string): { mtimeMs: number };
@@ -4301,13 +4340,30 @@ function liveContent(): BcmsLiveContent | null {
4301
4340
  for (const rows of Object.values(collections)) {
4302
4341
  if (!Array.isArray(rows)) continue;
4303
4342
  for (const row of rows) {
4304
- if (isRecord(row) && typeof row.slug === "string" && !bySlug.has(row.slug)) bySlug.set(row.slug, row);
4343
+ // A published row carries its values under \`data\` (the delivery API's shape); the snapshot a
4344
+ // template reads is the values themselves. Read flat, as before, where a row has none.
4345
+ if (isRecord(row) && typeof row.slug === "string" && !bySlug.has(row.slug)) {
4346
+ bySlug.set(row.slug, isRecord(row.data) ? { ...row.data, slug: row.slug } : row);
4347
+ }
4305
4348
  }
4306
4349
  }
4350
+ // A dynamic route reads \`page.entries[slug]\`: every published row, by its slug.
4351
+ const entries: Record<string, unknown> = Object.fromEntries(bySlug);
4352
+ for (const row of bySlug.values()) if (isRecord(row) && !("entries" in row)) row.entries = entries;
4307
4353
  const pages = isRecord(snapshot) && Array.isArray(snapshot.pages) ? snapshot.pages : [];
4308
4354
  const withLayout = pages.find((page: unknown) => isRecord(page) && isRecord(page.layout));
4309
- const layout = isRecord(withLayout) ? withLayout.layout : isRecord(snapshot) ? snapshot.layout : undefined;
4310
- bcmsLive = { stamp, bySlug, layout };
4355
+ // The site's Layout first; a page's own (its overrides resolved in) only where there is no other.
4356
+ const delivered = isRecord(snapshot) && isRecord(snapshot.layout) ? snapshot.layout : isRecord(withLayout) ? withLayout.layout : undefined;
4357
+ // A template addresses a Layout value as \`<section id>:<field id>\`; the published Layout holds
4358
+ // each Section's values under \`sections.<slug>.fields\`. Both spellings are answered.
4359
+ const layout: Record<string, unknown> | undefined = isRecord(delivered) ? { ...delivered } : undefined;
4360
+ for (const section of layout && isRecord(delivered) && isRecord(delivered.sections) ? Object.values(delivered.sections) : []) {
4361
+ if (!isRecord(section) || typeof section.id !== "string" || !isRecord(section.fields)) continue;
4362
+ for (const [field, value] of Object.entries(section.fields)) layout![section.id + ":" + field] = value;
4363
+ }
4364
+ // \`helper\` names this reader in the built bundle: the platform reads it to know the app shows a
4365
+ // publish on its next request, and an image BetterCMS copied beside it (@see bcmsImage).
4366
+ bcmsLive = { helper: "bcms-helper@${HELPER_VERSION}", stamp, bySlug, entries, layout };
4311
4367
  } catch {
4312
4368
  // Unreadable, or caught mid-write: keep serving the last good copy.
4313
4369
  }
@@ -4319,7 +4375,10 @@ function live(page: unknown): unknown {
4319
4375
  const content = liveContent();
4320
4376
  if (!content) return page;
4321
4377
  if (page === layoutSnapshot) return isRecord(content.layout) ? content.layout : page;
4322
- if (isRecord(page) && typeof page.slug === "string") return content.bySlug.get(page.slug) ?? page;
4378
+ if (isRecord(page) && typeof page.slug === "string") {
4379
+ // A collection's page has no row of its own: its entries are the published ones all the same.
4380
+ return content.bySlug.get(page.slug) ?? ("entries" in page ? page : { ...page, entries: content.entries });
4381
+ }
4323
4382
  return page;
4324
4383
  }
4325
4384
 
@@ -4333,7 +4392,10 @@ export function bcms(page: unknown, path: string, fallback: string, slug?: strin
4333
4392
 
4334
4393
  /** One field of the shared layout \u2014 the header and footer every route renders. */
4335
4394
  export function bcmsLayout(path: string, fallback: string): string {
4336
- return render(readPath(live(layoutSnapshot), path)) ?? fallback;
4395
+ const layout = live(layoutSnapshot);
4396
+ // The whole address first: a field id may carry a dot of its own (\`derived.nav-cta\`).
4397
+ const whole = isRecord(layout) ? layout[path] : undefined;
4398
+ return render(whole !== undefined ? whole : readPath(layout, path)) ?? fallback;
4337
4399
  }
4338
4400
 
4339
4401
  /**
@@ -4410,13 +4472,24 @@ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][
4410
4472
  * release write (\`writeImage\`) puts the editor's image on the \`data-bcms-field\` element, which
4411
4473
  * is where every published value lands. A plain string fallback (\`<img src="/hero.png">\`) has
4412
4474
  * no such rule and takes the CMS url directly.
4475
+ *
4476
+ * \u{1F534} ON A HOSTED SERVER THE EDITOR'S IMAGE IS A LOCAL FILE. A page the app renders per request has no
4477
+ * release write: what this returns is the page. BetterCMS copies a replaced image beside the app and
4478
+ * names the copy on the published value (\`local\`, with its size), and it is returned in the shape
4479
+ * of an import (\`{ src, width, height, format }\`), so \`<Image>\` optimises it like the site's own.
4480
+ * Absent (a build, or the site's own picture) the import stands.
4413
4481
  */
4414
4482
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
4415
4483
  page = live(page);
4416
- if (fallback !== null && typeof fallback === "object") return fallback;
4417
4484
  const entries = isRecord(page) ? page.entries : undefined;
4418
4485
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4419
- return render(readPath(root, path)) ?? fallback;
4486
+ const value = readPath(root, path);
4487
+ if (fallback !== null && typeof fallback === "object") {
4488
+ if (!isRecord(value) || typeof value.local !== "string" || typeof value.width !== "number" || typeof value.height !== "number") return fallback;
4489
+ const format = /\\.([a-z0-9]+)$/i.exec(value.local)?.[1]?.toLowerCase() ?? "";
4490
+ return { src: value.local, width: value.width, height: value.height, format: format === "jpg" ? "jpeg" : format } as unknown as T;
4491
+ }
4492
+ return render(value) ?? fallback;
4420
4493
  }
4421
4494
 
4422
4495
  /**
@@ -4749,7 +4822,8 @@ var HISTORICAL = {
4749
4822
  9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
4750
4823
  10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
4751
4824
  11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e",
4752
- 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528"
4825
+ 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528",
4826
+ 13: "cd8bdd74dbc944f2331503bdf8e995cb7f74f0d5b307d08aee4690e1695ae798"
4753
4827
  },
4754
4828
  "src/lib/bcms-content.ts": {
4755
4829
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
@@ -4763,7 +4837,8 @@ var HISTORICAL = {
4763
4837
  9: "eee9fb1635ae7d6f8f33676a1f390600ca24a62ee992775252273c73a011c494",
4764
4838
  10: "35bb7c0e30f5c41fe68e52259ec4764de6de2734064fa0e1521f3338eef0a09e",
4765
4839
  11: "818784f2088150d64d91b42eac944dc9503f104d32a0e7a18c753d2982a5adc0",
4766
- 12: "79dafc525a541feae55ada5dbe2ea966cac82acd741a4b41d61fe9e0cdfaa208"
4840
+ 12: "79dafc525a541feae55ada5dbe2ea966cac82acd741a4b41d61fe9e0cdfaa208",
4841
+ 13: "06ee70c9f0bb25e03f3930d2250da8dae172ade457f27ebba20382b6482ad3f4"
4767
4842
  },
4768
4843
  "composables/bcms-content.ts": {
4769
4844
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
@@ -4777,7 +4852,8 @@ var HISTORICAL = {
4777
4852
  9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
4778
4853
  10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
4779
4854
  11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e",
4780
- 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528"
4855
+ 12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528",
4856
+ 13: "cd8bdd74dbc944f2331503bdf8e995cb7f74f0d5b307d08aee4690e1695ae798"
4781
4857
  }
4782
4858
  };
4783
4859
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -10175,10 +10251,13 @@ async function convertSources(briefIn, sources, options) {
10175
10251
  async function convertFile(file, content, targets, parsed, declared, llmFallback, drill) {
10176
10252
  const outcomes = /* @__PURE__ */ new Map();
10177
10253
  const pendingTargets = [];
10254
+ let unreadImages = false;
10178
10255
  const dialect = dialectOf(file, content);
10179
10256
  for (const target of targets) {
10180
10257
  if (parsed && dialect && convertedHere(content, parsed, dialect, target, file)) {
10181
10258
  outcomes.set(target.key, "declared");
10259
+ if (target.kind === "image" && !content.includes("bcmsImage(")) unreadImages = true;
10260
+ else if (target.kind === "image" && !new RegExp(`bcmsImage\\([^)]*["']${target.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content)) unreadImages = true;
10182
10261
  } else pendingTargets.push(target);
10183
10262
  }
10184
10263
  const empty = {
@@ -10193,7 +10272,7 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
10193
10272
  snapshots: [],
10194
10273
  notes: []
10195
10274
  };
10196
- if (pendingTargets.length === 0) return empty;
10275
+ if (pendingTargets.length === 0 && !unreadImages) return empty;
10197
10276
  if (!dialect) {
10198
10277
  if (isTolerantDialect(file)) {
10199
10278
  const already = declaredByScan(content);
@@ -10394,15 +10473,15 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
10394
10473
  continue;
10395
10474
  }
10396
10475
  const declares = (d) => d.scope === target.scope && (d.path === target.path || d.path === shapeOfPath(target.path));
10397
- const declaredAt = new Set(declarations.filter(declares).map((d) => d.at));
10476
+ const declaredAt2 = new Set(declarations.filter(declares).map((d) => d.at));
10398
10477
  const claimedAt = new Set(declarations.filter((d) => !declares(d) && d.via === "field").map((d) => d.at));
10399
10478
  const writesField = (site) => site.where !== "attr" || target.kind === "image";
10400
10479
  const fresh = eligible.filter(
10401
- (site) => !declaredAt.has(site.attrInsertAt) && !(writesField(site) && claimedAt.has(site.attrInsertAt))
10480
+ (site) => !declaredAt2.has(site.attrInsertAt) && !(writesField(site) && claimedAt.has(site.attrInsertAt))
10402
10481
  );
10403
10482
  located += eligible.length;
10404
10483
  const blocked = eligible.filter(
10405
- (site) => !declaredAt.has(site.attrInsertAt) && writesField(site) && claimedAt.has(site.attrInsertAt)
10484
+ (site) => !declaredAt2.has(site.attrInsertAt) && writesField(site) && claimedAt.has(site.attrInsertAt)
10406
10485
  );
10407
10486
  if (blocked.length > 0) {
10408
10487
  outcomes.set(target.key, "AMBIGUOUS_LITERAL");
@@ -10411,7 +10490,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
10411
10490
  if (fresh.length === 0) {
10412
10491
  outcomes.set(
10413
10492
  target.key,
10414
- eligible.some((site) => declaredAt.has(site.attrInsertAt)) ? "declared" : "AMBIGUOUS_LITERAL"
10493
+ eligible.some((site) => declaredAt2.has(site.attrInsertAt)) ? "declared" : "AMBIGUOUS_LITERAL"
10415
10494
  );
10416
10495
  continue;
10417
10496
  }
@@ -10597,8 +10676,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
10597
10676
  },
10598
10677
  targets.filter((t) => {
10599
10678
  const outcome = outcomes.get(t.key);
10600
- return t.kind === "image" && t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
10601
- }).map((t) => ({ key: t.key, path: t.path, kind: t.kind, original: t.fallback, slug: t.slug }))
10679
+ return t.kind === "image" && t.scope === "page" && (outcome === void 0 || outcome === "declared" || UNPLACED.has(outcome));
10680
+ }).map((t) => ({ key: t.key, path: t.path, kind: t.kind, original: t.fallback, slug: t.slug, declared: outcomes.get(t.key) === "declared" }))
10602
10681
  );
10603
10682
  for (const key of images.bound) outcomes.set(key, "rewritten");
10604
10683
  for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);