Repository navigation
@unlayer/migrate 0.1.1: fixes from a field test on real projects - #66
Merged
Merged
Conversation
…te it A template an earlier run wrote in an --out folder was recognized only by its header comment, so --overwrite replaced it even after it was edited: with `--out ./unlayer2` (untracked), appending a line to an output file and running again with --overwrite exited 0 and the edit was gone. Each migrated template now records a hash of its content after its header (`// @unlayer/migrate hash …`), computed on a normalized form (LF line endings, no trailing whitespace or trailing blank lines, the hash line left out), so an editor's save doesn't count as an edit. --overwrite replaces an earlier output only while it still matches; one edited since is refused like any file git can't restore (committed, git can restore it; --allow-dirty overrides), naming the file and why. A template written by 0.1.0, which carries no hash, is treated as possibly edited: replaced only when git can restore it or with --allow-dirty. Design JSON files carry no marker and keep their rule (replaced only by the same design or when git can restore them); --write never replaces a migrated template (it's skipped as already migrated). Test: an untracked --out folder; an output with only CRLF and trailing spaces changed is replaced, an appended line is refused (exit 1, the file named and kept), an output without a hash is refused, and an edit is replaced once committed or with --allow-dirty. Fixtures standing for an earlier run's output now carry a hash. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check compared links, images and image text, but not inline SVG. A migration that kept an icon's <svg> but lost every attribute of its shapes (an upload arrow written as <path></path><polyline></polyline><line></line>) drew nothing, yet passed with no lost links or images. Each visible, outermost <svg> is now a target like an image: its shapes with the attributes that place and paint them, written one way whatever their order, case or number formatting (commas or spaces, 2.0 or 2), with a <use> read as what it refers to. Its size and place on the page are left to the look. One that draws nothing (no shape with what draws it, a size of 0, only definitions) isn't counted, so an icon whose shapes lost their attributes is listed as lost, and one drawn differently as lost and added. Tests: an icon stripped of its attributes or removed is lost; one with other points is lost and added; the same drawing rewritten (attribute order and case, number formatting, a size, a class) is the same; a sprite of definitions and a hidden icon aren't counted, and a <use> whose definition is lost is a drawing changed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…plate On Node 18 every template failed with "couldn't load it: g.toReversed is not a function…" and the run exited 2: the CLI and its module hooks use APIs Node added in 20.x (package.json engines says >=20.6, the first 20.x with module.register), and nothing checked the version. bin.ts now only checks process.versions.node, in plain code with no imports and no newer syntax, and exits 1 with "@unlayer/migrate needs Node.js 20.6 or later (you have …)" on an older one; otherwise it loads start.ts, which is the former bin.ts (module hooks, then the CLI). Nothing an old Node lacks loads before the check. Test (built CLI): run with process.versions.node reported as 18.20.4 and 20.5.1, the run stops with exit 1 and that one line, and 20.6.0 runs; on a real Node older than 20.6 installed with nvm (18.20.4 here), the same. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Where words sit across the page placed the text of an inline element by the element's own text-align. A button written as a plain link in a table cell, with text-align:center on the <a>, was read as centered in the cell, though the browser starts it at the cell's left (text-align aligns a block's lines; on an inline box it only reaches blocks inside it). Against a migration that put the same button at the left, the check listed "View my stats" as moved 235px where the screenshots show no move. An inline element's own text now sits on its block's lines, aligned as they are; blocks inside it still inherit its text-align. Test: a link with text-align:center in a cell is placed at the cell's left, and isn't moved against an inline-block button at the left; a block inside an inline box with text-align:right is aligned right. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check read the inbox preview only from the data-skip-in-text attribute. Older @react-email/preview (0.0.8, still used in real projects) renders the preview as a <div> hidden by its styles alone (display:none, max-height:0, opacity:0, a 1px line), so the original's preview was read as hidden text and ignored, while the migration's Elements preheader was read as the preview: the preview words were listed as extra, a content difference (exit 2), though both render the same. A box hidden by its own style with display:none and shrunk the way preheaders are (max-height or max-width 0, opacity 0, a font or line of 1px or less), before any text a reader sees, is now the preview, as data-skip-in-text is. Hidden text after shown text, and a block hidden only with display:none (a phone-only block), stay hidden text. This is also stricter: an older preview the migration drops is now lost text, and one it shows in the email is preview text shown. Tests: an older preheader inside the content passes against an Elements preheader; dropped, it's lost text, shown, it's text shown in another place; after visible text, or hidden only with display:none, it isn't the preview. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check compared a word's color and the background behind it as styles, so text that became invisible was only a look. In a migration that dropped the colored band behind a calendar badge's month, the month showed white on white: the check listed "background #6366f1 → #ffffff" under "looks different", though a reader no longer gets the month at all. Each word shown on both sides now also has its contrast with what's behind it (the WCAG ratio, over every color a fading gradient may show, its color faded by any opacity). Below 1.2 on one side (white on #f4f4f4 is 1.1) and readable (1.5 or more) on the other, it's listed as text shown on one side only (property "shown", which the CLI counts as content), with its color and background, in either direction: text the original hides by its color and the migration shows counts too. The style difference is still listed. Text on an image, or whose color or background is unknown, isn't claimed. Tests: white text whose band is lost, or on a nearly white band, or made translucent, is listed; white text the original can't show, shown, is listed; another band color, faint text kept faint and text on an image aren't. Three existing expectations now include the invisible text (white text on a lost gradient, on a lost fading glow, and white words a rule sets on the white page). Co-Authored-By: Claude Opus 5.5 <[email protected]>
A template whose layout component renders the whole document (<Html>, <Head> with its meta tags and <title>, <Body>) was migrated into one Html block holding that rendered document, so the email's body held a second <html>, <head> content and a <body>. The parser folds those tags into the page's own and a browser hides the <title>, so the check read the same words and passed, though email clients keep, drop or show such parts each their own way (one that drops the tag but keeps its text shows the title). The check now records the <html> and <body> start tags the parser folds inside the body, and the head content (<title>, <meta>, <link>, <base>) there. Those the migration's body has and the original's doesn't are listed as unverified "shown" on the migrated side (content: the CLI fails on it): a title with its words, and the second document with its tags and the words of the document it starts. Tests: a one-time code email kept whole inside a migrated block lists its title and words, and its meta, <html> and <body> tags with the document's words, and fails; the page's own head, and the same markup on both sides, aren't listed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check compared the background behind each word, but not the page's own color around the email. A migration that lost a gray (or dark) page background, keeping the color only behind the words, showed a white page around the email, and the check listed no difference: the words still sat on the same colors. The color at the page's sides is now worked out on both sides: the page's own (<html>'s, then <body>'s), under each box that spans the page and holds all of it (a full-width wrapper table, Elements' body table, a single full-width row), down to one that's narrower, holds more than one box or has padding at its sides; an image's box counts by the color under it. Where both can be read and they differ, it's a style difference on the background with no words and `page: true`: a look, not content. Tests: React Email's page color (body and padded full-width cell) against an Elements page that lost it is listed, light or dark; the same color set on the body, on a full-width row or on a wrapper table with bgcolor isn't, and a narrower card's color isn't the page's. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The previous change failed the check on any second document inside the migrated body. Where it's only a second <html> or <body> start tag, with no title or head content (a template whose root component is an alias of <Html>, kept as markup: <html dir="rtl" lang="ar"> around its text), the words in it show either way: the browser folds the tags into the page's own and email clients drop them. What can differ is how it looks (the attributes the page's own lacks, a body style dropped), so it's listed as unverified look, with the words of that document, not as content. A title or head content (<meta>, <link>, <base>) in the body is still content. Test: a kept document with only <html dir lang> and <body> around the words is one look entry with those words; the head-content case is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Next.js refuses to build app code that imports react-dom/server ("You're
importing a component that imports react-dom/server"), and the codemod
imported it in most migrated templates: markup it kept was rendered with
renderToStaticMarkup at render time (and post-processed for the email's head
styles), and htmlText rendered prop values that might be React elements.
Kept markup is now written as a KeptHtml element holding the original JSX
(KeptHtml renders its children; Elements 0.2.0 renders a component of the
template's own in a Column as an Html block, in the email and the design,
and its displayName keeps the block's u_content_html id). It closes over
props and loop variables in place. The head-style fixes the wrapper added at
render time are written into the kept JSX instead: border-collapse:separate
on tables, 1em margins on paragraphs, the surrounding text color on tables
and cells, on HTML elements and on the React Email components whose style
lands on them (Section, Container, Row, Column), before the element's own
style when that can't be read.
JSX inside text (an image in a link, `cond ? <>…</> : null`, `list.map(…)`)
is written as HTML text code: conditions and lists stay code, and each
element is rendered at migration time with markers for what comes from
code, which become htmlText/htmlAttribute calls (rendered twice with
different markers, so a component that does more with a value than show it
keeps its block instead). htmlText now only escapes text and numbers; a
value whose type admits React elements, or a component of the template's
own in text, keeps its block as an Html block.
On the 106-template corpus the designs (block types and ids) and the
content check are unchanged, and no output imports react-dom. A Next 15
app whose four templates were migrated with this build passes `next build`
and its route returns the original content.
Tests: a template with an image in a link, a condition, two lists and a
component of its own migrates without react-dom, keeps three text blocks
and one Html block, and matches the original with two sets of props; kept
markup in a list renders each item's props. Existing expectations that
named renderToStaticMarkup now expect KeptHtml; a kept component now
renders when React renders it, as in the original, so the impure-template
regression matches the original instead of failing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The invisible-text check skipped text on a CSS background image, whose colors aren't known, but not text in an email table cell with a background attribute (<td background="hero.jpg">), which the cascade doesn't count as an image behind the text: white text on it was read as white on white, and a migration that set a dark color under the same image would have been listed as text shown where the original hides it. A word now notes an image from a box's background attribute behind it, and its contrast isn't claimed there, as on any image. Test: white text in a cell with a background image is the same with or without a dark color under it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
While a template loads, CommonJS requires of React, React Email and Elements
resolve as the module hooks do: the CLI wrapped Node's resolver for each load
and put it back when the load ended, but only when its wrapper was still on
top. The TypeScript loader wraps the resolver again during each load and
stays, so none was ever put back: every load left a layer that each later
require went through, and that resolved again from its own project. In one
process the runs got slower load by load; in the test suite a template loaded
from a CommonJS folder with nothing installed took 40-80 s after the earlier
tests' runs (two tests timed out).
The resolver is now wrapped once for the process, and each load pushes its
project on a stack the wrapper reads and pops it when it ends; with no load
running, requires resolve as usual.
Test: after two runs of two templates, a counting resolver put on top sees a
single resolution for require.resolve("react") (nine without the fix, one per
layer left behind).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The migrated templates import @unlayer/react-elements, but a run that wrote them where it isn't installed said nothing, and tsc failed until it was. An --out folder in another project could also get an Elements older than the migrated code needs without the run noticing (only the templates' folders were checked). After a run writes templates where @unlayer/react-elements isn't found in a node_modules folder above them (a Yarn Plug'n'Play project is assumed to have it), it ends with one line per project: "Install Elements: npm install @unlayer/react-elements@^0.2.0", with the package manager the nearest lockfile names (pnpm add, yarn add, bun add, npm install) and the project's folder when it isn't the current one. The Elements version check that stops the run before anything is written now covers the folders --out writes to as well, and its install command uses the same package manager. Test: templates written inside the workspace (Elements resolves) say nothing; written to a project outside it, the line names pnpm, yarn, bun or npm by the lockfile there (npm without one); a dry run says nothing; an --out project with Elements 0.1.22 and yarn.lock stops the run with exit 1 and `yarn add @unlayer/react-elements@"^0.2.0"`, writing nothing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The test for a style computed from props that the migration drops used white text on a black body: dropping the color renders black on black, which a stricter content check reads as text the reader no longer sees (content, exit 2), not as a look difference. The fixture now drops a red color on a white body (black on white once dropped) with the same font size prop, so it keeps testing what it's about: the lost styles are listed, the template is written, and --strict fails it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The content check can report a style the page itself shows differently (a
lost page background) as a style change with `page: true` and no words. The
console line and the Markdown report printed it as one on words, ending in a
dangling " on " ("background #f4f4f5 → #ffffff on ").
Such a change now reads "the page's background #f4f4f5 → #ffffff" in the
console and "- the page's background: #f4f4f5 → #ffffff" in the report;
StyleChange (the JSON report's `styles`) documents `page`.
Test: a check that reports a page background change (the library's result
with that change added) prints it that way in the console line and in the
Markdown report.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Inlining a component writes its props' values where it reads them, which
gave code that doesn't type-check where the original did (86 errors in one
project, 41 in another) or that does something else: `{undefined && …}`
and `{"https://…" && …}` for a prop left out or given as text (always
falsy/truthy, TS2873/TS2872), a fragment of children tested as a condition
(TS2872), `[].map((l) => l.href)` from an empty default list (`l` is never),
`render(<>{message}</>)` for children passed to a function (the function got
an element, not the text), `(cond ? {…} : undefined).href` after a test of
the prop (TS2532), `(plan ? "A" : "B") ? … : …` (always truthy), a list
from a call that lost the prop's declared element type (TS2339), and the
same type imported twice from one module (TS2300). A prop left out in
`{tagline && <Text>{tagline}</Text>}` also gave an empty Paragraph.
One pass now runs on the source after inlining (normalize.ts): code whose
value is known is written as it (`&&`, `||`, `??`, `? :`, `!` and
comparisons on literals, JSX, objects and conditionals between truthy
literals; an empty array literal's length and what mapping or filtering it
gives), one child in a fragment used as a value is that child, a condition
on `c ? {…} : undefined` becomes a condition on `c` with the object written
for the value where it holds, and duplicate imports of a name from the
same module become one. Children that show nothing (`{undefined}`,
`{false}`, `{[]}`) are dropped instead of becoming empty blocks, and a list
or object a call gives a prop is written with the prop's declared type, as
literals already were.
Test: a template with five components from other files reproducing each
pattern (written for the test) type-checks under strict settings where the
original does, renders the original's content with two sets of props, and
has no empty text block; without the change it has nine type errors. The
106-template corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…t only the folder's
A migrated template rendered with React Email's render() (or a sender's
`react:` option) loses its <html> and <head>: its markup is only right through
Elements' renderToHtml(). After --write, the CLI listed the files still using
a migrated template ("Still using a migrated template…"), but only among the
files it scanned: run on ./emails, as the README says, it never saw the app's
routes and helpers that send them, and said nothing.
The files that use a written template are now looked for from the nearest
package.json above each folder given (and in that folder), outside
node_modules, dot folders and build output (.next, dist, …), tests and
CommonJS files included. A file uses a template when it imports it (its
default export, or a name the template's file exports it as), directly or
through an index that passes it on (export … from, export *, import then
export), or by import()/require, and its code uses it: not only its
PreviewProps or its type. Only files that name such a module (or an index
nearby) are parsed. The message says what to do: render it with
renderToHtml() from @unlayer/react-elements, not React Email's render(), or
in an Elements template.
Test: `migrate emails --write` in a project lists a route outside the folder
(through a tsconfig alias), a sender's helper, a mailer through an index, a
CommonJS script and a test, and leaves out a file reading only PreviewProps,
one importing only its type, .next and dist output; run from the folder
itself, a user outside it is listed too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
… React Email comes from
A file with no default export (an email exported by name, a shared
component) was listed as skipped, with why, when it imports React Email
itself, but dropped without a word when it uses React Email through the
project's own files (a ui file that re-exports it): those were followed only
for templates. Documenso's bulk-send-complete.tsx (`export const
BulkSendCompleteEmail`, components from ../components) was missing from the
run's list while the others were there; with PreviewProps it was even failed
as "the run can't find its React Email".
The project's files are now followed for every file, and one that reaches
React Email through them but isn't a template is listed as skipped with
notATemplate's reason, as a file importing React Email is. It isn't loaded,
written or checked against git. A run with nothing else to check lists it and
exits 1 ("No email template was checked").
Test: in one folder, a named-export email through a ui file and one importing
@react-email/components are both listed as skipped (no default-exported
component), in a dry run and with --out; each given on its own is listed the
same way and the run exits 1.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Two problems with the kept markup of the previous change, found migrating
two real projects:
- Under React 19, an <img> a component renders makes React write a
<link rel="preload" as="image"> before everything it renders. Elements
renders KeptHtml's markup, so the link landed before the email's rows,
where the design can't hold it ("<link> is not a valid Body child"): the
check failed 80 of one project's templates. The old helper stripped these
links from its own render. Images in kept markup (and in the file's own
components, which only render there) are now written with
loading="lazy", which React doesn't preload (before any spread, so one can
still set it). The file's other exports are left as they were: other
templates may render them in other emails.
- The browser's defaults were written before a style the codemod can't read
(`style={styles.table}` from another file): `{ borderCollapse: "separate",
...(styles.table) }` is TS2783 when that style sets the same property.
They're now written as one object spread first, and a style written out
as a literal doesn't get the properties it sets itself.
Test: a template with a table styled from another file, an image without a
width and a component of its own rendering an image type-checks where the
original does, has loading="lazy" on both images and no design warnings;
without the change it has TS2783. Corpus designs are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A template's line said "3 kinds of difference" without saying which: the
user had to open the report to learn that an image lost its corner radius or
a box its shadow.
The line now names them in short, in parentheses: each kind as the converter
lists it, without its explanation in parentheses, and for the kinds that are
lists of their own (style not converted, phone style not converted, attribute
not kept) what they are ("style not converted: box-shadow"). The report keeps
every detail.
Test: a template with a box shadow, an image corner radius and a fixed image
height prints "3 kinds of difference (image border radius dropped; image
height not kept; style not converted: box-shadow)".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A component of the template's own kept as markup and given a Tailwind class (`<PercentBadge className="ml-1.5" />`) had the class rewritten as a style prop, which its props type doesn't have: the migrated file failed to type-check (TS2322), and the style did nothing either way. The class list is now rewritten only on HTML elements, React Email components and components of the file's own that take a `style` (destructure it, or pass their props on); others keep their className. Test: a kept Text holding a component that takes only className, under <Tailwind>, type-checks where the original does and keeps the className; without the change it's TS2322. Corpus output is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
--help and the README say --strict fails a template with any difference, but it only failed one whose content differs or that the check sees looking different: a template showing "1 kind of difference" (an image's corner radius dropped, a box shadow not converted: what the check doesn't compare) passed --strict and was written. The documented policy stands: with --strict, a template the migration lists differences for fails too (exit 2, not written unless --force), and its line says why: "check failed (3 kinds of difference (…), with --strict)", or "looks different, 1 kind of difference (…)" when both. The help text says which differences count. Test: the template with a box shadow, an image corner radius and a fixed image height fails --strict (exit 2, not written) and is written with --force. The strict expectation of a template that looks different and has a head rule the converter couldn't keep names both now. Co-Authored-By: Claude Opus 5.5 <[email protected]>
An inline icon in a button (`<Link …><svg viewBox="0 0 24 24" …><path d="…"/><polyline points="…"/></svg>Share</Link>`) came out as `<svg style="…"><path></path>…</svg>`: inline content writes HTML elements with only the attributes text needs (a link's href and target, a style, `hidden`, a list's numbering), so every SVG attribute was lost and the icon drew nothing. Elements that aren't text markup (an <svg> and its shapes, an <img>, a table) are now written as React renders them, with all their attributes under their SVG/HTML names (strokeWidth as stroke-width), through the same migration-time render as React Email components in text. Text markup is written as before. Test: a button link with an SVG icon (path, polyline, line) migrates with the same <svg> markup as the original's render; before, the attributes were gone. Corpus output is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
`npx @unlayer/migrate emails --design` ran a dry run and wrote no design
JSON, without a word: --design only writes next to templates the run writes.
A run with --design and neither --write nor --out now says so on stderr
("--design writes design JSON with --write or --out: this run writes
nothing.") and goes on as the dry run it is (exit codes unchanged).
Test: --design alone prints that line and writes nothing; with --out it
prints nothing on stderr.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…kdown
`--report r.txt` was accepted without a word and written as Markdown: only a
.json name gets JSON, which the help (".md or .json") didn't say.
The help and README say a .json file gets JSON and any other name Markdown,
and when the name is neither .md (.markdown) nor .json the run's last line
says so: "Report: r.txt (written as Markdown: name it .json for JSON)".
Test: --report report.txt writes Markdown and prints that line; report.md and
report.json print the plain "Report: …" line.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A layout component wrapping React Email's <Html><Head><Body> (used by every
template of one project) wasn't inlined: the template gave it
`footer={<Footer … />}` for a prop with a default (`footer = <Footer />`),
or `brand={statusPageBrand(page, url)}`, a call the layout reads several
times and under a default. Kept as one custom component, the whole
template became a single Html block holding a nested <html><head><meta>
<title><body> document: nothing editable, the page background lost, and
under React 19 the hoisted <meta>/<title>/<link> tags gave the design
blocks it can't hold.
A prop with a default given a value that's never undefined (JSX, a
function, a template string, `new …`) is now written as given. A component
the template returns whose props run code is inlined after those values go
in consts before the return, in the order the props are written (only
where no other prop running code would then run after them). The layout's
Html and Body become the template's own, so its content converts and the
body's background is the email's.
Tests: a layout in another file with a default JSX footer, given another,
is inlined with no kept blocks and the body's background on <Email>; one
given `brand={pageBrand(page)}` (read three times, with a default) is
inlined through `const layoutBrand = pageBrand(page)`, renders the
original's content with two sets of props, and type-checks. Both kept the
layout as one block before. Corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The summary counted every file it listed as a template: a folder with one
shared component said "1 template: 0 migrated and checked, 1 skipped", and a
run with a template and two helpers said "3 templates".
The summary now counts the email templates (migrated, failed, already
migrated, or left for a file that renders them) and says how many other
files it skipped ("1 template: 1 migrated and checked. 1 other file skipped
(see above)."), or "No email templates." when there are none. Their lines
come after the templates' (a shared component, a helper, a route, a folder
link not followed), each saying why, as before.
Test: a template and a shared component print the template's line first,
then the component's, and that summary; the component's folder alone says
"No email templates. 1 other file skipped (see above)." A test that counted
a helper as a skipped template now expects it as another file.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
compare with an original outside the project failed with "Couldn't load the
templates: Cannot find module 'react-email'": it didn't say which of the two
files failed, nor that the original's imports resolve from its own folder,
where they weren't installed.
compare now loads the two files one by one and names the one that fails
("Couldn't load the original template, ../elsewhere/welcome.tsx: …"); when
the error is a module that can't be found, it adds what to do: put the file
in the project that has its dependencies (or install them there), then run
compare from that project. Exit code 2 as before.
Test: an original copied outside the project, importing react-email, which
isn't installed there: compare exits 2, naming it, the missing package, and
that advice.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…alues" The Markdown report's style coverage line always used the plural: "Styles compared on 1 words (1 values couldn't be worked out, and were left out)." It now agrees with the counts: "1 word (1 value couldn't be worked out, and was left out)", "3 words (2 values …, and were left out)". Test: the report for a check that compared styles on 1 word with 1 value left out, and on 3 words with 2 (the library's result with that coverage), says each. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Templates importing React Email's components from a file of their own
project that passes them on (`import { Html, Text } from "../components"`,
where components.ts has `export { Html, Text } from "react-email"`) weren't
converted at all: the components weren't recognized, so <Html> was "custom
component: Html" and the whole template one kept block, with no reason
given (28 templates of one project).
An import from a project file is now followed (through the module loader
the CLI passes) to React Email: `export { … } from`, `export * from`, and an
import exported again, through further such files; a name counts only if
React Email exports it. Those components convert like any others. The
template's import from that file keeps what the migrated code still reads
(other exports, components left in kept markup) and drops the rest, so it
doesn't clash with the Elements components replacing them.
Test: a template importing Html, Body, Container, Text, a renamed Heading
and a Button through an index file and a file it re-exports converts with
nothing kept, keeps `import { company } from "./ui"`, type-checks and
renders the original's content; before, it was one kept block.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Under React 19, an <img> a project's own component renders in a column
makes React start Elements' render with `<link rel="preload" as="image"
href="<src>">`, and Elements leaves it among the rows. renderToHtml then
writes it into the email's body and renderToJson warns that a <link> isn't
a valid Body child, so the check failed the template twice over ("head
content inside the email's body", "1 block(s) the editor wouldn't get"),
though no text, link or image is lost and the design JSON keeps every block.
Documenso had 27 of its 28 renderable templates fail for this alone.
The check now leaves out exactly these: a link in the migrated body with
rel="preload" and as="image", only the attributes React writes on it, and
an href that is the src of an <img> in the same document. verifyConversion
drops one "<link> is not a valid Body child" warning for each such link in
the same render (the design's and each flipped prop's), and no more.
Another image's preload, another kind of link, or a link with other
attributes still fails the check.
Test: a migrated template with React's preload for its own image passes the
check with no design warnings, with its prop flipped too; one preloading an
image the email doesn't show still fails with the warning. In the content
check, React's preload is no head content, and a stylesheet, a font preload,
a media query or another image's preload still are. Both fail without the
change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The converter wrote loading="lazy" on every <img> and <Img> it kept as markup, so that React 19 wouldn't add a preload link for it among the email's rows. That changed the email it sends without a word: a project with 100 kept images got 100 of them, on React 18 too, where there is no preload to avoid, and an email client that honors it (Apple Mail) could hold an image or a tracking pixel back until it's scrolled to. The original templates don't have it. The check now tells React 19's own image preloads apart from content, so the attribute isn't needed: kept images are written as they were. Test: a template whose kept markup and own component render images is migrated with no `loading` attribute in its code or its HTML, type-checks, and passes the check with no design warnings; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A component of the template's own kept as React (it uses a hook) that
renders `<table><tr><td>{label}</td></tr></table>` inside
`<Section style={{ backgroundColor: "#1f2937", color: "#ffffff" }}>` showed
its text black (contrast about 1.3): an Elements email sets
`table, td { color: #000000 }`, and the color written on kept markup's
tables and cells reached only the markup kept in the template, not the
component's own. 0.1.0 colored them at render time. The check listed it
only as a look.
Each block kept as React now records the text color around it, and
whether a color above it is computed from code. A component of the file
it renders (followed through the components that render it) gets that
color written on its tables and cells, as kept markup does (never over a
color of its own). Where its uses are under different colors, under a
computed one, or it's exported (other templates render it), the color
can't be written: that's reported as content (`lostShown`), since its
text shows black where the original's took the color around it. React
Email's Section and Container, whose own cell no style reaches, get a div
inside saying their color (their own, or the one around them).
Tests: a badge component and a card rendering it, both with hooks, under
a white-on-dark Section render with the original's styles; used under
different colors, or under a color from props, each is reported as
content. Before, the first had a color difference only, and nothing was
reported. Corpus: one template's kept Sections gain the color div; its
check is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A component's `{tagline && <Text>…</Text>}` given text folded to
`{(<Text>…</Text>)}`, which the migration printed as
`{ <Paragraph>…</Paragraph> }`: braces around a block for nothing.
The inlined code's normalization now writes JSX alone in braces among JSX
children as the JSX itself, as React reads it the same.
Test: a header component given a template literal tagline migrates with
no `{` before its Paragraph, and the original's content; before, the
braces were there. Corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A prop with a default given a value that reads it (`company={brand.name}`
for `company = "Acme Inc."`) was written as
`brand.name === undefined ? "Acme Inc." : brand.name`, as a destructuring
default applies only to undefined. Where the value is known and isn't null
(a constant, data from a JSON file), `brand.name ?? "Acme Inc."` is the
same and reads as written; it's now written so. A value that may be null
(a prop) keeps the default's own test.
Test: a footer given `brand.name` from a JSON file and a template's
optional prop: the first becomes `brand.name ?? "Acme Inc."`, the second
keeps `who === undefined ? …`, and both render the original's content with
and without the prop. Corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…y for lost content
Field-testing the CLI on real projects turned up messages that were
garbled or wrong:
- Text a style leaves unreadable read "shown in another place: shown shown
→ invisible (#000000 on #15171a) on "Watch", "update"". It now reads
"text no longer visible: "Watch update" is #000000 on #15171a", and the
other ways text shows differently (only in the inbox preview, hidden on
wider screens or in dark mode, in another order) say so the same way, in
the console and in the Markdown report.
- What the check couldn't verify on no words read "whether it's shown of
(…)". It now reads "whether it shows (…)", and on words "whether "Hello",
"there" show (font: inherit)". Head content in the body no longer wraps
the tags it lists in parentheses of their own.
- The summary called every check failure "content that differs (a
converter bug: please report it)", also what the check couldn't verify
(`font: inherit`), an Elements warning, or a style that leaves text
unreadable. Only words, links or images lost or added (on any screen,
with a prop flipped), text moved to or from the inbox preview or shown
whatever the props is called that now; the rest "failed the check", and a
written template says "written, but it failed the check".
- "couldn't convert it: Can't convert this template: …" said it twice; the
converter's own "Can't convert …" is now said once.
- A skipped file's reason, which has asides in parentheses of its own, came
in parentheses ("skipped (no default-exported component (a shared
component or helper file))"), as did a check failure's list. They now
follow a colon, as in the Markdown report.
- The install command was written two ways (`@^0.2.0`, `@"^0.2.0"`). Both
are now `"@unlayer/react-elements@^0.2.0"`, which zsh (even with
extendedglob), bash, PowerShell and cmd read as written.
- The --overwrite refusal listed every file on one line and said "Commit
it". Several files are now listed one per line (ten, then how many more),
with "Commit them".
Test: a template whose text a lost prop color leaves black on a dark page,
one with `font: inherit`, one that loses words, compare on head content in
the body, two the converter refuses, a helper file skipped, and twelve
uncommitted files --overwrite won't replace each give the new messages and
summary; the install command tests use the new quoting. They fail without
the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Text whose color comes from code (`style={{ color: accent }}`, a tone
picked from props) lost it: the codemod can only write styles it can read,
so the Paragraph got the color around it or #000000, and the prop and the
constant computing it were removed as unused. In one project's dark
template, "WATCH UPDATE" came out black on #15171a; the check now fails it
as content.
The color a Text's or Heading's own style computes, and a Button's color
and background, are now written on the block as that code, with the color
it would have without one after `||` (an empty or missing value takes the
color around it, as in the browser): `color={accent || "#000000"}`. The
values it reads stay. Other computed styles are still reported lost.
Test: a dark template whose label and button take an accent from a tone
prop has no lost style, type-checks, and renders the original's styles
for both tones; before, both were lost. Tests that used a computed color as
their example of a lost style now use a property still lost
(letter-spacing, font-weight); the destructured-names test now expects the
colors written as `color={text || "#000000"}`, and the CLI's lost-style
test lists only the font size. The trigger.dev template now migrates and
passes the check. Corpus designs are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Text with a background of its own in a column of several (a calendar card's "NOV" label, white on indigo) lost the background: a text box is drawn as a row of its own, which can't sit in a column, so the background was only noted as dropped and the label came out white on white. The check now fails that as content. A background computed from props was lost everywhere; with the previous change writing a computed text color, a step marker's white ✓ (on a green computed background) became white on white. Where a text's box can't be drawn (in a column, in code, in a box flattened into a column), its background now goes behind its words: a span in the Paragraph's or Heading's HTML (children are written as HTML for it). A background computed from props goes there as code, escaped when it renders, and where it can't, the color computed with it isn't written either. The box's padding, border and corners are listed as lost, and the note says the background is kept behind the words only. A computed color that's always text is written without the `||` fallback (TS2872). Tests: a date card whose month label and a title have backgrounds, in columns, render with the original's backgrounds and colors behind their words; a text with a color and background from a tone prop renders the original's styles for both tones and type-checks. Before, the first lost both backgrounds and the second rendered white on white. The new-templates field project now has 12 of 12 passing (event-invite and shipping-update). Corpus output is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ributes The variant that gives an element for each value a migrated template tests with holdsElements (1a6dde3) failed two real templates falsely: - `holdsElements(mentions.length)` (a .jsx template's untyped list) made it set an array's `length` to an element, which throws "Invalid array length", so the CLI said the migrated template doesn't render. A value is now given only inside plain objects. - A prop shown in text and also in an image's alt or src (`alt={team}`) becomes "[object Object]" there given an element, which React Email and Elements write differently: a difference in an attribute neither side means. Attributes holding "[object Object]" aren't compared in this variant; a link the element itself has still is. Test: a .jsx template with `{team}` in formatted text and in an image's alt, and `{members.length}`, passes the check with no variant; before, it threw. Without the attribute rule, the alt fails the variant. Co-Authored-By: Claude Opus 5.5 <[email protected]>
# Conflicts: # packages/from-react-email/src/codemod.ts
18e26e8 keeps a block as React renders it when the value left of `&&` beside markup has a call in it, since the code tested it and then showed it. One project's greeting, `{name && !name.includes("@") && <>Hi {name}, </>}`, went from editable text to an Html block for it. React shows the value of a failed `&&` only when it's a number (0); for one whose type can't be a number the code now tests it once and shows nothing when it fails. A test that may be a number, or whose type can't be read, still keeps its block. Test: the greeting stays a text block with `name.includes("@")` written once, and a count from a call (0) keeps its block and shows "0", with and without the name; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…itten twice A value shown beside markup whose type can't be read (an import, typed `any` because the converter doesn't read other files for types) is written both ways: `holdsElements(x) ? <KeptHtml>…</KeptHtml> : <Paragraph … />`. One project's postal address, an exported string constant, got that dead branch in 12 templates. A constant of the project's whose value is text or a number (read as styles' imported constants are) is now text: the Paragraph alone, still written as the code that reads it, with its import. Test: an imported address beside bold text keeps its import, writes neither its value nor `holdsElements`, is fully editable and passes the check; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
c1ac7ff writes a text color computed from props as code (`color={fg}`), but a box's background computed from the same prop was still dropped: with `<Container style={{ backgroundColor: dark ? "#000000" : "#ffffff" }}>` and `fg = dark ? "#ffffff" : "#111111"`, `dark: true` gave white text on the white the box fell back to, and the check failed it ("text no longer visible"). Before c1ac7ff both were fixed values, a look difference. A box's `backgroundColor` computed from props (Body, Container, Section, Row, Column, and the HTML tables, cells and divs that are boxes) now stands in as a placeholder color while the box is converted, and is written as its code wherever that color lands: the Email's `backgroundColor` (`x || "#ffffff"`), a Row's or Column's (`x || undefined`), or `x` alone when it's always a color. Where it can't be written (the box flattened or kept, its color inside HTML text, a `background` shorthand), the template is converted again with that background lost as before, and a text color computed under it isn't written as code either, so the result is never worse than with both fixed. Tests: a Body, a Container, a Section and a Column of several with a computed background and text colored from the same prop render with the original's colors and backgrounds for both values, with no lost style; a `background` shorthand keeps its text color fixed, and no text is unreadable either way. Before, the first four failed with `dark: true`. Corpus output is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A migrated template can still import React Email: a block kept as HTML
(and the switch between it and an editable one) renders React Email's
<Text> and <Link>. A second --write took such a file for a template to
migrate, so the check that git can restore what --write replaces refused
it once it wasn't committed ("--write replaces templates in place, and git
couldn't restore these"), exit 1, where a dry run said it's already
migrated and the first 0.1.1 build said "N templates already migrated:
nothing to do", exit 0.
A file a run wrote (its header, with or without the hash line an earlier
version left out, and Elements imported) is now done before any of those
checks, as one that imports only Elements is. A file of the project's own
that imports both without that header is still a template, checked as
before.
Test: a template whose text and link come from a lookup is written with
React Email's import kept; uncommitted, a second --write and a dry run exit
0 saying it's already migrated, and leave it as it was. A mixed file of the
project's own is still refused when git can't restore it. The first fails
without the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A migrated template that already imported React (`import * as React from
"react"`) got a second import from it for holdsElements
(`import { isValidElement } from "react"`).
holdsElements now calls `React.isValidElement` through the template's own
namespace or default import, adds `isValidElement` to its named value
imports from react, and only otherwise imports it on its own.
Test: templates importing React as a namespace, as a default, by name
(with a trailing comma) or as types only each end with one value import of
react, type-check and render the original's content; before, the first
three had two.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The converter reads a template with a TypeScript program that has only
the few library types it declares, so `Record`, `Partial`, `Pick`, `Omit`
and the other utility types read as `any`: with
`const messages: Record<Locale, { help: string }>`, `t.help` couldn't be
told to be text, and its block was written both ways (holdsElements),
keeping React Email components in its kept branch.
The library now has TypeScript's utility types, as lib.es5.d.ts writes
them (Partial, Required, Readonly, Pick, Record, Exclude, Extract, Omit,
NonNullable, Parameters, ReturnType).
Test: messages by locale in a Record, and a Partial Record of a Pick, shown
in formatted text with a link, migrate with no holdsElements and no React
Email import, and render the original's content for both locales; before,
the block was written both ways. Corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…onents
The kept branch of a block written both ways (`holdsElements(x) ?
<KeptHtml>…</KeptHtml> : <Paragraph/>`) held the block's original JSX, so
React Email's components stayed in the migrated file: 11 of 12 .jsx
templates of one field project still imported @react-email/components,
where users expect to be able to drop it.
The kept branch is now the HTML React Email renders for the block, written
back as JSX: its components are rendered at migration time with markers
for what comes from code (a value shown, an attribute or a style property
from code, a condition or a list of JSX, written back as code), and the
markup is read back with parse5 into JSX (React's prop names, style
objects, SVG's camelCase attributes). It's rendered twice with different
markers, so a component that does more with a value than show it (a
Button's padding) can't be written so: that block keeps React Email as
before, and the report says so ("keeps @react-email/components: <Button>
in the block for footer couldn't be rewritten"). The block's own
paragraph or heading margins are set to 0, as its block's padding holds
them, as it does the Paragraph's. CodeInline (a <style> and its text
twice) is a <code>, as the text block has it. React Email imports no one
reads any more go.
Tests: a .jsx block with a link from a prop, a style object, CodeInline
and an unreadable value migrates with no React Email import and matches
the original with text and with a link given for the value; its kept
markup is React Email's paragraph but for the margins; a Button in such a
block keeps React Email and is reported. Before, all three kept React
Email's components. The jsx-only field project now has 0 of 12 files
importing React Email. Corpus designs are unchanged; two templates' kept
branches change (one drops its React Email import), and their check is
unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A template whose preview props give a value in formatted text as an
element (`note: <em>your trial ends soon</em>`) reported "100% editable",
while its design, rendered with those props, takes the kept branch of the
block written both ways: an Html block.
The report now counts such a block as kept ("text from a value the
preview props give as elements: note") where the preview props give any
of its values as JSX; given text, it's an editable block, as the design
is.
Test: a template with a ReactNode note in formatted text and a second
text reports 50% editable with that reason when the preview gives an
element, and 100% when it gives text; before, 100% both ways. Corpus
reports are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With an element in PreviewProps (`note: <em>your trial ends soon</em>`),
`--design` walked into the element as if it were props: its type and
children became merge-tag candidates, so a placeholder replaced the
element's tag ("<uNlAyEr1 /> is using incorrect casing", "[Unlayer] <p>
threw while rendering, so it's left out: Error: Invalid tag: uNlAyEr1")
and the design got `{{note.props.children}}`; the template was still ✓.
Merge tags now skip React elements (objects with `$$typeof`). A merge-tag
attempt in which Elements leaves a block out is rejected, and what React
says of the placeholders isn't printed. The check's design render now
counts Elements' "[Unlayer]" errors (a block left out of the design) as
design warnings, so such a template fails as content instead of ✓.
Test: a template whose preview gives `note` as an element gets a design
with only `{{name}}`, no error about a placeholder tag, and no design
warning; a template whose component throws in the design's render has a
design warning naming it. Before, `note.props.children` became a merge
tag and the throw wasn't reported.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ct Email The merged converter now writes a switched block's kept branch as plain HTML and reads a Record's values as text, so the test's lookup template no longer kept React Email. A call beside markup in text still keeps its block as React Email's markup: the test uses that. It still fails without 2b150a1. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e a shared component left behind
`migrate emails --write` run again (in CI) printed "No email templates. 1
other file skipped" and "No email template was checked", exit 1, when the
folder still held a shared component the templates inlined (a Next.js app's
emails/components/footer.tsx): the templates an earlier run migrated weren't
counted, only the file left to look at. Now they're said ("4 templates
already migrated") and the run ends with 0, as one with nothing else in the
folder does.
Test: a template with a footer from another file, --write twice: the second
run says it's already migrated and exits 0; it fails without the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e templates already migrated Co-Authored-By: Claude Opus 5.5 <[email protected]>
ivoIturrieta
marked this pull request as ready for review
October 13, 2026 16:16
The converter reads a value typed as lists of text at any depth
(`rows: string[][]`) as text, but the generated htmlText flattened only one
level: the words of an inner list came out as nothing, where React renders
them one after another. htmlText now flattens lists at any depth.
Test: `{rows}` typed `string[][]` beside bold text shows every word, with
the preview props and with a list nested one level more; it fails without
the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…end of a run
A run's end counted every file that imports Elements as a template already
migrated, so a project's own route that renders with Elements (`import {
renderToHtml }`) beside a React Email file the run skipped made it end with
0 and "1 template already migrated", where no template was checked. Only
templates a run wrote (its header says so) count now.
Test: a route rendering with Elements and a React Email file with no default
export: the run exits 1, saying no email template was checked; it fails
without the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…r is still dropped c9d99b1 made htmlText call itself for a list's items. The pass that drops helpers a template doesn't use saw its name in its own body and kept it, so a template that shows no value from code failed noUnusedLocals (TS6133). It recurses through its inner function now, as before c9d99b1; the two codemod snapshots show the new helper. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A shared component the templates inlined (a footer in emails/components)
stays as it was, still importing React Email. Following the README ("React
Email can be removed once no template imports it") then broke the build:
`npm uninstall react-email` left footer.tsx with TS2307. After --write the
run now lists the files it left that still import React Email, to delete
the ones nothing imports before removing it.
Test: a template with a footer from another file, --write: the run names
emails/components/footer.tsx; it fails without the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…emoving React Email - The first command shown converts and checks without writing; --write follows, committed in git first. - Removing React Email: the shared files the run lists after --write still import it; React Email's own email dev / email export scripts don't render migrated templates (renderToHtml() does). - The converter API link is absolute: the relative ../from-react-email link doesn't resolve on npm. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
@unlayer/migrate 0.1.1: fixes from a field test on real projects
We ran 0.1.0, installed from npm with Elements 0.2.0, on:
This PR fixes what that found. Each fix was then re-tested on the same projects with the packed package and Elements 0.2.1 and 0.2.0 from npm. Elements isn't changed.
Results: 0.1.0 → 0.1.1
next buildwith migrated templatesreact-dom/server)react-dom/server.jsxfiles still importing React Email* Both are a module missing from that checkout (TS2307), the same as in the original templates.
Nothing in any project is worse than on 0.1.0: no text, link or image lost, no new type errors, and no lower editability.
Converter
react-dom/serverin generated code. Next.js forbids it in app code.<KeptHtml>component, which Elements renders as an Html block. The browser defaults it needs are written into its JSX.&&,||or??is evaluated once.ReactNodeprop, or one whose type can't be read (any,.jsx).holdsElements(x) ? <KeptHtml>…plain HTML…</KeptHtml> : <Paragraph html={…} />.Record/Partial/… and imported string constants, are written as text.<Html>/<Head>/<Body>are inlined. This includes layouts given JSX or a call's value, and the page background carries through.export *.<Tailwind>is refused, with the reason..jsxtemplates no longer crash.Content check
text no longer visible: "…" is #fff on #fff.<link rel="preload" as="image">React 19 adds for an image the email shows.CLI
--overwritekeeps edits. It keeps a migrated template edited after a run wrote it, using a hash in the header. A file written by 0.1.0, which has no hash, is replaced only when git can restore it, or with--allow-dirty.--writepasses. It says "N templates already migrated", also when a shared component is left in the folder. It counts only templates a run wrote.--write, it lists them, so you know what to delete before removing React Email. These are the shared components the templates inlined.--strict: fails a template with listed differences.Known, not fixed here
<img>rendered by a template's own component makes React add a<link rel="preload">to the email body, andrenderToJsonwarns about it. That's 112 tags in Dub and 55 in Documenso. It's harmless in email clients. A one-line Elements fix is ready separately.<Tailwind>inside kept markup.@react-email/components, a dependency of migrate (React Email 6 replaces it withreact-email). This is noise, also in 0.1.0; it moves in 0.1.2.Testing
create-emailproject and a fresh Next.js 16 app. Same words, links and images as the originals;next buildand the routes work. Its findings are fixed here: README order, a broken link, and the files to delete.🤖 Generated with Claude Code
📖 Storybook Preview: https://unlayer.github.io/elements/pr/66/