@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 +14 -0
- package/dist/cli.js +188 -23
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +26 -5
- package/dist/index.js +101 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 =
|
|
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
|
-
/**
|
|
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"
|
|
6823
|
-
var isDecorativeGlyph = (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 (
|
|
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
|
|
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
|
-
{
|
|
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.
|
|
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.");
|