Skip to content

Tags: unlayer/elements

Tags

v0.2.1

Toggle v0.2.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: render without random numbers, so Next.js 16 can prerender emails (

#65)

* fix: render without random numbers, so Next.js 16 can prerender emails

Next.js 16's Cache Components (create-next-app's default) prerender pages and
GET route handlers when the app builds, and fail the build if a render reads a
random number or the date. 0.2.0 asked crypto.getRandomValues() for each
render's nonce, so a new Next.js app couldn't build a page, route handler or
Client Component that renders an email; 0.1.22 could.

The nonce now comes from a count of the process's renders (shared by every
copy of Elements, as the collecting stack is) and performance's
high-resolution clock, which Next.js leaves for timing. Data written before a
render still can't name it, and it never reaches the output, so rendered HTML,
designs and plain text are unchanged.

- render-isolation.test.tsx: a render calls no crypto.getRandomValues(),
  crypto.randomUUID(), Math.random() or Date.now() (fails before the fix).
- tests/next16-integration: Next.js 16 with Cache Components, a route
  handler, Server Component and Client Component prerendered; added to CI.
  Its build fails with the published 0.2.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F9N7pEyxbxtqQmAw8BgEps

* test: renders the same where the clock stands still or there's none

The nonce now comes from performance's clock and a count of renders. Where
fake timers freeze the clock, or a runtime has no performance, an email (and
one rendered inside a component of it) must render exactly as it does with a
clock. Fails if the nonce assumes performance exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F9N7pEyxbxtqQmAw8BgEps

* docs: Waku's Server Components can't render emails

Waku gives server code a react-dom/server that has no renderer, so importing
Elements in a Server Component fails (0.1.22 when bundling, 0.2.0 when the
page loads). Rendering in a Client Component works, as in its server render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F9N7pEyxbxtqQmAw8BgEps

---------

Co-authored-by: Claude <[email protected]>

v0.2.0

Toggle v0.2.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
React Email → Elements migration (@unlayer/migrate) (#61)

* feat(react-elements): render a template component as its root

renderToHtml read the root's settings (fonts, lang, dir, phone styles)
from the element it was given. React Email users write
render(<Welcome />), and renderToHtml(<Welcome />) rendered the email but
silently dropped those settings.

renderToHtml and renderToHtmlParts now unwrap a template component to its
root first, as renderToJson already did. The shared helper also unwraps
memo and forwardRef, nested in any order, for all three. A wrapper that
can't be unwrapped (React hooks, a class, an async component) still
renders through React, with a warning that its root's settings are
ignored.

The bundle budget goes to 87KB (86,697 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): check a migrated template as it's exported

The check called the inner function of a template exported with memo or
forwardRef, so it passed while renderToHtml(Template(props)) threw on the
memo object. It now calls the export itself, unwrapping memo and
forwardRef the way renderToHtml does, and the README renders templates as
JSX: renderToHtml(<Welcome name="Alex" />).

A template that calls React hooks failed with "Invalid hook call". The
CLI now says it calls React hooks, which Elements templates can't use yet,
and what to do instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): fail on style values computed from props, apply head <style> rules

A style computed from props (`style={{ color, fontSize: size }}`) was
dropped whole, so white-on-black text came out black at 14px, invisible,
while the template still passed the check with only a note.

- Known values in a style object are kept one by one, and conditions the
  codemod can work out (`level === "h1" ? 24 : 20`, `undefined ? a : b`)
  are evaluated.
- Values computed from props are listed with their line as lost styles,
  and the check fails on them (exit 2; `--force` still writes). An element
  kept as HTML keeps its style, so it doesn't count.

Rules in a head <style> were dropped too, with nothing reported, and
text styled by a class rule lost its color and size. Rules on a single
class now apply under the element's own styles, phone media queries on
those classes become phone settings, and every other rule is listed in
the report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): follow re-export files when finding shared components

With --write, a file imported by another scanned file is a shared
component and is left in place. Two cases went wrong:

- An `emails/index.ts` that re-exports templates counted as using them,
  so every template was skipped as shared and nothing was migrated.
- A template importing a shared footer through a re-export file outside
  the scanned folder didn't reach the footer, which was then migrated
  into a full email.

`export … from` lines no longer count as uses, and an import is followed
through re-export files to the modules that define the names it takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): reject a value on a flag

`--write=false` ignored the value and replaced the templates in place.
Flags now take no value: `--write=false` stops with "--write takes no
value" (exit 1) and writes nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): report an image height that isn't kept

`<Img width={80} height={20}>` showing a square file rendered at 80×80
after migration, and the report listed no difference. An Elements image
shows at its file's own aspect ratio, and the converter doesn't load the
file to compare, so an explicit height is now reported as not kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(convert-core): compare words as written and fail on added links and images

The content check lowercased words and dropped standalone signs before
comparing, and only counted links and images the conversion lost. So
"Use coupon SAVE10" passed against "save10", "Balance − 100 USD" against
"Balance 100 USD", and a template with an extra image passed as "same
words, links and images".

- Words compare in their written case.
- A lone sign is a word. Minus signs are one character however they're
  written (-, −, &minus;), and text is NFC-normalized with zero-width
  characters removed, so encodings don't fail the check.
- Links, image sources and alt text only the conversion has fail the
  check too. They compare as sets, so a renderer repeating a link (a
  button's fallback) isn't an addition.

The benchmark's 212 conversions all still pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): drop React 19's image preload links from kept markup

React 19's renderToStaticMarkup adds `<link rel="preload" as="image">`
before each image. Blocks kept as React Email markup (rendered at runtime
by the migrated template, or at conversion in runtime mode) carried those
tags into the email body and the design JSON in React 19 projects, which
a fresh react-email 6 project is.

Both modes now render static markup through a small wrapper that removes
them. The 71 official demo templates on react-email 6 and React 19 now
give the same designs as on @react-email/components and React 18.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(from-react-email): add a migration fidelity smoke test to CI

The fidelity benchmark measures 106 templates, but only locally: its
corpus isn't in the repo. A layout regression, like phone sizes showing
on desktop, was caught only when someone ran it.

`pnpm --filter @unlayer/from-react-email test:fidelity` converts the
fixture templates in test/fixtures both ways and renders them next to
their originals in Chromium at 700px and 375px, with the network off. It
fails on lost or added content, on a word shown at another font size on
desktop, or on words moved more than bench/smoke-baseline.json allows.
A new fixture covers a theme's own Tailwind phone variant and type scale.

The benchmark's word measurement moves to bench/layout.ts, shared by
both. With the phone size put on desktop on purpose, the test fails and
names the resized words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(from-react-email): compare phone layout only where the fixtures match

The smoke test's baseline held the words moved on phones for every
fixture, but for the two fixtures that still differ there the number
depends on the machine's fonts: weekly-digest moves 29% on macOS and 64%
on CI's Linux, so CI failed without any change in the conversion.

The rule is now fixed instead of measured: every fixture must keep its
content, its desktop text sizes and its desktop layout, and phones must
match for the fixtures that match there (welcome, phone-variants). A 2%
tolerance absorbs font rendering. The baseline file is gone. A phone size
shown on desktop on purpose still fails it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): don't count a spread of undefined as a lost style

An inlined component with an optional `style` prop left out spreads
`undefined` into its style object (`{ ...base, ...(undefined) }`). That
adds nothing, but the codemod counted it as a style value computed from
props, so the CLI failed a template that converts fine (one of the 106 in
the benchmark). A spread of `undefined` or `null` is now skipped.

The benchmark didn't catch it because it didn't count lost styles; it
does now, like the CLI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): call template components in a render of their own

renderToHtml(<Template />) called the template function directly to reach
its root. Called while the app is rendering (a live preview in useMemo),
a template's hooks ran on the app component's hook list: the app crashed
on its next render ("Rendered fewer hooks than expected"). 0.1.22 didn't.

Each wrapper is now called inside a throwaway renderToStaticMarkup, which
sets up and restores its own dispatcher, the way 0.1.22's nested body
render did. Its hooks run there, never on the app's component, and they
work now: a template that uses hooks keeps its root's settings in
renderToHtml and unwraps in renderToJson. An async or suspending template
gets a clear error.

Every example email, component and mode renders byte-identical to before;
only a template with hooks changes, now keeping its settings. The bundle
budget goes to 88KB (87,556 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ci): publish the commit the run tested, not the latest main

Both publish jobs checked out `main` after the approval step. If another
change merged while an approval waited, the package that shipped wasn't
the commit this run had built and tested. They now check out the run's
own commit (github.sha), and the release tag goes on that commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): count a file behind `export *` as used only when it defines the name

With `index.ts` holding `export * from "./footer"; export * from "./welcome"`
and a template importing `{ Footer }` from it, `--write` skipped
welcome.tsx as a shared component: the imported names were passed to
every `export *` source, and any file reached counted as used. A file now
counts only when it exports one of the imported names itself (a file with
no ES exports still counts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): don't replace a component that doesn't render <Html>

`--write` on a shared component given alone (a footer, without the
template that imports it) replaced it with a full Email, and the template
still importing it then rendered an email inside an email. With `--write`,
a component that doesn't render <Html> is now skipped as a shared
component. `--out` keeps writing separate copies, as documented.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep text hidden with visibility: hidden hidden

Only `display: none` counted as hidden, so `<Text style={{ visibility:
"hidden" }}>` became a visible block. `visibility: hidden` now keeps the
element as its original HTML, like `display: none`, so it stays hidden
and keeps its space.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): show an image capped by max-width at the cap

`<Img width="100%" style={{ maxWidth: "300px" }}>` shows at 300px in
React Email, but the percent width isn't a size the converter reads, so
the cap was dropped and the image filled its column (600px on desktop).
The width is now the smaller of the width and the max-width, as in CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep phone-only rules that were lost on phones

Three cases looked like desktop on a 375px phone, with no difference
reported:

- React Email 6 writes phone queries in range syntax, `(480px>=width)`,
  which the converter didn't recognize (Tailwind `screens` with `max`).
  Phone queries are now read in every form: `max-width`, `width <= …`,
  `… >= width`; anything with a lower bound still isn't one.
- A CSS comment inside a phone `@media` block dropped its rules. Comments
  are removed first.
- Content hidden with `display: none` and shown by a phone rule stayed
  hidden. It's now converted as a block hidden on desktop. A box (a
  Section) can't be hidden that way, so it stays HTML and the report says
  it stays hidden on phones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep monospace text at the browser's monospace size

Browsers show monospace text at 13px where the text around it is the
default 16px, but only while that size is the default (`medium`). A
`<code>` with no size of its own became a 16px paragraph (Notion's login
code: 13px became 16px), and blocks kept as HTML stated `16px`, which
cancels the browser's monospace size too.

A paragraph that is a lone monospace element now gets 13px, and blocks
kept as HTML state the default size as `medium`: still 16px, including in
the editor's canvas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep the color of text in tables kept as HTML

Apple's receipt showed its invoice date and order number in black
instead of #333. They sit in a Section kept as HTML, inside a div with
the inherited color, but an Elements email sets `table, td { color:
#000000 }` (as the editor's export does), which beats inheritance.

Kept markup now gives its tables and cells the color of the div around
them, unless they set their own. Both modes; word positions unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): let React Email's inline styles beat a <style> rule

`.copy { font-size: 24px }` in a head <style> showed the text at 24px
after migration, but at 14px in React Email: `<Text>` sets its size
inline, and an inline style beats a stylesheet rule unless the rule is
`!important`.

A head rule (and a phone rule) now applies only to what the component
doesn't set inline itself (measured: Text's size, line height and
top/bottom margins; Link's color; Button, Img, Hr, Container and Row
defaults), unless it's `!important`. Both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): remove names the migrated file no longer reads

A template's `const paragraphStyle = {...}` used in a `style` prop stayed
in the migrated file after its values were written into the props, so
`tsc --noUnusedLocals` failed (TS6133).

The codemod now removes what the template read but the migrated file
doesn't: constants (at the top or inside functions), inlined components,
and imports (as TypeScript would drop them), plus helpers it added itself
and didn't use. Only declarations without side effects: a constant whose
value calls a function stays. Names the template never read stay too.

Across the benchmark, migrated files went from 292 unused names to 62;
the rest are unused in the originals as well, or inline Tailwind configs
built with a call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): space a loop's items like the same blocks written out

Three <Text> blocks from `items.map(...)` sat 56px apart after migration
where React Email shows 40px: each item kept both its margins (16px top,
40px bottom) as padding, so they added up instead of collapsing.

When a loop's items share their margins and the top one is no larger
than the bottom one (Text's 16px each), each item now keeps only its
bottom margin, which is what CSS shows between two items. The loop
carries both margins, so the block above it, the block below it, the run
spacer and the column it starts all collapse with its first and last
item. Other loops, and conditions, keep their margins as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): size em padding by the element's font, report % padding

A container with `padding: '0 10%'` lost its side gutters with nothing
in the report, and em padding was always taken at 16px per em.

Padding and margins in em now use the element's own font size, as CSS
does. A %, calc() or viewport unit, which Elements can't hold in px, is
listed in the report ("style not converted: padding: 0 10%"), and so is
an em the converter had to take at 16px because the element sets no px
font size. Kept HTML keeps its own CSS, so it's not reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): report a lost style's line in the template as written

The report named the line of a lost style after local components were
inlined, so a template with components above it got the wrong line (121
for a background image on line 79).

The line now comes from a line diff of the template and its inlined
source: a line the inlining left alone keeps its own number, and a style
inside an inlined component points to where the component is used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): migrate namespace imports and templates that render nothing

`import * as Email from "@react-email/components"` with `<Email.Text>`
failed with "Element type is invalid", and a template that returns null
for one prop value failed the check with "Cannot read properties of
null". Both render fine in React Email.

The codemod now reads a namespace import as named imports (`Email.Text`
becomes `Text`, with an alias when the file already uses the name),
unless the namespace is used some other way. The check compares a
template that renders nothing for some props with an empty original: it
passes when the original renders nothing too, and fails with the missing
words when it doesn't. A template that renders nothing with its own
PreviewProps gets a message that says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): draw a card's spacer columns as a border

Migrated emails ran 5x the size of their originals, and 21 of the 106
benchmark templates went over Gmail's ~102KB clipping limit. A card
inset in a colored box became three columns ([24, 592, 24]), and every
column is tables of its own in email HTML.

Spacer columns on both sides of a row, as wide and showing one opaque
color, now become a border of that color on the columns inside them. On
phones the border is as wide as the spacers were there (their share of
the row), or none where the row stacks, so phones look as before.
Spacers stay where a border can't stand in: rounded corners or a border
of the column's own, an image or translucent color behind, one side only
(right-to-left emails show the columns the other way round). The color
behind can be the email's own background.

Benchmark: no change on desktop, phones within pixel noise, no word
moved; 18 templates over 102KB instead of 21.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep the editor's empty-column dividers out of the email

Every column that only holds space carried an invisible zero-height
divider, so the editor wouldn't draw an empty column as a "No content
here" placeholder. In the email it changes nothing but costs about 1KB of
HTML each: 22% of the largest migrated email.

The migrated code no longer has them; the designs for the editor (the
CLI's design JSON, the runtime design, and the merge-tag comparison) get
one in each empty column instead, numbered after the design's own.

Benchmark: no change on desktop or phones; 13 templates over Gmail's
~102KB clipping limit instead of 18 (21 before the spacer border), the
median size 4.3x the original instead of 5x; no design has an empty
column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Revert "fix(from-react-email): keep the editor's empty-column dividers out of the email"

This reverts commit b49ba7a. With the dividers only in the CLI's design
JSON, a migrated template opened in the editor through `renderToJson`
(the usual Elements way) showed its spacer columns as "No content here"
placeholders again. The size it saved isn't worth that; Elements adding
the divider itself would be the way to drop them from the email.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): render Heading and Button text as text, and keep link URLs in their attribute

`<Heading>{name}</Heading>` and `<Button>{name}</Button>` wrote a string
as HTML, so markup in a value (a user's name) became live markup; a
Paragraph escapes it. A link URL with a `"` ended its href attribute. Both
were already in 0.1.22; a migration moved React Email templates, which
escape both, onto them.

- Heading and Button children are escaped like Paragraph's. The `text`
  prop still takes HTML.
- The Button exporter unescapes its text before writing it, where the
  editor's canvas shows it as the HTML it is: the text is escaped once on
  the way to it, so it renders as stored.
- `"`, `<` and `>` in a link URL (Button, Image and Video links, menu
  items) are percent-encoded on the way to the exporters, as a browser
  would.

The migration keeps a button label with markup in `text`, and its check
now also renders every string prop with `<i>x</i>&"` added: the migrated
template must show it as text, as React Email does.

The bundle budget is 89KB (88,154 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): let an !important <style> rule win over the template's own style

A class rule like `.notice { color: red !important; font-size: 40px
!important }` on a Text with `style={{ color: "#fff", fontSize: 14 }}`
shows red at 40px in the browser; the migration kept the inline white at
14px, which could make the text invisible, with no difference reported.
The earlier fix made React Email's own inline styles lose to an
!important rule, but not the template's.

Both modes now put a head rule marked !important over the element's own
style too; an important `margin` or `padding` also wins over the inline
sides it covers. Rules that aren't !important still lose to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a phone class that only one branch of a condition has

`className={hide ? "mobile:hidden" : ""}` became one static, empty class
list: branches that differ only in variant classes were merged, keeping
only spacing, from before phone styles converted. With `hide` true the
original hides the text on phones and the migration showed it, and the
check passed.

Branches whose phone classes differ in more than spacing are now split
like branches whose styles differ, so each keeps its own (`hide ?
<Paragraph hideOnMobile /> : <Paragraph />`). Spacing-only differences
and state variants (`hover:`, `dark:`) merge as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): only write in module values that can't change

A module-level `let destination = "…"` that an exported `configure(url)`
reassigns was written into the migrated button as its first value, so the
button kept linking to the default after `configure` ran.

Only a `const` is written in now; anything else stays as code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): render Fragments and user components among blocks like the blocks they hold

With a Fragment or a user component (`<Header />` returning a Row) before
a row, the blocks inside it lost the context Body threads to its rows:
their ids restarted at 1 and repeated other blocks' ids, so a `mobile`
setting on one paragraph resized another (the phone CSS targets ids), and
a component's row rendered in web mode inside an Email. renderToJson left
them out of the design.

Body, Row and Column, the head's phone CSS and renderToJson now expand
Fragments and user components among their children the same way, so they
all see the same blocks in the same order. A user component is called
once, in a render of its own (as a template component is), so its hooks
work; `memo` and `forwardRef` are unwrapped. Children without them are
passed on untouched: their output is byte for byte the same.

The bundle budget is 91KB (90,340 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): rerun cleanly, keep flags out of values, and say what to do with a .js template

- Running `--write` again on migrated templates failed (exit 2, the
  converter can't read Elements code) for any that kept an HTML block,
  and exited 1 when none did. Templates importing @unlayer/react-elements
  are skipped as already migrated, so a second run (in CI) passes.
- `emails --report --write` wrote a report named `--write` and migrated
  nothing, with exit 0. A value option now refuses the next flag as its
  value (exit 1).
- A dry run listed a default-exported shared component (no <Html>) as
  migrated, where `--write` skips it. A dry run now says what `--write`
  does; `--out` still writes copies, as documented.
- A .js template with JSX failed with "Unexpected token '<'": the CLI now
  says to rename it to .jsx.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fail the check for a heading level, image width or classes computed from props

`<Heading as={level}>` rendering an h3 became an h1, `<Img width={logo}>`
lost its width (64px showed at its full size), and a class list built from
a value (`text-${tone}-500`) was dropped, each with a passing check and at
most a note.

Like a style computed from props, each now fails the check, naming it with
its line (`--force` writes anyway). A class list that is a value or
nothing (`quiet ? undefined : "mb-9"`) is still only noted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): take color: inherit as the color around it

`<Text style={{ color: "inherit" }}>` inside a dark footer kept
`color="inherit"`, which an Elements email resolves against its
`table, td { color: #000000 }`: black text on black. Both modes now use
the inherited color for `inherit` (and `currentColor`), and an inherited
style isn't replaced by the word `inherit`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(convert-core): don't read a block hidden on desktop in the content check

Elements hides a block on desktop with display:none on its table. The
check skipped hidden text elements but not tables, so content a
template shows only on phones, converted correctly, failed the check as
extra text. Hidden elements are now skipped with everything inside them,
nested tags included; the preview text is still read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fill the page with a gradient background's color

A Body with `background: linear-gradient(...)` and no plain color became
a white page, so light text on it couldn't be seen. Elements can't show
the gradient, so the page takes its first color when every stop is an
opaque color. A gradient of several colors is noted in the report, and
so is one that fades to transparent, which leaves the page white.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): start text, buttons and images on the right in right-to-left templates

Elements writes text-align: left unless told otherwise, so a template
with dir="rtl" and no alignment of its own showed its text on the left,
where the original shows it on the right. In a right-to-left template,
text, buttons and images with no alignment now sit on the right, as in
the original. An alignment the template sets is kept, and left-to-right
templates don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): inline components imported through a re-export file

A template that imported its layout from an index.ts that passes it on
(`export { default as Shell } from`, `export *`, or an import exported
again) kept the whole template as one HTML block, 0% editable. The
inliner now follows re-export files to the module that declares the
component, and inlines it as it does a direct import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): follow a barrel that imports a component and exports it again

With --write, a layout reached through `import Shell from "./Shell";
export { Shell }` (or `export default Shell`) wasn't counted as shared:
it was replaced with a migrated email, and a template that still
imported it rendered through that. Such a barrel now counts as passing
the name on, like `export ... from`, and a namespace passed on
(`export * as Parts`) uses every name of its module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): check the Elements version where the templates are

The check looked in the folder the command ran in. Run from a monorepo's
root, it found no Elements there and fell back to its own, so an app on
an older release wasn't stopped. It now checks each template's folder,
where the migrated templates load Elements from, and says each problem
once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): give a heading the line height it inherits

React Email's Heading sets no line height, so in the browser it inherits
one: `lineHeight: 2` on Body makes a 60px heading's lines 120px. The
converter gave headings `normal`. They now inherit it, and a line height
set in % or em passes on in px, worked out with the font size where it's
set, as CSS does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep link targets, and report attributes blocks can't hold

Links and buttons opened in a new tab even when the template set
`target="_self"`: they keep the target now, a button's in its link. An
`id`, `title`, `role` or `aria-*` attribute on a text, heading, button
or image has no place in an Elements block, and is now listed in the
report, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): report phone styles Elements can't hold

A phone rule in a <style> tag that set a color (or a font weight, or
letter spacing) was dropped without a note. Elements holds padding,
margins, font size, line height, alignment and hiding on phones; the
rest is now listed in the report, in both modes. Runtime mode now also
lists the Tailwind phone classes the codemod already reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): show text placed straight in a Row or Column as text

A string in a Row or Column was written into the HTML as markup. With
Fragments and user components among blocks expanded, that also reached
text in a Fragment (`<Column><>{name}</></Column>`) and a component that
returns a string, so markup in user data could run in a web page. Such
text is escaped now, as React and Paragraph do, with a warning that
points to Paragraph for text and Html for raw HTML; the editor's design
never kept it. A string written straight in a Row or Column, which used
to be inserted as HTML, shows as text too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): align right-to-left text when dir comes from props

The codemod read only a literal `dir`, so `<Html dir={direction}>` kept
its text on the left. Each block with no alignment of its own now takes
it from the same expression: `textAlign={direction === "rtl" ? "right" :
"left"}`. An expression other than a name is worked out once, in a const
before the return, unless the email is one branch of a condition, where
the expression may only hold in that branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): record what components render for one render only

What a user component rendered was kept, per element object, for the
life of the process. An element made once and reused (`const footer =
<Footer />` at module level) kept its first output: a second render, for
another user, showed the first user's data. The record now lasts one
renderToHtml, renderToHtmlParts, renderToPlainText or renderToJson call,
where it keeps the blocks, ids and phone CSS of that render in step. The
bundle budget is 92KB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): keep the body's settings off HTML elements from a component

With components among the body's blocks expanded, a component that
returns an HTML element (`<div dangerouslySetInnerHTML>`) got the
settings the body passes its rows, and React wrote them as attributes:
`_config="[object Object]"`, with a warning. Only components get them
now; an HTML element renders as it did in 0.1.22.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): write only ltr, rtl or auto as the text direction

The root's `textDirection` reached the exporters as given, and they
write it into a `dir` attribute unescaped: a value from a request
(`rtl"><img src=x onerror=...>`) became live markup in an email. The
direction is now ltr, rtl or auto (any case); anything else is left out,
where it's read from the root and where it reaches the exporters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(react-elements): templates that use hooks are unwrapped

The renderToHtml JSDoc and the visual-editing guide said a template that
calls React hooks can't be unwrapped. Templates are called in a render
of their own, so their hooks work: only a class component, an async one,
or one that doesn't return an element stays as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep content a <style> class hides hidden

An element hidden by a class rule in the template's <style>
(`.hide{display:none}`, the dark logo of a dark-mode pair) was kept as
HTML whose class had no stylesheet any more, so it showed. The check,
which read only an inline display:none as hidden, counted it in the
original too, so it passed. Kept markup now carries that hiding inline,
in both modes, and the check reads elements a top-level class rule
hides (an !important rule over their own style) as hidden in both
documents. Rules in media queries, like dark mode, apply only sometimes:
they don't count, and they stay in the report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep an !important <style> declaration over a later plain one

`.copy{color:#000 !important} .copy{color:#fff}` made the text white on
white: the later declaration replaced the earlier one. Within a
stylesheet and across <style> tags, a declaration no longer replaces an
!important one unless it's !important too, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a quoted font name inside an inline style attribute

An inline style with a quoted font (`fontFamily: '"Inter", sans-serif'`,
or Tailwind's font-mono stack with "Liberation Mono") was written into
style="..." with its quotes as they were, which ended the attribute: the
styles after it were lost. Quotes in attribute values are written as
&quot; now. Two benchmark templates get their code's monospace font and
weight back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fail the check for a module const changed after it's declared

`const theme = {...}; theme.color = "#0000ff"` was written in with its
first value: another color, and the check passed. A const that's
changed (a member assigned, ++, delete, Object.assign, an array method
that changes it) isn't written in now, so a style that reads it fails
the check, as a style the codemod can't keep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fail the check for a column's or a linked image's width from props

`<Column width={wide ? 500 : 100}>` became two equal columns, and an
image's computed width inside a Link was written as one value, with the
check passing. Both are lost styles now, which fail the check, as a
plain image's computed width already did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a prop written as HTML out of merge tags

In runtime mode's design, a prop rendered as raw HTML (a footer through
dangerouslySetInnerHTML) became `{{footerHtml}}`, so its unsubscribe link
left the design: the comparison read entities as characters, so markup
and escaped text looked alike. A prop whose sample's markup is markup in
the output keeps its value now; one shown as text still becomes a tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): never replace a layout with --write, and follow jsconfig paths and workspace packages

With --write, a shared layout was replaced with a migrated email when the
scan couldn't follow a template's import of it: jsconfig.json paths
(Next.js) weren't read, and a workspace package linked into node_modules
counted as an outside package. jsconfig.json is read like tsconfig.json,
to load templates and to scan them, and a linked package is followed to
its source. As a net under both, a component that renders its children
(a layout) is left in place with --write, like one that doesn't render
<Html>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): accept React 18.0 to 18.2 without a second copy

migrate's own react and react-dom ranges started at 18.3.1 while its
peer range is >=18, so a project on React 18.0-18.2 got a nested React
19, and convertReactEmail from the library entry failed with two Reacts.
They start at 18.0.0 now; React 18.3 added no API the package uses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ci): publish migrate with a current Elements range, and never publish older code

Packing migrate wrote its Elements range from the committed
packages/react version, which CI bumps only on its runner: after the
first Elements release the range stayed ^0.2.0. It's now the Elements
the run just published, or npm's latest, and migrate republishes after
each Elements change. A commit that doesn't descend from the last
release tag (an older run re-run) publishes nothing, a manual release
runs from main only, and the workflow's default token only reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): keep line breaks in Heading and Button text

Escaping Heading and Button children (so a value like a user's name
can't inject markup) also turned a line break written in a string,
`{"The Last Light<br/>on the Dolomites"}`, into visible text: five
stories showed `<br/>`. A `<br>` in their text stays a line break now,
as it was, and so does a JSX `<br />`, which used to be dropped. All
other markup still shows as typed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): escape menu text in email, and social links, and test every user value for injection

A new test gives every value that often comes from users (text, links,
image sources and text, menu items, social links, preview text,
direction, language, font URLs, the title) markup in all three modes,
and fails when the parsed output has an element or attribute it added.
It found two holes that 0.1.22 also has: the email exporter writes menu
item text as it is, and social links went into their href unencoded.
Menu text is escaped in email now (the web and document exporters
already escape it), and social links are encoded like other links. The
bundle budget is 93KB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(react-elements): compare with the previous release, and snapshot the patterns that broke before

The realistic examples render with this build and with the last
published Elements (an npm alias, @unlayer/react-elements-previous):
their designs and plain text must be the same, and their HTML matches
reviewed snapshots, so any change to existing users' output fails until
it's updated and reviewed. The ways templates are put together that
broke before (Fragments, components returning arrays or null, hooks,
memo and forwardRef, a reused element, HTML from a component, text in
rows and columns, right-to-left, device hiding) have snapshots too, and
render-isolation tests check that a render never leaks into the next
and that the same input always gives the same output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(react-elements): compare every story's text in the visual-drift gate

The gate fingerprinted computed styles only, so a story whose words
changed passed it: five stories showed `<br/>` as text and nothing
failed. It records each story's visible text too now, a line per line
of text, so a change in the words fails as a change in style does.
Regenerating the baseline added text lines only: no story's styles
changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): give each component its own useId() values

Components placed among a Body's, Row's or Column's blocks are each called
in a render of their own, so useId() started over in each one: two sibling
components got the same id, and a link to one anchor could land on the
other's. Each call now gets its own identifier prefix, counted per render,
so ids are unique in the document and the same on every render. Body opens
the render scope itself when it's rendered straight through React.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ci): check again in each publish job that no newer commit was published

The check that a run's commit descends from the last release ran only in
the `changes` job. Re-running an older run's failed jobs reuses that job's
result from before a newer commit was published, so the older code could
be published as the newest version. Each publish job now checks again,
just before publishing, where publishes run one at a time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a project's own Button, and a button's text-transform

A capitalised tag was read as the React Email component of the same name,
so a project's own `Button` (or `BrandButton as Button`) became a plain
Elements button: its colors and padding were lost, and the check, which
compares words, passed. Only lowercase HTML tags are read as React Email
components now; a project's own component is kept with its styles.

A button's text-transform, italics and underline had no Elements prop and
were dropped ("TRACK IT" showed as "Track it"). They now stay on a span
around its text, as for Text, in the codemod and the runtime converter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): load only templates, and let templates listed in an index be written

Checking a template runs its code and calls its default export, and any
file importing React Email was checked, even in a dry run: a helper whose
default export sends an email was called. A file is loaded now only when
its default export is a component (not async, returning JSX or with
PreviewProps), read from the source; others are reported as skipped and
never run.

A template that called process.exit ended the run with its code, so a run
with failed checks could exit 0, and a timer a template started kept the
run open. process.exit now throws while templates run, failing that
template, and the run exits once its output is written.

With --write, a template imported by any scanned file was left in place,
so an index that imports the templates and exports them again (or a
helper that sends them) kept every template from being written, while the
dry run reported them as migrated. Only a file that renders what it
imports keeps it in place now, and the dry run reports the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): render a component's plain HTML in a Column, and give an element used twice its own ids

A component returning plain HTML (`<div dangerouslySetInnerHTML>`) inside a
Column was expanded into that div, which a Column skips, so its HTML (a
footer with an unsubscribe link) disappeared; the previous release rendered
it. Such a component is kept as it is now, and a Column renders its HTML as
before.

What a component rendered was recorded by element, so an element placed
twice (`{note}{note}`) rendered once and both places showed the same
useId() values. It's recorded by the place the element is in now: each
place is rendered, with its own ids, and still once per render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): keep phone settings from ending the head's style element

A phone setting (`mobile={{ fontSize }}`, padding, line height, alignment,
a column's border) is written into the head's CSS as is, so a value like
`16px</style><script>…` ended the style element and ran. The same value
as a desktop setting was already escaped by the exporters. A phone value
that could end the declaration, the rule or the style element is dropped
now, and a `</style` in the head's CSS can no longer end the element.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): write templates that a Next.js route sends

With --write, a template was kept in place when any file with JSX imported
it, so a Next.js route that sends it (`react: <Welcome />`) kept it from
being written. Only a template or a component that renders what it
imports keeps it in place now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(convert-core): compare text styles in the content check, and read hidden text as a browser does

The check compared the words, links and images two documents show, so a
conversion that changed how text looks passed: colors, sizes, bold,
italics, letter case, underline, the background behind text, a link's
target. It now reads both documents with a small CSS cascade (inline
styles, the stylesheet rules that apply at desktop width, the browser's
defaults and inheritance) and compares each word's style. A property it
can't work out (a selector or value it doesn't know) is left out, never
reported as different, and the check says how much it compared.

The same cascade decides what's hidden, so the words, links and images
read are the ones a browser shows: the hidden attribute, a box that clips
to nothing (`max-height:0;overflow:hidden`), visibility:hidden and opacity
0, compound selectors and rules in media queries that apply at desktop
width. On the 106 benchmark templates the words, links and images read
are the same as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep what the style check found changed: link targets, boxes in columns, list size, bold button labels

- A plain <a> kept its target (none); only React Email's Link opens in a
  new tab by default. A link whose URL prop is missing renders no href,
  as React renders it, instead of href="undefined".
- A box with a background or a border inside a column of several can't
  nest in Elements, and its content was poured into the column without
  it: it's kept as HTML now, as it renders.
- A list takes the size around it (the browser's 16px), not Text's 14px.
- In runtime mode, markup in a button's label (a bold word) is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep text styles from what's around, sizes relative to their parent, !important lengths, and hidden text

- Italics, letter case and underline a container sets (a Section with
  font-style or text-transform) reach the text and buttons inside it, on
  a span, as the element's own do.
- A heading stays bold when the body sets a normal weight: its own
  weight beats the one around it, as the browser's styles do.
- em and % font sizes are worked out against the parent's size (a 2em
  section in a 20px body), not 16px; rem against 16px. A Container's em
  width is relative to its own font size (37.5em in a 14px body is 525px).
- !important lengths are read (Tailwind's !p-6, a `24px !important`
  shorthand) instead of becoming 0.
- Hidden text stays hidden: the hidden attribute (from props too), opacity
  0, and a box that clips to nothing (max-height:0; overflow:hidden).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep image borders, widths and link targets, translucent row backgrounds and background shorthands

- An image with a border is kept as HTML: Elements images have none, and
  dropping it changed the image's size.
- A CSS width beats the width attribute, as in the browser (`width="608"`
  with `w-full` fills its column), and a percent width stays a percent.
- A linked image keeps its link's target (a plain <a> opens in the same
  tab, React Email's Link in a new one), and so does a button whose URL
  comes from props.
- A translucent background (`bg-black/80`) on a row is written as
  #rrggbbaa: an Elements Row drops rgba() backgrounds, and white text
  showed on white.
- The `background` shorthand's color, repeat and position are read
  (`#f4f4f4 url(…) no-repeat center`), not only a background that is a
  color alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): apply class rules in stylesheet order, keep each return's head styles to itself, and refuse styles it can't keep

- Class rules are merged as the browser does, by !important and then by
  where they are in the stylesheets, not by the order of the class
  attribute (`class="alert muted"` took the wrong color; `class="show
  hide"` hid visible text), in both converters and in kept HTML.
- A phone media query's class rules are read even when the block also
  holds another selector (a client hack), and a column a head <style>
  class stacks on phones stacks in the codemod too.
- Each return's head <style> rules apply to that return only.
- A class list spread onto an element (`{...shared}`) is resolved, and a
  class-list call (`cx`, `clsx`, `cn`) keeps its fixed classes; the ones
  it picks from values are reported, so the check fails.
- A <style> whose CSS comes from code or sits under a condition, and a
  <Tailwind> config built from props or the component's own values, are
  refused with the reason: the migrated template can't keep them.
- A style object changed through another name (`alias.color = …`) isn't
  written in as its first value.
- The check reports style differences for flipped boolean props too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): refuse to replace files in --out that the run didn't produce

--out silently replaced a template or design JSON already in the output
folder. A destination that exists and isn't the template itself (--write)
or the report now stops the run before anything is written, unless
--force. A file that appears there during the run isn't replaced either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): fail a template that isn't UTF-8, and keep CRLF line endings

A source saved in another encoding (a Latin-1 é) was read as U+FFFD and
written back corrupted: it now fails with a reason and isn't written. A
template whose lines end in CRLF is written with CRLF, not LF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): stop on any Elements version outside the supported range

0.2.0-beta.1 passed a ^0.2.0 check, and 0.3.0 or 1.0.0 only warned. The
check now reads the range as npm does: a newer line is an error with the
install command, like an older one, and a prerelease is accepted only by
a prerelease range of the same version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): remove the check's temporary copy however the run ends

The copy of a migrated template that a check loads stayed next to the
template when the run was interrupted (Ctrl-C, a kill). Temporary files
are now removed on SIGINT, SIGTERM and SIGHUP, and when the process
exits, as well as on errors. A template in a folder the run can't write
to fails with that reason, not as a migrated template that doesn't render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): finish the run when a template throws from a timer or a promise

A template that called process.exit from a timer, or threw in a promise
it didn't wait for, threw outside any check: the run crashed with no
summary. The command now collects those errors while it runs, prints the
summary, names each one, and exits with 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): load only React's classes, and fail templates wrapped in a call

A default-exported class with a render() method was loaded in a dry run
even when it wasn't a React component (a mailer): only a class that
extends React's Component or PureComponent is loaded now. A template that
renders <Html> but whose default export is wrapped in another call
(withTracking(Welcome)) was skipped as a helper and the run passed: it
now fails, saying to export the component itself. Helpers stay skipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): follow imports TypeScript can't resolve the way Node does

With --write, a shared template that a failing one imports was replaced
when TypeScript's resolver couldn't follow the import: a package's
exports with moduleResolution node, a #imports path, or no tsconfig.
Imports TypeScript can't resolve now fall back to Node's resolution from
the importing file, so the shared file is still seen as imported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): find templates that use React Email through the project's components

Only files importing @react-email/* or react-email were candidates, so a
template that imports a local ui file wrapping or re-exporting React
Email was never seen: the run said no templates were found, or passed
without it. A scanned file that passes the template check and imports a
project file that imports React Email, directly or through files that
pass it on, is now a candidate too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): resolve React Email and Elements where each module is, in a monorepo

The module hooks resolved React, React Email and Elements from the
current folder for every module. Run from a monorepo's root, an app's
templates were checked with the root's Elements while the version check
approved the app's; run from the app, a workspace package's TSX outside
it was compiled without the JSX runtime (React is not defined), and the
error blamed the migrated template.

- React Email and Elements now resolve from the importing module's own
  folder first, then the project, then this package. React and React DOM
  stay one copy, the project's, for every module, so hooks and context
  keep working.
- The project is the folder of the first template given, not the current
  folder, so the converter renders with the same copies as the app.
- Before loading anything, a run whose templates (or their --out copies)
  would load another React Email or Elements than the converter stops,
  naming the folder to migrate on its own.
- The JSX settings also cover the workspace root and the real paths of
  the folders they list.
- When the original is what doesn't render, the reason says so, and
  names a file compiled without the JSX settings.

The module order is shared by the hooks, CommonJS requires and the check
(resolution.ts); the argument parser moves to args.ts for bin.ts to read
where the templates are before the CLI loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): give nested text props markup in the check too

The check gave markup and &" only to top-level string props, so a
migrated template writing user.city or items[0].name into HTML as is
passed. Strings inside plain objects and arrays now get it too; React
elements, functions, dates and other objects are kept as given. migrate
compare reports such a template.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(migrate): make the example run on a fresh clone, and describe the new checks

- The example's migrate script ran the unlayer-migrate bin, which pnpm
  can't link on a fresh clone (the CLI isn't built when it installs): it
  now runs the built CLI, after pnpm build, and empties output/ first so
  running it again doesn't stop on the last run's files.
- The root README lists @unlayer/from-react-email as private, bundled
  into @unlayer/migrate, not as unpublished.
- The migrate README describes the checks added here: files already in
  the output folder, the supported Elements range, templates found
  through the project's components, wrapped templates, monorepos,
  encodings, interrupted runs and errors thrown after a render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(react-elements): render a migrated template as JSX in the migration guide, and note what --out won't replace

The guide called the template as a function (`Welcome({ name })`) while the
migrate README renders it as an element; it uses `<Welcome name="Alex" />`
now. It also says --out doesn't replace files already in the output folder
that the run didn't produce, unless --force.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): fail the check when text is shown in another style, and say which

The check now compares text styles (size, bold, italics, letter case,
underline, color, the background behind it, a link's target): a template
whose migration shows text in another style fails it and isn't written
unless --force. The line, the Markdown and JSON reports, and `compare`
list each change with the words it's on, for the preview props and for
each flipped boolean prop, and the report says how many words the styles
were compared on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep what the codemod can't convert as it renders, and refuse what it can't keep

- A component it doesn't convert inside text (an i18n <Trans>, one with
  hooks) is rendered where the template renders, not written into the
  HTML as a tag.
- JSX from a value (`{banner}`, a prop typed ReactElement or given JSX in
  the preview props) is kept as HTML, as it renders, not read as text.
- A component that goes through its children one by one (`Children.map`)
  isn't inlined: its children stayed one fragment, and two columns became
  one.
- An inlined component brings the types it needs (an imported type, a
  type, interface or enum of its module), so the migrated file
  type-checks.
- <Html> inside a component of the template's own (a context provider)
  is refused: the migrated root is <Email>, and the wrapper would go.
- An empty fragment return (`<></>`) stays as it is, rendering nothing,
  not an empty email; the check treats it like null.
- A heading margin from props (`m={margin}`) is reported as lost.
- HTML entities in JSX attribute strings are decoded, as JSX reads them
  (`&amp;` in a URL or alt text reached the design).
- `${` in a fixed attribute stays text, and the internal slot markers use
  characters a template's own strings don't hold (`"§13"` was rewritten).
- <Font fontWeight="bold"> asks Google Fonts for weight 700.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): make a text prop a merge tag only when any value shows as given

A prop became a merge tag when its marker came through intact and the
sample values gave the same output, so a change the sample didn't reveal
passed: `name.split(" ")[0]` of "Jordan", `title.slice(0, 40)` of a short
title, a capitalized first letter of "Alex". The design then held a plain
{{name}} the template changes. The template is also rendered with other
values (a lowercase first letter, more words, longer), which must show
exactly as given; a prop that doesn't keeps its sample value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep the migration's names clear of the template's, and JSX constants out of loops that would capture them

- The helpers the migrated file gets (escapeHtml, htmlText, htmlAttribute,
  renderToStaticMarkup) take another name when the template uses theirs.
- An Elements component the migration imports gets an alias when the
  template declares that name anywhere (a `Paragraph` inside the
  component, a prop named `Button`), not only at the top of the file.
- A JSX constant isn't written in where a loop's or a catch clause's
  variable (or a function or class in a block) of a name it uses would
  capture it.
- An array of JSX (`rows.push(<Text>…</Text>)`, `Element[]`) shown as
  `{rows}` is kept as HTML, as it renders, not read as text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: describe the migration check's style comparison

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep kept HTML's table and paragraph defaults in the editor's export

The editor's exported email sets `table, td, tr { border-collapse: collapse }`
and `p { margin: 0 }`. Markup kept as an Html block wasn't written for either:
a table lost its padding, rounded corners and cell spacing (and showed borders
set on its rows), and a bare paragraph its 1em margins. Kept markup now states
the browser's defaults: `border-collapse: separate` on tables that don't set
it, and the margins a paragraph doesn't set. The original and the Elements
render already show these, so only the editor's export changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Fix converter and check gaps: backgrounds, hidden text, columns, targets, hooks

The converter:
- fills a section's or a button's gradient with its first color (white text
  landed on the white page), and takes <Html>'s background when the Body
  has none;
- keeps text only screen readers get hidden (sr-only, placed off the page);
- keeps a column's width when it's shown under a condition next to others:
  the row's cells follow the condition;
- keeps a link target from props on buttons and linked images, and an inline
  Link's _blank when the prop is left out;
- keeps a class list a condition picks against no class (one element per
  class list), and fails the check for one it can't follow (tones[level]);
- doesn't inline a component whose prop default reads a name the template
  uses for something else;
- leaves out a style value from props that could end the span's attribute;
- reports a Body background image and a Container width it can't keep;
- calls templates and their components in a render of their own, so
  templates that use hooks migrate.

The check reads a gradient of opaque colors as its first stop, reports a
url() image lost behind text unless the color under it is kept, counts
<html>'s styles, and treats screen-reader-only text as hidden. The runtime
report keeps the style differences it computes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* CLI: fail a run that can't go on, add --overwrite, read migrated from imports

- A run that hits an error it can't recover from prints it and exits 2: it
  exited 0 having checked nothing. A file it can't read (a link to a file
  that's gone) fails that file, and the others still run.
- --overwrite replaces files at a destination the run didn't produce (a
  design file from an earlier run). --force only writes templates that fail
  the check: it did both, so following its hint could write those too.
- A template counts as migrated when it imports Elements, not when a comment
  names it.
- A component that renders optional children but has PreviewProps is an
  email of its own: --write writes it instead of leaving it as a layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Elements: call user components in the render React runs; fix plain-text entities and ids

- A user component in a Column is called in the render React runs, as in
  0.1.22: a separate render reset that render's hooks, so a component with a
  hook that renders plain HTML (<div dangerouslySetInnerHTML>) emptied its
  whole Column, and a Provider around a Row wasn't seen. Its HTML is read
  from that one call. renderToJson, which runs outside a React render, still
  calls components in a render of their own.
- renderToPlainText uppercases an h1/h2 around its entities: Q&A, not
  Q&AMP;A.
- renderRowToJson gives the same ids every call; template components inside
  each other get their own useId() ids; a phone value can't open a CSS
  comment that hides the phone rules after it.
- previous-release-patterns.test.tsx renders the ways users write their own
  components (hooks, context, useId, Fragments, memo/forwardRef, a template
  component, entities) with this build and the previous release.
- The bundle budget is 95KB (93,706 bytes now).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Check: fail closed on what it can't read; read sm:, variables and :not(); compare phones

The check passed whatever its cascade couldn't evaluate, so a converter
mistake there passed too. Now:
- A style, or whether words show, that one side sets in a way the cascade
  can't read is "unverified", with the rule, condition or value: the check
  fails on it. Unknown on both sides the same way (markup kept as it was)
  isn't.
- It reads what React Email writes: Tailwind's sm: as @media nested in the
  rule (range syntax), custom properties (an unset one drops the declaration,
  as in the browser, so a Tailwind gradient draws nothing), :not() and the
  structural pseudo-classes, and all 148 named colors. It drops a selector
  the browser can't read (React writes `.footer > p` in a <style> as
  `.footer &gt; p`), which it read as a rule for every p.
- Text over a gradient that fades must be on a color the gradient shows.
- A font size of 0 hides only the text it sets, not a child with a size.
- Both documents are read at a phone's width too: the same words must show.
- The converter writes only colors the browser reads (none, initial and a
  background shorthand's keywords were written as colors).

The 106 benchmark templates all pass with nothing unverified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Check: compare where text sits across the page; center sized blocks an align attribute places

The check didn't compare layout, so a converter that stacked side-by-side
columns, o…

migrate-v0.1.2

Toggle migrate-v0.1.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
@unlayer/migrate 0.1.2: React Email's current demo, and a regression …

…run on real projects (#67)

* migrate: 0.1.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Write a box's background image with a computed URL as code

`<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]>

* Skip a Tailwind config the templates share, instead of leaving it as 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]>

* Print the version with --version

`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]>

* Say a usage mistake in two lines, not with the whole help after it

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]>

* Say who wrote a file already in the output folder

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]>

* Quote words as the templates write them

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]>

* Don't tell a dry run to write when there's nothing to write

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]>

* Name a Tailwind class the migration doesn't keep as the template writes 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]>

* Text from a variable given JSX in a branch keeps its markup

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]>

* What a function of the file's own returning JSX gives is shown as elements

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]>

* Quote words without the characters that can't be seen in them

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]>

* Say an earlier version wrote a file it kept no hash for

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]>

* Point to --help after a path that isn't there, as after other mistakes

`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]>

* Say the version on any Node.js, before the version check

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]>

* Name files to delete before removing React Email only once every template 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]>

* Report editability from the design the editor gets

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]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

migrate-v0.1.1

Toggle migrate-v0.1.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
@unlayer/migrate 0.1.1: fixes from a field test on real projects (#66)

* migrate: --overwrite keeps a migrated template edited since a run wrote 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]>

* Compare what inline SVG icons draw in the content check

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]>

* migrate: say which Node.js the CLI needs instead of failing every template

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]>

* Align an inline box's text by its block, not its own text-align

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]>

* Read a hidden preheader as the preview text without data-skip-in-text

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]>

* Count text that can't be made out on one side as content

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]>

* List a second document inside the migrated body as content

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]>

* Compare the page's background around the email as a look

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]>

* Read a second document's bare <html> and <body> tags as a look

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]>

* Migrated templates no longer import react-dom/server

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]>

* Don't claim text on a table's background image is unreadable

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]>

* migrate: take the CommonJS resolution out after each template load

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]>

* migrate: say how to install Elements after writing migrated templates

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]>

* migrate: test a dropped prop style on text that stays visible

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]>

* migrate: name the page's own style change instead of a dangling " on "

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]>

* Fold the code inlining writes into code that type-checks

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]>

* migrate: list the project's files that render a migrated template, not 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]>

* migrate: list a file with no default export the same way wherever its 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]>

* Kept markup: no React 19 image preloads, no doubled style properties

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]>

* migrate: name the kinds of difference in the console line

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]>

* Don't give a style to a kept component that takes none

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]>

* migrate: --strict fails a template with differences the migration lists

--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]>

* Keep an inline SVG's attributes in text and button labels

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]>

* migrate: warn that --design writes nothing without --write or --out

`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]>

* migrate: say a report named other than .md or .json is written as Markdown

`--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]>

* Inline layout components given JSX or a call's value

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]>

* migrate: count only email templates in the summary

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]>

* migrate: name the file compare couldn't load, and say where to run it

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]>

* migrate: say "1 value couldn't be worked out" in the report, not "1 values"

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]>

* Read React Email components imported through a project's own file

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]>

* JavaScript templates: read module constants, no crash on JSX text code

The converter reads a template through a TypeScript program of that file
alone, created without `allowJs`, so a .jsx template wasn't in it: the
checker had no symbols for it, so every module-level style constant
(`style={main}`) was reported lost and dropped, and asking the type of a
value in text (`{firstName ? \`${firstName}, …\` : "…"}` in a Heading)
crashed it ("Cannot read properties of undefined (reading 'flags')"),
failing the template. The same file as .tsx worked.

The program now allows JavaScript, so a .jsx template is read as a .tsx
one is.

Test: a .jsx template with module-level styles on Body, a Heading (with a
multi-line ternary of a template literal) and a Text converts with no lost
style, the body's background on <Email>, and matches the original's
content and styles with and without the prop; before, it crashed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* migrate: keep a destination edited while the run loads templates

--overwrite decides which files at the destinations it may replace before any
template loads (an earlier run's template as it wrote it, a report, what git
can restore), then writes them after every check. A file edited in between
(minutes in a large project) was replaced anyway: for an earlier run's
template in an untracked --out folder, nothing could give the edit back.

The bytes of each destination the run may replace (other than a template
replaced in place, which already checks what it was converted from) are kept
when the run decides, and checked again right before the file is replaced;
one that changed (or appeared) isn't replaced: its template fails with
"welcome.tsx changed during the run: not replaced (the edit is kept), run
again", exit 2.

Test: a template that appends to the output an earlier run wrote when it's
loaded; --out with --overwrite exits 2 with that line, and the edit stays
(without the fix: exit 0 and the edit is gone).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep an apostrophe inside a word in the content check

A word lost or added was listed without its apostrophe ("Youre" for "You're"): the check took every apostrophe out
of a word before comparing. An apostrophe inside a word now stays, the same character whichever one was typed (a
curly one in the original still matches a straight one in the migration); quotes around a word still go.

Test: "You're" and "You’re" both normalize to "You're", and quotes and a trailing comma around a word go. It fails
without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Read style constants imported from the project's other files

Styles a template imports from another file of the project (`import { main,
h1 } from "./styles"`, a dark theme) were dropped as "lost": the codemod
reads only the template file's own constants. A value from a JSON import
(`color: catalog.brand.color`) was worse: the check couldn't read what the
JSON file exports, assumed it might hide content, and failed the template
as content.

A constant imported from a project file (through the module loader the
CLI passes) is now read as the template's own are: an exported `const`,
`export default`, `export { a as b }`, a re-export (`export { x } from`,
`export *`) and what that file imports in turn, and JSON data. Each file
is read once; an import back to a file being read gives nothing. Packages,
`let`s and values the template changes stay unread. A JSON file's text is
also what the check sees for a value from it that still can't be read.

Test: a template whose Body, Heading, Text and Link take styles from a
theme folder (index re-exporting colors from another file) and a JSON
file converts with no lost style, the body's background on <Email>, and
the original's styles; before, four styles were lost. The barrel test now
imports a function from the barrel, as its constant is written in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep bare text in a box whose style needs a span

Text straight in a Section or Column whose style Elements' Paragraph has no
prop for (`<Section style={{ textTransform: "uppercase" }}>Hello world</Section>`,
italics, an underline) came out as `html={`<span style="…"></span>`}`: the
words were lost. Plain text is written as the Paragraph's children, but a
style that needs a span puts the text in the html prop instead, which was
left empty for loose text (a Text did write it there).

Loose text whose inherited style needs a span is now written as HTML, as
for a Text.

Test: a Section with uppercase text, and Columns with italic text and an
underlined prop, keep their words and styles with two values of the prop;
before, all three lost their text. Corpus output is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Refuse a template whose kept markup renders React Email's <Tailwind>

A template that renders its content through a shared wrapper holding
React Email's <Tailwind>, given spread props (`<EmailTemplate t={t}
{...legalProps}>`, so it can't be inlined), was migrated with the wrapper
kept as React. <Tailwind> suspends the first time it renders a config, and
Elements renders synchronously: the migrated template failed to render
("A component suspended while responding to synchronous input"), or, in a
process where the original had rendered first, passed the check and would
then leave the content out in production. All 14 templates of one project.

Markup kept as React is now checked for <Tailwind>, directly or in a
component it renders (followed into the project's files and their
re-exports, a few levels deep), and such a template is refused with that
reason and what to do (make the component inlinable, or put <Tailwind> in
the template), so the CLI reports it as not migratable instead of writing
it. Full support is out of scope.

Tests: a template rendering an imported Tailwind wrapper through a
component with rest props is refused with that message; the same with a
wrapper without <Tailwind> converts, keeping the component. Corpus output
is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Style Tailwind classes worked out at render time in kept markup

Markup kept as React lost its Tailwind classes' styles when the class list
was worked out at render time (`className={`rounded px-1 ${color}`}` with
`color` from a helper's map): the migrated email has no <Tailwind> to
apply them, and only class lists written out were rewritten as styles, so
the badge came out as `class="rounded px-1 … bg-neutral-100"` with no
style.

When a template using Tailwind has such a class list, each class any string
of the file holds is resolved on its own, and kept markup (and the file's
own components it renders) gets `style={{ ...tailwindStyle(<the class
list>), … }}`: a generated table of those classes' styles, looked up at
render time. The className stays (it does nothing without a stylesheet).

Test: a kept Text holding a badge component whose classes are picked by a
helper renders with the original's styles (background and color) for a
rise and a fall; before, the check found the styles lost. The generated
code type-checks under strict settings. Corpus output is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Drop several inlined components from one import without breaking it

When two components imported in one multi-line import were both inlined
(`import {\n  ACCENT,\n  StatsEmailCard,\n  StatsEmailLayout,\n} from "…"`),
removing their names cut the list twice with overlapping ranges (the second
name cut from the end of the first), which left `import {\n  ACCENT,\n
from "…"`: the generated code didn't parse and the template failed to
convert. Inlining one of them only, as before the previous changes made
the second inlinable, hid it.

The list is now rewritten with the names that stay.

Test: a template importing a function and two components from one file,
both inlined, keeps `import { formatCount } from "./parts"` and renders
the original's content; before, the generated code didn't parse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep a prop's type when it's given one of two object literals

A component given `pill={isError ? { tone: "danger", label } : { tone:
"success", label }}` and passing `pill.tone` on to a prop typed `Tone` had
the conditional written in where it read `pill`: on its own, the literal's
`tone` widens to string, which TypeScript rejects for `Tone` (TS2322), where
the original's prop type kept it literal.

A conditional whose branches are object or array literals is now written
with the prop's declared type, as a literal already was (in parentheses,
so the type covers both branches). One side `undefined` is left alone, as
a cast would hide it.

Test: a header given one of two `{ tone, label }` objects, passing the tone
to a component that takes a union, type-checks where the original does and
renders both branches; before, TS2322. Corpus output is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Inline a component from another file in each place it's used

A layout component imported from another file and used in two returns of
a template (`if (paid) return <Layout>…</Layout>; return <Layout>…</Layout>;`)
was inlined in the first only: the constant of its file it needs
(`const openstatus = { … }`) had been copied into the template there, and
the second inlining refused a name the template already had. The second
return stayed one kept block holding a whole document.

A declaration identical to one already copied is now reused.

Test: a component reading its file's style constant, used in both returns
of a template, is inlined in both, with nothing kept, and each branch
renders the original's content and styles; before, the second was kept.
Corpus output is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Don't count a lost color from an unreadable file as content

A style value the codemod can't read is reported as lost, and as content
the original may show or hide by it (a content ✗) when what it reads could
hide something. For a value from another file that can't be read (passed
on from a third file, a `let`), "could hide" was always assumed, so
`color: palette.brand` failed a template as content instead of listing a
look difference.

A lost style whose property can't hide anything whatever its value
(color, background and border colors, radius, font family, weight and
style, alignment, decoration, transform, line height, padding) is now
only a lost style. `display` and the other properties that can hide are
still counted.

Test: a template with `color` and `display` from a re-exported `let`
lists both as lost and only `display` as possibly hiding content; before,
both did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep the import of text and links from another file: write only styles as values

Reading constants imported from the project's files (for styles) also wrote
imported text and URLs into the migrated template as literals: `Sent by
{brand}` became "Sent by Acme" and the import of `brand` went, so the template
no longer followed the project's own value. The CLI test that pins this
failed once the branches were merged.

An imported constant is now read as its value only where the codemod writes
a style or a class list as values (style props, classNames, a <Tailwind>
config, a kept element's own style); text, links and conditions keep the
identifier and its import, as before. A module read for a style reads what
it imports in turn.

Test: a template with an imported brand name, URL and accent color keeps
`from "./brand"`, writes neither the name nor the URL, writes the color, and
checks with no difference; it fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* migrate: 0.1.1

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Show a value beside markup in text once: a call's keeps its block

Text with markup in it (`{label() || <b>Fallback</b>}`) is written as HTML
template code that tests the value on the left and then shows it, so the
code on the left ran twice: a call could give other text the second time,
and what the email showed wasn't what the template showed. Only a value
that reads, with no call, `new` or assignment in it, is written so now;
anything else keeps its block, rendered as React renders it.

Also restores textAlign's early return for an element that sets no
alignment, which an edit had turned into a second `ownAlign` test (the same
result, by the last return).

Test: a call beside markup in text appears once in the migrated code, and
the block is kept; a prop beside markup stays editable text; the check finds
no difference with the preview props or none. It fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Decode a folded fragment's entities as JSX does

The normalization pass writes a fragment with one text child used as a value
as that text (`second && <>&nbsp;&middot;&nbsp;</>` becomes `second &&
"…"`), but wrote the text with its entities as typed: the migrated email
showed "&nbsp;&middot;&nbsp;" escaped, where the original shows " · ". The
text is now read as the codemod reads JSX text (entities decoded with JSX's
set), from one shared jsx-text module; telling a whitespace line apart still
reads it undecoded, so a line holding only `&nbsp;` isn't taken for blank.

Test: a condition beside a link shows `&nbsp;&middot;&nbsp;`, and `||` gives
`Terms &amp; conditions`, as the original does, with and without the props;
it fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Take a barrel's own export over what its `export *` passes on

A project file that passes React Email on (`export * from
"@react-email/components"`) and exports its own Button (`export { Button }
from "./button"`) was read statement by statement, so `export *` matched
first: the project's Button became Elements' Button with React Email's
defaults, and its styles were lost. As in ES modules, a name the file
exports explicitly, or declares and exports, is now its own; `export *` is
followed only for a name it doesn't export otherwise.

The codemod reads JSX text through the shared jsx-text module.

Test: a barrel with `export *` from React Email and its own Button (with a
hook, so it isn't inlined) keeps `<Button>` as the project's, with no
difference in the check; it fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Text with values that may be React elements: chosen when it renders

A value in formatted text whose type can't be read (`any`, a type imported
from another file, a .jsx template) was written into the Paragraph's HTML
through htmlText, which shows nothing for a React element: a caller giving
`footer={<a href="…">Help</a>}` lost it, and the check, rendering with
string PreviewProps, passed. A value typed ReactNode kept its whole block
as markup instead, so a template whose ReactNode prop is text in formatted
text lost its editable block.

Such a block is now written both ways:
`holdsElements(footer) ? <KeptHtml>…the original JSX…</KeptHtml> :
<Paragraph html={…} />`. holdsElements is a small generated helper
(React's isValidElement, lists, objects with $$typeof; no react-dom). The
design follows the props it's rendered with, so text stays an editable
block. Only values read where the block is (a prop, `props.user.bio`, not
a loop's item inside it, nor a call, which would run twice); others keep
what they did. A value alone in a run that shows nothing (null, "") gives
no block. Text whose type is provably text (strings, numbers, booleans,
lists of them) stays as it was, and so does plain text in a Paragraph's
children. The converter's TypeScript program now has list types, so a
loop item's fields (`article.author`) have their types instead of `any`.

The check gives each value a switch tests an element (a link) and
compares that render too, so the kept branch is checked. In the design,
such a value is still a merge tag (the helper's test is false for text).

Tests: a block with `{footer}` typed from an imported Props, as ReactNode,
and in a .jsx template is written both ways, type-checks, stays a text
block in the design with string props, a merge tag for footer, and shows a
link given as footer; a string/number value stays static; a migration
whose kept branch loses the value fails the check's element variant.
Before, the first three lost the link or the editable block. Corpus
designs are unchanged (two templates gain a switch for a value computed
from an imported function).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Pass the check on the image preloads React 19 adds itself

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]>

* Keep kept images as the original writes them: no loading="lazy"

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]>

* Kept components keep the text color around them, or say they can't

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]>

* No braces around a block a folded condition gives

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]>

* Write a known prop's default as `?? fallback`

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]>

* Say what failed the check in plain words, and blame the converter only 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]>

* Write a text color computed from props as code, not a fixed black

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]>

* Keep a text's background behind its words where its box can't be drawn

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]>

* Check's element variant: not for a list's length, nor stringified attributes

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]>

* Keep text editable beside a tested call that can't show a number

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]>

* Read a text constant from another file as text, so its block isn't written 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.…

migrate-v0.1.0

Toggle migrate-v0.1.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
React Email → Elements migration (@unlayer/migrate) (#61)

* feat(react-elements): render a template component as its root

renderToHtml read the root's settings (fonts, lang, dir, phone styles)
from the element it was given. React Email users write
render(<Welcome />), and renderToHtml(<Welcome />) rendered the email but
silently dropped those settings.

renderToHtml and renderToHtmlParts now unwrap a template component to its
root first, as renderToJson already did. The shared helper also unwraps
memo and forwardRef, nested in any order, for all three. A wrapper that
can't be unwrapped (React hooks, a class, an async component) still
renders through React, with a warning that its root's settings are
ignored.

The bundle budget goes to 87KB (86,697 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): check a migrated template as it's exported

The check called the inner function of a template exported with memo or
forwardRef, so it passed while renderToHtml(Template(props)) threw on the
memo object. It now calls the export itself, unwrapping memo and
forwardRef the way renderToHtml does, and the README renders templates as
JSX: renderToHtml(<Welcome name="Alex" />).

A template that calls React hooks failed with "Invalid hook call". The
CLI now says it calls React hooks, which Elements templates can't use yet,
and what to do instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): fail on style values computed from props, apply head <style> rules

A style computed from props (`style={{ color, fontSize: size }}`) was
dropped whole, so white-on-black text came out black at 14px, invisible,
while the template still passed the check with only a note.

- Known values in a style object are kept one by one, and conditions the
  codemod can work out (`level === "h1" ? 24 : 20`, `undefined ? a : b`)
  are evaluated.
- Values computed from props are listed with their line as lost styles,
  and the check fails on them (exit 2; `--force` still writes). An element
  kept as HTML keeps its style, so it doesn't count.

Rules in a head <style> were dropped too, with nothing reported, and
text styled by a class rule lost its color and size. Rules on a single
class now apply under the element's own styles, phone media queries on
those classes become phone settings, and every other rule is listed in
the report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): follow re-export files when finding shared components

With --write, a file imported by another scanned file is a shared
component and is left in place. Two cases went wrong:

- An `emails/index.ts` that re-exports templates counted as using them,
  so every template was skipped as shared and nothing was migrated.
- A template importing a shared footer through a re-export file outside
  the scanned folder didn't reach the footer, which was then migrated
  into a full email.

`export … from` lines no longer count as uses, and an import is followed
through re-export files to the modules that define the names it takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): reject a value on a flag

`--write=false` ignored the value and replaced the templates in place.
Flags now take no value: `--write=false` stops with "--write takes no
value" (exit 1) and writes nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): report an image height that isn't kept

`<Img width={80} height={20}>` showing a square file rendered at 80×80
after migration, and the report listed no difference. An Elements image
shows at its file's own aspect ratio, and the converter doesn't load the
file to compare, so an explicit height is now reported as not kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(convert-core): compare words as written and fail on added links and images

The content check lowercased words and dropped standalone signs before
comparing, and only counted links and images the conversion lost. So
"Use coupon SAVE10" passed against "save10", "Balance − 100 USD" against
"Balance 100 USD", and a template with an extra image passed as "same
words, links and images".

- Words compare in their written case.
- A lone sign is a word. Minus signs are one character however they're
  written (-, −, &minus;), and text is NFC-normalized with zero-width
  characters removed, so encodings don't fail the check.
- Links, image sources and alt text only the conversion has fail the
  check too. They compare as sets, so a renderer repeating a link (a
  button's fallback) isn't an addition.

The benchmark's 212 conversions all still pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): drop React 19's image preload links from kept markup

React 19's renderToStaticMarkup adds `<link rel="preload" as="image">`
before each image. Blocks kept as React Email markup (rendered at runtime
by the migrated template, or at conversion in runtime mode) carried those
tags into the email body and the design JSON in React 19 projects, which
a fresh react-email 6 project is.

Both modes now render static markup through a small wrapper that removes
them. The 71 official demo templates on react-email 6 and React 19 now
give the same designs as on @react-email/components and React 18.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(from-react-email): add a migration fidelity smoke test to CI

The fidelity benchmark measures 106 templates, but only locally: its
corpus isn't in the repo. A layout regression, like phone sizes showing
on desktop, was caught only when someone ran it.

`pnpm --filter @unlayer/from-react-email test:fidelity` converts the
fixture templates in test/fixtures both ways and renders them next to
their originals in Chromium at 700px and 375px, with the network off. It
fails on lost or added content, on a word shown at another font size on
desktop, or on words moved more than bench/smoke-baseline.json allows.
A new fixture covers a theme's own Tailwind phone variant and type scale.

The benchmark's word measurement moves to bench/layout.ts, shared by
both. With the phone size put on desktop on purpose, the test fails and
names the resized words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(from-react-email): compare phone layout only where the fixtures match

The smoke test's baseline held the words moved on phones for every
fixture, but for the two fixtures that still differ there the number
depends on the machine's fonts: weekly-digest moves 29% on macOS and 64%
on CI's Linux, so CI failed without any change in the conversion.

The rule is now fixed instead of measured: every fixture must keep its
content, its desktop text sizes and its desktop layout, and phones must
match for the fixtures that match there (welcome, phone-variants). A 2%
tolerance absorbs font rendering. The baseline file is gone. A phone size
shown on desktop on purpose still fails it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): don't count a spread of undefined as a lost style

An inlined component with an optional `style` prop left out spreads
`undefined` into its style object (`{ ...base, ...(undefined) }`). That
adds nothing, but the codemod counted it as a style value computed from
props, so the CLI failed a template that converts fine (one of the 106 in
the benchmark). A spread of `undefined` or `null` is now skipped.

The benchmark didn't catch it because it didn't count lost styles; it
does now, like the CLI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): call template components in a render of their own

renderToHtml(<Template />) called the template function directly to reach
its root. Called while the app is rendering (a live preview in useMemo),
a template's hooks ran on the app component's hook list: the app crashed
on its next render ("Rendered fewer hooks than expected"). 0.1.22 didn't.

Each wrapper is now called inside a throwaway renderToStaticMarkup, which
sets up and restores its own dispatcher, the way 0.1.22's nested body
render did. Its hooks run there, never on the app's component, and they
work now: a template that uses hooks keeps its root's settings in
renderToHtml and unwraps in renderToJson. An async or suspending template
gets a clear error.

Every example email, component and mode renders byte-identical to before;
only a template with hooks changes, now keeping its settings. The bundle
budget goes to 88KB (87,556 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ci): publish the commit the run tested, not the latest main

Both publish jobs checked out `main` after the approval step. If another
change merged while an approval waited, the package that shipped wasn't
the commit this run had built and tested. They now check out the run's
own commit (github.sha), and the release tag goes on that commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): count a file behind `export *` as used only when it defines the name

With `index.ts` holding `export * from "./footer"; export * from "./welcome"`
and a template importing `{ Footer }` from it, `--write` skipped
welcome.tsx as a shared component: the imported names were passed to
every `export *` source, and any file reached counted as used. A file now
counts only when it exports one of the imported names itself (a file with
no ES exports still counts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): don't replace a component that doesn't render <Html>

`--write` on a shared component given alone (a footer, without the
template that imports it) replaced it with a full Email, and the template
still importing it then rendered an email inside an email. With `--write`,
a component that doesn't render <Html> is now skipped as a shared
component. `--out` keeps writing separate copies, as documented.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep text hidden with visibility: hidden hidden

Only `display: none` counted as hidden, so `<Text style={{ visibility:
"hidden" }}>` became a visible block. `visibility: hidden` now keeps the
element as its original HTML, like `display: none`, so it stays hidden
and keeps its space.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): show an image capped by max-width at the cap

`<Img width="100%" style={{ maxWidth: "300px" }}>` shows at 300px in
React Email, but the percent width isn't a size the converter reads, so
the cap was dropped and the image filled its column (600px on desktop).
The width is now the smaller of the width and the max-width, as in CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep phone-only rules that were lost on phones

Three cases looked like desktop on a 375px phone, with no difference
reported:

- React Email 6 writes phone queries in range syntax, `(480px>=width)`,
  which the converter didn't recognize (Tailwind `screens` with `max`).
  Phone queries are now read in every form: `max-width`, `width <= …`,
  `… >= width`; anything with a lower bound still isn't one.
- A CSS comment inside a phone `@media` block dropped its rules. Comments
  are removed first.
- Content hidden with `display: none` and shown by a phone rule stayed
  hidden. It's now converted as a block hidden on desktop. A box (a
  Section) can't be hidden that way, so it stays HTML and the report says
  it stays hidden on phones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep monospace text at the browser's monospace size

Browsers show monospace text at 13px where the text around it is the
default 16px, but only while that size is the default (`medium`). A
`<code>` with no size of its own became a 16px paragraph (Notion's login
code: 13px became 16px), and blocks kept as HTML stated `16px`, which
cancels the browser's monospace size too.

A paragraph that is a lone monospace element now gets 13px, and blocks
kept as HTML state the default size as `medium`: still 16px, including in
the editor's canvas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep the color of text in tables kept as HTML

Apple's receipt showed its invoice date and order number in black
instead of #333. They sit in a Section kept as HTML, inside a div with
the inherited color, but an Elements email sets `table, td { color:
#000000 }` (as the editor's export does), which beats inheritance.

Kept markup now gives its tables and cells the color of the div around
them, unless they set their own. Both modes; word positions unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): let React Email's inline styles beat a <style> rule

`.copy { font-size: 24px }` in a head <style> showed the text at 24px
after migration, but at 14px in React Email: `<Text>` sets its size
inline, and an inline style beats a stylesheet rule unless the rule is
`!important`.

A head rule (and a phone rule) now applies only to what the component
doesn't set inline itself (measured: Text's size, line height and
top/bottom margins; Link's color; Button, Img, Hr, Container and Row
defaults), unless it's `!important`. Both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): remove names the migrated file no longer reads

A template's `const paragraphStyle = {...}` used in a `style` prop stayed
in the migrated file after its values were written into the props, so
`tsc --noUnusedLocals` failed (TS6133).

The codemod now removes what the template read but the migrated file
doesn't: constants (at the top or inside functions), inlined components,
and imports (as TypeScript would drop them), plus helpers it added itself
and didn't use. Only declarations without side effects: a constant whose
value calls a function stays. Names the template never read stay too.

Across the benchmark, migrated files went from 292 unused names to 62;
the rest are unused in the originals as well, or inline Tailwind configs
built with a call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): space a loop's items like the same blocks written out

Three <Text> blocks from `items.map(...)` sat 56px apart after migration
where React Email shows 40px: each item kept both its margins (16px top,
40px bottom) as padding, so they added up instead of collapsing.

When a loop's items share their margins and the top one is no larger
than the bottom one (Text's 16px each), each item now keeps only its
bottom margin, which is what CSS shows between two items. The loop
carries both margins, so the block above it, the block below it, the run
spacer and the column it starts all collapse with its first and last
item. Other loops, and conditions, keep their margins as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): size em padding by the element's font, report % padding

A container with `padding: '0 10%'` lost its side gutters with nothing
in the report, and em padding was always taken at 16px per em.

Padding and margins in em now use the element's own font size, as CSS
does. A %, calc() or viewport unit, which Elements can't hold in px, is
listed in the report ("style not converted: padding: 0 10%"), and so is
an em the converter had to take at 16px because the element sets no px
font size. Kept HTML keeps its own CSS, so it's not reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): report a lost style's line in the template as written

The report named the line of a lost style after local components were
inlined, so a template with components above it got the wrong line (121
for a background image on line 79).

The line now comes from a line diff of the template and its inlined
source: a line the inlining left alone keeps its own number, and a style
inside an inlined component points to where the component is used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): migrate namespace imports and templates that render nothing

`import * as Email from "@react-email/components"` with `<Email.Text>`
failed with "Element type is invalid", and a template that returns null
for one prop value failed the check with "Cannot read properties of
null". Both render fine in React Email.

The codemod now reads a namespace import as named imports (`Email.Text`
becomes `Text`, with an alias when the file already uses the name),
unless the namespace is used some other way. The check compares a
template that renders nothing for some props with an empty original: it
passes when the original renders nothing too, and fails with the missing
words when it doesn't. A template that renders nothing with its own
PreviewProps gets a message that says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): draw a card's spacer columns as a border

Migrated emails ran 5x the size of their originals, and 21 of the 106
benchmark templates went over Gmail's ~102KB clipping limit. A card
inset in a colored box became three columns ([24, 592, 24]), and every
column is tables of its own in email HTML.

Spacer columns on both sides of a row, as wide and showing one opaque
color, now become a border of that color on the columns inside them. On
phones the border is as wide as the spacers were there (their share of
the row), or none where the row stacks, so phones look as before.
Spacers stay where a border can't stand in: rounded corners or a border
of the column's own, an image or translucent color behind, one side only
(right-to-left emails show the columns the other way round). The color
behind can be the email's own background.

Benchmark: no change on desktop, phones within pixel noise, no word
moved; 18 templates over 102KB instead of 21.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep the editor's empty-column dividers out of the email

Every column that only holds space carried an invisible zero-height
divider, so the editor wouldn't draw an empty column as a "No content
here" placeholder. In the email it changes nothing but costs about 1KB of
HTML each: 22% of the largest migrated email.

The migrated code no longer has them; the designs for the editor (the
CLI's design JSON, the runtime design, and the merge-tag comparison) get
one in each empty column instead, numbered after the design's own.

Benchmark: no change on desktop or phones; 13 templates over Gmail's
~102KB clipping limit instead of 18 (21 before the spacer border), the
median size 4.3x the original instead of 5x; no design has an empty
column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Revert "fix(from-react-email): keep the editor's empty-column dividers out of the email"

This reverts commit b49ba7a. With the dividers only in the CLI's design
JSON, a migrated template opened in the editor through `renderToJson`
(the usual Elements way) showed its spacer columns as "No content here"
placeholders again. The size it saved isn't worth that; Elements adding
the divider itself would be the way to drop them from the email.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): render Heading and Button text as text, and keep link URLs in their attribute

`<Heading>{name}</Heading>` and `<Button>{name}</Button>` wrote a string
as HTML, so markup in a value (a user's name) became live markup; a
Paragraph escapes it. A link URL with a `"` ended its href attribute. Both
were already in 0.1.22; a migration moved React Email templates, which
escape both, onto them.

- Heading and Button children are escaped like Paragraph's. The `text`
  prop still takes HTML.
- The Button exporter unescapes its text before writing it, where the
  editor's canvas shows it as the HTML it is: the text is escaped once on
  the way to it, so it renders as stored.
- `"`, `<` and `>` in a link URL (Button, Image and Video links, menu
  items) are percent-encoded on the way to the exporters, as a browser
  would.

The migration keeps a button label with markup in `text`, and its check
now also renders every string prop with `<i>x</i>&"` added: the migrated
template must show it as text, as React Email does.

The bundle budget is 89KB (88,154 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): let an !important <style> rule win over the template's own style

A class rule like `.notice { color: red !important; font-size: 40px
!important }` on a Text with `style={{ color: "#fff", fontSize: 14 }}`
shows red at 40px in the browser; the migration kept the inline white at
14px, which could make the text invisible, with no difference reported.
The earlier fix made React Email's own inline styles lose to an
!important rule, but not the template's.

Both modes now put a head rule marked !important over the element's own
style too; an important `margin` or `padding` also wins over the inline
sides it covers. Rules that aren't !important still lose to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a phone class that only one branch of a condition has

`className={hide ? "mobile:hidden" : ""}` became one static, empty class
list: branches that differ only in variant classes were merged, keeping
only spacing, from before phone styles converted. With `hide` true the
original hides the text on phones and the migration showed it, and the
check passed.

Branches whose phone classes differ in more than spacing are now split
like branches whose styles differ, so each keeps its own (`hide ?
<Paragraph hideOnMobile /> : <Paragraph />`). Spacing-only differences
and state variants (`hover:`, `dark:`) merge as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): only write in module values that can't change

A module-level `let destination = "…"` that an exported `configure(url)`
reassigns was written into the migrated button as its first value, so the
button kept linking to the default after `configure` ran.

Only a `const` is written in now; anything else stays as code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): render Fragments and user components among blocks like the blocks they hold

With a Fragment or a user component (`<Header />` returning a Row) before
a row, the blocks inside it lost the context Body threads to its rows:
their ids restarted at 1 and repeated other blocks' ids, so a `mobile`
setting on one paragraph resized another (the phone CSS targets ids), and
a component's row rendered in web mode inside an Email. renderToJson left
them out of the design.

Body, Row and Column, the head's phone CSS and renderToJson now expand
Fragments and user components among their children the same way, so they
all see the same blocks in the same order. A user component is called
once, in a render of its own (as a template component is), so its hooks
work; `memo` and `forwardRef` are unwrapped. Children without them are
passed on untouched: their output is byte for byte the same.

The bundle budget is 91KB (90,340 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): rerun cleanly, keep flags out of values, and say what to do with a .js template

- Running `--write` again on migrated templates failed (exit 2, the
  converter can't read Elements code) for any that kept an HTML block,
  and exited 1 when none did. Templates importing @unlayer/react-elements
  are skipped as already migrated, so a second run (in CI) passes.
- `emails --report --write` wrote a report named `--write` and migrated
  nothing, with exit 0. A value option now refuses the next flag as its
  value (exit 1).
- A dry run listed a default-exported shared component (no <Html>) as
  migrated, where `--write` skips it. A dry run now says what `--write`
  does; `--out` still writes copies, as documented.
- A .js template with JSX failed with "Unexpected token '<'": the CLI now
  says to rename it to .jsx.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fail the check for a heading level, image width or classes computed from props

`<Heading as={level}>` rendering an h3 became an h1, `<Img width={logo}>`
lost its width (64px showed at its full size), and a class list built from
a value (`text-${tone}-500`) was dropped, each with a passing check and at
most a note.

Like a style computed from props, each now fails the check, naming it with
its line (`--force` writes anyway). A class list that is a value or
nothing (`quiet ? undefined : "mb-9"`) is still only noted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): take color: inherit as the color around it

`<Text style={{ color: "inherit" }}>` inside a dark footer kept
`color="inherit"`, which an Elements email resolves against its
`table, td { color: #000000 }`: black text on black. Both modes now use
the inherited color for `inherit` (and `currentColor`), and an inherited
style isn't replaced by the word `inherit`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(convert-core): don't read a block hidden on desktop in the content check

Elements hides a block on desktop with display:none on its table. The
check skipped hidden text elements but not tables, so content a
template shows only on phones, converted correctly, failed the check as
extra text. Hidden elements are now skipped with everything inside them,
nested tags included; the preview text is still read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fill the page with a gradient background's color

A Body with `background: linear-gradient(...)` and no plain color became
a white page, so light text on it couldn't be seen. Elements can't show
the gradient, so the page takes its first color when every stop is an
opaque color. A gradient of several colors is noted in the report, and
so is one that fades to transparent, which leaves the page white.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): start text, buttons and images on the right in right-to-left templates

Elements writes text-align: left unless told otherwise, so a template
with dir="rtl" and no alignment of its own showed its text on the left,
where the original shows it on the right. In a right-to-left template,
text, buttons and images with no alignment now sit on the right, as in
the original. An alignment the template sets is kept, and left-to-right
templates don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): inline components imported through a re-export file

A template that imported its layout from an index.ts that passes it on
(`export { default as Shell } from`, `export *`, or an import exported
again) kept the whole template as one HTML block, 0% editable. The
inliner now follows re-export files to the module that declares the
component, and inlines it as it does a direct import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): follow a barrel that imports a component and exports it again

With --write, a layout reached through `import Shell from "./Shell";
export { Shell }` (or `export default Shell`) wasn't counted as shared:
it was replaced with a migrated email, and a template that still
imported it rendered through that. Such a barrel now counts as passing
the name on, like `export ... from`, and a namespace passed on
(`export * as Parts`) uses every name of its module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): check the Elements version where the templates are

The check looked in the folder the command ran in. Run from a monorepo's
root, it found no Elements there and fell back to its own, so an app on
an older release wasn't stopped. It now checks each template's folder,
where the migrated templates load Elements from, and says each problem
once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): give a heading the line height it inherits

React Email's Heading sets no line height, so in the browser it inherits
one: `lineHeight: 2` on Body makes a 60px heading's lines 120px. The
converter gave headings `normal`. They now inherit it, and a line height
set in % or em passes on in px, worked out with the font size where it's
set, as CSS does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep link targets, and report attributes blocks can't hold

Links and buttons opened in a new tab even when the template set
`target="_self"`: they keep the target now, a button's in its link. An
`id`, `title`, `role` or `aria-*` attribute on a text, heading, button
or image has no place in an Elements block, and is now listed in the
report, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): report phone styles Elements can't hold

A phone rule in a <style> tag that set a color (or a font weight, or
letter spacing) was dropped without a note. Elements holds padding,
margins, font size, line height, alignment and hiding on phones; the
rest is now listed in the report, in both modes. Runtime mode now also
lists the Tailwind phone classes the codemod already reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): show text placed straight in a Row or Column as text

A string in a Row or Column was written into the HTML as markup. With
Fragments and user components among blocks expanded, that also reached
text in a Fragment (`<Column><>{name}</></Column>`) and a component that
returns a string, so markup in user data could run in a web page. Such
text is escaped now, as React and Paragraph do, with a warning that
points to Paragraph for text and Html for raw HTML; the editor's design
never kept it. A string written straight in a Row or Column, which used
to be inserted as HTML, shows as text too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): align right-to-left text when dir comes from props

The codemod read only a literal `dir`, so `<Html dir={direction}>` kept
its text on the left. Each block with no alignment of its own now takes
it from the same expression: `textAlign={direction === "rtl" ? "right" :
"left"}`. An expression other than a name is worked out once, in a const
before the return, unless the email is one branch of a condition, where
the expression may only hold in that branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): record what components render for one render only

What a user component rendered was kept, per element object, for the
life of the process. An element made once and reused (`const footer =
<Footer />` at module level) kept its first output: a second render, for
another user, showed the first user's data. The record now lasts one
renderToHtml, renderToHtmlParts, renderToPlainText or renderToJson call,
where it keeps the blocks, ids and phone CSS of that render in step. The
bundle budget is 92KB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): keep the body's settings off HTML elements from a component

With components among the body's blocks expanded, a component that
returns an HTML element (`<div dangerouslySetInnerHTML>`) got the
settings the body passes its rows, and React wrote them as attributes:
`_config="[object Object]"`, with a warning. Only components get them
now; an HTML element renders as it did in 0.1.22.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): write only ltr, rtl or auto as the text direction

The root's `textDirection` reached the exporters as given, and they
write it into a `dir` attribute unescaped: a value from a request
(`rtl"><img src=x onerror=...>`) became live markup in an email. The
direction is now ltr, rtl or auto (any case); anything else is left out,
where it's read from the root and where it reaches the exporters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(react-elements): templates that use hooks are unwrapped

The renderToHtml JSDoc and the visual-editing guide said a template that
calls React hooks can't be unwrapped. Templates are called in a render
of their own, so their hooks work: only a class component, an async one,
or one that doesn't return an element stays as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep content a <style> class hides hidden

An element hidden by a class rule in the template's <style>
(`.hide{display:none}`, the dark logo of a dark-mode pair) was kept as
HTML whose class had no stylesheet any more, so it showed. The check,
which read only an inline display:none as hidden, counted it in the
original too, so it passed. Kept markup now carries that hiding inline,
in both modes, and the check reads elements a top-level class rule
hides (an !important rule over their own style) as hidden in both
documents. Rules in media queries, like dark mode, apply only sometimes:
they don't count, and they stay in the report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep an !important <style> declaration over a later plain one

`.copy{color:#000 !important} .copy{color:#fff}` made the text white on
white: the later declaration replaced the earlier one. Within a
stylesheet and across <style> tags, a declaration no longer replaces an
!important one unless it's !important too, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a quoted font name inside an inline style attribute

An inline style with a quoted font (`fontFamily: '"Inter", sans-serif'`,
or Tailwind's font-mono stack with "Liberation Mono") was written into
style="..." with its quotes as they were, which ended the attribute: the
styles after it were lost. Quotes in attribute values are written as
&quot; now. Two benchmark templates get their code's monospace font and
weight back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fail the check for a module const changed after it's declared

`const theme = {...}; theme.color = "#0000ff"` was written in with its
first value: another color, and the check passed. A const that's
changed (a member assigned, ++, delete, Object.assign, an array method
that changes it) isn't written in now, so a style that reads it fails
the check, as a style the codemod can't keep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): fail the check for a column's or a linked image's width from props

`<Column width={wide ? 500 : 100}>` became two equal columns, and an
image's computed width inside a Link was written as one value, with the
check passing. Both are lost styles now, which fail the check, as a
plain image's computed width already did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a prop written as HTML out of merge tags

In runtime mode's design, a prop rendered as raw HTML (a footer through
dangerouslySetInnerHTML) became `{{footerHtml}}`, so its unsubscribe link
left the design: the comparison read entities as characters, so markup
and escaped text looked alike. A prop whose sample's markup is markup in
the output keeps its value now; one shown as text still becomes a tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): never replace a layout with --write, and follow jsconfig paths and workspace packages

With --write, a shared layout was replaced with a migrated email when the
scan couldn't follow a template's import of it: jsconfig.json paths
(Next.js) weren't read, and a workspace package linked into node_modules
counted as an outside package. jsconfig.json is read like tsconfig.json,
to load templates and to scan them, and a linked package is followed to
its source. As a net under both, a component that renders its children
(a layout) is left in place with --write, like one that doesn't render
<Html>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): accept React 18.0 to 18.2 without a second copy

migrate's own react and react-dom ranges started at 18.3.1 while its
peer range is >=18, so a project on React 18.0-18.2 got a nested React
19, and convertReactEmail from the library entry failed with two Reacts.
They start at 18.0.0 now; React 18.3 added no API the package uses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ci): publish migrate with a current Elements range, and never publish older code

Packing migrate wrote its Elements range from the committed
packages/react version, which CI bumps only on its runner: after the
first Elements release the range stayed ^0.2.0. It's now the Elements
the run just published, or npm's latest, and migrate republishes after
each Elements change. A commit that doesn't descend from the last
release tag (an older run re-run) publishes nothing, a manual release
runs from main only, and the workflow's default token only reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): keep line breaks in Heading and Button text

Escaping Heading and Button children (so a value like a user's name
can't inject markup) also turned a line break written in a string,
`{"The Last Light<br/>on the Dolomites"}`, into visible text: five
stories showed `<br/>`. A `<br>` in their text stays a line break now,
as it was, and so does a JSX `<br />`, which used to be dropped. All
other markup still shows as typed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): escape menu text in email, and social links, and test every user value for injection

A new test gives every value that often comes from users (text, links,
image sources and text, menu items, social links, preview text,
direction, language, font URLs, the title) markup in all three modes,
and fails when the parsed output has an element or attribute it added.
It found two holes that 0.1.22 also has: the email exporter writes menu
item text as it is, and social links went into their href unencoded.
Menu text is escaped in email now (the web and document exporters
already escape it), and social links are encoded like other links. The
bundle budget is 93KB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(react-elements): compare with the previous release, and snapshot the patterns that broke before

The realistic examples render with this build and with the last
published Elements (an npm alias, @unlayer/react-elements-previous):
their designs and plain text must be the same, and their HTML matches
reviewed snapshots, so any change to existing users' output fails until
it's updated and reviewed. The ways templates are put together that
broke before (Fragments, components returning arrays or null, hooks,
memo and forwardRef, a reused element, HTML from a component, text in
rows and columns, right-to-left, device hiding) have snapshots too, and
render-isolation tests check that a render never leaks into the next
and that the same input always gives the same output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(react-elements): compare every story's text in the visual-drift gate

The gate fingerprinted computed styles only, so a story whose words
changed passed it: five stories showed `<br/>` as text and nothing
failed. It records each story's visible text too now, a line per line
of text, so a change in the words fails as a change in style does.
Regenerating the baseline added text lines only: no story's styles
changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): give each component its own useId() values

Components placed among a Body's, Row's or Column's blocks are each called
in a render of their own, so useId() started over in each one: two sibling
components got the same id, and a link to one anchor could land on the
other's. Each call now gets its own identifier prefix, counted per render,
so ids are unique in the document and the same on every render. Body opens
the render scope itself when it's rendered straight through React.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ci): check again in each publish job that no newer commit was published

The check that a run's commit descends from the last release ran only in
the `changes` job. Re-running an older run's failed jobs reuses that job's
result from before a newer commit was published, so the older code could
be published as the newest version. Each publish job now checks again,
just before publishing, where publishes run one at a time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep a project's own Button, and a button's text-transform

A capitalised tag was read as the React Email component of the same name,
so a project's own `Button` (or `BrandButton as Button`) became a plain
Elements button: its colors and padding were lost, and the check, which
compares words, passed. Only lowercase HTML tags are read as React Email
components now; a project's own component is kept with its styles.

A button's text-transform, italics and underline had no Elements prop and
were dropped ("TRACK IT" showed as "Track it"). They now stay on a span
around its text, as for Text, in the codemod and the runtime converter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): load only templates, and let templates listed in an index be written

Checking a template runs its code and calls its default export, and any
file importing React Email was checked, even in a dry run: a helper whose
default export sends an email was called. A file is loaded now only when
its default export is a component (not async, returning JSX or with
PreviewProps), read from the source; others are reported as skipped and
never run.

A template that called process.exit ended the run with its code, so a run
with failed checks could exit 0, and a timer a template started kept the
run open. process.exit now throws while templates run, failing that
template, and the run exits once its output is written.

With --write, a template imported by any scanned file was left in place,
so an index that imports the templates and exports them again (or a
helper that sends them) kept every template from being written, while the
dry run reported them as migrated. Only a file that renders what it
imports keeps it in place now, and the dry run reports the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): render a component's plain HTML in a Column, and give an element used twice its own ids

A component returning plain HTML (`<div dangerouslySetInnerHTML>`) inside a
Column was expanded into that div, which a Column skips, so its HTML (a
footer with an unsubscribe link) disappeared; the previous release rendered
it. Such a component is kept as it is now, and a Column renders its HTML as
before.

What a component rendered was recorded by element, so an element placed
twice (`{note}{note}`) rendered once and both places showed the same
useId() values. It's recorded by the place the element is in now: each
place is rendered, with its own ids, and still once per render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(react-elements): keep phone settings from ending the head's style element

A phone setting (`mobile={{ fontSize }}`, padding, line height, alignment,
a column's border) is written into the head's CSS as is, so a value like
`16px</style><script>…` ended the style element and ran. The same value
as a desktop setting was already escaped by the exporters. A phone value
that could end the declaration, the rule or the style element is dropped
now, and a `</style` in the head's CSS can no longer end the element.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): write templates that a Next.js route sends

With --write, a template was kept in place when any file with JSX imported
it, so a Next.js route that sends it (`react: <Welcome />`) kept it from
being written. Only a template or a component that renders what it
imports keeps it in place now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(convert-core): compare text styles in the content check, and read hidden text as a browser does

The check compared the words, links and images two documents show, so a
conversion that changed how text looks passed: colors, sizes, bold,
italics, letter case, underline, the background behind text, a link's
target. It now reads both documents with a small CSS cascade (inline
styles, the stylesheet rules that apply at desktop width, the browser's
defaults and inheritance) and compares each word's style. A property it
can't work out (a selector or value it doesn't know) is left out, never
reported as different, and the check says how much it compared.

The same cascade decides what's hidden, so the words, links and images
read are the ones a browser shows: the hidden attribute, a box that clips
to nothing (`max-height:0;overflow:hidden`), visibility:hidden and opacity
0, compound selectors and rules in media queries that apply at desktop
width. On the 106 benchmark templates the words, links and images read
are the same as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep what the style check found changed: link targets, boxes in columns, list size, bold button labels

- A plain <a> kept its target (none); only React Email's Link opens in a
  new tab by default. A link whose URL prop is missing renders no href,
  as React renders it, instead of href="undefined".
- A box with a background or a border inside a column of several can't
  nest in Elements, and its content was poured into the column without
  it: it's kept as HTML now, as it renders.
- A list takes the size around it (the browser's 16px), not Text's 14px.
- In runtime mode, markup in a button's label (a bold word) is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep text styles from what's around, sizes relative to their parent, !important lengths, and hidden text

- Italics, letter case and underline a container sets (a Section with
  font-style or text-transform) reach the text and buttons inside it, on
  a span, as the element's own do.
- A heading stays bold when the body sets a normal weight: its own
  weight beats the one around it, as the browser's styles do.
- em and % font sizes are worked out against the parent's size (a 2em
  section in a 20px body), not 16px; rem against 16px. A Container's em
  width is relative to its own font size (37.5em in a 14px body is 525px).
- !important lengths are read (Tailwind's !p-6, a `24px !important`
  shorthand) instead of becoming 0.
- Hidden text stays hidden: the hidden attribute (from props too), opacity
  0, and a box that clips to nothing (max-height:0; overflow:hidden).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep image borders, widths and link targets, translucent row backgrounds and background shorthands

- An image with a border is kept as HTML: Elements images have none, and
  dropping it changed the image's size.
- A CSS width beats the width attribute, as in the browser (`width="608"`
  with `w-full` fills its column), and a percent width stays a percent.
- A linked image keeps its link's target (a plain <a> opens in the same
  tab, React Email's Link in a new one), and so does a button whose URL
  comes from props.
- A translucent background (`bg-black/80`) on a row is written as
  #rrggbbaa: an Elements Row drops rgba() backgrounds, and white text
  showed on white.
- The `background` shorthand's color, repeat and position are read
  (`#f4f4f4 url(…) no-repeat center`), not only a background that is a
  color alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): apply class rules in stylesheet order, keep each return's head styles to itself, and refuse styles it can't keep

- Class rules are merged as the browser does, by !important and then by
  where they are in the stylesheets, not by the order of the class
  attribute (`class="alert muted"` took the wrong color; `class="show
  hide"` hid visible text), in both converters and in kept HTML.
- A phone media query's class rules are read even when the block also
  holds another selector (a client hack), and a column a head <style>
  class stacks on phones stacks in the codemod too.
- Each return's head <style> rules apply to that return only.
- A class list spread onto an element (`{...shared}`) is resolved, and a
  class-list call (`cx`, `clsx`, `cn`) keeps its fixed classes; the ones
  it picks from values are reported, so the check fails.
- A <style> whose CSS comes from code or sits under a condition, and a
  <Tailwind> config built from props or the component's own values, are
  refused with the reason: the migrated template can't keep them.
- A style object changed through another name (`alias.color = …`) isn't
  written in as its first value.
- The check reports style differences for flipped boolean props too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): refuse to replace files in --out that the run didn't produce

--out silently replaced a template or design JSON already in the output
folder. A destination that exists and isn't the template itself (--write)
or the report now stops the run before anything is written, unless
--force. A file that appears there during the run isn't replaced either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): fail a template that isn't UTF-8, and keep CRLF line endings

A source saved in another encoding (a Latin-1 é) was read as U+FFFD and
written back corrupted: it now fails with a reason and isn't written. A
template whose lines end in CRLF is written with CRLF, not LF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): stop on any Elements version outside the supported range

0.2.0-beta.1 passed a ^0.2.0 check, and 0.3.0 or 1.0.0 only warned. The
check now reads the range as npm does: a newer line is an error with the
install command, like an older one, and a prerelease is accepted only by
a prerelease range of the same version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): remove the check's temporary copy however the run ends

The copy of a migrated template that a check loads stayed next to the
template when the run was interrupted (Ctrl-C, a kill). Temporary files
are now removed on SIGINT, SIGTERM and SIGHUP, and when the process
exits, as well as on errors. A template in a folder the run can't write
to fails with that reason, not as a migrated template that doesn't render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): finish the run when a template throws from a timer or a promise

A template that called process.exit from a timer, or threw in a promise
it didn't wait for, threw outside any check: the run crashed with no
summary. The command now collects those errors while it runs, prints the
summary, names each one, and exits with 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): load only React's classes, and fail templates wrapped in a call

A default-exported class with a render() method was loaded in a dry run
even when it wasn't a React component (a mailer): only a class that
extends React's Component or PureComponent is loaded now. A template that
renders <Html> but whose default export is wrapped in another call
(withTracking(Welcome)) was skipped as a helper and the run passed: it
now fails, saying to export the component itself. Helpers stay skipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): follow imports TypeScript can't resolve the way Node does

With --write, a shared template that a failing one imports was replaced
when TypeScript's resolver couldn't follow the import: a package's
exports with moduleResolution node, a #imports path, or no tsconfig.
Imports TypeScript can't resolve now fall back to Node's resolution from
the importing file, so the shared file is still seen as imported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): find templates that use React Email through the project's components

Only files importing @react-email/* or react-email were candidates, so a
template that imports a local ui file wrapping or re-exporting React
Email was never seen: the run said no templates were found, or passed
without it. A scanned file that passes the template check and imports a
project file that imports React Email, directly or through files that
pass it on, is now a candidate too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): resolve React Email and Elements where each module is, in a monorepo

The module hooks resolved React, React Email and Elements from the
current folder for every module. Run from a monorepo's root, an app's
templates were checked with the root's Elements while the version check
approved the app's; run from the app, a workspace package's TSX outside
it was compiled without the JSX runtime (React is not defined), and the
error blamed the migrated template.

- React Email and Elements now resolve from the importing module's own
  folder first, then the project, then this package. React and React DOM
  stay one copy, the project's, for every module, so hooks and context
  keep working.
- The project is the folder of the first template given, not the current
  folder, so the converter renders with the same copies as the app.
- Before loading anything, a run whose templates (or their --out copies)
  would load another React Email or Elements than the converter stops,
  naming the folder to migrate on its own.
- The JSX settings also cover the workspace root and the real paths of
  the folders they list.
- When the original is what doesn't render, the reason says so, and
  names a file compiled without the JSX settings.

The module order is shared by the hooks, CommonJS requires and the check
(resolution.ts); the argument parser moves to args.ts for bin.ts to read
where the templates are before the CLI loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): give nested text props markup in the check too

The check gave markup and &" only to top-level string props, so a
migrated template writing user.city or items[0].name into HTML as is
passed. Strings inside plain objects and arrays now get it too; React
elements, functions, dates and other objects are kept as given. migrate
compare reports such a template.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(migrate): make the example run on a fresh clone, and describe the new checks

- The example's migrate script ran the unlayer-migrate bin, which pnpm
  can't link on a fresh clone (the CLI isn't built when it installs): it
  now runs the built CLI, after pnpm build, and empties output/ first so
  running it again doesn't stop on the last run's files.
- The root README lists @unlayer/from-react-email as private, bundled
  into @unlayer/migrate, not as unpublished.
- The migrate README describes the checks added here: files already in
  the output folder, the supported Elements range, templates found
  through the project's components, wrapped templates, monorepos,
  encodings, interrupted runs and errors thrown after a render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(react-elements): render a migrated template as JSX in the migration guide, and note what --out won't replace

The guide called the template as a function (`Welcome({ name })`) while the
migrate README renders it as an element; it uses `<Welcome name="Alex" />`
now. It also says --out doesn't replace files already in the output folder
that the run didn't produce, unless --force.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(migrate): fail the check when text is shown in another style, and say which

The check now compares text styles (size, bold, italics, letter case,
underline, color, the background behind it, a link's target): a template
whose migration shows text in another style fails it and isn't written
unless --force. The line, the Markdown and JSON reports, and `compare`
list each change with the words it's on, for the preview props and for
each flipped boolean prop, and the report says how many words the styles
were compared on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep what the codemod can't convert as it renders, and refuse what it can't keep

- A component it doesn't convert inside text (an i18n <Trans>, one with
  hooks) is rendered where the template renders, not written into the
  HTML as a tag.
- JSX from a value (`{banner}`, a prop typed ReactElement or given JSX in
  the preview props) is kept as HTML, as it renders, not read as text.
- A component that goes through its children one by one (`Children.map`)
  isn't inlined: its children stayed one fragment, and two columns became
  one.
- An inlined component brings the types it needs (an imported type, a
  type, interface or enum of its module), so the migrated file
  type-checks.
- <Html> inside a component of the template's own (a context provider)
  is refused: the migrated root is <Email>, and the wrapper would go.
- An empty fragment return (`<></>`) stays as it is, rendering nothing,
  not an empty email; the check treats it like null.
- A heading margin from props (`m={margin}`) is reported as lost.
- HTML entities in JSX attribute strings are decoded, as JSX reads them
  (`&amp;` in a URL or alt text reached the design).
- `${` in a fixed attribute stays text, and the internal slot markers use
  characters a template's own strings don't hold (`"§13"` was rewritten).
- <Font fontWeight="bold"> asks Google Fonts for weight 700.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): make a text prop a merge tag only when any value shows as given

A prop became a merge tag when its marker came through intact and the
sample values gave the same output, so a change the sample didn't reveal
passed: `name.split(" ")[0]` of "Jordan", `title.slice(0, 40)` of a short
title, a capitalized first letter of "Alex". The design then held a plain
{{name}} the template changes. The template is also rendered with other
values (a lowercase first letter, more words, longer), which must show
exactly as given; a prop that doesn't keeps its sample value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(from-react-email): keep the migration's names clear of the template's, and JSX constants out of loops that would capture them

- The helpers the migrated file gets (escapeHtml, htmlText, htmlAttribute,
  renderToStaticMarkup) take another name when the template uses theirs.
- An Elements component the migration imports gets an alias when the
  template declares that name anywhere (a `Paragraph` inside the
  component, a prop named `Button`), not only at the top of the file.
- A JSX constant isn't written in where a loop's or a catch clause's
  variable (or a function or class in a block) of a name it uses would
  capture it.
- An array of JSX (`rows.push(<Text>…</Text>)`, `Element[]`) shown as
  `{rows}` is kept as HTML, as it renders, not read as text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: describe the migration check's style comparison

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep kept HTML's table and paragraph defaults in the editor's export

The editor's exported email sets `table, td, tr { border-collapse: collapse }`
and `p { margin: 0 }`. Markup kept as an Html block wasn't written for either:
a table lost its padding, rounded corners and cell spacing (and showed borders
set on its rows), and a bare paragraph its 1em margins. Kept markup now states
the browser's defaults: `border-collapse: separate` on tables that don't set
it, and the margins a paragraph doesn't set. The original and the Elements
render already show these, so only the editor's export changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Fix converter and check gaps: backgrounds, hidden text, columns, targets, hooks

The converter:
- fills a section's or a button's gradient with its first color (white text
  landed on the white page), and takes <Html>'s background when the Body
  has none;
- keeps text only screen readers get hidden (sr-only, placed off the page);
- keeps a column's width when it's shown under a condition next to others:
  the row's cells follow the condition;
- keeps a link target from props on buttons and linked images, and an inline
  Link's _blank when the prop is left out;
- keeps a class list a condition picks against no class (one element per
  class list), and fails the check for one it can't follow (tones[level]);
- doesn't inline a component whose prop default reads a name the template
  uses for something else;
- leaves out a style value from props that could end the span's attribute;
- reports a Body background image and a Container width it can't keep;
- calls templates and their components in a render of their own, so
  templates that use hooks migrate.

The check reads a gradient of opaque colors as its first stop, reports a
url() image lost behind text unless the color under it is kept, counts
<html>'s styles, and treats screen-reader-only text as hidden. The runtime
report keeps the style differences it computes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* CLI: fail a run that can't go on, add --overwrite, read migrated from imports

- A run that hits an error it can't recover from prints it and exits 2: it
  exited 0 having checked nothing. A file it can't read (a link to a file
  that's gone) fails that file, and the others still run.
- --overwrite replaces files at a destination the run didn't produce (a
  design file from an earlier run). --force only writes templates that fail
  the check: it did both, so following its hint could write those too.
- A template counts as migrated when it imports Elements, not when a comment
  names it.
- A component that renders optional children but has PreviewProps is an
  email of its own: --write writes it instead of leaving it as a layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Elements: call user components in the render React runs; fix plain-text entities and ids

- A user component in a Column is called in the render React runs, as in
  0.1.22: a separate render reset that render's hooks, so a component with a
  hook that renders plain HTML (<div dangerouslySetInnerHTML>) emptied its
  whole Column, and a Provider around a Row wasn't seen. Its HTML is read
  from that one call. renderToJson, which runs outside a React render, still
  calls components in a render of their own.
- renderToPlainText uppercases an h1/h2 around its entities: Q&A, not
  Q&AMP;A.
- renderRowToJson gives the same ids every call; template components inside
  each other get their own useId() ids; a phone value can't open a CSS
  comment that hides the phone rules after it.
- previous-release-patterns.test.tsx renders the ways users write their own
  components (hooks, context, useId, Fragments, memo/forwardRef, a template
  component, entities) with this build and the previous release.
- The bundle budget is 95KB (93,706 bytes now).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Check: fail closed on what it can't read; read sm:, variables and :not(); compare phones

The check passed whatever its cascade couldn't evaluate, so a converter
mistake there passed too. Now:
- A style, or whether words show, that one side sets in a way the cascade
  can't read is "unverified", with the rule, condition or value: the check
  fails on it. Unknown on both sides the same way (markup kept as it was)
  isn't.
- It reads what React Email writes: Tailwind's sm: as @media nested in the
  rule (range syntax), custom properties (an unset one drops the declaration,
  as in the browser, so a Tailwind gradient draws nothing), :not() and the
  structural pseudo-classes, and all 148 named colors. It drops a selector
  the browser can't read (React writes `.footer > p` in a <style> as
  `.footer &gt; p`), which it read as a rule for every p.
- Text over a gradient that fades must be on a color the gradient shows.
- A font size of 0 hides only the text it sets, not a child with a size.
- Both documents are read at a phone's width too: the same words must show.
- The converter writes only colors the browser reads (none, initial and a
  background shorthand's keywords were written as colors).

The 106 benchmark templates all pass with nothing unverified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Check: compare where text sits across the page; center sized blocks an align attribute places

The check didn't compare layout, so a converter that stacked side-by-side
columns, o…

v0.1.22

Toggle v0.1.22's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Merge pull request #57 from unlayer/feat/document-builder-parity

Update document rendering and pagination parity

v0.1.21

Toggle v0.1.21's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(shared): decode common named HTML entities in plaintext output (#54)

htmlToPlainText (behind the public renderToPlainText, which builds the
text/plain MIME part of an email) only mapped ~17 named entities, so common
ones such as &pound;, &euro;, &eacute;, &deg; and &times; leaked into the
plaintext as literal entity text (e.g. "Price &pound;5" instead of "Price
£5"). Expand the named-entity map to cover currency, typographic punctuation,
common symbols, and the Latin-1 accented letters used in European names/words.

Kept as a curated map (not the full HTML5 named-reference set) so the ESM
bundle stays within its CI size budget; anything omitted still decodes when
written as a numeric entity. Unknown entities are still left untouched.

Co-authored-by: eastagiletracker <[email protected]>

v0.1.20

Toggle v0.1.20's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(ci): support Node 24 releases and fork previews (#48)

* fix(ci): support Node 24 releases and fork previews

* revert(ci): defer fork Storybook previews

v0.1.19

Toggle v0.1.19's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
renderToHtmlParts: expose granular css/js/tags (#38)

* feat(react): expose granular css/js/tags on renderToHtmlParts

Non-breaking: head and body are unchanged; the pieces the assembled
head is built from are now also returned, for pipelines that need them
separately (CSS inlining, injecting into an existing style pipeline,
dropping scripts for email sends). No fonts field — Elements has no
font registry, and an always-empty array would misrepresent parity
with editor chunk exports.

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs(react): position granular parts vs the documented chunk parameters

The editor's documented chunks are body/css/js/fonts. Elements exposes
tags additionally because component head functions can contribute
them — reassembling from css + js alone would silently drop tags —
and omits fonts (no font registry; renderToHtml's fonts option covers
font links). Both deviations are now stated in the types and README.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore(react): remove stray prototype file from the branch

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>

v0.1.18

Toggle v0.1.18's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Custom tools: registerElementsTool renders editor custom tools from c…

…ode (#37)

* feat(react): registerElementsTool — render custom tools from code

Accepts the same tool definition embedders already write for the
editor's registerTool (docs.unlayer.com Custom Tools) and returns a
React component. One definition powers both runtimes: the Builder uses
the panel/canvas half (label, icon, options, Viewer, validator — all
accepted and ignored here), while Elements calls renderer.exporters per
mode and collects renderer.head, so code output matches editor exports
by construction. renderToJson emits { type: 'custom', slug, values }
with u_content_custom_<slug> meta ids — the shape custom-tool designs
already use — so trees round-trip into the Builder.

Details: document mode falls back to the web exporter and email-only
tools render via email everywhere (matching export fallbacks); tool
output passes through toSafeHtml when configured (matching editor
exports, which sanitize custom tool HTML); the content wrapper, head
extraction, and JSON emission honor an explicit meta base so ids match
editor-saved designs.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test(react): custom tool edge cases from real-world tool shapes

Adds option-group default seeding — the common real-world custom tool
shape keeps values: {} and defines defaults on each property widget, so
registerElementsTool now folds those in (disabled properties skipped,
tool-level values winning, matching the editor's precedence).

22 edge-case tests modeled on realistic tools: a product card (nested
object values, rich-text defaults, editor-only fields at every level)
and a head-heavy tool (css keyed on values._meta.htmlID, per-mode
branching, js emission, undefined heads, tag dedupe). Failure modes:
throwing exporters drop only their block with a logged error, non-string
returns coerce, special characters pass through raw, the sanitizer sees
each mode's output. Composition: per-slug counters, containerPadding,
base props excluded from values, plaintext extraction.

Round-trip verified against a live editor: a renderToJson design
containing a custom tool loads with the tool registered via customJS,
saveDesign returns type/slug/values/_meta intact, and exportHtml
renders through the tool's own exporter.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(react): review fixes + link-widget normalization + custom tool stories

Review feedback: exporter returns are coerced to a string before a
configured sanitizer sees them (a non-string return would crash a
sanitizer calling string methods), and the metaName doc now describes
the suffix rather than the full prefixed id.

Adding Storybook coverage surfaced a real parity gap the smoke test
caught: tools written for the editor read link-widget values in render
shape (values.<option>.url), but Elements only normalized fields named
href/action. Options declared with widget: 'link' are now normalized
to { url, target } before each render, matching what editor exporters
receive.

Four new stories under Custom Tools/Registered Tools: a countdown with
option-widget defaults, a customized variant, a product card with
nested image/link values, and both tools composed inside a full design.
Visual baseline extended (130 renders; all pre-existing renders
byte-identical to main).

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs(react): autodocs + full source snippets for custom tool stories

The stories had no visible code: the meta lacked the autodocs tag the
other component stories carry, and even autodocs would only show the
JSX usage — for custom tools the definition object is the code worth
copying. Each story now ships an explicit source snippet with the tool
definition, the registerElementsTool call, and the usage.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(react): gallery-grade custom tool examples + per-instance meta ids

Ports the accordion and tab tools from the public examples gallery as
stories, with the upgrades the originals lack: head css scoped per
instance via values._meta.htmlID, idempotent head js, and email
exporters (panels render expanded; tabs become stacked sections — email
has no JS). Stories render through renderToHtmlParts with a head
injector so the interactivity works in the canvas.

Building the two-instance story exposed a real bug: the value-level
_meta.htmlID came from the factory's index default, so every instance
in a Column rendered as _1 while head extraction numbered _1.._N —
instance >= 2's scoped css targeted an id that didn't exist. Column now
allocates the content id before rendering and threads it into the item,
so the value meta, wrapper id, and head/json numbering all agree.
Verified in-browser: two accordions with different colors style and
toggle independently. All snapshots byte-identical — built-in output
is unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(react): port the product, QR, and map gallery tools

Three more tools from the public examples gallery, completing the set:

- product_library — the gallery's flagship card, ported faithfully:
  nested image/link values, per-part colors, rich-text description, and
  the split price/CTA footer, in both div (web) and table (email)
  markup. The link-widget action arrives in render shape (.url/.target)
  exactly as editor exporters receive it.
- qr_tool — in the Builder a property-editor widget generates the code
  client-side; from code any generator URL works. Adds the email
  exporter the original leaves empty (tickets and menus are the prime
  QR use case).
- map_tool — the original builds a Google Static Maps URL around an
  API key, which a public example should not ship. This port computes
  OpenStreetMap tile coordinates from lat/lon/zoom in the exporter
  (slippy-map math), keyless, with a marker overlay on web and the
  same tile table rendering in email.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(react): name the API registerTool — same term as the Builder

Per the naming discussion: terms stay the same across the Builder and
Elements (that shared vocabulary is the point of the one-definition
contract), and the docs bridge the mental model for Elements-first
users — a custom tool is the custom component you create.
registerElementsTool remains exported as an alias.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>