diff --git a/CLAUDE.md b/CLAUDE.md
index 16698f69..ff6ef4ba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,8 +29,8 @@ cd packages/react && pnpm storybook # Storybook dev server
| `@unlayer-internal/shared-elements` | `packages/shared` | No (private, bundled into react) | Framework-agnostic types, config, utils |
| `@unlayer/elements-demo` | `packages/demo` | No | Demo/showcase app |
| `@unlayer/from-react-email` | `packages/from-react-email` | No (private, bundled into migrate; API at `@unlayer/migrate/react-email`) | React Email → Elements converter (codemod, runtime with merge tags, check against the original); fidelity benchmark in `bench/`, results in `FIDELITY.md` |
-| `@unlayer/migrate` | `packages/migrate` | Yes (npm) | React Email converter API at `@unlayer/migrate/react-email`; `npx @unlayer/migrate` CLI: migrate React Email templates, check them, write a report; `compare` for templates migrated by hand |
-| `@unlayer/convert-core` | `packages/convert-core` | No (private, bundled into the converters) | Elements tree, conversion report, TSX printer and content check that converters share |
+| `@unlayer/migrate` | `packages/migrate` | Yes (npm) | React Email converter API at `@unlayer/migrate/react-email`; `npx @unlayer/migrate` CLI: migrate React Email, MJML (`--from mjml`) and Beefree (`--from beefree`) templates, check them against the originals, write a report; `compare` for templates migrated by hand. The MJML and Beefree converters (`src/from-mjml`, `src/from-beefree`) build a design that `@unlayer/react-elements/codegen` writes as code; `src/design-source.ts` runs both (MJML's compile or Beefree's HTML export is what they're checked against). Measure them with `measure:mjml` and `scripts/measure-beefree.ts` |
+| `@unlayer/convert-core` | `packages/convert-core` | No (private, bundled into the converters) | Elements tree, conversion report, TSX printer and content check that converters share; declared differences (`declared.ts`: content a converter knows it changes, such as a Social icon's alt text, which the check expects and lists) |
### Component Hierarchy (strict)
diff --git a/packages/convert-core/src/cascade.ts b/packages/convert-core/src/cascade.ts
index c367d969..d6d77416 100644
--- a/packages/convert-core/src/cascade.ts
+++ b/packages/convert-core/src/cascade.ts
@@ -2114,7 +2114,8 @@ function compareArrays(a: number[], b: number[]): number {
export interface StyleDifference {
/**
* A style; "shown": text that is only the inbox preview on one side and in the email on the other, or that can't
- * be made out on one side (its color on what's behind it: white on white); "line": a line break moved.
+ * be made out on one side (its color on what's behind it: white on white, "invisible"; or too faint where the other
+ * side's is clear, "hard to read"); "line": a line break moved.
*/
property: (typeof STYLE_PROPERTIES)[number] | "shown" | "line";
original: string;
@@ -2227,6 +2228,11 @@ function luminance([r, g, b]: Rgba): number {
const INVISIBLE = 1.2;
/** At or above this, text can be read, if faintly (#cccccc on white is 1.6). */
const READABLE = 1.5;
+/**
+ * At or above this, text reads clearly (WCAG's least for large text; #949494 on white is 3): such text taken under
+ * READABLE (#f7dd72 on white is 1.4) is hard to read, not a color alone.
+ */
+const CLEAR = 3;
/**
* How text in a style stands out from what's behind it: the lowest WCAG contrast ratio over the colors it may be
@@ -2307,11 +2313,13 @@ export function compareStyles(originalHtml: string | StyledDocument, convertedHt
}
if (!a[i].shown || !b[i].shown) continue;
compared++;
- // Text a reader can't make out on one side only (white on white: a background lost): not the same words read.
+ // Text a reader can't make out on one side only (white on white: a background lost), or clear text made too faint
+ // to read (a black link in pale yellow): not the same words read.
const [ca, cb] = [contrast(a[i].style), contrast(b[i].style)];
- if (ca !== undefined && cb !== undefined && ((ca >= READABLE && cb < INVISIBLE) || (ca < INVISIBLE && cb >= READABLE))) {
- const original = ca < INVISIBLE ? `invisible (${onBackground(a[i].style)})` : "shown";
- const converted = cb < INVISIBLE ? `invisible (${onBackground(b[i].style)})` : "shown";
+ const unread = (low: number, high: number) => (low < INVISIBLE && high >= READABLE) || (low < READABLE && high >= CLEAR);
+ if (ca !== undefined && cb !== undefined && (unread(ca, cb) || unread(cb, ca))) {
+ const shown = (c: number, style: WordStyle) => (c < INVISIBLE ? `invisible (${onBackground(style)})` : c < READABLE ? `hard to read (${onBackground(style)})` : "shown");
+ const [original, converted] = [shown(ca, a[i].style), shown(cb, b[i].style)];
const key = `shown\u0000${original}\u0000${converted}`;
const group = groups.get(key) ?? { property: "shown" as const, original, converted, words: [] };
group.words.push(a[i].word);
diff --git a/packages/convert-core/src/declared.ts b/packages/convert-core/src/declared.ts
new file mode 100644
index 00000000..b6a31df2
--- /dev/null
+++ b/packages/convert-core/src/declared.ts
@@ -0,0 +1,111 @@
+/**
+ * Declared differences: content a converter knows it changes, said up front so the check expects it instead of
+ * failing on it. Each declaration names exactly what it replaces (this image's alt text, this glyph in this element),
+ * so it can't hide anything else: the original's HTML gets the declared changes, then the check compares as usual, and
+ * every change made is reported. A declaration that matches nothing changes nothing.
+ */
+
+import { parse, type DefaultTreeAdapterMap } from "parse5";
+
+type Node = DefaultTreeAdapterMap["node"];
+type Element = DefaultTreeAdapterMap["element"];
+type TextNode = DefaultTreeAdapterMap["textNode"];
+
+export type DeclaredDifference =
+ /** On images with exactly this `src` and exactly this alt text (`from`, "" for none), the migration writes `to`. */
+ | { kind: "alt"; src: string; from: string; to: string; reason: string }
+ /** The migration shows this image (`to`) where the original shows `from`, exactly: another picture of the same thing. */
+ | { kind: "src"; from: string; to: string; reason: string }
+ /** The migration leaves out text that is exactly `text` (trimmed) inside an element with the class `parentClass`. */
+ | { kind: "text"; text: string; parentClass: string; reason: string }
+ /**
+ * The migration shows, at every width, the elements with the class `className` that the original hides at some
+ * (a menu folded into a hamburger button on phones): they're shown in the original too (`display: block`).
+ */
+ | { kind: "shown"; className: string; reason: string }
+ /** The migration leaves out the elements with the class `className` (a hamburger button): hidden in the original too. */
+ | { kind: "hidden"; className: string; reason: string };
+
+export interface AppliedDifference {
+ declaration: DeclaredDifference;
+ /** How many places in the original it changed. */
+ count: number;
+}
+
+const attribute = (element: Element, name: string): string | undefined => element.attrs.find((a) => a.name === name)?.value;
+const escapeAttribute = (value: string) => value.replace(/&/g, "&").replace(/"/g, """).replace(/ (element && "attrs" in element ? attribute(element, "class") ?? "" : "").split(/\s+/).filter(Boolean);
+
+/**
+ * The original's HTML as the migration is expected to show it, with what each declaration changed. Only the attribute
+ * values and text the declarations name are rewritten; the rest of the document is kept byte for byte.
+ */
+export function applyDeclared(html: string, declared: readonly DeclaredDifference[]): { html: string; applied: AppliedDifference[] } {
+ if (!declared.length) return { html, applied: [] };
+ const document = parse(html, { sourceCodeLocationInfo: true });
+ const edits: Array<{ start: number; end: number; text: string }> = [];
+ const counts = declared.map(() => 0);
+
+ const replaceAttribute = (element: Element, name: string, value: string) => {
+ const location = element.sourceCodeLocation;
+ const at = location?.attrs?.[name];
+ if (at) edits.push({ start: at.startOffset, end: at.endOffset, text: `${name}="${escapeAttribute(value)}"` });
+ else if (location?.startTag) {
+ // ` {
+ if ("tagName" in node && node.tagName === "img") {
+ const src = attribute(node, "src") ?? "";
+ const alt = attribute(node, "alt") ?? "";
+ // One declaration per attribute of an image: the first that names it.
+ const altAt = declared.findIndex((d) => d.kind === "alt" && d.src === src && d.from === alt);
+ if (altAt >= 0 && replaceAttribute(node, "alt", (declared[altAt] as { to: string }).to)) counts[altAt]++;
+ const srcAt = declared.findIndex((d) => d.kind === "src" && d.from === src);
+ if (srcAt >= 0 && replaceAttribute(node, "src", (declared[srcAt] as { to: string }).to)) counts[srcAt]++;
+ }
+ if ("tagName" in node) {
+ const at = declared.findIndex((d) => (d.kind === "shown" || d.kind === "hidden") && classes(node).includes(d.className));
+ if (at >= 0) {
+ const style = attribute(node, "style");
+ const display = `${style ? `${style.replace(/;?\s*$/, ";")}` : ""}display:${declared[at].kind === "shown" ? "block" : "none"} !important`;
+ if (replaceAttribute(node, "style", display)) counts[at]++;
+ }
+ }
+ if (node.nodeName === "#text") {
+ const text = node as TextNode;
+ const at = declared.findIndex((d) => d.kind === "text" && text.value.trim() === d.text && classes(text.parentNode as Element).includes(d.parentClass));
+ const location = text.sourceCodeLocation;
+ if (at >= 0 && location) {
+ edits.push({ start: location.startOffset, end: location.endOffset, text: "" });
+ counts[at]++;
+ }
+ }
+ for (const child of "childNodes" in node ? node.childNodes : []) visit(child);
+ if ("content" in node && node.content) visit(node.content as unknown as Node);
+ };
+ visit(document as unknown as Node);
+
+ let out = html;
+ let last = Infinity;
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
+ if (edit.end > last) continue; // overlapping: the first one kept
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
+ last = edit.start;
+ }
+ return { html: out, applied: declared.map((declaration, i) => ({ declaration, count: counts[i] })).filter((a) => a.count > 0) };
+}
+
+/** A declared difference as a report lists it: its reason, and what it changed. */
+export function describeDeclared(applied: AppliedDifference): { reason: string; detail: string } {
+ const { declaration: d, count } = applied;
+ const times = count > 1 ? ` (×${count})` : "";
+ if (d.kind === "alt") return { reason: d.reason, detail: `alt "${d.from}" → "${d.to}"${times}` };
+ if (d.kind === "src") return { reason: d.reason, detail: `${d.from} → ${d.to}${times}` };
+ if (d.kind === "shown" || d.kind === "hidden") return { reason: d.reason, detail: `.${d.className}${times}` };
+ return { reason: d.reason, detail: `"${d.text}"${times}` };
+}
diff --git a/packages/convert-core/src/index.ts b/packages/convert-core/src/index.ts
index cf920a75..95d38bb3 100644
--- a/packages/convert-core/src/index.ts
+++ b/packages/convert-core/src/index.ts
@@ -24,3 +24,4 @@ export { compareStyles, DESKTOP_WIDTH, normalizeWord, StyledDocument, type Style
export { decodeHtmlEntities } from "./entities";
export { autoTableWidths, htmlText, unbreakableWords, wordWidth, type TableColumn } from "./text-width";
export { editorFonts, shareEditorFonts, type EditorFont } from "./fonts";
+export { applyDeclared, describeDeclared, type AppliedDifference, type DeclaredDifference } from "./declared";
diff --git a/packages/convert-core/test/declared.test.ts b/packages/convert-core/test/declared.test.ts
new file mode 100644
index 00000000..71458f69
--- /dev/null
+++ b/packages/convert-core/test/declared.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import { applyDeclared, describeDeclared, type DeclaredDifference } from "../src/declared";
+import { compareText } from "../src/verify";
+
+const ICON = "https://cdn.example.com/icons/facebook.png";
+const ALT: DeclaredDifference = { kind: "alt", src: ICON, from: "Facebook", to: "Facebook icon", reason: "social icon alt text is the editor's" };
+const GLYPH: DeclaredDifference = { kind: "text", text: "☰", parentClass: "menu-open", reason: "the menu toggle's glyph is left out" };
+
+const page = (body: string) => `
Follow us
Menu ☰
'), [GLYPH]); + expect(loose.applied).toEqual([{ declaration: GLYPH, count: 1 }]); + expect(compareText(loose.html, page("Menu
")).missing).toEqual(["☰"]); + }); + + it("leave out a declared glyph, and keep the rest of the document byte for byte", () => { + const original = page(''); + const { html, applied } = applyDeclared(original, [GLYPH]); + expect(html).toBe(original.replace("☰", "")); + expect(applied).toHaveLength(1); + expect(compareText(html, page('')).missing).toEqual([]); + }); + + it("swap a declared image source, and add an alt text the original's image lacks", () => { + const swapped = applyDeclared(page('
'), [
+ { kind: "src", from: "/mjml/facebook.png", to: "/unlayer/facebook.png", reason: "r" },
+ { kind: "alt", src: "/mjml/facebook.png", from: "", to: "Facebook icon", reason: "r" },
+ ]);
+ expect(swapped.html).toBe(page('
'));
+ expect(swapped.applied.map((a) => a.count)).toEqual([1, 1]);
+ });
+
+ it("show elements of a declared class the original hides at some widths (a menu folded into a hamburger)", () => {
+ const css = "";
+ const original = `${css}`;
+ const migrated = page('');
+ expect(compareText(original, migrated).phone.added).toEqual(["Shop"]);
+ const { html, applied } = applyDeclared(original, [{ kind: "shown", className: "menu-links", reason: "r" }]);
+ expect(html).toContain('