Repository navigation
Tags: unlayer/elements
Tags
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]>
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 (-, −, −), 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 " 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 (`&` 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&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 > 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…
@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]>
@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 && <> · </>` becomes `second && "…"`), but wrote the text with its entities as typed: the migrated email showed " · " 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 ` ` isn't taken for blank. Test: a condition beside a link shows ` · `, and `||` gives `Terms & 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.…
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 (-, −, −), 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 " 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 (`&` 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&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 > 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…
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 £, €, é, ° and × leaked into the plaintext as literal entity text (e.g. "Price £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]>
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]>
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]>
PreviousNext