@bettercms-ai/convert 0.17.1 → 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,19 @@
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
+
11
+ ## 0.17.2
12
+
13
+ ### Patch Changes
14
+
15
+ - d247026: `--xray` now measures a page exactly as the release pass does. `data-bcms-block` and `data-bcms-path` no longer own the text under them (only `data-bcms-field` and `data-bcms-layout-field` do); `<select>`, `<object>`, `<canvas>` and MathML are skipped; numbers such as `$1,200` and short copy such as `OK` count as copy; only a real in-page skip link is excluded. A page's unaddressable figure may rise: it now matches what the binding report says for the same build.
16
+
3
17
  ## 0.17.1
4
18
 
5
19
  ### 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");
@@ -6801,7 +6858,7 @@ function isSourceCandidate(path) {
6801
6858
  }
6802
6859
 
6803
6860
  // src/xray.ts
6804
- var SKIP_TAGS = /* @__PURE__ */ new Set(["svg", "iframe", "head", "title"]);
6861
+ var SKIP_TAGS = /* @__PURE__ */ new Set(["svg", "math", "iframe", "head", "title", "object", "canvas", "select"]);
6805
6862
  var CONTEXT_TAGS = /* @__PURE__ */ new Set([
6806
6863
  "nav",
6807
6864
  "header",
@@ -6819,10 +6876,16 @@ var CONTEXT_TAGS = /* @__PURE__ */ new Set([
6819
6876
  "figure",
6820
6877
  "blockquote"
6821
6878
  ]);
6822
- var BOUND_ATTRS = ["data-bcms-field", "data-bcms-layout-field", "data-bcms-path", "data-bcms-block"];
6823
- var isDecorativeGlyph = (text) => text.length <= 2 || /^\d{1,2}\s*[/.)\-–]?$/.test(text) || !new RegExp("\\p{L}", "u").test(text) || /^made with\b/i.test(text) || /^skip to\b/i.test(text);
6879
+ var BOUND_ATTRS = ["data-bcms-field", "data-bcms-layout-field"];
6880
+ var isDecorativeGlyph = (text) => {
6881
+ const t = flat2(text);
6882
+ return t === "" || !/[\p{L}\p{N}]/u.test(t) || new RegExp("^\\p{Nd}{1,2}\\s*[/.)\\-\u2013\u2014]?$", "u").test(t);
6883
+ };
6884
+ var PLATFORM_BADGE = /^made with\b/iu;
6824
6885
  var MAX_BUCKETS = 12;
6825
6886
  var flat2 = (value) => value.replace(/\s+/g, " ").trim();
6887
+ var textOf = (node) => node.type === "text" ? node.value : node.type === "element" ? node.children.map(textOf).join("") : "";
6888
+ var isSkipLink = (node, attrs) => node.tag === "a" && (attrs.get("href") ?? "").startsWith("#") && (/^skip\b/iu.test(flat2(textOf(node))) || /\bskip[-_]?(link|nav|to|content)\b/iu.test(attrs.get("class") ?? ""));
6826
6889
  function measureUnaddressable(html) {
6827
6890
  const parsed = parse2(html);
6828
6891
  let visible = 0;
@@ -6831,7 +6894,7 @@ function measureUnaddressable(html) {
6831
6894
  const visit = (node, tag, context, bound) => {
6832
6895
  if (node.type === "text") {
6833
6896
  const text = flat2(node.value);
6834
- if (!text || isDecorativeGlyph(text)) return;
6897
+ if (isDecorativeGlyph(text) || PLATFORM_BADGE.test(text)) return;
6835
6898
  visible += text.length;
6836
6899
  if (bound) return;
6837
6900
  unaddressable += text.length;
@@ -6845,7 +6908,7 @@ function measureUnaddressable(html) {
6845
6908
  }
6846
6909
  if (node.type !== "element" || node.raw || SKIP_TAGS.has(node.tag)) return;
6847
6910
  const attrs = new Map(node.attrs.map((a) => [a.name, a.value]));
6848
- if (attrs.has("hidden") || attrs.get("aria-hidden") === "true" || /display\s*:\s*none/.test(attrs.get("style") ?? "")) {
6911
+ if (attrs.has("hidden") || attrs.get("aria-hidden") === "true" || /display\s*:\s*none/i.test(attrs.get("style") ?? "") || isSkipLink(node, attrs)) {
6849
6912
  return;
6850
6913
  }
6851
6914
  const owned = bound || BOUND_ATTRS.some((name) => attrs.has(name));
@@ -6856,7 +6919,7 @@ function measureUnaddressable(html) {
6856
6919
  return {
6857
6920
  visible,
6858
6921
  unaddressable,
6859
- buckets: [...buckets.values()].sort((a, b) => b.chars - a.chars).slice(0, MAX_BUCKETS)
6922
+ buckets: [...buckets.values()].sort((a, b) => b.chars - a.chars || `${a.tag} @ ${a.context}`.localeCompare(`${b.tag} @ ${b.context}`)).slice(0, MAX_BUCKETS)
6860
6923
  };
6861
6924
  }
6862
6925
 
@@ -9558,8 +9621,21 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
9558
9621
  callSites: [{ file, literal: drilledProp.literal }]
9559
9622
  });
9560
9623
  }
9561
- const images = bindImages(
9562
- { 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
+ },
9563
9639
  targets.filter((t) => {
9564
9640
  const outcome = outcomes.get(t.key);
9565
9641
  return t.kind === "image" && t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
@@ -9567,6 +9643,10 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
9567
9643
  );
9568
9644
  for (const key of images.bound) outcomes.set(key, "rewritten");
9569
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
+ }
9570
9650
  const lanes = [...data.splices, ...images.splices];
9571
9651
  if (applied.length === 0 && lanes.length === 0) {
9572
9652
  return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [], notes: [] };
@@ -9798,10 +9878,58 @@ function emitStubs2(dialects, slugs, contents, out) {
9798
9878
  }
9799
9879
  }
9800
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
+
9801
9929
  // package.json
9802
9930
  var package_default = {
9803
9931
  name: "@bettercms-ai/convert",
9804
- version: "0.17.1",
9932
+ version: "0.18.0",
9805
9933
  type: "module",
9806
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.",
9807
9935
  bin: {
@@ -9864,6 +9992,8 @@ function parseArgs(argv) {
9864
9992
  forms: false,
9865
9993
  pins: {},
9866
9994
  xray: void 0,
9995
+ verifyStyles: false,
9996
+ base: "HEAD",
9867
9997
  plan: void 0,
9868
9998
  copy: "bind",
9869
9999
  root: ".",
@@ -9889,6 +10019,8 @@ function parseArgs(argv) {
9889
10019
  if (Object.hasOwn(args.pins, form)) throw new CliError(`--pin ${form} twice: give one file. See --help.`);
9890
10020
  args.pins[form] = raw.slice(at + 1);
9891
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";
9892
10024
  else if (arg === "--plan") args.plan = argv[++i];
9893
10025
  else if (arg === "--copy") {
9894
10026
  const mode = argv[++i];
@@ -9917,6 +10049,7 @@ var HELP = `bettercms-convert ${VERSION}
9917
10049
  bettercms-convert --componentize --brief <json> --plan <json> --root . [options]
9918
10050
  bettercms-convert --forms --brief <json> --root . [options]
9919
10051
  bettercms-convert --xray <dist-dir>
10052
+ bettercms-convert --verify-styles [--base <ref>] --root .
9920
10053
 
9921
10054
  --brief <file> The conversion brief, as get_conversion_brief returns it. Required.
9922
10055
  --componentize Lift each planned SECTION into a component the CMS can reorder.
@@ -9932,6 +10065,12 @@ var HELP = `bettercms-convert ${VERSION}
9932
10065
  after a build to see what the next conversion still has to reach,
9933
10066
  without deploying. Takes no --brief. Exit 2 with --strict when any
9934
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.
9935
10074
  --plan <file> The componentize plan, as get_componentize_plan returns it.
9936
10075
  Required with --componentize.
9937
10076
  --copy bind|instance Which copy model the plan is expected to describe. Default: bind
@@ -10089,6 +10228,31 @@ ${pages.length - over} of ${pages.length} pages at or under 2% unaddressable
10089
10228
  `);
10090
10229
  return strict && over > 0 ? 2 : 0;
10091
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
+ }
10092
10256
  async function main() {
10093
10257
  const args = parseArgs(process.argv.slice(2));
10094
10258
  if (args.help) {
@@ -10099,6 +10263,7 @@ async function main() {
10099
10263
  if (args.brief) throw new CliError("--xray measures a build; it reads no brief. See --help.");
10100
10264
  return await xray(resolve(args.xray), args.strict);
10101
10265
  }
10266
+ if (args.verifyStyles) return await verifyStyles(resolve(args.root), args.base, args.verbose);
10102
10267
  if (!args.brief) throw new CliError("--brief <file> is required. See --help.");
10103
10268
  if (args.plan && !args.componentize) {
10104
10269
  throw new CliError("--plan is only read with --componentize. See --help.");