Repository navigation
@unlayer/migrate: MJML and Beefree importers (--from mjml|beefree) - #70
Open
ivoIturrieta wants to merge 49 commits into
Open
ivoIturrieta wants to merge 49 commits into
ivoIturrieta wants to merge 49 commits into
Conversation
…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
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]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
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]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
khashaba
approved these changes
Oct 20, 2026
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tomainfirst (GitHub does that itself when #69's branch is deleted on merge).The repo's tests only run on pull requests into
mainordevelop, so CI starts here once the base ismain(a push after that triggers it). Until then, the validation below was run locally on the branch, with #69's head06a1825merged in.What
npx @unlayer/migrate <dir> --from mjmland--from beefree, with the same flow as--from react-email:--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>.tsxis 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--designwrites 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 (
mjmlpinned at 5.4.1)mj-attributes,mj-classandmj-includeare resolved. An include it can't read (outside the folder given) fails the template, naming it: its check can't see what's missing.mjml-migrate.direction(left to right unless it saysrtl), whatever<mjml dir>says. So<mjml dir="rtl">becomestextDirection="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, anddiris listed as not kept. A section's or group'sdirection="rtl"is listed only in a left-to-right email, where it has columns to reverse.Beefree
<name>.htmlsits beside the JSON. Otherwise the template is reported as unverified, and--strictfails 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
--strictstill 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
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
b35cc79merged in (the merges had no conflicts; since11ec4e2they 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"needsskipLibCheck, an image's source is written as given, a prop leaves out aurl()in double quotes); the round before: markup's inline styles hold what an escapedstylecan (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 asnullrenders 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 asnullis 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'sjavascript:URL loads nothing; highlight.js's time is bounded; Markdown's quotes and task lists anddesignToElements()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@applyTailwind 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 (javascript:,javascript:), in every shape, so nojavascript: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 ajavascript:URL as one that throws; anullMenu item or Social icon is skipped; and before that: ajavascript: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; acid:or base64 imageurl()is kept; a link ofnullis 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 whoseurl()could run code refused, link targets, Menu text and Social names that aren't strings, very long Markdown, Tailwind classes with a prop given asundefined, 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):@unlayer/migratehas its LICENSE, which CI's pack step and the publish job now check.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 (nohref: ""), and its HTML is the same, and the migration fidelity check passes on this merge.Release
@unlayer/migratepublishes as 0.2.0 (committed: new sources are a minor), with the Elements range^0.3.0set in #69.