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 @@ - 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 + + +
+ +
+ + + Logo + + + + 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`] = `"
+ {@render children?.()}

Template title

Responsive Heading

Multiple utilities text

Bold red text with margin


Column 1

Left column content

Column 2

Right column content


Logo 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`] = `"

Template title

Responsive Heading

Multiple utilities text

Bold red text with margin


Column 1

Left column content

Column 2

Right column content


Logo 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`] = `""`; + +exports[`Component Unique Features > Preview > should truncate preview text to max length 1`] = `""`; + +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(''); - }); - - it('should render different heading levels', () => { + it('should render different heading levels', async () => { const levels = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; + const renderer = new Renderer(); - levels.forEach((level) => { - const result = render(Heading, { - props: { - as: level, - children: testChildren - } + for (const level of levels) { + const html = await renderer.render(Heading, { + props: { as: level, children: testChildren } }); - - expect(result.body).toContain(`<${level}`); - expect(result.body).toContain(``); - }); - }); - - it('should apply custom style', () => { - const testStyles = 'color:rgb(37,99,235); font-size:32px; font-weight:700;'; - - const result = render(Heading, { - props: { - as: 'h2', - style: testStyles, - children: testChildren - } - }); - - expect(result.body).toContain('color:rgb(37,99,235)'); - expect(result.body).toContain('font-size:32px'); - expect(result.body).toContain('font-weight:700'); - }); - - it('should apply margin shorthand (m)', () => { - const result = render(Heading, { - props: { - as: 'h1', - m: '20', - children: testChildren - } - }); - - expect(result.body).toContain('margin:20px'); - }); - - it('should apply horizontal margin (mx)', () => { - const result = render(Heading, { - props: { - as: 'h2', - mx: '16', - children: testChildren - } - }); - - expect(result.body).toContain('margin-left:16px'); - expect(result.body).toContain('margin-right:16px'); - }); - - it('should apply vertical margin (my)', () => { - const result = render(Heading, { - props: { - as: 'h3', - my: '24', - children: testChildren - } - }); - - expect(result.body).toContain('margin-top:24px'); - expect(result.body).toContain('margin-bottom:24px'); - }); - - it('should apply individual margin props', () => { - const result = render(Heading, { - props: { - as: 'h1', - mt: '10', - mr: '15', - mb: '20', - ml: '25', - children: testChildren - } - }); - - expect(result.body).toContain('margin-top:10px'); - expect(result.body).toContain('margin-right:15px'); - expect(result.body).toContain('margin-bottom:20px'); - expect(result.body).toContain('margin-left:25px'); - }); - - it('should merge custom styles with margin styles', () => { - const result = render(Heading, { - props: { - as: 'h2', - style: 'color:blue; font-weight:600;', - my: '16', - children: testChildren - } - }); - - // Margin styles should be present - expect(result.body).toContain('margin-top:16px'); - expect(result.body).toContain('margin-bottom:16px'); - // Custom styles should be present - expect(result.body).toContain('color:blue'); - expect(result.body).toContain('font-weight:600'); - }); - - it('should handle empty style prop', () => { - const result = render(Heading, { - props: { - as: 'h1', - style: '', - m: '10', - children: testChildren - } - }); - - expect(result.body).toContain('margin:10px'); - }); - - it('should handle no margin props', () => { - const result = render(Heading, { - props: { - as: 'h3', - style: 'color:red;', - children: testChildren - } - }); - - expect(result.body).toContain('color:red'); - expect(result.body).toContain(' { - const result = render(Heading, { - props: { - as: 'h1', - id: 'main-heading', - class: 'heading-class', - children: testChildren - } - }); - - expect(result.body).toContain('id="main-heading"'); - expect(result.body).toContain('class="heading-class"'); - }); - - it('should render with preprocessor-transformed Tailwind styles', () => { - // Simulates preprocessor output: class="text-3xl font-bold text-blue-600 mb-4" - const preprocessedStyles = - 'font-size:1.875rem; line-height:2.25rem; font-weight:700; color:rgb(37,99,235); margin-bottom:1rem;'; - - const result = render(Heading, { - props: { - as: 'h1', - style: preprocessedStyles, - children: testChildren - } - }); - - expect(result.body).toContain('font-size:1.875rem'); - expect(result.body).toContain('line-height:2.25rem'); - expect(result.body).toContain('font-weight:700'); - expect(result.body).toContain('color:rgb(37,99,235)'); - expect(result.body).toContain('margin-bottom:1rem'); + expect(html).toMatchSnapshot(); + } }); - it('should combine margin props with preprocessor styles', () => { - const preprocessedStyles = 'font-size:2rem; font-weight:600; color:rgb(17,24,39);'; - - const result = render(Heading, { - props: { - as: 'h2', - style: preprocessedStyles, - mt: '32', - mb: '16', - children: testChildren - } + it('should apply margin shorthand (m)', async () => { + const renderer = new Renderer(); + const html = await renderer.render(Heading, { + props: { as: 'h1', m: '20', children: testChildren } }); - - // Margin props should be applied - expect(result.body).toContain('margin-top:32px'); - expect(result.body).toContain('margin-bottom:16px'); - // Preprocessor styles should be present - expect(result.body).toContain('font-size:2rem'); - expect(result.body).toContain('font-weight:600'); - expect(result.body).toContain('color:rgb(17,24,39)'); + expect(html).toMatchSnapshot(); }); - }); - describe('Img', () => { - it('should render img element with required attributes', () => { - const result = render(Img, { - props: { - src: 'https://example.com/image.png', - alt: 'Test Image', - width: '600', - height: '400' - } + it('should apply horizontal margin (mx)', async () => { + const renderer = new Renderer(); + const html = await renderer.render(Heading, { + props: { as: 'h2', mx: '16', children: testChildren } }); - - expect(result.body).toContain(' { - const result = render(Img, { - props: { - src: 'https://example.com/image.png', - alt: 'Test', - width: '100', - height: '100' - } + it('should apply vertical margin (my)', async () => { + const renderer = new Renderer(); + const html = await renderer.render(Heading, { + props: { as: 'h3', my: '24', children: testChildren } }); - - expect(result.body).toContain('display:block'); - expect(result.body).toContain('outline:none'); - expect(result.body).toContain('border:none'); - expect(result.body).toContain('text-decoration:none'); + expect(html).toMatchSnapshot(); }); - it('should apply custom style', () => { - const testStyles = 'border-radius:8px; max-width:100%;'; - - const result = render(Img, { - props: { - src: 'https://example.com/image.png', - alt: 'Test', - width: '600', - height: '400', - style: testStyles - } + it('should apply individual margin props', async () => { + const renderer = new Renderer(); + const html = await renderer.render(Heading, { + props: { as: 'h1', mt: '10', mr: '15', mb: '20', ml: '25', children: testChildren } }); - - expect(result.body).toContain('border-radius:8px'); - expect(result.body).toContain('max-width:100%'); - }); - - it('should merge custom style with default styles', () => { - const result = render(Img, { - props: { - src: 'https://example.com/image.png', - alt: 'Test', - width: '100', - height: '100', - style: 'margin:20px;' - } - }); - - // Default styles should be present - expect(result.body).toContain('display:block'); - expect(result.body).toContain('border:none'); - // Custom style should be present - expect(result.body).toContain('margin:20px'); + expect(html).toMatchSnapshot(); }); - it('should pass through additional HTML attributes', () => { - const result = render(Img, { - props: { - src: 'https://example.com/image.png', - alt: 'Test', - width: '100', - height: '100', - class: 'email-image', - id: 'header-logo' - } + it('should combine margin props with inline styles', async () => { + const renderer = new Renderer(); + const html = await renderer.render(Heading, { + props: { as: 'h2', style: 'color:blue;', my: '16', children: testChildren } }); - - expect(result.body).toContain('class="email-image"'); - expect(result.body).toContain('id="header-logo"'); + expect(html).toMatchSnapshot(); }); }); describe('Link', () => { - it('should render anchor element with href', () => { - const result = render(Link, { - props: { - href: 'https://example.com', - children: testChildren - } + it('should have default target="_blank"', async () => { + const renderer = new Renderer(); + const html = await renderer.render(Link, { + props: { href: 'https://example.com', children: testChildren } }); - - expect(result.body).toContain(''); + expect(html).toMatchSnapshot(); }); - it('should have default target="_blank"', () => { - const result = render(Link, { - props: { - href: 'https://example.com', - children: testChildren - } + it('should allow custom target', async () => { + const renderer = new Renderer(); + const html = await renderer.render(Link, { + props: { href: 'https://example.com', target: '_self', children: testChildren } }); - - expect(result.body).toContain('target="_blank"'); - }); - - it('should allow custom target', () => { - const result = render(Link, { - props: { - href: 'https://example.com', - target: '_self', - children: testChildren - } - }); - - expect(result.body).toContain('target="_self"'); - }); - - it('should have default styles', () => { - const result = render(Link, { - props: { - href: 'https://example.com', - children: testChildren - } - }); - - expect(result.body).toContain('text-decoration-line:none'); - expect(result.body).toContain('color:#067df7'); - }); - - it('should apply custom style', () => { - const testStyles = 'color:rgb(255,0,0); font-weight:600;'; - - const result = render(Link, { - props: { - href: 'https://example.com', - style: testStyles, - children: testChildren - } - }); - - expect(result.body).toContain('color:rgb(255,0,0)'); - expect(result.body).toContain('font-weight:600'); - }); - - it('should merge custom style with default styles', () => { - const result = render(Link, { - props: { - href: 'https://example.com', - style: 'font-size:18px;', - children: testChildren - } - }); - - // Default styles should be present - expect(result.body).toContain('text-decoration-line:none'); - // Custom style should be present - expect(result.body).toContain('font-size:18px'); - }); - - it('should pass through additional HTML attributes', () => { - const result = render(Link, { - props: { - href: 'https://example.com', - class: 'email-link', - id: 'cta-link', - children: testChildren - } - }); - - expect(result.body).toContain('class="email-link"'); - expect(result.body).toContain('id="cta-link"'); + expect(html).toMatchSnapshot(); }); }); describe('Preview', () => { - it('should render preview div with text', () => { - const previewText = 'This is a preview text for the email'; - - const result = render(Preview, { - props: { - preview: previewText - } - }); - - expect(result.body).toContain('