refactor(email): replace react-email with Maizzle-compiled templates - #428
Merged
Conversation
Rebuild @maple/email on Maizzle: templates are authored in packages/email/emails/*.html (Tailwind classes + [[token]] placeholders + fragment markers) and compiled at build time — Tailwind flattened into inline styles via @maizzle/framework — into checked-in generated modules under src/generated/. A zero-dependency runtime (template.ts) splices fragments and HTML-escapes every interpolated value; Maizzle/PostHTML cannot run on Workers (posthtml-expressions needs new Function), so request-time rendering is plain string interpolation. Same exports and props; deriveDigestStatus and all formatters unchanged in weekly-digest-core. DigestService/alert-email now render synchronously (Effect.try), and react, @react-email/* and the preview server drop out of apps/api entirely. Verified against the react-email output in headless Chrome: the alert notification is pixel-identical; digest variants differ only in antialiasing noise (<0.015% of pixels). Table census (presentational vs default border-spacing) matches the original exactly; hostile-input escaping suite passes. Regenerate templates with: bun run --cwd packages/email build Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the v5 raw-HTML + fragment-marker sources with Maizzle v6 Vue components: one root SFC per email composing ~30 shared components (header, sections, panels, rows, pills), each fragment a component whose props default to its [[token]] placeholders. Compiled output keeps the exact PAGE/FRAGMENTS shape, so the zero-dependency runtime, generated- module consumers and apps/api are byte-for-byte untouched. Tailwind v4's named text sizes are pinned to exact px line-heights (--text-sm--line-height: 20px, …) — lightningcss truncates the unitless ratios (1.4285714… → 1.42857), which shifted text lines by 1px vs the react-email output. A build-time guard (assertExactLineHeights) now rejects truncated ratios, alongside the tight-join whitespace guard. Verified in headless Chrome against the react-email golden renders: alert notification pixel-identical, digest variants at antialiasing noise (<0.015%), identical to the v5 port's parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports main's BrandMark change (143fd52 "feat: fix old logo") into the Maizzle v6 Vue authoring, and drops the react-email sources main still had. Conflicts: - `src/weekly-digest.tsx` / `src/alert-notification.tsx` (modify/delete) — removed; this branch renders from `emails/*.vue`. Main's change to them was the logo swap, ported to `components/MapleBrandMark.vue`: the same hosted PNG at the same URL, emitting exactly what react-email's `<Img>` did (attribute order, the `display/outline/border/text-decoration` reset, and the `<link rel="preload" as="image">` React 19 injected alongside it, declared here via `useHead`). `src/brand-mark.tsx` removed with them — nothing outside the package imported `@maple/email/brand-mark`. - `package.json` — kept this branch's v6 devDependencies and scripts. Main's only change was the `./brand-mark` export, which dies with the file. - `bun.lock` — regenerated with `bun install` over the merged manifests. Verified against a golden regenerated from main's own react-email sources: 0 element, style and text diffs across all four sample variants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
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.
Summary
Replaces react-email with Maizzle v6 in
@maple/email. No functional change — same exports, same props, visually identical output — but react,@react-email/*and their tailwind/prettier/prism machinery (~2MB of module eval that we were already dynamic-importing around) drop out of the API worker entirely. The email package now has zero runtime dependencies.Architecture
Maizzle's
render()runs on Node and cannot execute inside a Worker (and our emails are per-request dynamic — loops and conditionals over live data — so their static build-time pattern doesn't fit either). Compilation and rendering are therefore split:emails/*.vue, ~30 lines each) composing 30 sharedMaple*components (components/): header, section/panel chrome, service/error/detail rows, delta pills, sparkline bars. Fragment components' props default to their own[[token]]placeholders; dynamic regions appear in roots as literal[[#slot]]holes.bun run --cwd packages/email build):@maizzle/frameworkv6 + Tailwind v4 render each root and each fragment component once, flatten every class into inline styles, and emit readable, checked-in modules undersrc/generated/(build:checkguards drift; output is byte-deterministic). Build-time guards assert no whitespace creeps into tight inline joins and no truncated line-height ratios survive (see below).src/template.ts, ~60 lines, zero deps): splices page + fragments and HTML-escapes every interpolated value;[[#slot]]markup can only come from other compiled templates, substituted text is never rescanned, unknown tokens throw instead of shipping[[…]]to an inbox.deriveDigestStatus, formatters and all prop types are unchanged inweekly-digest-core.ts;DigestService/alert-emailrender synchronously now (Effect.tryinstead ofEffect.tryPromise+ dynamic import).Fidelity verification
Rendered all 4 sample variants (digest healthy/watch/critical, alert) with the old and new stack, compared DOM-normalized (element counts, computed styles incl. table attributes, text) and as headless-Chrome screenshots:
cellpadding/cellspacinginjection on every table lost the browser-default 2px border-spacing react-email's plain layout tables kept (~37px cumulative) →useTransformers: { addAttributes: false }, table census now matches golden exactly.1.4285714…→1.42857= 19.9999px), shifting text lines by 1px → named text sizes pinned to exact px line-heights +assertExactLineHeightsbuild guard.turbo typecheck(api + email + dependents): 5/5. API tests: 1690 passed.Notes
packages/email/emailsandcomponentsare oxfmt-ignored: template whitespace is load-bearing (a reflowed newline becomes a rendered space)..vue<script setup lang="ts">blocks aren't covered bytsc --noEmit(novue-tscin the repo) — they're thin prop declarations; addingvue-tscis a possible follow-up.escapeHtmlprevents attribute breakout but doesn't filter URL schemes (javascript:in a config-provided URL would land inhref) — same behavior as react-email's<Link>; cheap follow-up if we want it hardened.email devpreview server is replaced bybun run --cwd packages/email preview(renders the 4 sample variants to/tmp/maple-email-preview/).🤖 Generated with Claude Code