Skip to content

React Email → Elements migration (@unlayer/migrate) - #61

Draft
ivoIturrieta wants to merge 49 commits into
mainfrom
feat/react-email-migration
Draft

ivoIturrieta wants to merge 49 commits into
mainfrom
feat/react-email-migration

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

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.

npx @unlayer/migrate ./emails --report migration.md   # convert and check, write nothing
npx @unlayer/migrate ./emails --write                 # replace the templates
npx @unlayer/migrate compare <original> <migrated>    # check a template converted by hand

The PR has two parts, and each commit passes the full test suite on its own:

  1. Elements rendering fixes. Building the converter turned these up.
  2. The converter, the CLI and docs (including the packaging change).

1. Elements rendering fixes

  • <Row noStackMobile> now keeps columns side by side on phones. It had no effect, for two reasons:
    • The exporters read "do not stack on mobile" only from the row's mobile override (_override.mobile.noStackMobile), where the editor saves it. mapSemanticProps now writes it there.
    • A later row's .u-row .u-col rules overrode an earlier row's .no-stack rules of the same specificity. The no-stack rules now use .u-row.no-stack.
  • rgb() colors render. The exporters turned rgb() into #NaN… in Outlook's bgcolor, and Tailwind writes every color as rgb(). Colors are now written as hex, or rgba() when translucent.
  • Font names in double quotes no longer break the style attribute. They're written with single quotes.
  • previewText is escaped. Markup in it, often user data like a name, could end the hidden preheader.
  • Root components accept fonts ([{ url }]). renderToHtml links them in the document head.
  • Root components accept textDirection and lang. renderToHtml puts both on <html>; the direction also reaches the content and the design JSON, as the editor stores it.
  • Phone settings match the editor's device CSS. mobile accepts padding, text size, line height, alignment, image/button width and column borders. Rows and content also accept hideOnMobile and hideOnDesktop; columns don't, as in the editor. Four small editor exports are saved as CSS parity fixtures.
  • Bundle budget is now 86KB. The ESM bundle is 85,559 bytes.

Behavior changes:

  • Rows with noStackMobile stop stacking on phones.
  • Design JSON saves it as _override.mobile.noStackMobile.
  • Markup and entities in previewText show as typed ("Fish & chips", not "Fish &amp; chips").
  • Designs with _override.mobile now render their phone settings at the editor's 480px breakpoint.
  • renderToJson keeps phone settings and visibility flags. Every stacked column gets the box's phone side padding.

2. React Email migration

Packages

@unlayer/migrate is the one published migration package. It provides the CLI and the react-email subpath.

The CLI (npx @unlayer/migrate):

  • It runs in the user's project, with their React, React Email and tsconfig paths.
  • It writes nothing until asked to (--write or --out). All templates are converted and checked before output is written; --write leaves files imported by another scanned file unchanged so failed or other importers can keep using them.
  • --design writes design JSON for loadDesign(), with text props as merge tags ({{name}}).
  • --report writes the report in Markdown or JSON.
  • The exit code is 0 when every template passes and 2 when one fails, so it fits in CI.

The library (@unlayer/migrate/react-email):

  • Codemod (convertSource) keeps props, .map() loops, conditions and PreviewProps, and inlines the project's own components.
  • Runtime (convertReactEmail) renders a template for the visual editor, with text props as merge tags.
  • verifyConversion checks a migrated template against its original.

Open in the editor. --design makes any React Email template an Unlayer design for loadDesign(). 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's fonts.customFonts shape (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-editor runs 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-email and @unlayer/convert-core are 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 and alt text must still be there, and renderToJson must 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 an Html block 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, and pnpm --filter @unlayer/from-react-email bench reruns the benchmark.

Codemod Runtime
Convert and type-check 106 / 106 106 / 106
Lose or add a word, lose a link or image 0 0
Native (editable) content 98.4% (96 fully native) 98.4% (97 fully native)
Words out of place on desktop: median / mean 0% / 0.9% 0% / 1.1%
Words out of place on phones: median 0% (was 16.5%) 0% (was 13.9%)

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 p margins), 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 writes 140% line heights where Elements writes 1.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:

  • Column vertical alignment: email columns sit at the top.
  • Unsupported state variants, unresolved utilities, phone font weight and letter spacing remain in the report.
  • Phone settings use the editor's 480px breakpoint; source queries can use 600px. Narrow-box decisions use 375px.
  • Image border radius is dropped.
  • On phones, columns side by side keep their share of the row (icon gaps scale with the screen). Text whose longest word wouldn't fit gets a smaller phone size; words inside Html blocks can still break.
  • Painted cards stacked inside a column lose their own background and border (Elements paints a column once).
  • Shadows and gradients are dropped.
  • A small bordered box beside text (a numbered step badge) becomes a bordered column as tall as its row.

Testing

  • Tests: 783 passed: shared 99, react 496, convert-core 24, from-react-email 105, migrate 59. The packed consumer test checks the library in ESM and CommonJS, runs the installed CLI (with its own React 19) against a React 18 project, and checks that it stops on an Elements release older than it supports.
  • Elements checks: browser E2E checks phone padding, image width and visibility at 375px in email and web, with negative controls. The 12 editor CSS comparisons pass. Storybook smoke passes all 138 stories; visual drift passes at 2 widths, with all 137 existing stories unchanged. CSP, browser runtime, coverage and the packed Next.js integration build pass.
  • Node: tested on Node 22 and Node 25. The built CLI was also run end to end in a separate project.

Releasing

The release workflow now publishes each package only when its code changed since its last release tag (Elements v*, migrate migrate-v*), each behind the existing approval. Merges that change neither publish nothing.

  • Elements 0.2.0. packages/react/package.json is set to 0.2.0: this release changes behavior (noStackMobile works, previewText is escaped), and a version committed ahead of npm now wins over the automatic patch bump. Packing migrate turns its workspace:^ dependency into ^0.2.0, so it can't install the old 0.1.x, which lacks the settings the converter writes.
  • Migrate publishes after Elements, checks that the Elements range it requires is on npm, and tags migrate-vX.Y.Z (releases not marked latest). Until its first version is on npm, the workflow skips it with a notice.
  • The CLI also stops (exit 1, with the install command) when the project's Elements is older than the range it supports.

After merging:

  1. Approve the run: Elements 0.2.0 publishes.
  2. Publish @unlayer/migrate 0.1.0 by hand from main (pnpm pack in packages/migrate, then publish the tarball with public access), and push the tag migrate-v0.1.0 on that commit.
  3. On npmjs.com, add the trusted publisher for @unlayer/migrate: repository unlayer/elements, workflow publish.yml, environment npm-publish.
  4. Run npx @unlayer/migrate on a sample template in an empty folder.

Not in this PR

  • The first @unlayer/migrate release. npm adds a trusted publisher only to an existing package, so its first version is published by hand (see Releasing).
  • Output has been checked in Chromium only, not in real email clients.
  • MJML as a second source format.

🤖 Generated with Claude Code


📖 Storybook Preview: https://unlayer.github.io/elements/pr/61/

ivoIturrieta and others added 30 commits October 4, 2026 18:05
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>
ivoIturrieta and others added 2 commits October 5, 2026 15:14
…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

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.77%. Comparing base (b3d4e7b) to head (49d1610).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

ivoIturrieta and others added 17 commits October 5, 2026 20:33
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant