Repository navigation
@unlayer/migrate 0.1.2: React Email's current demo, and a regression run on real projects - #67
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`<Section style={{ backgroundImage: \`url(${baseUrl}/static/hero.png)\`,
backgroundSize: "cover" }}>`, with `baseUrl` from `process.env`, lost its
image (the URL can't be read when migrating), and the check failed the
template as a lost image; the same URL written out converted fine. React
Email's own Arcane promo demo does this.
A box's `backgroundImage` that is a template literal of one `url(…)` now
stands in as a placeholder URL while the box is converted, as computed
background colors do (e4d21d9), and is written as the template's code
for the URL where it lands: a Row's or Column's
`backgroundImage={{ url: \`${baseUrl}/static/hero.png\`, size: "cover", … }}`,
with its size, repeat and position as written. Where it can't be written
(the box flattened, the URL inside HTML text), the template is converted
again with it lost, as before. A Body's image isn't converted either way.
Test: a hero Section with a computed background image URL migrates with
the URL's code, no lost style, type-checks under noUnusedLocals, passes the
check with no lost image, and its design's background image is the URL
evaluated, with VERCEL_URL unset and set; before, the image was lost. A
test that used a computed background image as its example of a dropped
style now uses an outline color. Corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…a template
React Email's demo keeps tailwind.config.ts beside its templates, and
three of them attach it (`X.tailwindConfig = tailwindConfig`). The config
imports React Email's types, so the run took it for a template; as templates
import it, it was left "as it is" for them, and since a migrated template
still attaches it, the run ended with "Templates left as they are weren't
checked … Migrate them with --out <dir>", exit 1, with --write and in a dry
run, though every template migrated.
A file the templates import whose default export isn't a component (a
config object, plain data, a helper) is now skipped as what it is, as it
would be on its own ("its default export isn't a component"), never a
template left as it is for the files that import it.
Test: two templates that import and attach a shared Tailwind config: a dry
run and --write exit 0, list the config as skipped, and leave it as it was.
It fails without the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`npx @unlayer/migrate --version` was an unknown option: there was no way to tell which version a project's npx cache runs, short of reading its package.json. --version (and -v) now prints it, and the help lists it. Test: --version and -v print the package's version and nothing else, exit 0; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
An unknown option, a value left out, no templates given with other options, or compare without both files printed the mistake and then the whole help, about 45 lines: on most screens the mistake scrolled away before it could be read. It now prints the mistake and one line saying where the options are (`npx @unlayer/migrate --help`); compare's says how it's called. Run with nothing at all, it still shows the help, as that's what a first run is after. Test: an unknown option, --write alone, --report without a value and compare with one file each print their mistake and the --help line only, exit 1; a run with nothing shows the help. It fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A second run into the same --out folder stopped with "Output already exists: migrated/welcome.tsx. This run didn't produce it", though the run before it had. The message now says who wrote the file, as far as can be told: "written by an earlier run" (its header), "written by an earlier run, and edited since" (its hash), "a design, written by an earlier run or the editor" (a design JSON says neither), or "not written by @unlayer/migrate", then what to do as before. Test: a second run into an --out folder names the template an earlier run wrote, then the same edited, then its design JSON; a file of someone's own there is "not written by @unlayer/migrate". It fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check reads each word as a reader does, so a hyphen and a minus sign are the same, and a file name or an address is its letters and digits. The run and compare then quoted those words as the check reads them: "A−10293" with a minus sign where the template has "A-10293", "sarahexamplecom" for "[email protected]", "proofofworkwebp" for "proof-of-work.webp", which a reader can't find in their template. Each word is now quoted as the source writes it, without the quotes, brackets or sentence punctuation around it (an abbreviation keeps its last period): a lost word as the original has it, an added one as the migrated template has it. A word only a flipped prop's render shows stays as the check reads it. Test: compare on a template that loses "A-10293" and "([email protected])" and adds "“B‑7”," and "U.S." quotes them as "A-10293", "[email protected]", "B‑7" and "U.S.", with no minus sign; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A dry run on a folder whose templates are all migrated (beside a shared component file that still imports React Email) ended "No email templates left to migrate … Nothing written: pass --write or --out <dir> to write the migrated templates", though --write would write nothing. A dry run now says how to write only when a template would be written (one migrated, or one that failed the check, which is written unless --strict); otherwise it says "Nothing to write." Test: after --write, a dry run on the same folder says "Nothing to write." and nothing about --write; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…es it The report named a class it couldn't inline as `md_text-base`, not `md:text-base`: React Email's <Tailwind> renames a class it can't inline (its sanitizeClassName: `:` and other characters to `_`, `!` to `imprtnt`, a leading digit spelled out), and the converter reported the renamed leftover. The same name went into the lost-style message for a class that hides (`dark_hidden`). resolveTailwind now maps each leftover back to the class the template wrote (matching the template's classes through the same renaming), and the report and the lost-style message use it. The renamed name is still what the phone rules are read by. Tests: `md:text-base`, `lg:!text-[18px]` and `hover:underline` left uninlined are reported by those names; the phone test's `hover:` note is `hover:text-red-500`, not `hover_text-red-500`. The CLI's two hidden-class regressions now expect `dark:hidden` and `sm:hidden` in the message. Co-Authored-By: Claude Opus 5.5 <[email protected]>
One project's payout template ran words together ("status.If",
"fee.Note") and lost a link: `let statusMessage: string | React.ReactNode
= ""` is given a fragment with line breaks and links in one branch, and
`<Text>{statusMessage}</Text>` was written as a Paragraph's children,
which Elements reads as plain text. The value's type couldn't be read (an
unresolved React type is `any`) and its declaration holds text, so
nothing said it can be JSX.
A variable given JSX after it's declared (`message = <>…</>`) now counts as
a value that may hold elements: its block is written both ways
(`holdsElements(message) ? <KeptHtml>…</KeptHtml> : <Paragraph/>`), and
a value shown loose that way is kept as it renders.
Test: a template whose message is a fragment with <br />s and a link in
one branch and text in the other passes the check with both values;
before, "status" and "If" were lost as "statusIf" and the link was lost.
The Dub template now passes. Corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ments
Two inbox-zero templates ran words together ("2019−", "50+Sarah",
"dealAlso", "JuneAction"): `{renderGuestBriefings(guests)}` and
`{renderBulletSection("Decisions", items)}`, calls to functions of the
template's file that return JSX (a list of Texts), were written as a
Paragraph's children, which Elements reads as plain text, so each Text's
line was lost. Nothing said the call gives elements: its return type
can't be read without React's types.
A call to a function of the file's own that returns JSX now counts as
giving elements: shown loose, it's kept as it renders (an Html block);
in text, it keeps its block.
Test: a recap whose sections come from a helper returning a title and a
list of Texts passes the check with lists and with an empty one; before,
"Ship in June" and "Hire two engineers" were lost as "JuneHire". Both
inbox-zero templates now pass. Corpus output is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Quoting words as the template writes them carried along what a template's
text has but no one sees: a code block's tokens begin with a zero-width
joiner and space, so the run quoted `"client"` and `"DevTool({"`, with
the invisible U+200D and U+200B inside the quotes. Format characters
(Unicode Cf: zero-width spaces and joiners, word joiner, byte order mark,
soft hyphen, direction marks) are now left out of a quoted word. A bracket
the word opens itself is kept at its end ("connect()"), where the ones
closing an aside around it still go.
Test: compare on a template that loses text with zero-width joiners and
spaces, a word joiner, a byte order mark and a soft hyphen in it quotes
"client.connect()", "on", "startup", with no format character; it fails
without the change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A run into an --out folder that 0.1.0 wrote, without --overwrite, said its template there was "written by an earlier run", the same as one this version wrote, though 0.1.0 didn't record a hash, so whether it's been edited since can't be told. It now says "written by an earlier version of @unlayer/migrate, which didn't record whether it's edited since", as --overwrite's refusal does. Test: a template written into an --out folder, then without its hash line, is named as an earlier version's when a run won't replace it; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
`unlayer-migrate nope-dir` said "Not found: nope-dir" and stopped, where every other usage mistake says where the options are. A template or folder that isn't there, for a run or for compare, now gets the same line: "Run npx @unlayer/migrate --help to see the options." Test: a folder that isn't there, and compare with an original that isn't, print the mistake and the --help line only; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
On Node.js 18, `unlayer-migrate --version` said it needs Node.js 20.6 instead of which version it is: the Node.js check runs first, for everything. A command that is only --version (or -v) is now answered before the check, from the package's package.json, read with node:fs only, which any Node.js loads; anything else still stops at the check first. Test: the built CLI prints its version and nothing else for --version and -v on a Node.js reporting 18.20.4 and 20.6.0, and on an installed Node.js 18; a run with templates and --version on 18 still stops at the check. It fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…late is written After --write, the run lists the files it left that still import React Email (a shared component the templates had inlined), to delete before React Email is removed. It did so after --write --strict left a template unwritten too, though that template still imports the footer it named: deleting it would break the template, and React Email can't go anyway. The note now shows only when every template the run found was written (or already migrated). Test: with a footer two templates import, --write --strict writes the one that passes, leaves the one whose content differs, and names no files to delete; it fails without the change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The report's "editable" disagreed with the design JSON for a block written both ways (`holdsElements(statusMessage) ? <KeptHtml> : <Paragraph>`): Dub's partner-payout-force-withdrawal reported 87.5% while its design had two Html blocks of eight (75%). The count came from the converter's static guess (18a6688), which only knew a value is elements when the preview props give it as JSX; here it's a local variable that's a fragment on every path. verifyConversion now returns `editable`: the share of the design's content blocks that aren't Html blocks (spacer dividers not counted), so each block written both ways counts by the branch the preview render takes, whatever its value is. The CLI's report uses it. Test (CLI): a template whose message variable is a fragment on both branches, beside a text, writes a design of [text, html] and reports 50% editable; before, 100%. Co-Authored-By: Claude Opus 5.5 <[email protected]>
ivoIturrieta
marked this pull request as ready for review
October 13, 2026 19:50
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #67 +/- ##
=======================================
Coverage 96.55% 96.55%
=======================================
Files 40 40
Lines 2789 2789
Branches 795 795
=======================================
Hits 2693 2693
Misses 95 95
Partials 1 1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.2: React Email's current demo, and a regression run on real projects
We ran the published 0.1.1 on React Email's current demo templates, 62 of them from the react-email repository, kept as a real project: shared files, a Tailwind config, and URLs built from
baseUrl. 61 kept the same content, and all loaded in the Unlayer editor. Our corpus has these templates flattened by hand into single files, which is why it missed the two bugs the demo found. This release fixes them, the two content failures left from the 0.1.1 field test, and what a new-user test found. Elements isn't changed.Fixes
url(${baseUrl}/…)) is written as the template's own code. Before, it was dropped, and the content check failed the template (React Email's Arcane promo lost its hero image).tailwind.config.tsthat templates attach (X.tailwindConfig = tailwindConfig) is skipped as a non-template. Before, it was "left as it is", and a clean--writeor dry run exited 1. Any file whose default export isn't a component is now handled the same way.let message: string | ReactNode), and calls to a function of the template's own file that returns JSX, count as possibly holding elements. Their markup is kept. Before, it was flattened to text, so sentences ran together and links and bold were lost: Dub'spartner-payout-processed, inbox-zero'smeeting-briefingandmeeting-recap.md:text-base, notmd_text-base).CLI
--versionworks, also on a Node.js too old to run the CLI.--help, not the whole help.--writeappears only when every template was written.Results: 0.1.1 → 0.1.2
--writeexits 1--writein a project with shared filesNothing in any project is worse than in 0.1.1: no text, link or image lost, no new type errors, and no new React Email imports.
The one editor exception is an editor bug, not a migration one. An HTML block with an
<svg class="…">logsr.trim is not a function, because an SVG's class isn't a string. That design still loads and exports with all its words.Not changed
@react-email/components: moving to React Email 6'sreact-emailpackage would pull its whole CLI (esbuild, socket.io, tailwindcss, babel) into everynpxrun.Testing
next buildon Elements 0.2.0 and 0.2.1), and new templates in ten setups.🤖 Generated with Claude Code
📖 Storybook Preview: https://unlayer.github.io/elements/pr/67/