diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f4c34862..83bfd59c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,22 +1,28 @@ # Release & Publish # -# Gated publish flow: +# Gated publish flow, one package at a time: # 1. PR merged to main → triggers build & test -# 2. A reviewer approves the publish in the Actions UI (environment gate) -# 3. Package is published to npm via trusted publishing (OIDC) +# 2. Each package publishes only when its code changed since its last +# release tag (Elements: v*, migrate: migrate-v*) +# 3. A reviewer approves each publish in the Actions UI (environment gate) +# 4. The package is published to npm via trusted publishing (OIDC) # # Version: reads the latest published version from npm and bumps from that. # Defaults to patch. Add a `release:minor` or `release:major` PR label to -# override. Manual dispatch also supports bump selection. +# override. Manual dispatch also supports bump selection. A version +# committed ahead of npm (a planned minor or major) wins over the bump. # # Auth: Uses npm trusted publishing (OIDC) — no NPM_TOKEN needed. # The `npm-publish` environment must be configured in repo Settings → # Environments with at least one required reviewer. # -# npm trusted publishing must also be configured on npmjs.com: +# npm trusted publishing must also be configured on npmjs.com, per package: # Package Settings → Publishing access → Add trusted publisher → -# Repository: unlayer/elements -# Workflow: publish.yml +# Repository: unlayer/elements +# Workflow: publish.yml +# Environment: npm-publish +# npm only offers this on a package that exists, so a new package's first +# version is published by hand, with its release tag (e.g. migrate-v0.1.0). name: Release @@ -25,6 +31,14 @@ on: branches: [main] workflow_dispatch: inputs: + package: + description: 'Package to release' + required: true + type: choice + options: + - react-elements + - migrate + default: react-elements bump: description: 'Version bump type' required: true @@ -75,9 +89,53 @@ jobs: - name: Run tests run: pnpm test + changes: + name: Detect package changes + runs-on: ubuntu-latest + outputs: + elements: ${{ steps.detect.outputs.elements }} + migrate: ${{ steps.detect.outputs.migrate }} + + steps: + - name: Checkout code (with tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Compared with each package's last release tag, so a release that + # failed or was rejected is retried on the next push. Fails open: a git + # error counts as a change. + - name: Detect changes since each package's last release + id: detect + shell: bash + run: | + changed() { + local tag + tag=$(git tag -l "$1" --sort=-v:refname | head -1) + shift + [ -z "$tag" ] && { echo true; return; } + git diff --quiet "$tag" HEAD -- "$@" && echo false || echo true + } + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + ELEMENTS=$([ "${{ inputs.package }}" = "react-elements" ] && echo true || echo false) + MIGRATE=$([ "${{ inputs.package }}" = "migrate" ] && echo true || echo false) + else + ELEMENTS=$(changed 'v*' packages/react packages/shared pnpm-workspace.yaml) + MIGRATE=$(changed 'migrate-v*' packages/migrate packages/from-react-email packages/convert-core) + fi + # The first version of a new package is published by hand (see above). + if [ "$MIGRATE" = "true" ] && ! npm view @unlayer/migrate version >/dev/null 2>&1; then + echo "::notice::@unlayer/migrate isn't on npm yet: publish its first version by hand, tag it migrate-v, and add its trusted publisher." + MIGRATE=false + fi + echo "elements=$ELEMENTS" >> $GITHUB_OUTPUT + echo "migrate=$MIGRATE" >> $GITHUB_OUTPUT + echo "Elements changed: $ELEMENTS, migrate changed: $MIGRATE" + publish: - name: Publish to npm - needs: build-and-test + name: Publish @unlayer/react-elements + needs: [build-and-test, changes] + if: needs.changes.outputs.elements == 'true' runs-on: ubuntu-latest concurrency: group: publish @@ -151,8 +209,9 @@ jobs: id: version working-directory: packages/react run: | + COMMITTED=$(node -p "require('./package.json').version") # Read latest published version from npm (falls back to package.json for first publish) - CURRENT=$(npm view @unlayer/react-elements version 2>/dev/null || node -p "require('./package.json').version") + CURRENT=$(npm view @unlayer/react-elements version 2>/dev/null || echo "$COMMITTED") echo "current=$CURRENT" >> $GITHUB_OUTPUT # Write current version to package.json so npm version can bump from it @@ -161,6 +220,12 @@ jobs: # Bump VERSION=$(npm version ${{ steps.bump.outputs.type }} --no-git-tag-version) VERSION="${VERSION#v}" + + # A version committed ahead of npm (a planned minor or major) wins + if [ "$COMMITTED" != "$VERSION" ] && [ "$(printf '%s\n%s\n' "$VERSION" "$COMMITTED" | sort -V | tail -1)" = "$COMMITTED" ]; then + npm version "$COMMITTED" --no-git-tag-version --allow-same-version + VERSION="$COMMITTED" + fi echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Pre-publish summary @@ -205,15 +270,172 @@ jobs: git push origin "$TAG" fi + # Notes start at the previous Elements tag: migrate-v* tags don't match v*. - name: Create GitHub Release run: | TAG="v${{ steps.version.outputs.version }}" + PREVIOUS=$(git ls-remote --tags --refs origin | sed 's#.*refs/tags/##' | grep -E '^v[0-9]' | grep -vFx "$TAG" | sort -V | tail -1) if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG already exists — skipping" else gh release create "$TAG" \ --title "$TAG" \ - --generate-notes + --generate-notes \ + ${PREVIOUS:+--notes-start-tag "$PREVIOUS"} + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-migrate: + name: Publish @unlayer/migrate + needs: [build-and-test, changes, publish] + # After Elements (when it publishes too), so the Elements range migrate requires is on npm. + if: >- + !cancelled() && + needs.build-and-test.result == 'success' && + needs.changes.outputs.migrate == 'true' && + (needs.publish.result == 'success' || needs.publish.result == 'skipped') + runs-on: ubuntu-latest + concurrency: + group: publish-migrate + cancel-in-progress: false + environment: npm-publish + permissions: + contents: write + id-token: write + pull-requests: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.7.0 + + - name: Setup Node.js 24 (with npm registry for OIDC) + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Update npm for trusted publishing + run: npm install -g npm@12 + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Determine version bump + id: bump + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "type=${{ inputs.bump }}" >> $GITHUB_OUTPUT + else + LABELS=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0].labels[].name' 2>/dev/null || echo "") + if echo "$LABELS" | grep -Fxq "release:major"; then + echo "type=major" >> $GITHUB_OUTPUT + elif echo "$LABELS" | grep -Fxq "release:minor"; then + echo "type=minor" >> $GITHUB_OUTPUT + else + echo "type=patch" >> $GITHUB_OUTPUT + fi + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine version + id: version + working-directory: packages/migrate + run: | + COMMITTED=$(node -p "require('./package.json').version") + CURRENT=$(npm view @unlayer/migrate version 2>/dev/null || echo "$COMMITTED") + echo "current=$CURRENT" >> $GITHUB_OUTPUT + npm version "$CURRENT" --no-git-tag-version --allow-same-version + VERSION=$(npm version ${{ steps.bump.outputs.type }} --no-git-tag-version) + VERSION="${VERSION#v}" + if [ "$COMMITTED" != "$VERSION" ] && [ "$(printf '%s\n%s\n' "$VERSION" "$COMMITTED" | sort -V | tail -1)" = "$COMMITTED" ]; then + npm version "$COMMITTED" --no-git-tag-version --allow-same-version + VERSION="$COMMITTED" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Packing writes `^` as migrate's Elements range: + # that release must be on npm, or installs would fail (or, with an older + # range, silently use an Elements without the converter's settings). + - name: Check the Elements range is published + id: elements + working-directory: packages/migrate + run: | + RANGE="^$(node -p "require('../react/package.json').version")" + echo "range=$RANGE" >> $GITHUB_OUTPUT + for attempt in 1 2 3 4 5 6; do + if [ -n "$(npm view "@unlayer/react-elements@$RANGE" version 2>/dev/null)" ]; then exit 0; fi + sleep 10 + done + echo "::error::@unlayer/react-elements@$RANGE isn't on npm: release Elements first." + exit 1 + + - name: Pre-publish summary + run: | + echo "## Publish Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **Package** | \`@unlayer/migrate\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Version** | ${{ steps.version.outputs.current }} → ${{ steps.version.outputs.version }} (${{ steps.bump.outputs.type }}) |" >> $GITHUB_STEP_SUMMARY + echo "| **Elements range** | ${{ steps.elements.outputs.range }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Trigger** | ${{ github.event_name }} |" >> $GITHUB_STEP_SUMMARY + + - name: Publish to npm (trusted publishing) + working-directory: packages/migrate + run: | + pnpm pack --pack-destination /tmp + TARBALL=$(ls /tmp/unlayer-migrate-*.tgz) + npm publish "$TARBALL" --access public + + - name: Tag release + run: | + TAG="migrate-v${{ steps.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git ls-remote --tags origin | awk '{print $2}' | grep -Fxq "refs/tags/$TAG"; then + echo "Tag $TAG already exists — skipping" + else + git tag "$TAG" + git push origin "$TAG" + fi + + # Not marked latest: the repository's latest release stays Elements. + - name: Create GitHub Release + run: | + TAG="migrate-v${{ steps.version.outputs.version }}" + PREVIOUS=$(git ls-remote --tags --refs origin | sed 's#.*refs/tags/##' | grep -E '^migrate-v[0-9]' | grep -vFx "$TAG" | sort -V | tail -1) + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists — skipping" + else + gh release create "$TAG" \ + --title "@unlayer/migrate ${{ steps.version.outputs.version }}" \ + --generate-notes \ + --latest=false \ + ${PREVIOUS:+--notes-start-tag "$PREVIOUS"} fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6aa622ef..3fc95d51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,16 +55,16 @@ jobs: run: pnpm --filter @unlayer/react-elements typecheck # ── Bundle size budget ────────────────────────────────────── - # Fail if the react-elements ESM bundle exceeds 76KB. - # Current size: ~76KB (15 components + image width-pinning geometry - # + per-mode full-document shells for renderToHtml). + # Fail if the react-elements ESM bundle exceeds 87KB. + # Current size: 86,697 bytes, including phone device-override CSS, + # visibility settings and template-component unwrapping. # The budget tracks the unminified ESM output as a proxy for code volume; # it still flags accidental dependency bundling (any real dep is 10KB+). - name: Check bundle size run: | BUNDLE="packages/react/dist/index.js" SIZE=$(wc -c < "$BUNDLE" | tr -d ' ') - MAX_SIZE=76000 + MAX_SIZE=87000 echo "Bundle: $BUNDLE" echo "Size: $SIZE bytes (budget: $MAX_SIZE bytes)" if [ "$SIZE" -gt "$MAX_SIZE" ]; then @@ -117,6 +117,15 @@ jobs: - name: Browser E2E gate run: pnpm --filter @unlayer/react-elements test:e2e + # ── Migration fidelity smoke test ─────────────────────────── + # Converts the committed fixture templates both ways (codemod and + # runtime) and renders them next to their React Email originals in + # Chromium at desktop and phone widths. Fails on lost or added content, + # a word shown at another size on desktop, or words moved (on phones, + # only for the fixtures that match there). + - name: Migration fidelity smoke test + run: pnpm --filter @unlayer/from-react-email test:fidelity + - name: Storybook smoke test run: pnpm --filter @unlayer/react-elements test-storybook:ci diff --git a/CLAUDE.md b/CLAUDE.md index c493fc9f..7848f82b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,9 @@ cd packages/react && pnpm storybook # Storybook dev server | `@unlayer/react-elements` | `packages/react` | Yes (npm) | React components, renderers, context | | `@unlayer-internal/shared-elements` | `packages/shared` | No (private, bundled into react) | Framework-agnostic types, config, utils | | `@unlayer/elements-demo` | `packages/demo` | No | Demo/showcase app | +| `@unlayer/from-react-email` | `packages/from-react-email` | No (private, bundled into migrate; API at `@unlayer/migrate/react-email`) | React Email → Elements converter (codemod, runtime with merge tags, check against the original); fidelity benchmark in `bench/`, results in `FIDELITY.md` | +| `@unlayer/migrate` | `packages/migrate` | Yes (npm) | React Email converter API at `@unlayer/migrate/react-email`; `npx @unlayer/migrate` CLI: migrate React Email templates, check them, write a report; `compare` for templates migrated by hand | +| `@unlayer/convert-core` | `packages/convert-core` | No (private, bundled into the converters) | Elements tree, conversion report, TSX printer and content check that converters share | ### Component Hierarchy (strict) @@ -102,15 +105,18 @@ Example: ` + Loading editor… + +
+ + + + diff --git a/examples/react-email-in-editor/emails/order-receipt.tsx b/examples/react-email-in-editor/emails/order-receipt.tsx new file mode 100644 index 00000000..834658c0 --- /dev/null +++ b/examples/react-email-in-editor/emails/order-receipt.tsx @@ -0,0 +1,75 @@ +import { Body, Column, Container, Head, Heading, Hr, Html, Link, Preview, Row, Section, Text } from "@react-email/components"; + +interface Item { + name: string; + quantity: number; + price: string; +} + +interface OrderReceiptProps { + orderId?: string; + items?: Item[]; + total?: string; + trackingUrl?: string; +} + +const text = { margin: 0, fontSize: "14px", lineHeight: "22px", color: "#334155" }; +const label = { ...text, color: "#64748b" }; + +export default function OrderReceipt({ + orderId = "NW-1042", + items = [], + total = "$0.00", + trackingUrl = "https://example.com/track", +}: OrderReceiptProps) { + return ( + + + Receipt for order {orderId} + + + + Thanks for your order + + Order {orderId} +
+
+ {items.map((item) => ( + + + {item.name} + Qty {item.quantity} + + + {item.price} + + + ))} +
+
+ + + Total + + + {total} + + + + Your order ships within two business days. Track your package. + +
+ + + ); +} + +OrderReceipt.PreviewProps = { + orderId: "NW-1042", + items: [ + { name: "Linen notebook", quantity: 2, price: "$24.00" }, + { name: "Brass pen", quantity: 1, price: "$34.00" }, + ], + total: "$58.00", + trackingUrl: "https://example.com/track/NW-1042", +} satisfies OrderReceiptProps; diff --git a/examples/react-email-in-editor/emails/welcome.tsx b/examples/react-email-in-editor/emails/welcome.tsx new file mode 100644 index 00000000..3ba80fa3 --- /dev/null +++ b/examples/react-email-in-editor/emails/welcome.tsx @@ -0,0 +1,73 @@ +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Hr, + Html, + Preview, + Row, + Section, + Tailwind, + Text, +} from "@react-email/components"; + +interface WelcomeEmailProps { + name?: string; + workspace?: string; + dashboardUrl?: string; +} + +const steps = [ + { title: "Invite your team", body: "Bring teammates in with one link." }, + { title: "Connect your data", body: "Import from a spreadsheet or an API." }, + { title: "Share a report", body: "Send a live dashboard to anyone." }, +]; + +export default function WelcomeEmail({ + name = "Ada", + workspace = "Northwind", + dashboardUrl = "https://example.com/dashboard", +}: WelcomeEmailProps) { + return ( + + + + Welcome to {workspace}, {name} + + + Welcome to {workspace}, {name} + + Your workspace is ready. Here are three things most teams do in their first week. + +
+ + {steps.map((step) => ( + + {step.title} + {step.body} + + ))} + +
+ +
+ + You're receiving this because you created a {workspace} workspace. + +
+ +
+ + ); +} + +WelcomeEmail.PreviewProps = { + name: "Ada", + workspace: "Northwind", + dashboardUrl: "https://example.com/dashboard", +} satisfies WelcomeEmailProps; diff --git a/examples/react-email-in-editor/package.json b/examples/react-email-in-editor/package.json new file mode 100644 index 00000000..af991cd3 --- /dev/null +++ b/examples/react-email-in-editor/package.json @@ -0,0 +1,24 @@ +{ + "name": "@unlayer/react-email-in-editor-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "migrate": "unlayer-migrate emails --out output --design --report output/report.json", + "preview": "http-server . -a 127.0.0.1 -p 3002 -c-1", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@react-email/components": "^1.0.12", + "@unlayer/migrate": "workspace:*", + "@unlayer/react-elements": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "http-server": "14.1.1", + "typescript": "^5.6.3" + } +} diff --git a/examples/react-email-in-editor/tsconfig.json b/examples/react-email-in-editor/tsconfig.json new file mode 100644 index 00000000..ec87ae7d --- /dev/null +++ b/examples/react-email-in-editor/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["emails"] +} diff --git a/packages/convert-core/package.json b/packages/convert-core/package.json new file mode 100644 index 00000000..ce37fa1b --- /dev/null +++ b/packages/convert-core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@unlayer/convert-core", + "version": "0.0.0", + "private": true, + "description": "Shared model for converters into Unlayer Elements: the Elements tree, conversion report, and TSX / design JSON / HTML outputs.", + "license": "MIT", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest" + }, + "dependencies": { + "prettier": "^3.3.3", + "entities": "^7.0.1" + }, + "peerDependencies": { + "@unlayer/react-elements": "workspace:*", + "react": ">=18" + }, + "devDependencies": { + "@unlayer/react-elements": "workspace:*", + "@types/react": "^18.3.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.6.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/convert-core/src/css.ts b/packages/convert-core/src/css.ts new file mode 100644 index 00000000..9bc2a9ba --- /dev/null +++ b/packages/convert-core/src/css.ts @@ -0,0 +1,61 @@ +/** + * CSS helpers every converter needs: reading lengths and box shorthands. + */ + +/** "16px" → 16, "1.5em" → 24, 12 → 12; undefined for %, auto, calc(), … */ +export function toPx(value: unknown, emBase = 16): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return undefined; + const match = /^(-?\d*\.?\d+)(px|em|rem|pt)?$/.exec(value.trim()); + if (!match) return undefined; + const n = Number.parseFloat(match[1]); + switch (match[2]) { + case "em": + case "rem": + return n * emBase; + case "pt": + return (n * 4) / 3; + default: + return n; + } +} + +export interface BoxSides { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Resolve a margin/padding: the shorthand (`style.padding`) and the longhands + * (`style.paddingTop`, …) that override it, in px. Unreadable sides are 0. + */ +export function boxSides(style: Record, property: "margin" | "padding"): BoxSides { + const sides: BoxSides = { top: 0, right: 0, bottom: 0, left: 0 }; + const shorthand = style[property]; + if (shorthand !== undefined) { + const parts = String(shorthand).trim().split(/\s+/).map((part) => toPx(part) ?? 0); + const [t, r = t, b = t, l = r] = typeof shorthand === "number" ? [shorthand] : parts; + Object.assign(sides, { top: t, right: r, bottom: b, left: l }); + } + for (const side of ["Top", "Right", "Bottom", "Left"] as const) { + const value = style[`${property}${side}`]; + if (value !== undefined) sides[side.toLowerCase() as keyof BoxSides] = toPx(value) ?? 0; + } + return sides; +} + +/** "color: red; font-size: 14px" → { color: "red", fontSize: "14px" } */ +export function parseStyle(css: string): Record { + const out: Record = {}; + for (const declaration of css.split(";")) { + const index = declaration.indexOf(":"); + if (index === -1) continue; + const property = declaration.slice(0, index).trim(); + const value = declaration.slice(index + 1).trim(); + if (!property || !value) continue; + out[property.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())] = value; + } + return out; +} diff --git a/packages/convert-core/src/entities.ts b/packages/convert-core/src/entities.ts new file mode 100644 index 00000000..ee327242 --- /dev/null +++ b/packages/convert-core/src/entities.ts @@ -0,0 +1,4 @@ +import { decodeHTMLStrict } from "entities"; + +/** Decode the complete HTML entity set, including numeric references. */ +export const decodeHtmlEntities = decodeHTMLStrict; diff --git a/packages/convert-core/src/fonts.ts b/packages/convert-core/src/fonts.ts new file mode 100644 index 00000000..62208bfe --- /dev/null +++ b/packages/convert-core/src/fonts.ts @@ -0,0 +1,144 @@ +/** + * Web fonts for the Unlayer editor. A design's fontFamily values name fonts + * but don't load them: the editor loads and exports a web font only when it + * is registered with it (`fonts.customFonts` when the editor is created). + */ + +/** A font as the editor's `fonts.customFonts` takes it. */ +export interface EditorFont { + label: string; + value: string; + url: string; +} + +/** + * The web fonts a design uses, from the stylesheets the template links + * (`fonts` on the Elements root): one entry per family, with the design's own + * font stack as its value. Google Fonts and @font-face data stylesheets are + * matched to their families; other stylesheets can't be without fetching them. + */ +export function editorFonts(design: unknown, stylesheets: Array<{ url: string }> = []): EditorFont[] { + const urls = familyStylesheets(stylesheets.map((s) => s.url)); + if (!urls.size) return []; + const stacks = new Map(); + const texts: string[] = []; + const walk = (node: unknown, key?: string): void => { + if (Array.isArray(node)) return node.forEach((child) => walk(child)); + if (!node || typeof node !== "object") { + if (typeof node === "string" && key === "text") texts.push(node); + return; + } + const font = node as { label?: unknown; value?: unknown }; + if (key === "fontFamily" && typeof font.value === "string") { + const family = firstFamily(font.value); + if (!stacks.has(family)) stacks.set(family, { label: typeof font.label === "string" ? font.label : undefined, value: font.value }); + } + for (const [k, v] of Object.entries(node)) walk(v, k); + }; + walk(design); + const inText = texts.join("\n").toLowerCase(); + const fonts: EditorFont[] = []; + for (const [family, url] of urls) { + const stack = stacks.get(family.toLowerCase()); + if (stack) fonts.push({ label: stack.label ?? family, value: stack.value, url }); + else if (inText.includes(family.toLowerCase())) fonts.push({ label: family, value: `'${family}'`, url }); + } + return fonts; +} + +/** + * The same lists with one stylesheet per family across all of them, covering + * every style any list loads. An editor registers each family once, so fonts + * gathered from several templates must each load what all of them use. + */ +export function shareEditorFonts(lists: EditorFont[][]): EditorFont[][] { + const urls = new Map(); + for (const font of lists.flat()) { + const family = firstFamily(font.value); + urls.set(family, [...(urls.get(family) ?? []), font.url]); + } + const shared = new Map(); + for (const [family, list] of urls) { + const merged = [...familyStylesheets(list)].find(([name]) => name.toLowerCase() === family); + shared.set(family, merged?.[1] ?? list[0]); + } + return lists.map((fonts) => fonts.map((font) => ({ ...font, url: shared.get(firstFamily(font.value)) ?? font.url }))); +} + +/** A stack's first family, unquoted and lowercased: how the editor matches fonts. */ +function firstFamily(stack: string): string { + return stack.split(",")[0].replace(/['"]/g, "").trim().toLowerCase(); +} + +/** Each family a stylesheet loads, with one stylesheet URL for it. */ +function familyStylesheets(urls: string[]): Map { + const google = new Map(); + const css = new Map(); + const other = new Map(); + for (const url of urls) { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + continue; + } + if (parsed.protocol === "data:") { + const comma = url.indexOf(","); + let text: string; + try { + text = decodeURIComponent(url.slice(comma + 1)); + } catch { + continue; + } + for (const m of text.matchAll(/@font-face\s*\{[^}]*?font-family:\s*(['"]?)([^;'"}]+)\1/g)) { + const family = m[2].trim(); + const list = css.get(family) ?? []; + if (!list.includes(text)) list.push(text); + css.set(family, list); + } + } else if (parsed.hostname === "fonts.googleapis.com") { + const families = parsed.pathname === "/css2" ? parsed.searchParams.getAll("family") : (parsed.searchParams.get("family") ?? "").split("|"); + for (const spec of families) { + const family = spec.split(":")[0].trim(); + if (!family) continue; + if (parsed.pathname === "/css2") google.set(family, [...(google.get(family) ?? []), spec]); + else if (!other.has(family)) other.set(family, url); + } + } + } + const out = new Map(other); + for (const [family, specs] of google) out.set(family, googleStylesheet(family, specs)); + for (const [family, texts] of css) out.set(family, `data:text/css,${encodeURIComponent(texts.join(""))}`); + return out; +} + +/** + * One Google Fonts css2 URL with every weight and style the specs ask for + * (`Inter:wght@400` and `Inter:wght@600` become `Inter:wght@400;600`). + * Specs with other axes or ranges are kept as the first one given. + */ +function googleStylesheet(family: string, specs: string[]): string { + const name = family.replace(/ /g, "+"); + const url = (spec: string) => `https://fonts.googleapis.com/css2?family=${spec.replace(/ /g, "+")}&display=swap`; + const styles = new Set(); + for (const spec of specs) { + const axes = spec.slice(family.length + 1); + if (!axes) { + styles.add("0,400"); + continue; + } + const [names, values] = axes.split("@"); + const keys = names.split(","); + if (!values || keys.some((k) => k !== "ital" && k !== "wght")) return url(specs[0]); + for (const tuple of values.split(";")) { + const parts = tuple.split(","); + if (parts.length !== keys.length || parts.some((p) => !/^\d+$/.test(p))) return url(specs[0]); + const ital = keys.includes("ital") ? parts[keys.indexOf("ital")] : "0"; + const wght = keys.includes("wght") ? parts[keys.indexOf("wght")] : "400"; + styles.add(`${ital},${wght}`); + } + } + const sorted = [...styles].map((s) => s.split(",").map(Number)).sort((a, b) => a[0] - b[0] || a[1] - b[1]); + if (sorted.some(([ital]) => ital === 1)) return url(`${name}:ital,wght@${sorted.map((s) => s.join(",")).join(";")}`); + return url(`${name}:wght@${sorted.map((s) => s[1]).join(";")}`); +} diff --git a/packages/convert-core/src/index.ts b/packages/convert-core/src/index.ts new file mode 100644 index 00000000..deadf8f6 --- /dev/null +++ b/packages/convert-core/src/index.ts @@ -0,0 +1,20 @@ +export { + el, + expr, + isExpr, + hole, + fallbackHtml, + contentNodes, + ROOT_TYPES, + LAYOUT_TYPES, + CONTENT_TYPES, + type ElementNode, + type Expr, +} from "./tree"; +export { ReportBuilder, type ConversionReport, type ReportEntry } from "./report"; +export { treeToElement, treeToDesign, treeToHtml, pinImageWidths } from "./render"; +export { treeToTsx, printJsx, formatTsx, type PrintOptions } from "./print"; +export { parseStyle, toPx, boxSides, type BoxSides } from "./css"; +export { compareText, htmlAttributes, htmlWords, type TextCheck } from "./verify"; +export { decodeHtmlEntities } from "./entities"; +export { editorFonts, shareEditorFonts, type EditorFont } from "./fonts"; diff --git a/packages/convert-core/src/print.ts b/packages/convert-core/src/print.ts new file mode 100644 index 00000000..567a3b6c --- /dev/null +++ b/packages/convert-core/src/print.ts @@ -0,0 +1,121 @@ +/** + * Print an Elements tree as a TSX module, formatted with Prettier so the + * same tree always gives the same file. + */ + +import * as prettier from "prettier"; +import { isExpr, type ElementNode, type Expr } from "./tree"; + +export interface PrintOptions { + /** Name of the default-exported component. */ + componentName?: string; + /** Comment lines for the top of the file. */ + header?: string[]; +} + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * The JSX for a tree, and the Elements components it uses. `rename` prints a + * component under another name (to avoid clashing with an existing import). + */ +export function printJsx(tree: ElementNode, rename: Record = {}): { jsx: string; used: Set } { + const used = new Set(); + return { jsx: printNode(tree, used, rename), used }; +} + +export async function treeToTsx(tree: ElementNode, options: PrintOptions = {}): Promise { + const { jsx: body, used } = printJsx(tree); + const imports = [...used].sort(); + const header = (options.header ?? []).map((line) => (line ? `// ${line}` : "//")); + const source = [ + // Lets tools without JSX config (plain `tsx`, esbuild) run the file. + "/** @jsxRuntime automatic */", + ...header, + `import { ${imports.join(", ")} } from "@unlayer/react-elements";`, + "", + `export default function ${options.componentName ?? "Template"}() {`, + ` return (${body});`, + "}", + "", + ].join("\n"); + return formatTsx(source); +} + +export function formatTsx(source: string): Promise { + return prettier.format(source, { parser: "typescript", printWidth: 100 }); +} + +function printNode(node: ElementNode | string | Expr, used: Set, rename: Record = {}): string { + if (typeof node === "string") return printText(node); + if (isExpr(node)) return `{${node.$expr}}`; + if (node.type === "#expr") { + const code = (node.code ?? "").replace(/§(\d+)/g, (_, i: string) => { + const slot = node.slots?.[Number(i)] ?? []; + const single = slot.length === 1 && typeof slot[0] !== "string" && !(slot[0] as ElementNode).fallback; + if (single) return printNode(slot[0], used, rename); + // Several nodes: a keyed array, not a fragment — renderToJson walks + // arrays but not fragments, so rows inside a fragment would be lost. + const items = slot + .filter((child): child is ElementNode => typeof child !== "string") + .map((child, n) => + child.type === "#expr" + ? printNode(child, used, rename).slice(1, -1) // bare code inside the array + : printNode({ ...child, props: { key: n, ...child.props }, fallback: undefined }, used, rename) + ); + return `[${items.join(",\n")}]`; + }); + return `{${code}}`; + } + used.add(node.type); + const attrs = Object.entries(node.props ?? {}).filter(([, value]) => value !== undefined).map(([name, value]) => { + if (name === "layout" && typeof value === "string") { + used.add("ColumnLayouts"); + return `layout={ColumnLayouts.${value}}`; + } + return printAttribute(name, value); + }); + const tag = rename[node.type] ?? node.type; + const open = `<${tag}${attrs.map((a) => ` ${a}`).join("")}`; + const todo = node.fallback ? `{/* TODO(convert): ${node.fallback.replace(/\*\//g, "* /")} */}\n` : ""; + const children = node.children ?? []; + if (children.length === 0) return `${todo}${open} />`; + // Text mixed with code stays on one line, so spaces between them survive. + if (children.length > 1 && children.every((child) => typeof child === "string" || isExpr(child))) { + const inline = children + .map((child) => (isExpr(child) ? `{${child.$expr}}` : /[<>{}&\n]/.test(child) ? `{${JSON.stringify(child)}}` : child)) + .join(""); + return `${todo}${open}>${inline}`; + } + return `${todo}${open}>${children.map((child) => printNode(child, used, rename)).join("\n")}`; +} + +/** Text children print raw when JSX keeps them exactly, else as an expression. */ +function printText(text: string): string { + const plain = /^[^\s<>{}&]([^<>{}&\n\r\t]*[^\s<>{}&])?$/.test(text) && !text.includes(" "); + return plain ? text : `{${printLiteral(text)}}`; +} + +function printAttribute(name: string, value: unknown): string { + if (isExpr(value)) return `${name}={${value.$expr}}`; + // JSX attribute strings decode HTML entities, so `&` needs an expression. + if (typeof value === "string" && !/["&\n\r\\]/.test(value)) return `${name}="${value}"`; + return `${name}={${printLiteral(value)}}`; +} + +function printLiteral(value: unknown): string { + if (isExpr(value)) return value.$expr; + if (typeof value === "string") { + if (!/["\n]/.test(value)) return JSON.stringify(value); + // HTML reads best as a template literal. + return `\`${value.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")}\``; + } + if (Array.isArray(value)) return `[${value.map(printLiteral).join(", ")}]`; + if (value !== null && typeof value === "object") { + const entries = Object.entries(value).map( + ([key, item]) => `${IDENTIFIER.test(key) ? key : JSON.stringify(key)}: ${printLiteral(item)}` + ); + return `{ ${entries.join(", ")} }`; + } + return JSON.stringify(value) ?? "undefined"; +} diff --git a/packages/convert-core/src/render.ts b/packages/convert-core/src/render.ts new file mode 100644 index 00000000..6024c7af --- /dev/null +++ b/packages/convert-core/src/render.ts @@ -0,0 +1,61 @@ +/** + * Outputs of the Elements tree that go through @unlayer/react-elements: + * a React element, design JSON for the Unlayer editor, and HTML. + */ + +import React from "react"; +import * as Elements from "@unlayer/react-elements"; +import { isExpr, type ElementNode, type Expr } from "./tree"; + +const COMPONENTS = Elements as unknown as Record>; +const LAYOUTS = Elements.ColumnLayouts as unknown as Record; + +/** Build the React element the tree describes. */ +export function treeToElement(tree: ElementNode): React.ReactElement { + const build = (node: ElementNode | string | Expr, key?: number): React.ReactNode => { + if (typeof node === "string") return node; + if (isExpr(node) || node.type === "#expr") { + throw new Error("This tree holds code (from the codemod): render the generated TSX instead"); + } + const component = COMPONENTS[node.type]; + if (!component) throw new Error(`Unknown Elements component "${node.type}"`); + const props: Record = { ...node.props, key }; + if (typeof props.layout === "string") props.layout = LAYOUTS[props.layout]; + const children = (node.children ?? []).map((child, i) => build(child, i)); + return React.createElement(component, props, ...children); + }; + return build(tree) as React.ReactElement; +} + +/** Design JSON the editor opens with `loadDesign()`. */ +export function treeToDesign(tree: ElementNode): Record { + return pinImageWidths(Elements.renderToJson(treeToElement(tree)) as Record); +} + +/** + * The editor's canvas gives every `img` `width: auto`, which overrides a width + * attribute: an image in a text or HTML block also gets its width in its style. + * What the design renders to is unchanged. + */ +export function pinImageWidths(design: T): T { + const pin = (html: string) => + html.replace(/]*>/g, (tag) => { + const width = /\swidth=["']?(\d+(?:\.\d+)?%?)/.exec(tag)?.[1]; + const style = /\sstyle="([^"]*)"/.exec(tag); + if (!width || (style && /(^|;)\s*width\s*:/.test(style[1]))) return tag; + const value = width.endsWith("%") ? width : `${width}px`; + if (style) return tag.replace(style[0], ` style="${style[1].replace(/;?\s*$/, "")}${style[1].trim() ? ";" : ""}width:${value}"`); + return tag.replace(/^ { + if (Array.isArray(node)) return node.map(walk); + if (!node || typeof node !== "object") return node; + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, (k === "text" || k === "html") && typeof v === "string" ? pin(v) : walk(v)])); + }; + return walk(design) as T; +} + +/** A complete HTML document, as `renderToHtml` writes it. */ +export function treeToHtml(tree: ElementNode, options: { fonts?: Array<{ url: string }> } = {}): string { + return Elements.renderToHtml(treeToElement(tree), options); +} diff --git a/packages/convert-core/src/report.ts b/packages/convert-core/src/report.ts new file mode 100644 index 00000000..cbaf8c19 --- /dev/null +++ b/packages/convert-core/src/report.ts @@ -0,0 +1,92 @@ +/** + * The conversion report: how much of a template became native Elements + * components (editable in the visual editor) and what fell back, and why. + */ + +import { contentNodes, type ElementNode } from "./tree"; + +export interface ReportEntry { + /** A stable category, used to rank causes across templates. */ + reason: string; + /** Specifics: the class, element or value involved. */ + detail?: string; +} + +export interface ConversionReport { + /** Content blocks in the output. */ + contentNodes: number; + /** Blocks that are native Elements components. */ + nativeNodes: number; + /** Html blocks standing in for content that couldn't be mapped. */ + fallbackNodes: number; + /** nativeNodes / contentNodes (1 when there's no content). */ + nativeRatio: number; + /** One entry per fallback block. */ + fallbacks: ReportEntry[]; + /** Differences from the original: things approximated or dropped (e.g. hover styles). */ + notes: ReportEntry[]; + /** What the conversion did that doesn't change how it looks (a component inlined, a class split). */ + info: ReportEntry[]; + /** + * Words the original shows that the conversion doesn't, when the conversion + * was checked against the original's HTML. Empty means nothing was lost. + */ + missingText?: string[]; + /** Words the conversion shows that the original doesn't. */ + addedText?: string[]; + /** Links, image sources and image text the original has and the conversion doesn't. */ + missingAttributes?: string[]; + /** Links, image sources and image text only the conversion has. */ + addedAttributes?: string[]; + /** + * Style values computed from props or state that the conversion can't keep, + * with where they were. They change how the email looks: the check fails on them. + */ + lostStyles?: string[]; +} + +/** Collects notes while converting; `finish` adds the counts from the tree. */ +export class ReportBuilder { + private readonly notes: ReportEntry[] = []; + private readonly infos: ReportEntry[] = []; + private readonly lost: string[] = []; + + /** A difference from the original. */ + note(reason: string, detail?: string): void { + this.notes.push(detail === undefined ? { reason } : { reason, detail }); + } + + /** A style dropped that changes how the email looks: the check fails on it. */ + lostStyle(detail: string): void { + this.lost.push(detail); + } + + /** Something the conversion did that doesn't change how the email looks. */ + info(reason: string, detail?: string): void { + this.infos.push(detail === undefined ? { reason } : { reason, detail }); + } + + finish(tree: ElementNode): ConversionReport { + const blocks = contentNodes(tree); + const fallbacks = blocks + .filter((node) => node.fallback !== undefined) + .map((node) => parseReason(node.fallback as string)); + const native = blocks.length - fallbacks.length; + return { + contentNodes: blocks.length, + nativeNodes: native, + fallbackNodes: fallbacks.length, + nativeRatio: blocks.length === 0 ? 1 : native / blocks.length, + fallbacks, + notes: this.notes, + info: this.infos, + ...(this.lost.length ? { lostStyles: this.lost } : {}), + }; + } +} + +/** "unsupported element: div" → { reason: "unsupported element", detail: "div" } */ +function parseReason(text: string): ReportEntry { + const index = text.indexOf(": "); + return index === -1 ? { reason: text } : { reason: text.slice(0, index), detail: text.slice(index + 2) }; +} diff --git a/packages/convert-core/src/tree.ts b/packages/convert-core/src/tree.ts new file mode 100644 index 00000000..cef55ea5 --- /dev/null +++ b/packages/convert-core/src/tree.ts @@ -0,0 +1,101 @@ +/** + * The Elements tree: what every converter produces and every output reads. + * + * Same shape as the tree the Unlayer MCP server's `render_elements` takes — + * `{ type, props, children }` with @unlayer/react-elements component names and + * a Row `layout` given by name ("TwoEqual") — so a converted template can go + * straight to the MCP server too. + */ + +/** + * Code standing in for a value or for children, when converting source code + * whose content comes from props or logic (`{user.name}`, `{items.map(…)}`). + */ +export interface Expr { + $expr: string; +} + +export function expr(code: string): Expr { + return { $expr: code }; +} + +export function isExpr(value: unknown): value is Expr { + return typeof value === "object" && value !== null && typeof (value as Expr).$expr === "string"; +} + +export interface ElementNode { + /** + * Component name: Email, Row, Column, Paragraph, … — or "#expr" for a + * code hole whose `code` contains `§0`, `§1`, … where each `slots` entry + * (more Elements nodes) goes, e.g. `items.map((item) => (§0))`. + */ + type: string; + props?: Record; + /** Child nodes; strings are text (for Heading/Button). Exprs are code. */ + children?: Array; + code?: string; + slots?: Array>; + /** + * On an Html block standing in for something the converter couldn't map: + * why. Counts as a fallback in the report and prints as a TODO comment. + */ + fallback?: string; + /** + * An invisible Divider standing for space in an otherwise empty Column (the visual editor + * shows an empty column as a placeholder that changes the layout). Not counted as content. + */ + spacer?: boolean; +} + +export const ROOT_TYPES = ["Email", "Page", "Document"] as const; + +export const LAYOUT_TYPES = ["Row", "Column"] as const; + +export const CONTENT_TYPES = [ + "Button", + "Divider", + "Heading", + "Html", + "Image", + "Menu", + "Paragraph", + "PageBreak", + "Social", + "Table", + "Video", +] as const; + +export function el( + type: string, + props: Record = {}, + children: Array = [] +): ElementNode { + const node: ElementNode = { type }; + const cleaned = Object.fromEntries(Object.entries(props).filter(([, v]) => v !== undefined)); + if (Object.keys(cleaned).length > 0) node.props = cleaned; + if (children.length > 0) node.children = children; + return node; +} + +/** An Html block keeping content the converter couldn't map, and why. */ +export function fallbackHtml(html: string, reason: string): ElementNode { + return { type: "Html", props: { html }, fallback: reason }; +} + +/** A code hole holding more nodes (see ElementNode.code). */ +export function hole(code: string, slots: Array>): ElementNode { + return { type: "#expr", code, slots }; +} + +/** Every content node (Paragraph, Button, Html, …) in document order. */ +export function contentNodes(tree: ElementNode): ElementNode[] { + const out: ElementNode[] = []; + const visit = (node: ElementNode | string | Expr) => { + if (typeof node === "string" || isExpr(node)) return; + if ((CONTENT_TYPES as readonly string[]).includes(node.type) && !node.spacer) out.push(node); + node.children?.forEach(visit); + node.slots?.forEach((slot) => slot.forEach(visit)); + }; + visit(tree); + return out; +} diff --git a/packages/convert-core/src/verify.ts b/packages/convert-core/src/verify.ts new file mode 100644 index 00000000..328447ff --- /dev/null +++ b/packages/convert-core/src/verify.ts @@ -0,0 +1,198 @@ +import { decodeHtmlEntities } from "./entities"; + +/** + * Content check: does the converted HTML still say everything the original + * says? Compares the visible words of two HTML documents as multisets, with + * no browser, so a dropped block, row or loop shows up as missing words. The + * same words in another order count too: words out of place are missing. + */ + +export interface TextCheck { + /** Words the original shows and the conversion doesn't (one per lost occurrence). */ + missing: string[]; + /** Words only the conversion shows. */ + added: string[]; + /** Links (href), images (src) and image text (alt) the original has and the conversion doesn't. */ + missingAttributes: string[]; + /** Links, images and image text only the conversion has. */ + addedAttributes: string[]; +} + +const HIDDEN = /<(span|div|p|code|td)\b[^>]*style="[^"]*display:\s*none[^"]*"[^>]*>[\s\S]*?<\/\1>/gi; +const INLINE = /<\/?(?:a|span|strong|b|em|i|u|s|small|code|sup|sub|font|mark|abbr)\b[^>]*>/gi; + +/** The words a reader sees in `html`, as written, with meaningful numeric punctuation and lone signs preserved, in order. */ +export function htmlWords(html: string): string[] { + const text = html + .replace(//gi, " ") + .replace(/<(style|script|title)\b[\s\S]*?<\/\1>/gi, " ") + // React separates adjacent text with (`{name}'s` → `Alex's`). + .replace(//g, "") + // Hidden elements (e.g. CodeInline's hidden copy for some clients), but + // not the preview text: inboxes show it. + .replace(HIDDEN, (match) => + /data-skip-in-text/.test(match.slice(0, match.indexOf(">"))) + ? match + : " ", + ) + // Inline tags don't break words (`Hello` reads "Hello"); block tags + // do, and so do inline tags styled as boxes (a link with display:block, + // pills side by side with display:inline-block). + .replace(INLINE, (tag) => + /display:\s*(block|flex|grid|table|list-item|inline-block|inline-flex)/i.test( + tag, + ) + ? " " + : "", + ) + .replace(/<[^>]+>/g, " "); + return decodeHtmlEntities(text) + .normalize("NFC") + // Zero-width characters (preview text padding) aren't read. + .replace(/[\u200b-\u200d\u2060\ufeff]/g, "") + // One minus, however it was written (-, −, −). + .replace(/[-−-﹣]/g, "−") + .split(/\s+/) + .map((word) => { + // Punctuation around prose is cosmetic. Numeric separators, signs, + // currency symbols and percentages change what the reader is told, + // and so does a sign on its own ("Balance − 100"). + if (/\p{N}/u.test(word)) { + return word.replace(/[^\p{L}\p{N}\p{Sc}.,/:'’+−%‰]/gu, "").replace(/[.,]+$/g, ""); + } + if (/^[+−±]$/.test(word)) return word; + return word.replace(/[^\p{L}\p{Sc}%‰]/gu, ""); + }) + .filter(Boolean); +} + +/** + * The links, images and image text a reader gets: `href=` of links, `src=` + * and `alt=` of images (alt shows when images are blocked, common in email), + * entities decoded. Hidden elements and MSO-only markup aren't counted. + */ +export function htmlAttributes(html: string): string[] { + const visible = visibleHtml(html); + const out: string[] = []; + for (const [, tag, attrs] of visible.matchAll(/<(a|img)\b((?:"[^"]*"|'[^']*'|[^'">])*)>/gi)) { + if (tag.toLowerCase() === "a") { + const href = attribute(attrs, "href"); + if (href) out.push(`href ${href}`); + } else { + const src = attribute(attrs, "src"); + const alt = attribute(attrs, "alt"); + if (src) out.push(`src ${src}`); + if (alt) out.push(`alt ${alt}`); + } + } + return out; +} + +/** Compare the words, links and images two HTML documents show. */ +export function compareText(originalHtml: string, convertedHtml: string): TextCheck { + const originalWords = htmlWords(originalHtml); + const convertedWords = htmlWords(convertedHtml); + const counts = new Map(); + for (const word of convertedWords) counts.set(word, (counts.get(word) ?? 0) + 1); + const missing: string[] = []; + for (const word of originalWords) { + const left = counts.get(word) ?? 0; + if (left > 0) counts.set(word, left - 1); + else missing.push(word); + } + const added = [...counts].flatMap(([word, n]) => Array(n).fill(word)); + // Every word there, but not in order: two values swapped places (a total + // and a subtotal, say). The words out of place count as missing. + if (!missing.length) { + const moved = outOfOrder(originalWords, convertedWords); + missing.push(...moved); + added.push(...moved); + } + // Count visible occurrences; Outlook-only duplicates are already excluded. + const missingAttributes = lostOccurrences(htmlAttributes(originalHtml), htmlAttributes(convertedHtml)); + // Added ones as a set: a renderer may repeat a link (a button's fallback), but a new destination is new. + const originalAttributes = new Set(htmlAttributes(originalHtml)); + const addedAttributes = [...new Set(htmlAttributes(convertedHtml))].filter((item) => !originalAttributes.has(item)); + // A destination must also stay on the same link/image, even if every URL + // still appears elsewhere in the document. + for (const item of lostOccurrences(attributePairs(originalHtml), attributePairs(convertedHtml))) { + const [kind, value, label] = JSON.parse(item) as [string, string, string]; + if (!missingAttributes.includes(`${kind} ${value}`)) missingAttributes.push(`${kind} ${value} (${label})`); + } + return { missing, added, missingAttributes, addedAttributes }; +} + +function visibleHtml(html: string): string { + return html.replace(//gi, " ") + .replace(//g, "") + .replace(HIDDEN, (match) => (/data-skip-in-text/.test(match.slice(0, match.indexOf(">"))) ? match : " ")); +} + +function attribute(attrs: string, name: string): string | undefined { + const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs); + return match ? decodeHtmlEntities(match[1] ?? match[2] ?? match[3]).trim() : undefined; +} + +function attributePairs(html: string): string[] { + const visible = visibleHtml(html); + const pairs: string[] = []; + for (const [, attrs, inner] of visible.matchAll(/])*)>([\s\S]*?)<\/a\s*>/gi)) { + const href = attribute(attrs, "href"); + const label = htmlWords(inner).join(" ") || htmlAttributes(inner).join(", "); + if (href) pairs.push(JSON.stringify(["href", href, label])); + } + for (const [, attrs] of visible.matchAll(/])*)>/gi)) { + const src = attribute(attrs, "src"); + const alt = attribute(attrs, "alt"); + if (src) pairs.push(JSON.stringify(["src", src, alt ?? ""])); + } + return pairs; +} + +/** + * Words of `original` outside the longest sequence the two share in order. + * Past a size limit, every word between the shared start and end. + */ +function outOfOrder(original: string[], converted: string[]): string[] { + let start = 0; + while (start < original.length && start < converted.length && original[start] === converted[start]) start++; + let end = 0; + while (end < original.length - start && end < converted.length - start && original[original.length - 1 - end] === converted[converted.length - 1 - end]) end++; + const a = original.slice(start, original.length - end); + const b = converted.slice(start, converted.length - end); + if (!a.length) return []; + if (a.length * b.length > 1_000_000) return a; + // Longest common subsequence, then the words of `a` it leaves out. + const width = b.length + 1; + const table = new Uint32Array((a.length + 1) * width); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + table[i * width + j] = a[i] === b[j] ? table[(i + 1) * width + j + 1] + 1 : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]); + } + } + const out: string[] = []; + let i = 0; + let j = 0; + while (i < a.length) { + if (j < b.length && a[i] === b[j]) { + i++; + j++; + } else if (j < b.length && table[i * width + j + 1] >= table[(i + 1) * width + j]) { + j++; + } else { + out.push(a[i++]); + } + } + return out; +} + +function lostOccurrences(original: string[], converted: string[]): string[] { + const counts = new Map(); + for (const value of converted) counts.set(value, (counts.get(value) ?? 0) + 1); + return original.filter((value) => { + const count = counts.get(value) ?? 0; + if (!count) return true; + counts.set(value, count - 1); + return false; + }); +} diff --git a/packages/convert-core/test/core.test.ts b/packages/convert-core/test/core.test.ts new file mode 100644 index 00000000..dde45082 --- /dev/null +++ b/packages/convert-core/test/core.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { el, editorFonts, pinImageWidths, shareEditorFonts, expr, fallbackHtml, hole, printJsx, ReportBuilder, treeToDesign, treeToTsx, boxSides, toPx } from "../src/index"; + +const tree = el("Email", { contentWidth: "600px" }, [ + el("Row", { layout: "TwoEqual" }, [ + el("Column", {}, [el("Paragraph", { html: '

Tom & Jerry

' })]), + el("Column", {}, [fallbackHtml("
raw
", "unsupported element: div")]), + ]), +]); + +describe("printing", () => { + it("prints valid, formatted TSX that keeps HTML exactly", async () => { + const code = await treeToTsx(tree, { componentName: "Welcome" }); + expect(code).toContain("export default function Welcome()"); + expect(code).toContain("layout={ColumnLayouts.TwoEqual}"); + expect(code).toContain('html={`

Tom & Jerry

`}'); + expect(code).toContain("{/* TODO(convert): unsupported element: div */}"); + expect(code).toMatch(/^\/\*\* @jsxRuntime automatic \*\//); + }); + + it("prints code holes and expressions", () => { + const withCode = el("Column", {}, [ + hole("items.map((item) => (§0))", [[el("Paragraph", { key: expr("item.id") }, ["Hi ", expr("item.name")])]]), + ]); + const { jsx } = printJsx(withCode); + expect(jsx).toContain("{items.map((item) => (Hi {item.name}))}"); + }); + + it("prints phone props and preserves them in the editor design", async () => { + const tree = el("Email", {}, [el("Row", {}, [el("Column", { mobile: { padding: "8px 16px" } }, [el("Image", { src: { url: "https://example.com/image.png", width: 640 }, width: "120px", mobile: { autoWidth: true }, hideOnMobile: true })])])]); + const code = await treeToTsx(tree); + expect(code).toContain('padding: "8px 16px"'); + expect(code).toContain("autoWidth: true"); + expect(code).toContain("hideOnMobile={true}"); + const column = treeToDesign(tree).body.rows[0].columns[0]; + expect(column.values._override.mobile).toMatchObject({ padding: "8px 16px" }); + expect(column.contents[0].values._override.mobile).toMatchObject({ hideMobile: true, src: { autoWidth: true } }); + }); + + it("renames components that clash with other imports", () => { + expect(printJsx(el("Html", { html: "x" }), { Html: "UnlayerHtml" }).jsx).toBe(''); + }); +}); + +describe("report", () => { + it("counts native and fallback blocks", () => { + const report = new ReportBuilder(); + report.note("image border radius dropped", "8px"); + const result = report.finish(tree); + expect(result).toMatchObject({ contentNodes: 2, nativeNodes: 1, fallbackNodes: 1, nativeRatio: 0.5 }); + expect(result.fallbacks).toEqual([{ reason: "unsupported element", detail: "div" }]); + expect(result.notes).toEqual([{ reason: "image border radius dropped", detail: "8px" }]); + }); +}); + +describe("outputs and css", () => { + it("renders design JSON through Elements", () => { + const design = treeToDesign(tree); + expect(design.body.rows[0].cells).toEqual([1, 1]); + expect(design.body.rows[0].columns[1].contents[0].type).toBe("html"); + }); + + it("reads lengths and box shorthands", () => { + expect(toPx("1.5em")).toBe(24); + expect(toPx("12pt")).toBe(16); + expect(boxSides({ padding: "8px 16px", paddingTop: "4px" }, "padding")).toEqual({ top: 4, right: 16, bottom: 8, left: 16 }); + }); +}); + +describe("meaningful text", () => { + it.each([ + ["$1.00", "$100"], + ["1,000", "1000"], + ["-10", "10"], + ["10%", "10"], + ["€10", "$10"], + ["10 %", "10"], + ["1/2", "12"], + ["07:30", "0730"], + ["1'000", "1000"], + ])("detects %s changed to %s", async (original, converted) => { + const { compareText } = await import("../src/index"); + expect( + compareText( + `

Pay ${original} today.

`, + `

Pay ${converted} today!

`, + ).missing.length, + ).toBeGreaterThan(0); + }); + + it("decodes the full entity set while tolerating sentence punctuation", async () => { + const { compareText, decodeHtmlEntities } = await import("../src/index"); + expect( + compareText( + "

Total: 10 €, ½ ≂̸

", + "

Total 10 €, ½ ≂̸

", + ).missing, + ).toEqual([]); + expect(decodeHtmlEntities("�")).toBe("�"); + }); + + it("counts words that changed places as missing", async () => { + const { compareText } = await import("../src/index"); + const check = compareText( + "

Subtotal 10

Shipping free

Total 12

", + "

Subtotal 12

Shipping free

Total 10

", + ); + expect(check.missing.sort()).toEqual(["10", "12"]); + expect( + compareText("

Hello there

Bye

", "

Hello there

Bye

").missing, + ).toEqual([]); + }); +}); + +describe("editor fonts", () => { + const design = { + body: { + values: { fontFamily: { label: "Inter", value: "Inter,Arial,sans-serif" } }, + rows: [{ values: {}, columns: [{ contents: [ + { type: "heading", values: { fontFamily: { label: "Instrument Serif", value: "'Instrument Serif',Georgia,serif" } } }, + { type: "text", values: { text: '

x

' } }, + ] }] }], + }, + }; + + it("registers each family the design uses once, with all its weights", () => { + const fonts = editorFonts(design, [ + { url: "https://fonts.googleapis.com/css2?family=Instrument+Serif:ital,wght@1,400&display=swap" }, + { url: "https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap" }, + { url: "https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap" }, + { url: "https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400&display=swap" }, + { url: "https://fonts.googleapis.com/css2?family=Unused:wght@400&display=swap" }, + ]); + expect(fonts).toEqual([ + { label: "Instrument Serif", value: "'Instrument Serif',Georgia,serif", url: "https://fonts.googleapis.com/css2?family=Instrument+Serif:ital,wght@0,400;1,400&display=swap" }, + { label: "Inter", value: "Inter,Arial,sans-serif", url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" }, + ]); + }); + + it("matches @font-face stylesheets and fonts named only in text", () => { + const face = (weight: number) => `data:text/css,${encodeURIComponent(`@font-face{font-family:'Brand';src:url('https://cdn.example.com/brand-${weight}.woff2');font-weight:${weight}}`)}`; + const [font] = editorFonts(design, [{ url: face(400) }, { url: face(700) }]); + expect(font.label).toBe("Brand"); + expect(font.value).toBe("'Brand'"); + expect(decodeURIComponent(font.url)).toContain("brand-400.woff2"); + expect(decodeURIComponent(font.url)).toContain("brand-700.woff2"); + }); + + it("keeps stylesheets it can't merge, and skips ones it can't match", () => { + expect(editorFonts(design, [{ url: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400" }, { url: "https://cdn.example.com/inter.css" }])).toEqual([ + { label: "Inter", value: "Inter,Arial,sans-serif", url: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400&display=swap" }, + ]); + }); +}); + +describe("shared editor fonts", () => { + it("gives every list one stylesheet per family with all the styles any list uses", () => { + const inter = (weights: string) => ({ label: "Inter", value: "Inter,Arial,sans-serif", url: `https://fonts.googleapis.com/css2?family=Inter:wght@${weights}&display=swap` }); + const serif = { label: "Serif", value: "'Instrument Serif',serif", url: "https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400&display=swap" }; + const [a, b] = shareEditorFonts([[inter("400;600"), serif], [inter("300;400")]]); + const all = "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap"; + expect(a).toEqual([{ ...inter("400;600"), url: all }, serif]); + expect(b).toEqual([{ ...inter("300;400"), url: all }]); + }); +}); + +describe("pinned image widths", () => { + it("adds an image's width attribute to its style in text and HTML blocks", () => { + const design = { rows: [{ contents: [ + { values: { text: 'X' } }, + { values: { html: '', src: { url: "a.png", width: 18 } } }, + ] }] }; + const [text, html] = pinImageWidths(design).rows[0].contents; + expect(text.values.text).toBe('X'); + expect(html.values).toEqual(design.rows[0].contents[1].values); + }); +}); diff --git a/packages/convert-core/test/verify.test.ts b/packages/convert-core/test/verify.test.ts new file mode 100644 index 00000000..24d2ddd7 --- /dev/null +++ b/packages/convert-core/test/verify.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { compareText } from "../src/verify"; + +describe("link and image occurrences", () => { + it("detects reordered values even when the conversion adds words", () => { + const check = compareText('

Subtotal $10

Total $12

', '

Subtotal $12

Total $10

Thanks

'); + expect(check.missing.length).toBeGreaterThan(0); + }); + + it("allows additional words when the original order is preserved", () => { + const check = compareText('

Subtotal $10

Total $12

', '

Subtotal $10

Thanks

Total $12

'); + expect(check.missing).toEqual([]); + expect(check.added).toEqual(["Thanks"]); + }); + it("detects a lost occurrence of a repeated URL", () => { + const original = 'PayHelp'; + expect(compareText(original, 'PayHelp').missingAttributes).toContain("href /pay"); + }); + + it("keeps destinations associated with their link labels", () => { + const check = compareText('PayHelp', 'PayHelp'); + expect(check.missing).toEqual([]); + expect(check.missingAttributes.length).toBeGreaterThan(0); + }); + + it("keeps image sources associated with their alt text", () => { + const check = compareText('AB', 'AB'); + expect(check.missingAttributes.length).toBeGreaterThan(0); + }); + + it("ignores MSO-only duplicate links and normalizes inline text", () => { + const check = compareText('Pay now', 'Pay now'); + expect(check.missingAttributes).toEqual([]); + }); +}); + +describe("strict content check", () => { + it("compares letter case as written", () => { + expect(compareText("

Use coupon SAVE10

", "

Use coupon save10

").missing).toEqual(["SAVE10"]); + }); + + it("keeps a lone minus sign, however it's written", () => { + expect(compareText("

Balance − 100 USD

", "

Balance 100 USD

").missing).toEqual(["−"]); + expect(compareText("

Balance − 100 USD

", "

Balance - 100 USD

").missing).toEqual([]); + }); + + it("finds links and images only the conversion has, but not a repeated one", () => { + const original = 'Go'; + expect(compareText(original, `${original}`).addedAttributes).toEqual(["src https://example.com/x.png"]); + expect(compareText(original, `${original}Go`).addedAttributes).toEqual([]); + }); + + it("ignores zero-width characters", () => { + expect(compareText("

Hello‌ world

", "

Hello world

").missing).toEqual([]); + }); +}); diff --git a/packages/convert-core/tsconfig.json b/packages/convert-core/tsconfig.json new file mode 100644 index 00000000..99ee0e04 --- /dev/null +++ b/packages/convert-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "types": ["react"] + }, + "include": ["src/**/*"] +} diff --git a/packages/convert-core/vitest.config.ts b/packages/convert-core/vitest.config.ts new file mode 100644 index 00000000..a2bf93cf --- /dev/null +++ b/packages/convert-core/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { jsx: "automatic" }, + test: { environment: "node" }, +}); diff --git a/packages/from-react-email/.gitignore b/packages/from-react-email/.gitignore new file mode 100644 index 00000000..c46e2d4d --- /dev/null +++ b/packages/from-react-email/.gitignore @@ -0,0 +1,4 @@ +.corpus/ +.bench-out/ +test/.tmp-*/ +dist/ diff --git a/packages/from-react-email/FIDELITY.md b/packages/from-react-email/FIDELITY.md new file mode 100644 index 00000000..980ab925 --- /dev/null +++ b/packages/from-react-email/FIDELITY.md @@ -0,0 +1,76 @@ +# Conversion fidelity + +How closely converted templates match their React Email originals, measured on 106 real templates. Rerun with `pnpm --filter @unlayer/from-react-email bench`; `tsx bench/summary.ts` prints the tables below. CI runs a smoke version on the fixture templates in `test/fixtures` (`pnpm --filter @unlayer/from-react-email test:fidelity`): content, desktop text sizes, and words moved on desktop (and on phones for the fixtures that match there). + +## Results + +| Mode | Templates | Convert | Type-check | Lose content | Flipped-prop problems | Editor skips | Native (avg) | Fully native | Words moved, desktop (median / mean / >25%) | Words moved, phone (median / >25%) | +|---|---|---|---|---|---|---|---|---|---|---| +| Codemod | 106 | 106 | 106 | 0 | 0 | 0 | 98.4% | 96 | 0% / 0.9% / 1 | 0% / 8 of 85 | +| Runtime | 106 | 106 | 106 | 0 | 0 | 0 | 98.4% | 97 | 0% / 1.1% / 2 | 0% / 9 of 85 | + +- **Lose content**: a word, link (`href`), image (`src`) or image `alt` the original renders and the conversion doesn't. +- **Flipped-prop problems**: the same check with each boolean prop flipped (branches `PreviewProps` doesn't take). +- **Editor skips**: blocks `renderToJson` can't represent (they wouldn't open in the visual editor). +- **Native**: the share of content that became editable Elements blocks; the rest is kept as `Html` blocks that render as before. +- **Words moved**: the share of the original's words more than 48px off sideways in the conversion, after removing a uniform shift. Reading order breaks (the text running back up the page) are counted separately: 4 on desktop across the corpus, in two templates, from columns that the original centers vertically and Elements tops. On phones, the 21 originals wider than the screen (fixed 600–660px tables that scroll sideways) are left out: there's nothing comparable to match. + +Desktop is the faithful view: in 105 of 106 templates, at most a quarter of the words sit somewhere else, and the median is none. The one outlier sets no font, so the browser shows serif while Elements uses its sans-serif default. + +Phone medians improved from 16.5% to 0% (codemod) and from 13.9% to 0% (runtime). No template has a higher phone moved-word score than the saved baseline, including the originals that overflow. Every word in all 212 desktop conversions has the same coordinates as before. + +Phone padding, margins, text size, line height, alignment, full-width images and hiding now become device overrides, using only settings the editor supports: rows and content can be hidden on phones, columns can't, so a column hidden on phones hides its content instead. Every stacked column keeps its box's phone side padding. A narrow box that takes the phone's full width keeps the space around it as padding on its column, with a phone value, instead of spacer columns. The report still lists unsupported classes and dropped styles. + +### In the visual editor + +Five converted designs (a receipt with a bordered card, a newsletter with stacking columns, inline rating stars, a fixed-width button, a card with columns that stay side by side on phones) were loaded into the Unlayer editor, saved and exported. Every row, column setting, border and button width came back unchanged, and the editor's export has every word, link and image of the original. + +Two more converted designs (90 elements with phone settings) were loaded into the editor, saved and exported: every phone setting came back unchanged from `saveDesign`, and the editor's export applied them (59 phone CSS rules, 33 rows kept side by side). The device CSS also matches four small editor exports in email, web and document modes (12 fixture comparisons). + +Visibility follows the editor: rows and content can be hidden per device, columns can't. The CSS fixtures were generated with a local editor build (1.477.0); the in-scope CSS functions were also checked against the current source. + +## Known differences + +Codemod report counts and remaining model differences. + +| Difference | Templates | Why | +|---|---|---| +| Column vertical alignment | 58 | A React Email `Column` is a ``, centered vertically by default; Elements email columns sit at the top. Only visible next to a taller column. | +| Unsupported classes and phone declarations | 24 | State variants, unresolved utilities, phone font weight/letter spacing, and full-width text declarations can remain unconverted. Supported phone properties are mapped even when another declaration in the same class is unsupported. | +| Image border radius | 18 | Elements images have no radius. | +| Small max-width boxes inside cards, on phones | Case dependent | A box narrower than the phone keeps spacer columns, so its width stays proportional. Wider boxes take the phone's width through their column's phone padding. | +| Shadows, gradients, transforms, opacity | 13 | No Elements equivalent. | +| Max-width text in a column of several | 7 | A block can't be narrower than its column. | +| Box outline around stacking columns, on phones | 4 | Each stacked column takes a piece of the border. Desktop is exact. | +| Kept as `Html` | 10 | Unknown elements (`div` with layout, raw `table`), Rows nested in a column of several, images with no width. They render as before. | + +Phone device settings use the editor's 480px breakpoint. A source query such as `max-width: 600px` therefore differs between 481px and 600px; arbitrary breakpoints, phone font weight and letter spacing, and other unsupported declarations are not carried over. Narrow-box decisions use the benchmark's 375px phone width. + +On phones, fixed-width images and narrow text boxes keep their px width when it fits there (a phone image width; column padding with a phone value), as React Email's do. Columns side by side keep their share of the row, and a narrow box holding columns side by side keeps its spacers, so icon gaps still scale with the screen. A React Email cell widens to fit its longest word; an Elements column can't, and the editor's CSS breaks the word instead. So text whose longest word wouldn't fit its column on a phone (a total, a stat label) gets a smaller phone size, and the same block in the other columns gets the same size. Words inside `Html` blocks can still break. + +## Corpus + +106 templates, used as local test inputs only and never committed (`.corpus/` is git-ignored): + +- **71 official**: React Email's demo and example emails (MIT, `resend/react-email@b0b4668`, `apps/demo/emails`). That's five themed sets of 8 (Barebone, Arcane, Matte, Protocol, Studio), 22 brand demos, the `create-email` starters and the benchmark pair. Their local theme, font and Tailwind config imports were inlined so each file stands alone. +- **25 community**: templates from MIT repositories: + - `inboundemail/inbound` (6), `slowfound/react-email-tailwind-templates` (5), `langfuse/langfuse` (4), `Kondasamy/nextjs-saas-template` (3), `moinulmoin/chadnext` (2); + - one each from `better-auth`, `BearStudio/start-ui-web`, `hyperlink-academy/leaflet`, `ryanharman/invoice-gen` and `projectplannerai/nextjs-clerk-convex-stripe-resend-template`. + + Local imports were inlined. +- **10 written by agents**: welcome, password reset, magic link, receipt, weekly digest, invoice due, team invite, shipping update, trial ending, product launch. Half use Tailwind, half inline styles. + +88 of the 96 non-agent templates use ``. + +## Method + +For each template, in both modes: + +1. Convert it, and type-check the output with `tsc --strict`. It passes when it has no more errors than the original (some originals already fail strict mode, and the codemod keeps their code). +2. Render the original with React Email's `render()` and the conversion with `renderToHtml()`, both with the template's `PreviewProps`. Compare their words, links and images. For the codemod, repeat with each boolean prop flipped. +3. Render the design JSON with `renderToJson` and count the blocks it skips. +4. Open both in Chromium at 700px (desktop; Elements stacks email columns below the content width + 20px) and at 375px, and find where each word lands. + +Fonts: templates load web fonts with ``, and the conversion links the same families through Google Fonts. Those files can differ from the template's own: other versions, and in React Email's demo templates a 404 for Inter 400, where the original falls back to Arial. Layout is what's measured, so the conversion is rendered with the original's `@font-face` and `@import` rules. Assets are fetched once and served from a local cache, so every render sees the same ones. + +`.bench-out/