Repository navigation
React Email → Elements migration (@unlayer/migrate) - #61
Draft
ivoIturrieta wants to merge 49 commits into
Draft
ivoIturrieta wants to merge 49 commits into
ivoIturrieta wants to merge 49 commits into
Conversation
Normalizing CSS values for the exporters and escaping previewText add about 1.8KB to the unminified ESM bundle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orters read them The exporters turn rgb() colors into "#NaN…" for Outlook's bgcolor, and Tailwind (React Email's included) writes every color as rgb(). Font stacks with double-quoted names ended the style attribute early. mapSemanticProps now writes rgb()/rgba() as hex (or rgba() when translucent) and quotes font names with single quotes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
previewText went into the hidden preheader as raw HTML. It often carries user data (a name), and markup in it, like </div>, ended the hidden preview and showed what followed. It's now escaped as text, as React escapes text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Email, Page and Document take fonts: [{ url }], web font stylesheets the content uses. renderToHtml links them in the document head together with its own fonts option, without duplicates, so a template can carry its fonts instead of every caller passing them. renderToJson and the head extraction ignore the prop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two problems kept rows with noStackMobile stacking on phones: - The exporters read "do not stack on mobile" only from the row's mobile override (_override.mobile.noStackMobile), where the editor saves it, so the prop never reached the HTML. mapSemanticProps now writes it there, for renderToHtml and renderToJson alike. - Every Row writes its own grid CSS, so a later row's .u-row .u-col rules overrode an earlier row's .no-stack ones of the same specificity, and the columns went full width anyway. The no-stack rules now use .u-row.no-stack. The browser E2E gate checks a no-stack row followed by a stacking row at phone width, in email and web modes; it fails on the old CSS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A private package that converters into Unlayer Elements share: an Elements tree (with code holes for logic the codemod keeps), the conversion report, a TSX printer (Prettier), design JSON and HTML output, and a content check comparing the words, links and images two HTML documents show. It's bundled into the converter packages, not published on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ents
@unlayer/from-react-email turns @react-email/components templates into
Elements in two modes, sharing one mapping and one layout engine:
- Codemod (convertSource): rewrites the template's source. Props, .map()
loops, conditions, helpers, types and PreviewProps stay; components from
the same file or other project files are inlined; Tailwind classes are
resolved with React Email's own Tailwind and the template's config.
- Runtime (convertReactEmail): renders the template and converts what it
renders, for opening it in the visual editor. Text props become merge
tags ({{user.name}}) where the template shows them as given.
The layout engine turns CSS boxes into Elements rows: backgrounds, inset
cards with their borders and corners, narrow and centered boxes, collapsing
margins. On phones, rows stay side by side as React Email's tables do,
unless the template stacks its columns (mobile:!block).
Anything Elements can't express natively stays as an Html block that
renders as before, and every visual difference is in the report.
verifyConversion checks a migrated template against the original: words,
links, images and alt text, with each boolean prop flipped, and the blocks
the editor gets.
FIDELITY.md has the results on 106 templates (React Email's demos, MIT
community templates, agent-written ones) and how to rerun the benchmark.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
npx @unlayer/migrate ./emails converts every React Email template it finds
and checks each one against the original before writing it: rendered with
PreviewProps and with each boolean prop flipped, every word, link, image
and alt text must survive, and the visual editor must get every block.
- Runs in the user's project, with its React, React Email and tsconfig
paths; nothing is written without --write or --out.
- --design writes each template's design JSON for loadDesign(), text props
as merge tags ({{name}}) unless --no-merge-tags.
- --report writes the migration report (Markdown or JSON): how much is
editable, what was kept as HTML, and every difference from the original.
- compare <original> <migrated> checks a template migrated by hand the
same way, for agents and people converting templates themselves.
Exit code 0 when every template passed, 2 when one failed to convert or
lost content, so it can run in CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A guide in the Elements docs (how to run the migration, what the check guarantees, how to read the report, rules for agents), the new packages in the README and CLAUDE.md, and a context7 rule pointing agents to npx @unlayer/migrate and its compare command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ord order in the content check - Decode the full HTML entity set (the `entities` package, bundled) instead of a short hand-written table. - Keep the punctuation that changes a number's meaning (separators, signs, currency symbols, percentages), so "$1.00" and "$100" no longer read as the same words. - Count words that changed places as missing: two values that swapped (a subtotal and a total) used to pass, because words were compared only as multisets. Every corpus conversion keeps the original order. - Drop the unused Node types from the type check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… alt, inline helpers by React's purity rule Codemod: - Convert every `return` of the template, including early returns, `switch` branches and `cond ? <Html>… : null` roots. A return that renders nothing (`null`, `false`, `""`, `undefined`) stays as written; helper returns are left alone. - Keep JSX named entities as characters. - Inline local helpers on the assumption React makes: rendering is pure. Expressions are classed by what evaluating them can do. Pure ones (literals, reads of bindings nothing writes, property reads, closures) are copied freely. Calls, and reads of `let`/`var` or written bindings, may move but must still run exactly once, eagerly (not repeated, not moved into a branch or callback). Writes (`=`, `++`, `delete`), hooks, `this` and `arguments` keep the helper as HTML. Read counts follow constants and defaults through to the substituted output. A template that breaks the rule fails the check against the original, which now also compares word order. Merge tags: - Tag text, link destinations and alt text only (parsed with parse5, which is bundled). CSS, font URLs, backgrounds and image sources, including images kept in HTML, keep their sample values. Preheader text is tagged in design JSON too. Check: - Boolean-flipped variants also go through `renderToJson`, so a block the editor can't represent fails there even when the HTML agrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ymlinked output folders - Load each template the way its project runs: tsx's ESM loader for ESM projects, a scoped CommonJS registration for CommonJS ones, with the project's tsconfig paths resolved to absolute paths. Registrations are released after each run. - `--out` refuses symlinked directories anywhere inside the output folder, not only those that lead outside it. - New consumer test: the built CLI migrates templates in CommonJS and ESM projects outside the workspace. - Bundle `entities` and `parse5` (notices in THIRD_PARTY_NOTICES.md) and publish with public access. - Docs: the check compares word order and numeric punctuation, checks the design exporter on boolean variants, and which values merge tags leave as samples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bundle the React Email converter into @unlayer/migrate/react-email with ESM, CommonJS and self-contained declarations. Keep the converter private and move its packed-consumer check into migrate. Update programmatic usage docs while preserving converter logic and the CLI entry points. Co-Authored-By: Codex <noreply@openai.com>
Add mobile props that preserve device overrides in design JSON, with CSS matching exported editor fixtures and browser checks at phone and desktop widths. Co-Authored-By: Codex <noreply@openai.com>
Exclude hidden columns from stacking rules while preserving no-stack width precedence. Check empty columns in both phone layouts. Co-Authored-By: Codex <noreply@openai.com>
Map phone classes in both conversion modes. Preserve every stacked column’s side padding, phone spacing, typography, full-width images and hidden spacer columns while retaining desktop positions. Co-Authored-By: Codex <noreply@openai.com>
The device-override port brings the ESM bundle to 84,826 bytes. Co-Authored-By: Codex <noreply@openai.com>
Keep the smoke runner from treating other workspace packages’ Vitest snapshots as obsolete. All 138 stories pass. Co-Authored-By: Codex <noreply@openai.com>
Record the supported phone API, corpus results, editor parity checks and remaining rendering limits. Co-Authored-By: Codex <noreply@openai.com>
… open in it - Elements: columns no longer take hideOnMobile/hideOnDesktop. The editor hides rows and content per device, never a column, and its column export has no hide class. The column stacking CSS is back to the editor's selectors. - Converter: a column hidden on phones hides its content (and its row when every column is hidden); empty spacer columns are no longer hidden. - Converter: a narrow box that takes the phone's full width keeps the space around it as padding on its column, with a phone value, instead of spacer columns. Columns with a phone padding of their own aren't folded into spacers. Phone medians over the 85 templates that fit 375px: codemod 4.6% (6.2% with column hiding, 16.5% before phone settings), runtime 3.8% (4.7%, 13.9%). Desktop word positions are unchanged for all 212 conversions, and no template is worse on phones than before phone settings. Two migrated designs with 90 phone settings load into the editor, save unchanged and export with their phone CSS applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…condition When a className's branches differed only in variant classes, the codemod kept just the plain classes, dropping phone ones both branches share (`max-sm:!block` making columns stack). Now: - An element already inside a branch of the same condition takes that branch's classes. A condition named by a const (`const isLeft = …`) matches its value. - Otherwise the variant classes both branches share are kept. Phones: Arcane welcome 21% → 2% words moved, Arcane promo 31% → 2%, Barebone welcome 30% → 5%, Barebone product update 18% → 6% (codemod). Codemod phone median 4.6% → 4.1%. Desktop unchanged for all templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Elements: columns take a phone border (`mobile.border`), mirroring the editor's device CSS for column borders (checked against an editor export: same rule, same media query, kept by saveDesign). - Converter: a row inside a card that stacks on phones gets a phone border in the color of the card's spacers, as wide as a spacer at phone width. Its other rows keep their spacers there, so the card's edge no longer jumps in and out. The padding gives up the border's width, so text stays put. The editor can't pad a row's sides in email or hide a column. - Converter: a column made a block on phones (`mobile:!block`) takes its phone margins as phone padding: React Email's stacked cells space out that way. - Conditional classNames: a condition that's a literal once props are filled in (`(true)`, `(undefined)`) picks its branch. Phone spacing only one branch has is kept rather than dropped. Desktop word positions are unchanged for all 212 conversions; no template is worse on phones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor's phone border rule for columns adds about 440 bytes; the ESM bundle is 85,095 bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bare text in a box (`<Section className="text-[36px]">🌟<Text>…</Text></Section>`) took React Email Text's 14px/24px. Outside a Text it inherits, as the browser renders it: the CodePen challenge's 36px emoji were 14px. No other template's layout changes, apart from a slight phone improvement in one (Netlify welcome). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Images with a fixed px width keep it on phones when it fits the room their column has there, as the original's do. They get a phone width (`_override.mobile.src`, an editor setting). Elements sizes an image as a share of its column, so a 48px logo rendered at about 31px on a phone. - A narrow box of text keeps the space around it as column padding, with a phone value that holds the box at its own width when it fits, as the original's max-width does. Spacer columns kept a share of the row instead, so a 280px slogan was about 164px wide on a phone. Narrow boxes holding side-by-side columns keep their spacers. Phone medians over the 85 templates that fit 375px: 0% words moved in both modes (4.1% codemod and 3.8% runtime before). 64 conversions improve, none get worse; desktop is unchanged for all 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he editor's position terms
- A painted box as wide as the content with a background image (Arcane
promo's hero) put its paint on its columns, which can't take an image,
so the image was dropped. Its image and color now go on its rows'
content box, editor settings both, with the columns left unpainted.
Inset or rounded boxes still drop it, as reported.
- Background positions are written as the editor stores them
("top-center", "center", "custom" with coordinates). CSS's "top center"
read as auto auto.
- Neighbouring rows merge across a box's closing border (a top border on
the first part, a bottom one on the last) and across differing phone
padding, whose gap goes on the next block as a phone value. Fewer rows,
and a background image no longer restarts part-way down its box.
- The report says when a background's cover/contain won't apply in email:
the editor's email export leaves out background-size, as Elements does.
No other template changes; desktop word positions are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A React Email cell widens to fit its longest word. An Elements column keeps its share of the row, and the editor's CSS breaks a word that doesn't fit, so on a 375px phone a receipt total showed as "$14. / 99" and a stat label as "documen / ts". When the longest word of a Paragraph or Heading in a row of columns side by side won't fit its phone width, the block's side padding shrinks with the row, then the text gets a phone size it fits (from Arial advance widths, padded for wider faces). The same block in the other columns takes the same size, so a row of labels stays even. Columns that are mostly padding (a step badge) and single columns are left alone, and links and email addresses don't count as long words. Benchmark: desktop unchanged for all 212 conversions; on phones, four templates change (Apple and Nike receipts, Papermark, CodePen) and none gets worse in words moved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #61 +/- ##
==========================================
+ Coverage 95.54% 95.77% +0.22%
==========================================
Files 32 33 +1
Lines 1975 2082 +107
Branches 390 454 +64
==========================================
+ Hits 1887 1994 +107
Misses 87 87
Partials 1 1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Email, Page and Document accept `textDirection` and `lang`. renderToHtml puts both on <html> (the language escaped), and the direction also reaches the content and the design JSON's body values, as the editor stores it. A design's own `values.textDirection` sets the direction too. Renderer options still override both. `lang` is document metadata: the editor's design JSON has no field for it, so renderToJson leaves it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, JS
- migrate: React, React DOM, React Email and Elements now resolve from
the project first for every importer. An installed CLI lives under
node_modules, so the old check never applied and the CLI rendered a
React 18 project's templates with its own React 19 ("Objects are not
valid as a React child"). A packed-install test runs the CLI with
React 19 against a React 18 project, in ESM and CommonJS.
- from-react-email: an element with `display: none` (including Tailwind
`hidden`) stays as hidden HTML and is reported; before, it became a
visible block and the check couldn't see it. A hidden column keeps its
whole row as HTML.
- from-react-email: Html `dir` and `lang` become Email `textDirection`
and `lang`, in both modes, dynamic values included.
- convert-core: words must stay in order even when the conversion adds
words; before, an added word skipped the order check, so swapped
amounts passed.
- from-react-email: .js and .jsx templates get JavaScript helpers (no
type annotations), and the CLI loads JavaScript templates.
Benchmark unchanged: no template fails, no new HTML blocks, desktop and
phone word positions identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inlining a JSX constant delayed its reads until the constant was used. Keep it at its original declaration when assignments or potentially mutating calls can change a value it reads.
Immediate writes changed imported components before their consumers were converted. Prepare all output first and keep source dependencies intact during in-place migration so failed and external importers can continue using them.
Template interpolation turned absent and boolean child values into literal words. Normalize dynamic text with React text semantics, including numeric zero, so every preview value reaches the string-only exporter as text.
React memo and forwardRef exports are objects, so they were silently treated as helpers. Resolve their render functions and preview props for migration and comparison, and convert the underlying source while preserving the default wrapper.
Verification ignored extra text, allowing absent values and newly visible content to pass. Check added words in sample and boolean variant renders, report them across the API and CLI, and count them as benchmark failures.
@unlayer/migrate depends on Elements through `workspace:^`, which packing turns into a caret range on the version in this file. It said 0.1.1, so a packed migrate required ^0.1.1, and npm installed the published 0.1.22, which lacks the phone settings, the no-stack fix and the root props the converter writes. The migration still passed its word check, but the emails lost their phone layout. This release changes behavior (noStackMobile now works, previewText is escaped), so it's a minor: 0.2.0. Migrate now requires ^0.2.0, and installing it fails loudly if that version isn't published yet. The release workflow still takes its version from npm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migrations render with the project's @unlayer/react-elements, else the CLI's own. An older release ignores settings the converter writes (phone layout, root props), and the check, which compares words, still passes, so the emails silently lose their phone layout. Before converting, the CLI and `compare` now read that Elements version and stop with exit code 1 and the install command when it's older than the package's peer range. A newer major line only warns. In development the range is a workspace link and nothing is checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release workflow published @unlayer/react-elements on every merge and knew nothing about @unlayer/migrate. Now: - A `changes` job compares each package with its last release tag (Elements v*, migrate migrate-v*), so a release that failed or was rejected is retried on the next push. Docs-only and other merges publish nothing. Manual runs choose the package. - Elements publishes as before. A version committed ahead of npm (a planned minor or major) now wins over the automatic bump; release notes start at the previous v* tag. - Migrate publishes after Elements, behind the same approval, with its own version, migrate-v* tags and releases that aren't marked latest. It first checks that the Elements range it will require is on npm. - Until migrate's first version is on npm (published by hand, since npm adds a trusted publisher only to an existing package), the workflow skips it with a notice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vertical space the engine couldn't put on a neighbouring row became an empty row (a column with only padding). The sent email looked right, but the visual editor shows every empty column as a "No content here" placeholder, so most migrated designs opened with placeholder bands between their sections. After rows are merged, each spacer row now folds into a neighbour that looks the same where they meet: - the same paint at every point across (column background, else the row's content background) and the same side borders: the space becomes that row's column padding; - a transparent spacer next to a row with the same band colour: the space becomes that row's padding. A row that stacks on phones takes the space on its first or last column there only, so stacked columns don't repeat it. Phone heights and per-device visibility carry over. Rows from code, background images, rounded corners and borders on the touching edge keep their spacer. Across the benchmark's 106 templates, empty rows drop from 472 to 81 (designs with one: 86 to 37) and rows from 1,192 to 782. Desktop and phone word positions are unchanged in all 212 conversions, and the editor keeps the new padding through load and save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A runnable example for the workflow the migration enables: any React Email template becomes a design the visual editor opens. - examples/react-email-in-editor: two React Email templates (Tailwind with phone styles and a loop; inline styles with a list), a `migrate` script that writes design JSON and a report, and an editor page that lists every template that passed the check and opens it with `loadDesign()`. Templates added to `emails/` show up after the next run. The README covers the same flow in a user's project, with react-email-editor and on a server. - The migration guide, the docs index, the visual editing guide and the agent rules link to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The visual editor draws every empty column as a "No content here" placeholder that also takes height: in edit mode it stretched rows (a gap column next to a product image made the whole row tall) and covered neighbouring text, so migrated designs opened looking broken even though the sent email was right. Columns that only hold space (a card's inset, a gap beside an image, the spacer rows that can't fold) now hold a Divider with no line and no padding. It has no height, so the email is unchanged, and the editor shows the design as it looks; the divider can be selected and deleted like any block. Spacers are marked so the report doesn't count them as content. Across the benchmark's 106 templates no design has an empty column left (there were 674 beside content plus the spacer rows). Desktop and phone word positions are unchanged in all 212 conversions, the editable share is unchanged, and the editor's own export of a filled design matches the unfilled one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…decides A column whose only content is a condition or a loop can render nothing, and the editor then draws its "No content here" placeholder. Give it the invisible divider after that content too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocks kept as HTML inherit the text size around them. The browser's 16px was left implicit, so the editor's canvas showed those blocks at its own 14px. Email output is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor loads and exports a web font only when it is registered when the editor is created (fonts.customFonts). The design JSON can't carry it, so text fell back to Arial in the editor and its export. - The JSON report gives each template's fonts in the editor's shape, one stylesheet per family covering every weight the run's templates use. The library returns the same list (editorFonts). - The design is built from the migrated template's PreviewProps, where JSX passed as a prop has styles instead of Tailwind classes. - The example registers the fonts, and the docs show how. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… style The editor's canvas styles every img with width: auto, which overrides a width attribute, so inline icons (social links) showed at their file's size while editing. The design JSON now also sets the width in the style; what it renders to is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
A one-command path from React Email (
@react-email/components) to Elements. Users keep their templates and gain visual editing in the Unlayer editor, plus web and PDF output. It also gives coding agents a clear route to Elements.The PR has two parts, and each commit passes the full test suite on its own:
1. Elements rendering fixes
<Row noStackMobile>now keeps columns side by side on phones. It had no effect, for two reasons:_override.mobile.noStackMobile), where the editor saves it.mapSemanticPropsnow writes it there..u-row .u-colrules overrode an earlier row's.no-stackrules of the same specificity. The no-stack rules now use.u-row.no-stack.rgb()colors render. The exporters turnedrgb()into#NaN…in Outlook'sbgcolor, and Tailwind writes every color asrgb(). Colors are now written as hex, orrgba()when translucent.styleattribute. They're written with single quotes.previewTextis escaped. Markup in it, often user data like a name, could end the hidden preheader.fonts([{ url }]).renderToHtmllinks them in the document head.textDirectionandlang.renderToHtmlputs both on<html>; the direction also reaches the content and the design JSON, as the editor stores it.mobileaccepts padding, text size, line height, alignment, image/button width and column borders. Rows and content also accepthideOnMobileandhideOnDesktop; columns don't, as in the editor. Four small editor exports are saved as CSS parity fixtures.Behavior changes:
noStackMobilestop stacking on phones._override.mobile.noStackMobile.previewTextshow as typed ("Fish & chips", not"Fish & chips")._override.mobilenow render their phone settings at the editor's 480px breakpoint.renderToJsonkeeps phone settings and visibility flags. Every stacked column gets the box's phone side padding.2. React Email migration
Packages
@unlayer/migrateis the one published migration package. It provides the CLI and thereact-emailsubpath.The CLI (
npx @unlayer/migrate):--writeor--out). All templates are converted and checked before output is written;--writeleaves files imported by another scanned file unchanged so failed or other importers can keep using them.--designwrites design JSON forloadDesign(), with text props as merge tags ({{name}}).--reportwrites the report in Markdown or JSON.0when every template passes and2when one fails, so it fits in CI.The library (
@unlayer/migrate/react-email):convertSource) keeps props,.map()loops, conditions andPreviewProps, and inlines the project's own components.convertReactEmail) renders a template for the visual editor, with text props as merge tags.verifyConversionchecks a migrated template against its original.Open in the editor.
--designmakes any React Email template an Unlayer design forloadDesign(). Spacing between sections becomes row and column padding instead of empty rows (472 → 81 across the benchmark), and a column that only holds space gets an invisible zero-height divider. The editor draws every empty column as a "No content here" placeholder that stretches its row, so migrated designs used to open looking broken; now none of the 106 has an empty column, and the sent email is unchanged. The editor loads and exports a web font only when it's registered when the editor is created, so the JSON report lists each template's fonts in the editor'sfonts.customFontsshape (the library returns them too). Blocks kept as HTML state their font size, and images in text and HTML blocks state their width in their style, since the editor's canvas would otherwise show the blocks at 14px and the images at their file's size.examples/react-email-in-editorruns it end to end: it migrates templates and opens them in the hosted editor, and the docs and agent rules link to it.@unlayer/from-react-emailand@unlayer/convert-coreare private workspace packages, bundled into migrate. Convert-core holds the shared Elements tree, report, TSX printer and content check.How each conversion is checked. The original and the migrated template are rendered with
PreviewProps, then again with each boolean prop flipped. Every word, link, image andalttext must still be there, andrenderToJsonmust get every block. No words may be added, including when a boolean prop is flipped. A template that fails isn't written. What Elements can't express natively stays as anHtmlblock that renders as before, and unsupported features and dropped styles are in the report.Results
Measured on 106 templates: React Email's own demos, MIT community templates and agent-written ones. Details are in
packages/from-react-email/FIDELITY.md, andpnpm --filter @unlayer/from-react-email benchreruns the benchmark.All 212 conversions keep exactly the baseline word coordinates at both 700px and 375px.
All 106 designs were loaded into the hosted editor (fonts registered from the report), saved and exported. All open with no errors, and the editor keeps every value the design sets. The editor's exported HTML puts every word exactly where Elements' HTML does for 98 of the 106. Of the other 8, two have real differences: an HTML block's paragraphs lose their spacing (the editor's CSS resets
pmargins), and Dropbox's text shows in Open Sans, which the template names but never loads; the editor has it built in. In three, a line or two shifts by 2–5px because the hosted editor writes140%line heights where Elements writes1.4. In three Barebone templates a few words wrap differently: their 420-weight text is drawn with a 450 face that another template's font list adds when all fonts are registered in one editor.Five converted designs were loaded into the editor, saved and exported. Every row, border, button width and no-stack setting came back unchanged. Two more designs with phone settings (90 elements) were loaded, saved and exported: every setting came back unchanged, and the editor's export applied them.
Known differences:
Htmlblocks can still break.Testing
Releasing
The release workflow now publishes each package only when its code changed since its last release tag (Elements
v*, migratemigrate-v*), each behind the existing approval. Merges that change neither publish nothing.packages/react/package.jsonis set to 0.2.0: this release changes behavior (noStackMobileworks,previewTextis escaped), and a version committed ahead of npm now wins over the automatic patch bump. Packing migrate turns itsworkspace:^dependency into^0.2.0, so it can't install the old 0.1.x, which lacks the settings the converter writes.migrate-vX.Y.Z(releases not marked latest). Until its first version is on npm, the workflow skips it with a notice.After merging:
@unlayer/migrate0.1.0 by hand frommain(pnpm packinpackages/migrate, then publish the tarball with public access), and push the tagmigrate-v0.1.0on that commit.@unlayer/migrate: repositoryunlayer/elements, workflowpublish.yml, environmentnpm-publish.npx @unlayer/migrateon a sample template in an empty folder.Not in this PR
@unlayer/migraterelease. npm adds a trusted publisher only to an existing package, so its first version is published by hand (see Releasing).🤖 Generated with Claude Code
📖 Storybook Preview: https://unlayer.github.io/elements/pr/61/