Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
196cc4a
feat(convert-core): let a converter declare differences it makes on p…
ivoIturrieta Oct 15, 2026
5ee516b
feat(migrate): migrate MJML and Beefree templates (--from mjml|beefree)
ivoIturrieta Oct 15, 2026
67099bb
chore(release): release migrate as 0.2.0 with the MJML and Beefree im…
ivoIturrieta Oct 15, 2026
3e02e37
fix(migrate): fail an MJML template with an include it couldn't read
ivoIturrieta Oct 15, 2026
16927a3
fix(migrate): keep a template's file name on its header comment's line
ivoIturrieta Oct 15, 2026
c619568
fix(migrate): don't call an include it couldn't read a converter bug
ivoIturrieta Oct 16, 2026
2cdfa12
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 18, 2026
c4a9387
fix(migrate): keep a link's own color in Beefree's legacy text module
ivoIturrieta Oct 20, 2026
1a8431d
fix(migrate): fail the check where clear text turns too faint to read
ivoIturrieta Oct 20, 2026
59c17f1
test(migrate): don't use replaceAll, which ES2020's types don't have
ivoIturrieta Oct 20, 2026
526573b
fix(migrate): keep an MJML email's columns in MJML's order under dir=…
ivoIturrieta Oct 20, 2026
dbc11c2
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 20, 2026
031a60c
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 20, 2026
83d4b91
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 22, 2026
30e0b76
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 22, 2026
c7c7cbd
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 22, 2026
9fb7f53
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 22, 2026
4c47952
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 22, 2026
6a148d0
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 22, 2026
ba0d92d
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
1b14d74
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
8db2050
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
d9bf980
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
51aedde
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
103430f
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
eea9f05
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
eeb1dd7
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
a8e7d52
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 23, 2026
968200a
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
4b6a73d
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
8c16073
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
2f1a484
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
94c22fe
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
07ef311
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
72d24c6
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
ced22a9
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
f7def31
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
a60e642
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
aa39347
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 24, 2026
7bd693e
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 25, 2026
72c037d
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 25, 2026
d3316bf
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 27, 2026
90b986e
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 27, 2026
f0f0075
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 27, 2026
4a28256
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 28, 2026
67fb1cb
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 28, 2026
a8d6774
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 28, 2026
f28da33
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
4f4da33
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
f3e60e4
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
098d3fe
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
694551b
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
591d159
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
15fa7e4
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
baa102c
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
4549c11
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
7a8819c
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
bc214c2
Merge branch 'feat/react-elements-0.3' into feat/migrate-mjml-beefree
ivoIturrieta Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 13 additions & 5 deletions packages/convert-core/src/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
111 changes: 111 additions & 0 deletions packages/convert-core/src/declared.ts
Original file line number Diff line number Diff line change
@@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
const classes = (element: Element | undefined): string[] => (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) {
// `<img` is four characters: the attribute goes right after the tag's name.
const after = location.startTag.startOffset + 1 + element.tagName.length;
edits.push({ start: after, end: after, text: ` ${name}="${escapeAttribute(value)}"` });
} else return false;
return true;
};

const visit = (node: Node): void => {
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}` };
}
1 change: 1 addition & 0 deletions packages/convert-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
83 changes: 83 additions & 0 deletions packages/convert-core/test/declared.test.ts
Original file line number Diff line number Diff line change
@@ -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) => `<!doctype html><html><head><title>t</title></head><body>${body}</body></html>`;
const icon = (alt: string, src = ICON) => `<p>Follow us</p><a href="https://facebook.com/acme"><img src="${src}" alt="${alt}" width="32"></a>`;

describe("declared differences", () => {
it("make the original show what the migration is expected to: the check passes, and each change is reported", () => {
const original = page(icon("Facebook"));
const migrated = page(icon("Facebook icon"));
expect(compareText(original, migrated).missingAttributes.length).toBeGreaterThan(0);
const expected = applyDeclared(original, [ALT]);
const check = compareText(expected.html, migrated);
expect([check.missing, check.added, check.missingAttributes, check.addedAttributes]).toEqual([[], [], [], []]);
expect(expected.applied.map(describeDeclared)).toEqual([{ reason: "social icon alt text is the editor's", detail: 'alt "Facebook" → "Facebook icon"' }]);
});

it("change only what they name: another alt change, another image, or other text still fails as content", () => {
// The alt text the migration writes isn't the declared one.
const wrongAlt = compareText(applyDeclared(page(icon("Facebook")), [ALT]).html, page(icon("Facebook logo")));
expect(wrongAlt.missingAttributes).toContain("alt Facebook icon");
expect(wrongAlt.addedAttributes).toContain("alt Facebook logo");
// The original's alt isn't the one declared: nothing changes, and the difference stays.
const otherFrom = applyDeclared(page(icon("FB")), [ALT]);
expect(otherFrom.applied).toEqual([]);
expect(compareText(otherFrom.html, page(icon("Facebook icon"))).missingAttributes).toContain("alt FB");
// Another image with the same alt text isn't touched.
const otherImage = applyDeclared(page(icon("Facebook", "https://cdn.example.com/photo.png")), [ALT]);
expect(otherImage.applied).toEqual([]);
// A glyph outside the element it's declared in is content.
const loose = applyDeclared(page('<p>Menu ☰</p><span class="menu-open">☰</span>'), [GLYPH]);
expect(loose.applied).toEqual([{ declaration: GLYPH, count: 1 }]);
expect(compareText(loose.html, page("<p>Menu</p>")).missing).toEqual(["☰"]);
});

it("leave out a declared glyph, and keep the rest of the document byte for byte", () => {
const original = page('<div><label><span class="menu-open">&#9776;</span><span class="menu-close" style="display:none">✕</span></label><a href="/a">Shop</a></div>');
const { html, applied } = applyDeclared(original, [GLYPH]);
expect(html).toBe(original.replace("&#9776;", ""));
expect(applied).toHaveLength(1);
expect(compareText(html, page('<div><a href="/a">Shop</a></div>')).missing).toEqual([]);
});

it("swap a declared image source, and add an alt text the original's image lacks", () => {
const swapped = applyDeclared(page('<img src="/mjml/facebook.png">'), [
{ 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('<img alt="Facebook icon" src="/unlayer/facebook.png">'));
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 = "<style>@media (max-width: 720px) { .menu-checkbox ~ .menu-links { display: none !important } }</style>";
const original = `<!doctype html><html><head>${css}</head><body><input type="checkbox" class="menu-checkbox"><div class="menu-links"><a href="/a">Shop</a></div></body></html>`;
const migrated = page('<div><a href="/a">Shop</a></div>');
expect(compareText(original, migrated).phone.added).toEqual(["Shop"]);
const { html, applied } = applyDeclared(original, [{ kind: "shown", className: "menu-links", reason: "r" }]);
expect(html).toContain('<div style="display:block !important" class="menu-links">');
expect(applied).toHaveLength(1);
expect(compareText(html, migrated).phone.added).toEqual([]);
});

it("hide elements of a declared class (a hamburger button and its icon)", () => {
const original = page('<label class="menu-trigger"><span class="open" style="background-image:url(/burger.svg);width:18px;height:18px;display:block"></span></label><a href="/a">Shop</a>');
const migrated = page('<a href="/a">Shop</a>');
expect(compareText(original, migrated).missingAttributes.length).toBeGreaterThan(0);
const { html, applied } = applyDeclared(original, [{ kind: "hidden", className: "menu-trigger", reason: "r" }]);
expect(html).toContain('<label style="display:none !important" class="menu-trigger">');
expect(applied).toHaveLength(1);
expect(compareText(html, migrated).missingAttributes).toEqual([]);
});

it("change nothing when nothing is declared", () => {
const original = page(icon("Facebook"));
expect(applyDeclared(original, [])).toEqual({ html: original, applied: [] });
});
});
11 changes: 11 additions & 0 deletions packages/convert-core/test/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,22 @@ describe("text that can't be made out on one side", () => {
expect(check.styles).toContainEqual({ property: "shown", original: "invisible (#ffffff on #ffffff)", converted: "shown", words: ["Internal", "reference", "4471"] });
});

it("is content when clear text turns too faint to read: a black link in pale yellow on white, and the other way", () => {
const link = (color: string) => page(`<p>Read <a href="https://example.com/guide" style="color:${color}">the guide</a> first.</p>`);
expect(compareText(link("#000000"), link("#f7dd72")).styles).toContainEqual({ property: "shown", original: "shown", converted: "hard to read (#f7dd72 on #ffffff)", words: ["the", "guide"] });
expect(compareText(link("#f7dd72"), link("#000000")).styles).toContainEqual({ property: "shown", original: "hard to read (#f7dd72 on #ffffff)", converted: "shown", words: ["the", "guide"] });
// Still a color difference too.
expect(compareText(link("#000000"), link("#f7dd72")).styles).toContainEqual(expect.objectContaining({ property: "color", original: "#000000", converted: "#f7dd72" }));
});

it("isn't listed where it reads the same, faint on both sides, or on an image", () => {
expect(compareText(badge("background-color:#0f766e;"), badge("background-color:#115e59;")).styles.filter((s) => s.property === "shown")).toEqual([]);
// A faint watermark kept as faint.
const faint = page('<p style="color:#eeeeee">Draft</p>');
expect(compareText(faint, page('<p style="color:#f2f2f2">Draft</p>')).styles.filter((s) => s.property === "shown")).toEqual([]);
// Lighter but still read (a color, not content), or light gray made fainter (not clear to begin with).
expect(compareText(page('<p style="color:#333333">Footer</p>'), page('<p style="color:#999999">Footer</p>')).styles.filter((s) => s.property === "shown")).toEqual([]);
expect(compareText(page('<p style="color:#999999">Footer</p>'), page('<p style="color:#dddddd">Footer</p>')).styles.filter((s) => s.property === "shown")).toEqual([]);
// On an image, what's behind the text isn't known: not claimed either way.
const hero = (bg: string) => page(`<div style="${bg}"><p style="color:#ffffff">Summer sale</p></div>`);
expect(compareText(hero("background-image:url(https://example.com/hero.jpg)"), hero("background-image:url(https://example.com/hero.jpg)")).styles).toEqual([]);
Expand Down
Loading