diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fe21bd0..ea9cb77 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -60,7 +60,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: lts/*
- registry-url: "https://registry.npmjs.org"
+ registry-url: 'https://registry.npmjs.org'
- name: Get current version
id: current_version
diff --git a/bun.lock b/bun.lock
index cbe842a..27bb804 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,8 +4,10 @@
"": {
"name": "better-svelte-email",
"dependencies": {
+ "css-tree": "^3.1.0",
"html-to-text": "^9.0.5",
"magic-string": "^0.30.21",
+ "parse5": "^8.0.0",
"tw-to-css": "^0.0.12",
},
"devDependencies": {
@@ -16,6 +18,7 @@
"@sveltejs/package": "^2.5.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
+ "@types/css-tree": "^2.3.11",
"@types/html-to-text": "^9.0.4",
"@types/node": "^24.9.1",
"eslint": "^9.38.0",
@@ -29,7 +32,7 @@
"publint": "^0.3.15",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
- "svelte": "5.42.2",
+ "svelte": "5.43.3",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
@@ -274,6 +277,8 @@
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
+ "@types/css-tree": ["@types/css-tree@2.3.11", "", {}, "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg=="],
+
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -406,6 +411,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -442,7 +449,7 @@
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
- "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
@@ -636,6 +643,8 @@
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
+ "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
+
"mdsvex": ["mdsvex@0.12.6", "", { "dependencies": { "@types/mdast": "^4.0.4", "@types/unist": "^2.0.3", "prism-svelte": "^0.4.7", "prismjs": "^1.17.1", "unist-util-visit": "^2.0.1", "vfile-message": "^2.0.4" }, "peerDependencies": { "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" } }, "sha512-pupx2gzWh3hDtm/iDW4WuCpljmyHbHi34r7ktOqpPGvyiM4MyfNgdJ3qMizXdgCErmvYC9Nn/qyjePy+4ss9Wg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -698,6 +707,8 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
+ "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
+
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@@ -838,7 +849,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
- "svelte": ["svelte@5.42.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-iSry5jsBHispVczyt9UrBX/1qu3HQ/UyKPAIjqlvlu3o/eUvc+kpyMyRS2O4HLLx4MvLurLGIUOyyP11pyD59g=="],
+ "svelte": ["svelte@5.43.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-kjkAjCk41mJfvJZG56XcJNOdJSke94JxtcX8zFzzz2vrt47E0LnoBzU6azIZ1aBxJgUep8qegAkguSf1GjxLXQ=="],
"svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="],
@@ -976,12 +987,16 @@
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"hast-util-to-html/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+ "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"mdast-util-to-hast/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
diff --git a/package.json b/package.json
index ca050bd..a5e2e78 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,10 @@
}
},
"dependencies": {
+ "css-tree": "^3.1.0",
"html-to-text": "^9.0.5",
"magic-string": "^0.30.21",
+ "parse5": "^8.0.0",
"tw-to-css": "^0.0.12"
},
"optionalDependencies": {
@@ -34,6 +36,7 @@
"@sveltejs/package": "^2.5.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
+ "@types/css-tree": "^2.3.11",
"@types/html-to-text": "^9.0.4",
"@types/node": "^24.9.1",
"eslint": "^9.38.0",
@@ -85,6 +88,11 @@
"import": "./dist/utils/index.js",
"default": "./dist/utils/index.js"
},
+ "./render": {
+ "types": "./dist/render/index.d.ts",
+ "import": "./dist/render/index.js",
+ "default": "./dist/render/index.js"
+ },
"./package.json": "./package.json"
},
"description": "Svelte email renderer with Tailwind support",
@@ -92,6 +100,7 @@
"dist",
"!dist/**/*.test.*",
"!dist/**/*.spec.*",
+ "!dist/**/__fixtures__",
"!dist/emails"
],
"keywords": [
@@ -112,6 +121,7 @@
"build": "bun run prepack && vite build",
"preview": "vite preview",
"package": "svelte-package",
+ "package:watch": "nodemon -x \"bun run package\" -i dist -e ts,svelte",
"prepare": "svelte-kit sync || echo ''",
"prepack": "svelte-kit sync && svelte-package && publint",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
diff --git a/src/lib/components/Body.svelte b/src/lib/components/Body.svelte
index 70eb257..98f18c4 100644
--- a/src/lib/components/Body.svelte
+++ b/src/lib/components/Body.svelte
@@ -1,15 +1,19 @@
- |
+ |
{@render children?.()}
|
diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte
index 2ce97d6..e3b7b1f 100644
--- a/src/lib/components/Button.svelte
+++ b/src/lib/components/Button.svelte
@@ -1,6 +1,6 @@
- {#if pX}
-
- {@html ``}
-
- {/if}
+
+ {@html ``}
+
+
{@render children?.()}
- {#if pX}
-
- {@html ``}
-
- {/if}
+
+
+ {@html ``}
+
diff --git a/src/lib/components/__tests__/__fixtures__/nested/nested.svelte b/src/lib/components/__tests__/__fixtures__/nested/nested.svelte
new file mode 100644
index 0000000..4fefdd6
--- /dev/null
+++ b/src/lib/components/__tests__/__fixtures__/nested/nested.svelte
@@ -0,0 +1,7 @@
+
+
+
+ Template title
+
diff --git a/src/lib/components/__tests__/__fixtures__/test-email.svelte b/src/lib/components/__tests__/__fixtures__/test-email.svelte
new file mode 100644
index 0000000..6a04b3c
--- /dev/null
+++ b/src/lib/components/__tests__/__fixtures__/test-email.svelte
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Responsive Heading
+
+
+ Multiple utilities text
+
+
+ Bold red text with margin
+
+
+
+
+
+
+
+
+ Column 1
+ Left column content
+
+
+ Column 2
+ Right column content
+
+
+
+
+
+
+
+
+
+
+
+ Visit our website
+
+
+
+
+
+
+
+
+
+
+ Nested Container
+
+ This tests complex spacing, borders, and shadows
+
+
+
+
+ Custom arbitrary values
+
+
+ Text with empty style attribute
+
+
+ Pure inline styles
+
+
+
diff --git a/src/lib/components/__tests__/__snapshots__/end-to-end.test.ts.snap b/src/lib/components/__tests__/__snapshots__/end-to-end.test.ts.snap
new file mode 100644
index 0000000..37c6bf2
--- /dev/null
+++ b/src/lib/components/__tests__/__snapshots__/end-to-end.test.ts.snap
@@ -0,0 +1,5 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`End-to-End Email Rendering > should handle custom Tailwind config (bg-brand color) 1`] = `" This is a preview text that should be visible in email clients Responsive Heading Multiple utilities text Bold red text with margin Column 1 Left column content | Column 2 Right column content |
| Visit our website Click Me Gradient Button Nested Container This tests complex spacing, borders, and shadows | Custom arbitrary values Text with empty style attribute Pure inline styles |
|
"`;
+
+exports[`End-to-End Email Rendering > should render a complete email with all Tailwind classes inlined 1`] = `" This is a preview text that should be visible in email clients Responsive Heading Multiple utilities text Bold red text with margin Column 1 Left column content | Column 2 Right column content |
| Visit our website Click Me Gradient Button Nested Container This tests complex spacing, borders, and shadows | Custom arbitrary values Text with empty style attribute Pure inline styles |
|
"`;
diff --git a/src/lib/components/__tests__/__snapshots__/rendering.test.ts.snap b/src/lib/components/__tests__/__snapshots__/rendering.test.ts.snap
new file mode 100644
index 0000000..c2a1b91
--- /dev/null
+++ b/src/lib/components/__tests__/__snapshots__/rendering.test.ts.snap
@@ -0,0 +1,41 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Component Unique Features > Column > should handle align attribute 1`] = `""`;
+
+exports[`Component Unique Features > Column > should handle colspan attribute 1`] = `""`;
+
+exports[`Component Unique Features > Heading > should apply horizontal margin (mx) 1`] = `""`;
+
+exports[`Component Unique Features > Heading > should apply individual margin props 1`] = `""`;
+
+exports[`Component Unique Features > Heading > should apply margin shorthand (m) 1`] = `""`;
+
+exports[`Component Unique Features > Heading > should apply vertical margin (my) 1`] = `""`;
+
+exports[`Component Unique Features > Heading > should combine margin props with inline styles 1`] = `""`;
+
+exports[`Component Unique Features > Heading > should render different heading levels 1`] = `""`;
+
+exports[`Component Unique Features > Heading > should render different heading levels 2`] = `""`;
+
+exports[`Component Unique Features > Heading > should render different heading levels 3`] = `""`;
+
+exports[`Component Unique Features > Heading > should render different heading levels 4`] = `""`;
+
+exports[`Component Unique Features > Heading > should render different heading levels 5`] = `""`;
+
+exports[`Component Unique Features > Heading > should render different heading levels 6`] = `""`;
+
+exports[`Component Unique Features > Html > should render with RTL direction 1`] = `""`;
+
+exports[`Component Unique Features > Html > should render with default lang and dir attributes 1`] = `""`;
+
+exports[`Component Unique Features > Link > should allow custom target 1`] = `""`;
+
+exports[`Component Unique Features > Link > should have default target="_blank" 1`] = `""`;
+
+exports[`Component Unique Features > Preview > should add whitespace padding for short text 1`] = `"Short
"`;
+
+exports[`Component Unique Features > Preview > should truncate preview text to max length 1`] = `"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
"`;
+
+exports[`Component Unique Features > Text > should render with custom element tag (as prop) 1`] = `""`;
diff --git a/src/lib/components/__tests__/end-to-end.test.ts b/src/lib/components/__tests__/end-to-end.test.ts
index 69616ff..198c24d 100644
--- a/src/lib/components/__tests__/end-to-end.test.ts
+++ b/src/lib/components/__tests__/end-to-end.test.ts
@@ -1,58 +1,33 @@
import { describe, it, expect } from 'vitest';
-import { render } from 'svelte/server';
-import TestEmail from '../../emails/test-email.svelte';
-
-describe('End-to-End Component Rendering with Tailwind', () => {
- it('should render component with Tailwind classes converted to inline styles', () => {
- const result = render(TestEmail, {
- props: {}
- });
-
- // Container should have bg-gray-100 and p-8 converted to inline styles
- expect(result.body).toContain('background-color:rgb(243,244,246)');
- expect(result.body.replace(/\s/g, '')).toContain('padding:2rem'); // p-8 = 2rem
-
- // Text should have font-bold, text-blue-600 converted
- expect(result.body).toContain('font-weight: bold'); // font-bold
- expect(result.body).toContain('color:rgb(37,99,235)'); // text-blue-600
-
- // Button should have bg-brand (from custom tailwind config), text-white, px-4, py-2, rounded converted
- expect(result.body).toContain('background-color:rgb(255,62,0)');
- expect(result.body).toContain('color:rgb(255,255,255)');
- expect(result.body).toContain('padding-left:1rem'); // px-4 = 1rem
- expect(result.body).toContain('padding-right:1rem');
- expect(result.body).toContain('padding-top:0.5rem'); // py-2 = 0.5rem
- expect(result.body).toContain('padding-bottom:0.5rem');
- expect(result.body).toContain('border-radius:0.25rem'); // rounded
+import Renderer from '$lib/render/index.js';
+import TestEmail from './__fixtures__/test-email.svelte';
+
+describe('End-to-End Email Rendering', () => {
+ it('should render a complete email with all Tailwind classes inlined', async () => {
+ const renderer = new Renderer();
+ const html = await renderer.render(TestEmail);
+
+ // Snapshot captures:
+ // - All Tailwind classes converted to inline styles
+ // - Proper HTML structure with email-safe DOCTYPE
+ // - All components rendered correctly
+ // - Mixed inline styles and Tailwind classes handled properly
+ expect(html).toMatchSnapshot();
});
- it('should not have class attributes with Tailwind classes', () => {
- const result = render(TestEmail, {
- props: {}
+ it('should handle custom Tailwind config (bg-brand color)', async () => {
+ const renderer = new Renderer({
+ theme: {
+ extend: {
+ colors: {
+ brand: '#ff3e00'
+ }
+ }
+ }
});
+ const html = await renderer.render(TestEmail);
- // Original Tailwind classes should be removed/converted
- expect(result.body).not.toContain('class="bg-gray-100');
- expect(result.body).not.toContain('class="text-lg');
- expect(result.body).not.toContain('class="bg-brand');
- });
-
- it('should have inline styles in the rendered HTML', () => {
- const result = render(TestEmail, {
- props: {}
- });
-
- // After rendering, styleString prop becomes style attribute in HTML
- // Components receive styleString and apply it to their style attribute
- expect(result.body).toContain('style=');
-
- // Verify styles are actually applied (not just attributes present)
- const hasInlineBackgroundColor = result.body.includes('background-color:rgb(243,244,246)');
- const hasInlineTextColor = result.body.includes('color:rgb(37,99,235)');
- const hasInlineButtonColor = result.body.includes('background-color:rgb(255,62,0)');
-
- expect(hasInlineBackgroundColor).toBe(true);
- expect(hasInlineTextColor).toBe(true);
- expect(hasInlineButtonColor).toBe(true);
+ // The bg-brand class should use the custom color from config
+ expect(html).toMatchSnapshot();
});
});
diff --git a/src/lib/components/__tests__/rendering.test.ts b/src/lib/components/__tests__/rendering.test.ts
index 57248a8..71bb741 100644
--- a/src/lib/components/__tests__/rendering.test.ts
+++ b/src/lib/components/__tests__/rendering.test.ts
@@ -1,1041 +1,140 @@
import { describe, it, expect } from 'vitest';
-import { render } from 'svelte/server';
-import Body from '../Body.svelte';
-import Button from '../Button.svelte';
+import Renderer from '$lib/render/index.js';
import Column from '../Column.svelte';
-import Container from '../Container.svelte';
-import Head from '../Head.svelte';
import Heading from '../Heading.svelte';
-import Hr from '../Hr.svelte';
import Html from '../Html.svelte';
-import Img from '../Img.svelte';
import Link from '../Link.svelte';
import Preview from '../Preview.svelte';
-import Row from '../Row.svelte';
-import Section from '../Section.svelte';
import Text from '../Text.svelte';
const testChildren = () => 'test';
-describe('Component Rendering with style', () => {
- describe('Body', () => {
- it('should render body with table structure', () => {
- const result = render(Body, {
- props: {
- children: testChildren
- }
- });
-
- expect(result.body).toContain('');
- expect(result.body).toContain(' {
- const testStyles = 'background-color:rgb(243,244,246); padding:20px;';
-
- const result = render(Body, {
- props: {
- style: testStyles,
- children: testChildren
- }
- });
-
- expect(result.body).toContain('background-color:rgb(243,244,246)');
- expect(result.body).toContain('padding:20px');
- });
-
- it('should pass through HTML attributes', () => {
- const result = render(Body, {
- props: {
- class: 'email-body',
- id: 'main-body',
- children: testChildren
- }
- });
-
- expect(result.body).toContain('class="email-body"');
- expect(result.body).toContain('id="main-body"');
- });
- });
-
- describe('Button', () => {
- it('should apply style to the anchor element', () => {
- const testStyles = 'background-color:rgb(59,130,246); color:rgb(255,255,255); padding:16px;';
-
- const result = render(Button, {
- props: {
- style: testStyles,
- href: 'https://example.com',
- children: testChildren
- }
- });
-
- // Check that styles are applied
- expect(result.body).toContain('background-color:rgb(59,130,246)');
- expect(result.body).toContain('color:rgb(255,255,255)');
- expect(result.body).toContain('padding:16px');
- expect(result.body).toContain('href="https://example.com"');
-
- // Verify it's an anchor tag with style attribute
- expect(result.body).toContain(' {
- const result = render(Button, {
- props: {
- style: 'background-color:blue;',
- children: testChildren
- }
- });
-
- // Base styles should be present
- expect(result.body).toContain('text-decoration:none');
- expect(result.body).toContain('display:inline-block');
- // Custom style should be present
- expect(result.body).toContain('background-color:blue');
- });
-
- it('should handle empty style', () => {
- const result = render(Button, {
- props: {
- style: '',
- children: testChildren
- }
- });
-
- // Should still have base styles
- expect(result.body).toContain('style=');
- expect(result.body).toContain('text-decoration');
- });
-
- it('should properly separate styles with semicolons', () => {
- const styles =
- 'background-color:rgb(59,130,246); color:rgb(255,255,255); padding:16px; border-radius:4px;';
-
- const result = render(Button, {
- props: {
- style: styles,
- children: testChildren
- }
- });
-
- // All styles should be in the output
- const styleMatch = result.body.match(/style="([^"]*)"/);
- expect(styleMatch).toBeTruthy();
-
- if (styleMatch) {
- const styleContent = styleMatch[1];
- expect(styleContent).toContain('background-color:rgb(59,130,246)');
- expect(styleContent).toContain('color:rgb(255,255,255)');
- expect(styleContent).toContain('padding:16px');
- expect(styleContent).toContain('border-radius:4px');
- }
- });
- });
-
- describe('Container', () => {
- it('should apply style to the table element', () => {
- const testStyles = 'background-color:rgb(243,244,246); padding:32px;';
-
- const result = render(Container, {
- props: {
- style: testStyles,
- children: testChildren
- }
- });
-
- expect(result.body).toContain('background-color:rgb(243,244,246)');
- expect(result.body).toContain('padding:32px');
- expect(result.body).toContain(' {
- const result = render(Container, {
- props: {
- style: 'padding:20px;',
- children: testChildren
- }
- });
-
- // Base max-width should be present
- expect(result.body).toContain('max-width:37.5em');
- // Custom style should be present
- expect(result.body).toContain('padding:20px');
- });
-
- it('should have default max-width', () => {
- const result = render(Container, {
- props: {
- children: testChildren
- }
- });
-
- expect(result.body).toContain('max-width:37.5em');
- });
- });
-
- describe('Column', () => {
- it('should render as td element', () => {
- const result = render(Column, {
- props: {
- children: testChildren
- }
- });
-
- expect(result.body).toContain('| ');
- });
-
- it('should apply custom style', () => {
- const testStyles = 'background-color:rgb(243,244,246); padding:16px;';
-
- const result = render(Column, {
- props: {
- style: testStyles,
- children: testChildren
- }
- });
-
- expect(result.body).toContain('background-color:rgb(243,244,246)');
- expect(result.body).toContain('padding:16px');
- });
-
- it('should merge style with default styles', () => {
- const result = render(Column, {
- props: {
- style: 'vertical-align:top;',
- children: testChildren
- }
- });
-
- expect(result.body).toContain('vertical-align:top');
- });
-
- it('should handle colspan attribute', () => {
- const result = render(Column, {
- props: {
- colspan: 2,
- children: testChildren
- }
- });
-
- expect(result.body).toContain('colspan="2"');
+describe('Component Unique Features', () => {
+ describe('Html', () => {
+ it('should render with default lang and dir attributes', async () => {
+ const renderer = new Renderer();
+ const html = await renderer.render(Html, { props: {} });
+ expect(html).toMatchSnapshot();
});
- it('should handle align attribute', () => {
- const result = render(Column, {
- props: {
- align: 'center',
- children: testChildren
- }
- });
-
- expect(result.body).toContain('align="center"');
+ it('should render with RTL direction', async () => {
+ const renderer = new Renderer();
+ const html = await renderer.render(Html, { props: { lang: 'ar', dir: 'rtl' } });
+ expect(html).toMatchSnapshot();
});
});
describe('Text', () => {
- it('should apply style to the paragraph element', () => {
- const testStyles = 'color:rgb(55,65,81); font-size:18px; font-weight:600;';
-
- const result = render(Text, {
- props: {
- style: testStyles,
- children: testChildren
- }
- });
-
- expect(result.body).toContain('color:rgb(55,65,81)');
- expect(result.body).toContain('font-size:18px');
- expect(result.body).toContain('font-weight:600');
- expect(result.body).toContain(' {
- const result = render(Text, {
- props: {
- style: 'color:red;',
- children: testChildren
- }
- });
-
- // Base styles should be present
- expect(result.body).toContain('font-size:14px');
- expect(result.body).toContain('line-height:24px');
- // Custom style should be present
- expect(result.body).toContain('color:red');
- });
-
- it('should render with custom element tag', () => {
- const result = render(Text, {
- props: {
- as: 'h1',
- style: 'font-size:32px;',
- children: testChildren
- }
- });
-
- expect(result.body).toContain(' {
- const result = render(Text, {
- props: {
- children: testChildren
- }
- });
-
- expect(result.body).toContain('font-size:14px');
- expect(result.body).toContain('line-height:24px');
- expect(result.body).toContain('margin:16px 0');
- });
- });
-
- describe('Section', () => {
- it('should apply style to the table element', () => {
- const testStyles = 'padding:20px; background-color:rgb(255,255,255);';
-
- const result = render(Section, {
- props: {
- style: testStyles,
- children: testChildren
- }
- });
-
- expect(result.body).toContain('padding:20px');
- expect(result.body).toContain('background-color:rgb(255,255,255)');
- expect(result.body).toContain(' {
- it('should apply style to the html element', () => {
- const testStyles = 'background-color:rgb(255,255,255);';
-
- const result = render(Html, {
- props: {
- style: testStyles,
- lang: 'en'
- }
- });
-
- expect(result.body).toContain('background-color:rgb(255,255,255)');
- expect(result.body).toContain('lang="en"');
- expect(result.body).toContain(' {
- const result = render(Html, {
- props: {
- lang: 'ar',
- dir: 'rtl'
- }
- });
-
- expect(result.body).toContain('dir="rtl"');
- expect(result.body).toContain('lang="ar"');
- });
-
- it('should have default lang and dir attributes', () => {
- const result = render(Html, {
- props: {}
+ it('should render with custom element tag (as prop)', async () => {
+ const renderer = new Renderer();
+ const html = await renderer.render(Text, {
+ props: { as: 'h1', style: 'font-size:32px;', children: testChildren }
});
-
- expect(result.body).toContain('lang="en"');
- expect(result.body).toContain('dir="ltr"');
+ expect(html).toMatchSnapshot();
});
});
- describe('Head', () => {
- it('should render with meta tags', () => {
- const result = render(Head, {
- props: {}
- });
-
- expect(result.body).toContain('');
- expect(result.body).toContain('http-equiv="content-type"');
- expect(result.body).toContain('name="viewport"');
- expect(result.body).toContain('width=device-width');
+ describe('Column', () => {
+ it('should handle colspan attribute', async () => {
+ const renderer = new Renderer();
+ const html = await renderer.render(Column, { props: { colspan: 2, children: testChildren } });
+ expect(html).toMatchSnapshot();
});
- });
- describe('Hr', () => {
- it('should have default styles along with custom styles', () => {
- const result = render(Hr, {
- props: {
- style: 'border-color:red;'
- }
+ it('should handle align attribute', async () => {
+ const renderer = new Renderer();
+ const html = await renderer.render(Column, {
+ props: { align: 'center', children: testChildren }
});
-
- expect(result.body).toContain(' {
- it('should render as h1 by default when as="h1" is specified', () => {
- const result = render(Heading, {
- props: {
- as: 'h1',
- children: testChildren
- }
- });
-
- expect(result.body).toContain(' |