Skip to content

@unlayer/migrate: MJML and Beefree importers (--from mjml|beefree) - #70

Open
ivoIturrieta wants to merge 49 commits into
feat/react-elements-0.3from
feat/migrate-mjml-beefree
Open

ivoIturrieta wants to merge 49 commits into
feat/react-elements-0.3from
feat/migrate-mjml-beefree

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on the 0.3.0 library PR (#69), since it uses @unlayer/react-elements/codegen: it shows only the importer changes. Merge it after #69, with its base changed to main first (GitHub does that itself when #69's branch is deleted on merge).

The repo's tests only run on pull requests into main or develop, so CI starts here once the base is main (a push after that triggers it). Until then, the validation below was run locally on the branch, with #69's head 06a1825 merged in.

What

npx @unlayer/migrate <dir> --from mjml and --from beefree, with the same flow as --from react-email:

  • convert, check against the original, report;
  • --write / --out / --design / --report / --strict, the same git-safety and collision rules, the same ✓/✗ output and exit codes.

The source files stay in place, and <name>.tsx is written beside each one.

Both converters are pure source → Unlayer design functions (src/from-mjml, src/from-beefree). The library's codegen writes the design as Elements code, and --design writes the design itself. MJML and Beefree templates carry no program logic, so nothing is lost by going through the design. The same functions could later power editor imports.

MJML (mjml pinned at 5.4.1)

  • mj-attributes, mj-class and mj-include are resolved. An include it can't read (outside the folder given) fails the template, naming it: its check can't see what's missing.
  • These become editable blocks: sections, columns, text, buttons, images, dividers, spacers, social (as Social blocks), navbar and simple tables.
  • Hero overlays, carousels and accordions stay as MJML's own HTML. They're counted separately as not editable, so they don't inflate ✓.
  • MJML 3 files fail with a pointer to mjml-migrate.
  • Direction: Elements lays out every Row's columns in the email's direction, where MJML lays out each section's and group's by its own direction (left to right unless it says rtl), whatever <mjml dir> says. So <mjml dir="rtl"> becomes textDirection="rtl" only where no section or group shows two or more columns left to right; otherwise the email stays left to right, with its columns where MJML shows them, and dir is listed as not kept. A section's or group's direction="rtl" is listed only in a left-to-right email, where it has columns to reverse.
  • Checked against MJML's own output.

Beefree

  • Classic JSON in its known wrappers and versions. An unknown version fails loudly.
  • Rows, columns and modules map to blocks. The legacy text module keeps a link's own color, as Beefree's export does (the module's link color goes only to links without one); the paragraph module, which Beefree exports with its link color over a link's own, is unchanged.
  • Checked against Beefree's HTML export when <name>.html sits beside the JSON. Otherwise the template is reported as unverified, and --strict fails it.

Declared differences and unreadable text (convert-core)

A converter can declare specific changes it knows it makes. The editor's Social block writes alt text as "Facebook icon", and Beefree's phone hamburger menu has no equivalent. The check expects these and lists them under "looks different" instead of flagging a converter bug. Anything undeclared still fails, and --strict still fails declared ones.

The check also fails text that reads clearly in the original (contrast 3 or more) and is too faint to read in the migration (under 1.5), such as a black link turned pale yellow on white: text now hard to read, with the colors, not called a converter bug. Other color changes stay look differences.

Results on real templates

MJML (114 documents) 112 ✓, 0 ✗, 96.2% of blocks editable. The other 2 don't compile in MJML either.
Beefree (107 templates) 107 converted, exact design round trip, 97.6% editable. 33 ✓ / 0 ✗ against Beefree's exports; 74 have no export.

Committed fixtures are permissively licensed (MIT, Apache-2.0), with LICENSE and NOTICE files. The rest of the corpus was used locally only.

Validation

With #69's head b35cc79 merged in (the merges had no conflicts; since 11ec4e2 they bring #69's fixes from four more review rounds (an email service's tags kept whole in rich text, background URLs and plain text's headings; display conditions written around rows and blocks; rich text's sanitizer tightened; a Table's cells from data written as text; style values' double quotes kept), and before them: every timing test in the react and shared packages through one helper (a cost at two sizes timed again where it looks slow, each size's fastest of three samples of 20 ms at least; a cost against a limit run twice more where it's over, held to the limit times the machine's measured slowness), after one more CI flake on a 5 ms baseline; and the streamed-context test's page data released once its shell is written, not on a timer that React 18's setImmediate-scheduled shell could lose to; the round before: Tailwind's stylesheet, which each build reads again whole, read in time linear in its size on Node 20 (a compiler's of 2,400 classes, 114 KB, took 118 ms a build, now 5.6 ms); the Tailwind class growth fuzz retimed, with the one variant shape Tailwind's own compiler reads in time squared left out and read at the 512-character cap under 100 ms; the round before: tests that hold on CI's runners (a turn of the event loop between tests, so vitest's report of them can't time out; the Tailwind swap test's stylesheets read where Elements reads them; the growth tests time a shape that looks slow again, then at ten times the size, and each quote against a quarter of its size; the deep code test within Node 20's stack; 30 s a test), and docs that say what ships (moduleResolution: "node" needs skipLibCheck, an image's source is written as given, a prop leaves out a url() in double quotes); the round before: markup's inline styles hold what an escaped style can (a theme's double-quoted font names, url("…"), a string's ;), and refuse only what could end the attribute or start a tag once read, or a URL a component can't take (url(javascript:…)); a render's shared work is kept across copies of Elements and in all the code it runs; plain text leaves out blocked URLs and keeps its placeholders from text that decodes to them; text placed straight in a Row or Column is said once per render; the main entry's budget is 221 KB; the round before: a handle keeps a fresh Tailwind compiler ready, so a synchronous loop of renders gets one at once, and past 8,000 classes on one compiler with none ready a render that needs a new class fails saying to yield or use the async renderers; markup inlines no class value a prop can't take; a render's shared work is kept to its own render (template wrappers, nested renders) and Markdown's table cells count against it; code themes are read by their own names; plain text's skip names are read as written; the main entry's budget is 219.5 KB; the round before: Tailwind's shared compiler is given only classes it builds in time (512 characters, 8 variants, 4 & in a class's variants, brackets 8 deep, a render's 64 KB of classes), and a class its build throws for without keeping it is found and left out; a Markdown's headings' and rich text children's classes are compiled with the render's others; an image's size string is read once; Markdown's and code's work is shared across a render, not per block; plain text's tag names are indexed by their parts; the main entry's budget is 218 KB; the round before: a content prop given as null renders nothing, as in 0.2.1; a logo's height class keeps its shape; a Video's id can't add markup; Tailwind classes, rich text's markup, HTML's plain text, code's trailing whitespace, and Markdown's would-be tables, link text, list items, paragraph lines and references are read in time linear in their size; the main entry's budget is 216 KB; the round before: a Tailwind plugin's error is tied to its class by the value Tailwind gave the function, so a user's class can't fail the app's own; Tailwind classes in a Heading's or a Button's children and on a lone <Link> are inlined; a prop given as null is one not given; a <script>'s or <style>'s text in rich text children is left out; a Row's anchor, a Video's link and thumbnail and a Social icon's image can't add markup; a Font's javascript: URL loads nothing; highlight.js's time is bounded; Markdown's quotes and task lists and designToElements() take time linear in their size; the main entry's budget is 207 KB; the round before: Markdown's inline syntax from data is read in time linear in its size; a Tailwind plugin's function that gives nothing, or an @apply Tailwind can't compile, no longer breaks the shared handle, and an error that can't be tied to a class fails only its render; an async render that renders again lists and says each part it leaves out once; the docs say a Table's cells are HTML and style props are written as given, and the Markdown guide gives its densest shapes' output, time and memory; the round before: a block's link is read with its character references decoded (&#106;avascript:, javascript&colon;), in every shape, so no javascript: link the editor's exporters write runs; a Tailwind plugin's error is pinned on its class where another utility gives the class CSS too and inside compound variants, in every render; an async render whose own class makes Tailwind compile again renders again once it has; the Markdown guide gives memory for its worst shape, deeply nested quotes; the round before: a Tailwind plugin that throws for one class fails only the component whose class it is and changes nothing in other renders; the links the editor's exporters write get a javascript: URL as one that throws; a null Menu item or Social icon is skipped; and before that: a javascript: URL in the links Elements writes is one that throws with React 18 too; a render reads its first 2,000 different Tailwind classes; only the Tailwind class built alone and seen to fail is refused; a <Tailwind>'s own classes are built at once; a cid: or base64 image url() is kept; a link of null is none; and before them, Markdown and Tailwind classes from data rendering in time linear in their size, a table in nested quotes charged once, a class whose url() could run code refused, link targets, Menu text and Social names that aren't strings, very long Markdown, Tailwind classes with a prop given as undefined, class values or link targets that could end an HTML attribute, Markdown's tables, nesting and nested tables, inline JSX's encoded URLs and merge tags in the plain text, and what a context reads around an app's renders):

  • migrate 366 tests and convert-core 220 pass, and so do the other packages' (react 1,894, from-react-email 344, shared 142); the typechecks are clean. The packed @unlayer/migrate has its LICENSE, which CI's pack step and the publish job now check.
  • The results above were measured on the branch with @unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes #69's earlier head (cddea6f) merged in. The merges since change no converter code; of @unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes #69's changes, only generated code for a Menu item without a link differs (no href: ""), and its HTML is the same, and the migration fidelity check passes on this merge.
  • A replay of the repo's CI workflow on a clean checkout passes every step: frozen install, build, typechecks, bundle size, tests, CSP, browser runtime, E2E, migration fidelity, the differential check, Storybook smoke and visual gates, and the Next.js 14/15/16 and Jest 30 consumers of the packed package (Next.js 16 on 16.4.2, from @unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes #69), the packed migrate's LICENSE, and the project with Tailwind CSS 3 and prettier 2.
  • New coverage: per-tag and per-module unit tests, fixture tests, CLI e2e for both sources, and declared-difference tests (an undeclared alt change still fails); a right-to-left MJML template's columns where MJML shows them, a Beefree legacy text module's link colors, and links made too faint to read failing the check.

Release

@unlayer/migrate publishes as 0.2.0 (committed: new sources are a minor), with the Elements range ^0.3.0 set in #69.

ivoIturrieta and others added 5 commits October 15, 2026 19:09
…urpose

Some content changes are known and accepted, such as the editor's Social block writing an
icon's alt text as "Facebook icon", or Beefree's phone hamburger menu having no equivalent.
Without a way to say so, the content check reported them as a converter bug.

applyDeclared(originalHtml, declarations) applies only the named substitutions (an alt or src
on an exact image, a glyph inside a named class, an element shown or hidden) to the reference
before the unchanged content check runs, and describeDeclared lists each one as a difference.
Anything undeclared still fails; --strict still fails declared ones.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Same flow as React Email: convert, check against the original, report, and write with --write
or --out under the same git-safety, collision and exit-code rules. Both converters are pure
source → Unlayer design functions; @unlayer/react-elements/codegen writes the design as code and
--design writes the design itself.

- MJML (mjml pinned at 5.4.1): mj-attributes, mj-class and mj-include resolved; sections,
  columns, text, buttons, images, dividers, spacers, social, navbar and simple tables become
  editable blocks; hero overlays, carousels and accordions stay as MJML's own HTML, counted
  separately as not editable. MJML 3 files fail with a pointer to mjml-migrate. Checked against
  MJML's own output.
- Beefree: classic JSON in its known wrappers and versions (unknown versions fail loudly); rows,
  columns and modules map to blocks. Checked against Beefree's HTML export when a <name>.html
  sits beside the JSON, otherwise reported as unverified (--strict fails it).
- Social alt text and Beefree's hamburger menu are declared differences.

On real templates: MJML 112 of 114 ✓, 0 ✗, 96.2% of blocks editable (the other 2 don't compile in
MJML either); Beefree 107 of 107 converted, 33 ✓ and 0 ✗ against its exports. Committed fixtures
are permissively licensed, with LICENSE and NOTICE files.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…porters

New sources are a feature, so a minor: the release job publishes a
committed version ahead of npm instead of its default patch.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
An mj-include outside the folder given (or one that fails to read) is
left out of the conversion, and the reference is compiled from the same
document, so the check couldn't see the missing content and passed it.
The template now fails, naming what wasn't read, in the run and the
report; it's still written, flagged, as other content failures are.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The file name went into a // comment of the code that's written and
loaded to check the template, so a name with a line break ended the
comment and ran what followed. Line breaks become spaces.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
@ivoIturrieta
ivoIturrieta marked this pull request as ready for review October 16, 2026 05:16
A template that fails for an mj-include it couldn't read now says it
failed the check, not that its content differs, and the summary no
longer asks to report it as a converter bug.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
ivoIturrieta and others added 7 commits October 18, 2026 22:36
The module's link color replaced every link's own, so the black links of
toptile-legacy/signup-email came out pale yellow on white. Beefree's
export of the legacy text module keeps a link's color and gives the
module's link color (else the body's) only to links without one; its
paragraph module is exported with the link color over a link's own, as
before.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A black link that came out pale yellow on white (contrast 21 to 1.4)
passed with a color listed under "looks different": the check only
counted text it couldn't make out at all (under 1.2). Text that reads
clearly on one side (3 or more) and under 1.5 on the other is now not
the same words read either: the check fails with "text now hard to
read", without calling it a converter bug. Other color changes stay
look differences.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The package type-checks against ES2020's library, where strings have no
replaceAll: a global pattern does the same in the faint links test.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…"rtl"

<mjml dir="rtl"> became a right-to-left email, whose columns Elements
lays out right to left, where MJML's sections show theirs left to right
whatever dir says (each section and group has a direction of its own,
ltr unless set). The email is now right to left only where no section
or group shows its columns left to right, so they're where MJML shows
them on desktop and stacked in order on phones; otherwise dir is listed
as not kept. Sections or groups with direction="rtl" in such an email
need nothing listed; in a left-to-right one they're listed as before,
now only where they have columns to reverse, groups too.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
ivoIturrieta and others added 14 commits October 22, 2026 16:18
ivoIturrieta and others added 22 commits October 23, 2026 22:47

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants