@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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6cb71a0: - **Images passed to a section component are bound.** `<Hero src={heroImage} />` rendering `<Image src={src}>` used to be `IMAGE_ASSET_UNRESOLVED`. The call site now reads `bcmsImage(...)` and passes `bcmsBindings`, and the component's `<Image>` gets the binding. No class or markup changes.
8
+ - **`bcmsImage` keeps an imported asset at build** (helper v8). `astro:assets` refuses a remote url without width and height, so returning the CMS url broke the build once an editor replaced an image. The published page shows the editor's image through the release write.
9
+ - **New `--verify-styles [--base <ref>]`.** It lists every `class`, `className` and `style` token changed since the base commit, repo-wide so a `--componentize` move is not an edit, and exits 1 when there are any. Put its `styleEdits` in the conversion receipt; a receipt with edits is refused.
10
+
3
11
  ## 0.17.2
4
12
 
5
13
  ### Patch Changes
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import { execFileSync } from "child_process";
4
5
  import { readdir, readFile, writeFile, mkdir, stat } from "fs/promises";
5
6
  import { dirname, join as join2, relative, resolve, sep } from "path";
6
7
 
@@ -3187,6 +3188,7 @@ function soleChild(node) {
3187
3188
  const kept = node.children.filter((c) => !(c.type === "text" && c.value.trim() === ""));
3188
3189
  return kept.length === 1 ? kept[0] : null;
3189
3190
  }
3191
+ var ASSET_TAGS = /* @__PURE__ */ new Set(["Image", "Picture"]);
3190
3192
  function findPropTargets(content, parsed, propName, dialect, imported = null) {
3191
3193
  const empty = {
3192
3194
  targets: [],
@@ -3203,6 +3205,7 @@ function findPropTargets(content, parsed, propName, dialect, imported = null) {
3203
3205
  const reception = dialect === "astro" ? astroReception(ast, propName, offset, content) : receptionIn(component, propName, offset, content);
3204
3206
  if (!reception.receivable) return empty;
3205
3207
  const names = new Set(reception.names.size > 0 ? reception.names : [propName]);
3208
+ const assetComponents = /from\s+["']astro:assets["']/.test(content);
3206
3209
  const within = (node) => component === null || node.start >= (component.start ?? 0) + offset && node.start <= (component.end ?? 0) + offset;
3207
3210
  const targets = [];
3208
3211
  const forwards = [];
@@ -3212,6 +3215,15 @@ function findPropTargets(content, parsed, propName, dialect, imported = null) {
3212
3215
  return;
3213
3216
  }
3214
3217
  if (node.type !== "element") return;
3218
+ const sources = IMAGE_SOURCES[node.tag.toLowerCase()] ?? (assetComponents && ASSET_TAGS.has(node.tag) ? ["src"] : []);
3219
+ const readsImage = node.attrs.some((attr) => {
3220
+ if (!sources.includes(attr.name) || !attr.expressionRange) return false;
3221
+ return readsProp(content.slice(attr.expressionRange.start, attr.expressionRange.end), propName, names);
3222
+ });
3223
+ if (readsImage) {
3224
+ if (within(node)) targets.push({ attrInsertAt: node.attrInsertAt, inner: null, tag: node.tag });
3225
+ return;
3226
+ }
3215
3227
  if (isIntrinsic(node.tag)) {
3216
3228
  const child = soleChild(node);
3217
3229
  if (within(node) && child?.type === "expr" && readsProp(child.value, propName, names)) {
@@ -4069,6 +4081,27 @@ function baseName(url) {
4069
4081
  const base = file.split(".")[0] ?? "";
4070
4082
  return base === "" ? null : base.toLowerCase();
4071
4083
  }
4084
+ function passedTo(content, parsed, binding) {
4085
+ const out = [];
4086
+ const visit = (node) => {
4087
+ if (node.type === "expr") {
4088
+ for (const child of node.children) visit(child);
4089
+ return;
4090
+ }
4091
+ if (node.type !== "element" || node.raw) return;
4092
+ const isComponent = /^[A-Z]/.test(node.tag) && !ASSET_COMPONENTS.has(node.tag);
4093
+ if (isComponent && !node.attrs.some((a) => a.name === "bcmsBindings")) {
4094
+ for (const attr of node.attrs) {
4095
+ if (!attr.expressionRange) continue;
4096
+ const inner = content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/^\{|\}$/g, "").trim();
4097
+ if (inner === binding) out.push({ node, prop: attr.name, attr });
4098
+ }
4099
+ }
4100
+ for (const child of node.children) visit(child);
4101
+ };
4102
+ for (const root of parsed.roots) visit(root);
4103
+ return out;
4104
+ }
4072
4105
  function assetCandidates(parsed, dialect, original) {
4073
4106
  const wanted = baseName(original);
4074
4107
  if (wanted === null || parsed.error) return [];
@@ -4106,8 +4139,8 @@ function renderedBy(content, parsed, dialect, binding) {
4106
4139
  for (const root of parsed.roots) visit(root);
4107
4140
  return out;
4108
4141
  }
4109
- function bindImages(ctx, targets) {
4110
- const out = { splices: [], slugs: [], bound: [], refused: [] };
4142
+ async function bindImages(ctx, targets) {
4143
+ const out = { splices: [], slugs: [], bound: [], refused: [], componentEdits: [], props: [] };
4111
4144
  const rule = DIALECT_RULES[ctx.dialect];
4112
4145
  if (!rule.attr || !rule.text || ctx.parsed.error) return out;
4113
4146
  const claimed = /* @__PURE__ */ new Set();
@@ -4115,6 +4148,23 @@ function bindImages(ctx, targets) {
4115
4148
  const binding = assetImport(ctx.parsed, ctx.dialect, target.original);
4116
4149
  const rendered = binding === null ? [] : renderedBy(ctx.content, ctx.parsed, ctx.dialect, binding);
4117
4150
  const free = rendered.filter((hit2) => !claimed.has(hit2.node.attrInsertAt));
4151
+ const args = (b) => [ctx.snapshotName(target.slug), JSON.stringify(target.path), b, ...ctx.slug ? [ctx.slug] : []];
4152
+ if (free.length === 0 && binding !== null && ctx.drill) {
4153
+ const calls = passedTo(ctx.content, ctx.parsed, binding).filter((c) => !claimed.has(c.node.attrInsertAt));
4154
+ const call = calls.length === 1 ? calls[0] : null;
4155
+ const drilled = call ? await ctx.drill(call.node.tag, call.prop, target.kind || "image") : null;
4156
+ if (call && drilled?.ok) {
4157
+ claimed.add(call.node.attrInsertAt);
4158
+ const literal = `${call.prop}: ${JSON.stringify(target.path)}`;
4159
+ out.splices.push({ start: call.attr.range.start, end: call.attr.range.end, text: rule.attr(call.prop, `${ctx.image}(${args(binding).join(", ")})`) });
4160
+ out.splices.push({ start: call.node.attrInsertAt, end: call.node.attrInsertAt, text: ` bcmsBindings={{ ${literal} }}` });
4161
+ out.componentEdits.push(...drilled.edits);
4162
+ out.props.push({ file: drilled.edits[0].file, prop: call.prop, path: target.path, literal });
4163
+ out.slugs.push(target.slug);
4164
+ out.bound.push(target.key);
4165
+ continue;
4166
+ }
4167
+ }
4118
4168
  if (free.length !== 1) {
4119
4169
  out.refused.push({ key: target.key, reason: "IMAGE_ASSET_UNRESOLVED" });
4120
4170
  continue;
@@ -4122,16 +4172,10 @@ function bindImages(ctx, targets) {
4122
4172
  const hit = free[0];
4123
4173
  claimed.add(hit.node.attrInsertAt);
4124
4174
  const attr = hit.node.attrs.find((a) => a.name === hit.attr);
4125
- const args = [
4126
- ctx.snapshotName(target.slug),
4127
- JSON.stringify(target.path),
4128
- binding,
4129
- ...ctx.slug ? [ctx.slug] : []
4130
- ];
4131
4175
  out.splices.push({
4132
4176
  start: attr.range.start,
4133
4177
  end: attr.range.end,
4134
- text: rule.attr(hit.attr, `${ctx.image}(${args.join(", ")})`)
4178
+ text: rule.attr(hit.attr, `${ctx.image}(${args(binding).join(", ")})`)
4135
4179
  });
4136
4180
  out.splices.push({
4137
4181
  start: hit.node.attrInsertAt,
@@ -4146,7 +4190,7 @@ function bindImages(ctx, targets) {
4146
4190
 
4147
4191
  // src/helper.ts
4148
4192
  import { createHash as createHash2 } from "crypto";
4149
- var HELPER_VERSION = 7;
4193
+ var HELPER_VERSION = 8;
4150
4194
  var HEADER = "// @bettercms-ai/convert helper v";
4151
4195
  var HELPER_PATH = Object.fromEntries(
4152
4196
  Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
@@ -4278,8 +4322,18 @@ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][
4278
4322
  return paths.map((row, index) => bcmsList(page, row, fallback[index] ?? [], slug));
4279
4323
  }
4280
4324
 
4281
- /** An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED */
4325
+ /**
4326
+ * An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED
4327
+ *
4328
+ * \u{1F534} AN IMPORTED ASSET IS KEPT AT BUILD. \`<Image src={\u2026}>\` from \`astro:assets\` refuses a remote
4329
+ * url string without width, height and an allowed domain, so returning the CMS url here broke the
4330
+ * build the moment an editor replaced the image. The build renders the optimised import; the
4331
+ * release write (\`writeImage\`) puts the editor's image on the \`data-bcms-field\` element, which
4332
+ * is where every published value lands. A plain string fallback (\`<img src="/hero.png">\`) has
4333
+ * no such rule and takes the CMS url directly.
4334
+ */
4282
4335
  export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
4336
+ if (fallback !== null && typeof fallback === "object") return fallback;
4283
4337
  const entries = isRecord(page) ? page.entries : undefined;
4284
4338
  const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
4285
4339
  return render(readPath(root, path)) ?? fallback;
@@ -4445,7 +4499,8 @@ var HISTORICAL = {
4445
4499
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
4446
4500
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4447
4501
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4448
- 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649"
4502
+ 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4503
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4449
4504
  },
4450
4505
  "src/lib/bcms-content.ts": {
4451
4506
  1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
@@ -4453,7 +4508,8 @@ var HISTORICAL = {
4453
4508
  3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e",
4454
4509
  4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af",
4455
4510
  5: "c8c93655320fa65664611ba84541f3fa7bfa292cc9a2fd655373440b99049fba",
4456
- 6: "63f1fa29e47e70c5193b81ee24addb238ba100247124d55bc17713102d587df5"
4511
+ 6: "63f1fa29e47e70c5193b81ee24addb238ba100247124d55bc17713102d587df5",
4512
+ 7: "ed92fb88de953d9951abbe6a1edae1d048f4050eaf6fb607ac08167f94d8bcc7"
4457
4513
  },
4458
4514
  "composables/bcms-content.ts": {
4459
4515
  1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
@@ -4461,7 +4517,8 @@ var HISTORICAL = {
4461
4517
  3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
4462
4518
  4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595",
4463
4519
  5: "0472fe3ffdcfa49e719c7b9da9cb63b6eba637cc22783bb00c3c2cadd3d800da",
4464
- 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649"
4520
+ 6: "c907e7245578d947c6541b1ab2c5c6659ea7789a1519e895b4a4aed122c6f649",
4521
+ 7: "f22514ef48c50230985e605bea6cf9bf41756384a7dc80603e347baba9b65d93"
4465
4522
  }
4466
4523
  };
4467
4524
  var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
@@ -9564,8 +9621,21 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
9564
9621
  callSites: [{ file, literal: drilledProp.literal }]
9565
9622
  });
9566
9623
  }
9567
- const images = bindImages(
9568
- { content, parsed, dialect: writing, image: helperName("bcmsImage"), snapshotName, slug: params?.slug ?? null },
9624
+ const images = await bindImages(
9625
+ {
9626
+ content,
9627
+ parsed,
9628
+ dialect: writing,
9629
+ image: helperName("bcmsImage"),
9630
+ snapshotName,
9631
+ slug: params?.slug ?? null,
9632
+ // An image handed to a section component is bound in that component, by the same drill a
9633
+ // text prop takes. @see images.ts
9634
+ drill: async (component, prop, kind) => {
9635
+ const drilled = await drillProp(drill, file, component, prop, kind, 1);
9636
+ return drilled.ok ? { ok: true, edits: drilled.edits } : { ok: false, reason: drilled.reason ?? "PROP_TARGET_NOT_FOUND" };
9637
+ }
9638
+ },
9569
9639
  targets.filter((t) => {
9570
9640
  const outcome = outcomes.get(t.key);
9571
9641
  return t.kind === "image" && t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
@@ -9573,6 +9643,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
9573
9643
  );
9574
9644
  for (const key of images.bound) outcomes.set(key, "rewritten");
9575
9645
  for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
9646
+ componentEdits.push(...images.componentEdits);
9647
+ for (const drilledProp of images.props) {
9648
+ dynamic.push({ file: drilledProp.file, prop: drilledProp.prop, path: drilledProp.path, callSites: [{ file, literal: drilledProp.literal }] });
9649
+ }
9576
9650
  const lanes = [...data.splices, ...images.splices];
9577
9651
  if (applied.length === 0 && lanes.length === 0) {
9578
9652
  return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [], notes: [] };
@@ -9804,10 +9878,58 @@ function emitStubs2(dialects, slugs, contents, out) {
9804
9878
  }
9805
9879
  }
9806
9880
 
9881
+ // src/styles.ts
9882
+ var ATTR = /\b(class|className|style)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([^`$]*)`\s*\})/g;
9883
+ function styleTokens(file, content) {
9884
+ const out = [];
9885
+ for (const match of content.matchAll(ATTR)) {
9886
+ const attr = match[1] === "className" ? "class" : match[1];
9887
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
9888
+ const line = content.slice(0, match.index).split("\n").length;
9889
+ const tokens = attr === "style" ? value.split(";").map((d) => d.replace(/\s+/g, "").toLowerCase()).filter(Boolean) : value.split(/\s+/).filter(Boolean);
9890
+ for (const token of tokens) out.push({ attr, token, file, line });
9891
+ }
9892
+ return out;
9893
+ }
9894
+ function styleEdits(before, after) {
9895
+ const count = (files) => {
9896
+ const seen = /* @__PURE__ */ new Map();
9897
+ for (const f of files) {
9898
+ for (const o of styleTokens(f.path, f.content)) {
9899
+ const key = `${o.attr}\0${o.token}`;
9900
+ seen.set(key, [...seen.get(key) ?? [], o]);
9901
+ }
9902
+ }
9903
+ return seen;
9904
+ };
9905
+ const perFile = (list) => {
9906
+ const files = /* @__PURE__ */ new Map();
9907
+ for (const o of list) files.set(o.file, [...files.get(o.file) ?? [], o]);
9908
+ return files;
9909
+ };
9910
+ const pick = (from, other, n) => {
9911
+ const mine = perFile(from);
9912
+ const theirs = perFile(other);
9913
+ const out = [];
9914
+ for (const [file, list] of mine) out.push(...list.slice(theirs.get(file)?.length ?? 0));
9915
+ return out.slice(0, n);
9916
+ };
9917
+ const was = count(before);
9918
+ const now = count(after);
9919
+ const edits = [];
9920
+ for (const key of /* @__PURE__ */ new Set([...was.keys(), ...now.keys()])) {
9921
+ const a = was.get(key) ?? [];
9922
+ const b = now.get(key) ?? [];
9923
+ if (a.length > b.length) for (const o of pick(a, b, a.length - b.length)) edits.push({ change: "removed", ...o });
9924
+ else if (b.length > a.length) for (const o of pick(b, a, b.length - a.length)) edits.push({ change: "added", ...o });
9925
+ }
9926
+ return edits.sort((x, y) => x.file.localeCompare(y.file) || x.line - y.line || x.token.localeCompare(y.token));
9927
+ }
9928
+
9807
9929
  // package.json
9808
9930
  var package_default = {
9809
9931
  name: "@bettercms-ai/convert",
9810
- version: "0.17.2",
9932
+ version: "0.18.0",
9811
9933
  type: "module",
9812
9934
  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.",
9813
9935
  bin: {
@@ -9870,6 +9992,8 @@ function parseArgs(argv) {
9870
9992
  forms: false,
9871
9993
  pins: {},
9872
9994
  xray: void 0,
9995
+ verifyStyles: false,
9996
+ base: "HEAD",
9873
9997
  plan: void 0,
9874
9998
  copy: "bind",
9875
9999
  root: ".",
@@ -9895,6 +10019,8 @@ function parseArgs(argv) {
9895
10019
  if (Object.hasOwn(args.pins, form)) throw new CliError(`--pin ${form} twice: give one file. See --help.`);
9896
10020
  args.pins[form] = raw.slice(at + 1);
9897
10021
  } else if (arg === "--xray") args.xray = argv[++i];
10022
+ else if (arg === "--verify-styles") args.verifyStyles = true;
10023
+ else if (arg === "--base") args.base = argv[++i] ?? "HEAD";
9898
10024
  else if (arg === "--plan") args.plan = argv[++i];
9899
10025
  else if (arg === "--copy") {
9900
10026
  const mode = argv[++i];
@@ -9923,6 +10049,7 @@ var HELP = `bettercms-convert ${VERSION}
9923
10049
  bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
9924
10050
  bettercms-convert --forms --brief <json> --root . [options]
9925
10051
  bettercms-convert --xray <dist-dir>
10052
+ bettercms-convert --verify-styles [--base <ref>] --root .
9926
10053
 
9927
10054
  --brief <file> The conversion brief, as get_conversion_brief returns it. Required.
9928
10055
  --componentize Lift each planned SECTION into a component the CMS can reorder.
@@ -9938,6 +10065,12 @@ var HELP = `bettercms-convert ${VERSION}
9938
10065
  after a build to see what the next conversion still has to reach,
9939
10066
  without deploying. Takes no --brief. Exit 2 with --strict when any
9940
10067
  page is above 2 %.
10068
+ --verify-styles Check that no class, className or style was changed by hand: every
10069
+ token in the repository is compared with --base (default HEAD).
10070
+ Conversion maps content into the CMS and never edits the site's
10071
+ design; exit 1 lists each removed or added token with file:line.
10072
+ Include the result in the conversion receipt as styleEdits.
10073
+ --base <ref> With --verify-styles: the commit before the conversion.
9941
10074
  --plan <file> The componentize plan, as get_componentize_plan returns it.
9942
10075
  Required with --componentize.
9943
10076
  --copy bind|instance Which copy model the plan is expected to describe. Default: bind
@@ -10095,6 +10228,31 @@ ${pages.length - over} of ${pages.length} pages at or under 2% unaddressable
10095
10228
  `);
10096
10229
  return strict && over > 0 ? 2 : 0;
10097
10230
  }
10231
+ function treeAt(root, ref) {
10232
+ const git = (...argv) => execFileSync("git", ["-C", root, ...argv], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
10233
+ let listed;
10234
+ try {
10235
+ listed = git("ls-tree", "-r", "--name-only", ref);
10236
+ } catch {
10237
+ throw new CliError(`--base ${ref} is not a commit in ${root}.`);
10238
+ }
10239
+ return listed.split("\n").filter((path) => path && isSourceCandidate(path)).map((path) => ({ path, content: git("show", `${ref}:${path}`) }));
10240
+ }
10241
+ async function verifyStyles(root, base, verbose) {
10242
+ const edits = styleEdits(treeAt(root, base), await readTree(root, verbose));
10243
+ process.stdout.write(`${JSON.stringify({ base, styleEdits: edits }, null, 2)}
10244
+ `);
10245
+ if (edits.length === 0) {
10246
+ process.stderr.write(`styles: unchanged since ${base}.
10247
+ `);
10248
+ return 0;
10249
+ }
10250
+ process.stderr.write(
10251
+ `styles: ${edits.length} class/style token(s) changed since ${base}. Conversion must not edit the site's design \u2014 restore these, the CMS keeps inline markup as written:
10252
+ ` + edits.map((e) => ` ${e.file}:${e.line} ${e.change === "removed" ? "-" : "+"} ${e.attr} ${e.token}`).join("\n") + "\n"
10253
+ );
10254
+ return 1;
10255
+ }
10098
10256
  async function main() {
10099
10257
  const args = parseArgs(process.argv.slice(2));
10100
10258
  if (args.help) {
@@ -10105,6 +10263,7 @@ async function main() {
10105
10263
  if (args.brief) throw new CliError("--xray measures a build; it reads no brief. See --help.");
10106
10264
  return await xray(resolve(args.xray), args.strict);
10107
10265
  }
10266
+ if (args.verifyStyles) return await verifyStyles(resolve(args.root), args.base, args.verbose);
10108
10267
  if (!args.brief) throw new CliError("--brief <file> is required. See --help.");
10109
10268
  if (args.plan && !args.componentize) {
10110
10269
  throw new CliError("--plan is only read with --componentize. See --help.");