├── .nvmrc ├── .npmrc ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── static ├── apple-logo.png ├── apple-wallet.png ├── vercel-arrow.png ├── vercel-logo.png ├── vercel-team.png ├── vercel-user.png ├── apple-card-icon.png ├── apple-hbo-max-icon.jpeg └── favicon.svg ├── src ├── lib │ ├── render │ │ ├── __fixtures__ │ │ │ ├── EmptyComponent.svelte │ │ │ ├── NoHeadComponent.svelte │ │ │ ├── CustomColorComponent.svelte │ │ │ ├── UnknownClassComponent.svelte │ │ │ ├── ResponsiveComponent.svelte │ │ │ ├── BasicComponent.svelte │ │ │ ├── CombinedStylesComponent.svelte │ │ │ ├── MultipleClassesComponent.svelte │ │ │ ├── VariablesComponent.svelte │ │ │ ├── CommentsComponent.svelte │ │ │ ├── PropsComponent.svelte │ │ │ ├── NestedComponent.svelte │ │ │ └── LibraryComponentsTest.svelte │ │ ├── utils │ │ │ ├── tailwindcss │ │ │ │ ├── tailwind-stylesheets │ │ │ │ │ └── utilities.ts │ │ │ │ ├── setup-tailwind.spec.ts │ │ │ │ ├── pixel-based-preset.ts │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── setup-tailwind.spec.ts.snap │ │ │ │ ├── setup-tailwind.ts │ │ │ │ ├── add-inlined-styles-to-element.ts │ │ │ │ └── add-inlined-styles-to-element.spec.ts │ │ │ ├── html │ │ │ │ ├── is-valid-node.ts │ │ │ │ ├── remove-attributes-functions.ts │ │ │ │ └── walk.ts │ │ │ ├── compatibility │ │ │ │ ├── sanitize-class-name.spec.ts │ │ │ │ └── sanitize-class-name.ts │ │ │ └── css │ │ │ │ ├── __snapshots__ │ │ │ │ ├── make-inline-styles-for.spec.ts.snap │ │ │ │ ├── resolve-calc-expressions.spec.ts.snap │ │ │ │ ├── extract-rules-per-class.spec.ts.snap │ │ │ │ ├── sanitize-non-inlinable-rules.spec.ts.snap │ │ │ │ ├── resolve-all-css-variables.spec.ts.snap │ │ │ │ └── sanitize-declarations.spec.ts.snap │ │ │ │ ├── sanitize-stylesheet.ts │ │ │ │ ├── get-custom-properties.ts │ │ │ │ ├── resolve-calc-expressions.spec.ts │ │ │ │ ├── make-inline-styles-for.spec.ts │ │ │ │ ├── extract-rules-per-class.ts │ │ │ │ ├── sanitize-non-inlinable-rules.ts │ │ │ │ ├── is-rule-inlinable.ts │ │ │ │ ├── sanitize-non-inlinable-rules.spec.ts │ │ │ │ ├── extract-rules-per-class.spec.ts │ │ │ │ ├── make-inline-styles-for.ts │ │ │ │ ├── resolve-all-css-variables.ts │ │ │ │ ├── resolve-calc-expressions.ts │ │ │ │ └── resolve-all-css-variables.spec.ts │ │ └── index.ts │ ├── index.ts │ ├── components │ │ ├── __tests__ │ │ │ ├── __fixtures__ │ │ │ │ ├── nested │ │ │ │ │ └── nested.svelte │ │ │ │ └── test-email.svelte │ │ │ ├── end-to-end.test.ts │ │ │ ├── rendering.test.ts │ │ │ └── __snapshots__ │ │ │ │ └── rendering.test.ts.snap │ │ ├── Column.svelte │ │ ├── Hr.svelte │ │ ├── Head.svelte │ │ ├── Section.svelte │ │ ├── Row.svelte │ │ ├── Body.svelte │ │ ├── Link.svelte │ │ ├── Html.svelte │ │ ├── Img.svelte │ │ ├── Text.svelte │ │ ├── Container.svelte │ │ ├── index.ts │ │ ├── Heading.svelte │ │ ├── Preview.svelte │ │ └── Button.svelte │ ├── preview │ │ ├── theme.css │ │ ├── email-tree.ts │ │ ├── Favicon.svelte │ │ └── EmailTreeNode.svelte │ ├── utils │ │ └── index.ts │ └── emails │ │ ├── demo-email.svelte │ │ └── examples │ │ └── vercel-invite-user.svelte ├── routes │ ├── docs │ │ ├── +page.server.ts │ │ ├── render │ │ │ └── +page.md │ │ ├── components │ │ │ └── +page.md │ │ ├── migrating-to-v1 │ │ │ └── +page.md │ │ ├── getting-started │ │ │ └── +page.md │ │ ├── +layout.svelte │ │ └── email-preview │ │ │ └── +page.md │ ├── preview │ │ └── [...email] │ │ │ ├── +page.svelte │ │ │ └── +page.server.ts │ ├── +layout.svelte │ ├── +page.server.ts │ └── +page.svelte ├── app.d.ts ├── app.html └── app.css ├── context7.json ├── .prettierignore ├── .env.example ├── .gitignore ├── .prettierrc ├── tsconfig.json ├── ROADMAP.md ├── .npmignore ├── vite.config.ts ├── .cursor └── rules │ └── commands.mdc ├── LICENSE ├── eslint.config.js ├── svelte.config.js ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: konixy 2 | -------------------------------------------------------------------------------- /static/apple-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/apple-logo.png -------------------------------------------------------------------------------- /static/apple-wallet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/apple-wallet.png -------------------------------------------------------------------------------- /static/vercel-arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/vercel-arrow.png -------------------------------------------------------------------------------- /static/vercel-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/vercel-logo.png -------------------------------------------------------------------------------- /static/vercel-team.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/vercel-team.png -------------------------------------------------------------------------------- /static/vercel-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/vercel-user.png -------------------------------------------------------------------------------- /static/apple-card-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/apple-card-icon.png -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/EmptyComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/apple-hbo-max-icon.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konixy/better-svelte-email/HEAD/static/apple-hbo-max-icon.jpeg -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/NoHeadComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 |
No Head Tag
3 | 4 | -------------------------------------------------------------------------------- /context7.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://context7.com/konixy/better-svelte-email", 3 | "public_key": "pk_BsTWhU89uE119Tn9NKGdX" 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/render/utils/tailwindcss/tailwind-stylesheets/utilities.ts: -------------------------------------------------------------------------------- 1 | const css = ` 2 | @tailwind utilities; 3 | `; 4 | 5 | export default css; 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | bun.lock 6 | bun.lockb 7 | 8 | # Miscellaneous 9 | /static/ 10 | -------------------------------------------------------------------------------- /src/routes/docs/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | export function load() { 4 | throw redirect(302, '/docs/getting-started'); 5 | } 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Resend API Key (get from https://resend.com/api-keys) 2 | RESEND_API_KEY=re_your_api_key_here 3 | 4 | # Email configuration 5 | FROM_EMAIL=onboarding@resend.dev 6 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/CustomColorComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Custom Color
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/UnknownClassComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Content
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/ResponsiveComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Responsive Text
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/BasicComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
Hello World
6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/CombinedStylesComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Styled Text
5 | 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | .vercel 13 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/MultipleClassesComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Multiple Classes
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/VariablesComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Variable Color
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/render/utils/html/is-valid-node.ts: -------------------------------------------------------------------------------- 1 | import type { AST } from '$lib/render/index.js'; 2 | 3 | export function isValidNode(node: AST.ChildNode): node is AST.Element { 4 | return !node.nodeName.startsWith('#'); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/CommentsComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
Content
7 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/PropsComponent.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 |
Hello {name}
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // Export email components 2 | export * from './components/index.js'; 3 | 4 | // Export renderer 5 | export { 6 | default as Renderer, 7 | toPlainText, 8 | type TailwindConfig, 9 | type RenderOptions 10 | } from './render/index.js'; 11 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/NestedComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 | Nested 7 |
8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/lib/components/__tests__/__fixtures__/nested/nested.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Template title 7 | 8 | -------------------------------------------------------------------------------- /src/routes/preview/[...email]/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | Email Preview · Better Svelte Email 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/lib/components/Column.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | {@render children?.()} 14 | 15 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/lib/render/utils/compatibility/sanitize-class-name.spec.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeClassName } from './sanitize-class-name.js'; 2 | import { expect, test } from 'vitest'; 3 | 4 | test('sanitizeClassName', () => { 5 | expect(sanitizeClassName('min-height-[calc(25px+100%-20%*2/4)]')).toBe( 6 | 'min-height-calc25pxplus100pc-20pc_2_4' 7 | ); 8 | }); 9 | -------------------------------------------------------------------------------- /src/lib/render/__fixtures__/LibraryComponentsTest.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | Test Email 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ], 15 | "tailwindStylesheet": "./src/app.css" 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "module": "NodeNext", 13 | "moduleResolution": "NodeNext" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Better Svelte Email 8 | %sveltekit.head% 9 | 10 | 11 |
%sveltekit.body%
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/__snapshots__/make-inline-styles-for.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`makeInlineStylesFor() > does basic local variable resolution 1`] = `"background-color: #3490dc ;color: #fff ;padding: 0.5rem 1rem ;border-radius: 0.25rem ;"`; 4 | 5 | exports[`makeInlineStylesFor() > works in simple use case 1`] = `"background-color: #f56565 ;width: 100% ;"`; 6 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/sanitize-stylesheet.ts: -------------------------------------------------------------------------------- 1 | import type { Root } from 'postcss'; 2 | import { resolveAllCssVariables } from './resolve-all-css-variables.js'; 3 | import { resolveCalcExpressions } from './resolve-calc-expressions.js'; 4 | import { sanitizeDeclarations } from './sanitize-declarations.js'; 5 | 6 | export function sanitizeStyleSheet(root: Root) { 7 | resolveAllCssVariables(root); 8 | resolveCalcExpressions(root); 9 | sanitizeDeclarations(root); 10 | } 11 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # `better-svelte-email` Roadmap 2 | 3 | ## v1.0.0 4 | 5 | - [ ] Responsive behaviours on the preview UI 6 | - [ ] Make Tailwind support optional (maybe through `@better-svelte-email/tailwind`) 7 | - [ ] Insight on email good practices and things not supported (like react-email) 8 | - [x] Tailwind v4 support 9 | - [x] New Preview UI 10 | - [x] Improve default preview emails 11 | - [x] Dark mode on website & preview 12 | - [x] Docs in website 13 | - [x] New website 14 | -------------------------------------------------------------------------------- /src/lib/components/Hr.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | -------------------------------------------------------------------------------- /src/lib/components/Head.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {@render children?.()} 13 | 14 | -------------------------------------------------------------------------------- /src/lib/render/utils/html/remove-attributes-functions.ts: -------------------------------------------------------------------------------- 1 | import type { AST } from '$lib/render/index.js'; 2 | import { isValidNode } from './is-valid-node.js'; 3 | import { walk } from './walk.js'; 4 | 5 | export function removeAttributesFunctions(ast: AST.Document): AST.Document { 6 | return walk(ast, (node) => { 7 | if (isValidNode(node)) { 8 | node.attrs = node.attrs?.filter((attr) => !['onload', 'onerror'].includes(attr.name)) ?? []; 9 | } 10 | 11 | return node; 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 |
10 | {@render children()} 11 |
12 | 13 | 27 | -------------------------------------------------------------------------------- /src/lib/render/utils/tailwindcss/setup-tailwind.spec.ts: -------------------------------------------------------------------------------- 1 | import { setupTailwind } from './setup-tailwind.js'; 2 | import { expect, test } from 'vitest'; 3 | 4 | test('setupTailwind() and addUtilities()', async () => { 5 | const { addUtilities, getStyleSheet } = await setupTailwind({}); 6 | 7 | addUtilities(['text-red-500', 'sm:bg-blue-300', 'bg-slate-900']); 8 | 9 | expect(getStyleSheet().toString()).toMatchSnapshot(); 10 | 11 | addUtilities(['bg-red-100']); 12 | 13 | expect(getStyleSheet().toString()).toMatchSnapshot(); 14 | }); 15 | -------------------------------------------------------------------------------- /src/lib/components/Section.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 23 | 26 | 27 | 28 |
24 | {@render children?.()} 25 |
29 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | *.test.ts 4 | *.spec.ts 5 | __tests__/ 6 | 7 | # Documentation 8 | IMPLEMENTATION_GUIDE.md 9 | .prettierrc 10 | 11 | # Config files 12 | .eslintrc.js 13 | eslint.config.js 14 | tsconfig.json 15 | vite.config.ts 16 | vitest-setup-client.ts 17 | svelte.config.js 18 | 19 | # Development files 20 | .git/ 21 | .github/ 22 | node_modules/ 23 | .svelte-kit/ 24 | .vscode/ 25 | .idea/ 26 | 27 | # Build artifacts 28 | .DS_Store 29 | *.log 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | pnpm-debug.log* 34 | 35 | -------------------------------------------------------------------------------- /src/lib/components/Row.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 23 | {@render children?.()} 24 | 25 | 26 |
27 | -------------------------------------------------------------------------------- /src/lib/components/Body.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 |
17 | {@render children?.()} 18 |
22 | 23 | -------------------------------------------------------------------------------- /src/lib/components/Link.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | {@render children?.()} 22 | 23 | -------------------------------------------------------------------------------- /src/lib/components/Html.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | {@html doctype} 18 | 19 | {@render children?.()} 20 | 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | # Enable version updates for npm 9 | - package-ecosystem: 'npm' 10 | # Look for `package.json` and `lock` files in the `root` directory 11 | directory: '/' 12 | # Check the npm registry for updates every day (weekdays) 13 | schedule: 14 | interval: 'daily' 15 | -------------------------------------------------------------------------------- /src/lib/components/Img.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib/components/Text.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | {@render children?.()} 25 | 26 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/__snapshots__/resolve-calc-expressions.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`resolveCalcExpressions() > does not modify complex calc expressions 1`] = ` 4 | " 5 | .px-3{padding-inline:calc(0.25rem*(3 + 1px))} 6 | .py-2{padding-block:calc(0.25rem*(2 + 1px))} 7 | " 8 | `; 9 | 10 | exports[`resolveCalcExpressions() > resolves calc expressions repeating decimals 1`] = ` 11 | " 12 | .w-1/3 { width: 33.33333333333333%; } 13 | " 14 | `; 15 | 16 | exports[`resolveCalcExpressions() > resolves spacing calc expressions from tailwind v4 1`] = ` 17 | " 18 | .px-3{padding-inline:0.75rem} 19 | .py-2{padding-block:0.5rem} 20 | " 21 | `; 22 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | let cache: { githubStars: number; lastUpdated: Date } | null = null; 2 | 3 | export async function load() { 4 | if (cache && cache.lastUpdated > new Date(Date.now() - 1000 * 60 * 60)) { 5 | return cache; 6 | } 7 | try { 8 | const response = await fetch('https://api.github.com/repos/Konixy/better-svelte-email'); 9 | 10 | if (!response.ok) { 11 | throw new Error(`GitHub API error: ${response.status}`); 12 | } 13 | 14 | const data = await response.json(); 15 | 16 | cache = { 17 | lastUpdated: new Date(), 18 | githubStars: data.stargazers_count || 0 19 | }; 20 | return cache; 21 | } catch (error) { 22 | console.error('Failed to fetch GitHub stars:', error); 23 | 24 | return { githubStars: 0 }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | import { sveltekit } from '@sveltejs/kit/vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [tailwindcss(), sveltekit()], 7 | test: { 8 | expect: { requireAssertions: true }, 9 | server: { 10 | deps: { 11 | inline: ['@sveltejs/kit'] 12 | } 13 | }, 14 | projects: [ 15 | { 16 | extends: './vite.config.ts', 17 | test: { 18 | name: 'server', 19 | environment: 'node', 20 | include: ['src/**/*.{test,spec}.{js,ts}'], 21 | exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'], 22 | alias: { 23 | $app: '/node_modules/@sveltejs/kit/src/runtime/app' 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/lib/components/Container.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | 28 | 29 | 32 | 33 | 34 |
30 | {@render children?.()} 31 |
35 | -------------------------------------------------------------------------------- /src/lib/components/index.ts: -------------------------------------------------------------------------------- 1 | // Email components for better-svelte-email 2 | 3 | export { default as Body } from './Body.svelte'; 4 | export { default as Button } from './Button.svelte'; 5 | export { default as Column } from './Column.svelte'; 6 | export { default as Container } from './Container.svelte'; 7 | export { default as Head } from './Head.svelte'; 8 | export { default as Heading } from './Heading.svelte'; 9 | export { default as Hr } from './Hr.svelte'; 10 | export { default as Html } from './Html.svelte'; 11 | export { default as Img } from './Img.svelte'; 12 | export { default as Link } from './Link.svelte'; 13 | export { default as Preview } from './Preview.svelte'; 14 | export { default as Row } from './Row.svelte'; 15 | export { default as Section } from './Section.svelte'; 16 | export { default as Text } from './Text.svelte'; 17 | -------------------------------------------------------------------------------- /src/lib/components/Heading.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 | 33 | {@render children?.()} 34 | 35 | -------------------------------------------------------------------------------- /src/lib/components/Preview.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
31 | {text} 32 |
33 | {renderWhiteSpace(text)} 34 |
35 |
36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - develop 8 | push: 9 | branches: 10 | - develop 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Bun 22 | uses: oven-sh/setup-bun@v2 23 | with: 24 | bun-version: latest 25 | 26 | - name: Install dependencies 27 | run: bun install 28 | 29 | - name: Build project 30 | run: bun run build 31 | 32 | - name: Run linter 33 | run: bun run lint || echo "Linter not configured, skipping..." 34 | 35 | - name: Run tests 36 | run: bun run test 37 | 38 | - name: Test summary 39 | if: always() 40 | run: | 41 | if [ $? -eq 0 ]; then 42 | echo "✅ All tests passed!" 43 | else 44 | echo "❌ Tests failed!" 45 | exit 1 46 | fi 47 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/get-custom-properties.ts: -------------------------------------------------------------------------------- 1 | import type { Root, Declaration } from 'postcss'; 2 | 3 | export interface CustomProperty { 4 | syntax?: Declaration; 5 | inherits?: Declaration; 6 | initialValue?: Declaration; 7 | } 8 | 9 | export type CustomProperties = Map; 10 | 11 | export function getCustomProperties(root: Root): CustomProperties { 12 | const customProperties = new Map(); 13 | 14 | root.walkAtRules('property', (atRule) => { 15 | const propertyName = atRule.params.trim(); 16 | 17 | if (propertyName.startsWith('--')) { 18 | const prop: CustomProperty = {}; 19 | 20 | atRule.walkDecls((decl) => { 21 | if (decl.prop === 'syntax') { 22 | prop.syntax = decl; 23 | } 24 | if (decl.prop === 'inherits') { 25 | prop.inherits = decl; 26 | } 27 | if (decl.prop === 'initial-value') { 28 | prop.initialValue = decl; 29 | } 30 | }); 31 | 32 | customProperties.set(propertyName, prop); 33 | } 34 | }); 35 | 36 | return customProperties; 37 | } 38 | -------------------------------------------------------------------------------- /.cursor/rules/commands.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | alwaysApply: true 3 | --- 4 | 5 | ## Package Manager & Commands 6 | 7 | ### Use Bun Instead of npm/pnpm/yarn 8 | 9 | - Always use `bun` instead of `npm`, `pnpm`, or `yarn` for package management 10 | - Use `bunx` instead of `npx` for running packages 11 | - If `bunx` fails, try `bun x` as an alternative 12 | 13 | ### Prioritize package.json Scripts 14 | 15 | - Always check and use the scripts defined in `package.json` for: 16 | - Building: `bun run build` 17 | - Testing: `bun run test` 18 | - Development: `bun run dev` 19 | - Packaging and other tasks defined in the project 20 | - Don't reinvent commands that already exist in package.json 21 | 22 | ### Working Directory 23 | 24 | - Don't `cd` into the working directory before every command 25 | - The shell is already in the correct workspace directory 26 | 27 | ### Testing Commands 28 | 29 | - **Never** run `bun test` directly 30 | - **Always** run `bun run test` to use the project's test script 31 | - For specific test files or directories, run: `bun run test src/path/to/file` 32 | -------------------------------------------------------------------------------- /src/lib/render/utils/compatibility/sanitize-class-name.ts: -------------------------------------------------------------------------------- 1 | const digitToNameMap = { 2 | '0': 'zero', 3 | '1': 'one', 4 | '2': 'two', 5 | '3': 'three', 6 | '4': 'four', 7 | '5': 'five', 8 | '6': 'six', 9 | '7': 'seven', 10 | '8': 'eight', 11 | '9': 'nine' 12 | } as const; 13 | 14 | /** 15 | * Replaces special characters to avoid problems on email clients. 16 | * 17 | * @param className - This should not come with any escaped charcters, it should come the same 18 | * as is on the `className` attribute on React elements. 19 | */ 20 | export function sanitizeClassName(className: string) { 21 | return className 22 | .replaceAll('+', 'plus') 23 | .replaceAll('[', '') 24 | .replaceAll('%', 'pc') 25 | .replaceAll(']', '') 26 | .replaceAll('(', '') 27 | .replaceAll(')', '') 28 | .replaceAll('!', 'imprtnt') 29 | .replaceAll('>', 'gt') 30 | .replaceAll('<', 'lt') 31 | .replaceAll('=', 'eq') 32 | .replace(/^[0-9]/, (digit) => { 33 | return digitToNameMap[digit as keyof typeof digitToNameMap]; 34 | }) 35 | .replace(/[^a-zA-Z0-9\-_]/g, '_'); 36 | } 37 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin 'tailwindcss-motion'; 3 | @custom-variant dark (&:where(.dark, .dark *)); 4 | @import './lib/preview/theme.css'; 5 | 6 | @theme { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --color-card: var(--card); 10 | --color-card-foreground: var(--card-foreground); 11 | --color-popover: var(--popover); 12 | --color-popover-foreground: var(--popover-foreground); 13 | --color-primary: var(--) primary; 14 | --color-primary-foreground: var(--primary-foreground); 15 | --color-secondary: var(--secondary); 16 | --color-secondary-foreground: var(--secondary-foreground); 17 | --color-muted: var(--muted); 18 | --color-muted-foreground: var(--muted-foreground); 19 | --color-accent: var(--accent); 20 | --color-accent-foreground: var(--accent-foreground); 21 | --color-destructive: var(--destructive); 22 | --color-border: var(--border); 23 | --color-input: var(--input); 24 | --color-ring: var(--ring); 25 | --color-svelte: var(--svelte); 26 | } 27 | 28 | html { 29 | background-color: var(--color-background); 30 | color: var(--color-foreground); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/resolve-calc-expressions.spec.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import { resolveCalcExpressions } from './resolve-calc-expressions.js'; 3 | import { expect, describe, it } from 'vitest'; 4 | 5 | describe('resolveCalcExpressions()', () => { 6 | it('resolves spacing calc expressions from tailwind v4', () => { 7 | const root = postcss.parse(` 8 | .px-3{padding-inline:calc(0.25rem*3)} 9 | .py-2{padding-block:calc(0.25rem*2)} 10 | `); 11 | resolveCalcExpressions(root); 12 | expect(root.toString()).toMatchSnapshot(); 13 | }); 14 | 15 | it('resolves calc expressions repeating decimals', () => { 16 | const root = postcss.parse(` 17 | .w-1/3 { width: calc(0.3333333333333333*100%); } 18 | `); 19 | resolveCalcExpressions(root); 20 | expect(root.toString()).toMatchSnapshot(); 21 | }); 22 | 23 | it('does not modify complex calc expressions', () => { 24 | const root = postcss.parse(` 25 | .px-3{padding-inline:calc(0.25rem*(3 + 1px))} 26 | .py-2{padding-block:calc(0.25rem*(2 + 1px))} 27 | `); 28 | resolveCalcExpressions(root); 29 | expect(root.toString()).toMatchSnapshot(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Anatole Dufour 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/make-inline-styles-for.spec.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import { makeInlineStylesFor } from './make-inline-styles-for.js'; 3 | import { expect, describe, it } from 'vitest'; 4 | 5 | describe('makeInlineStylesFor()', async () => { 6 | it('works in simple use case', () => { 7 | const root = postcss.parse(` 8 | .bg-red-500 { background-color: #f56565; } 9 | .w-full { width: 100%; } 10 | `); 11 | 12 | // Get the rules from the root 13 | const rules = root.nodes.filter((node) => node.type === 'rule'); 14 | expect(makeInlineStylesFor(rules, new Map())).toMatchSnapshot(); 15 | }); 16 | 17 | it('does basic local variable resolution', () => { 18 | const root = postcss.parse(` 19 | .btn { 20 | --btn-bg: #3490dc; 21 | --btn-text: #fff; 22 | background-color: var(--btn-bg); 23 | color: var(--btn-text); 24 | padding: 0.5rem 1rem; 25 | border-radius: 0.25rem; 26 | } 27 | `); 28 | 29 | const rules = root.nodes.filter((node) => node.type === 'rule'); 30 | expect(makeInlineStylesFor(rules, new Map())).toMatchSnapshot(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/lib/components/__tests__/end-to-end.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import Renderer from '$lib/render/index.js'; 3 | import TestEmail from './__fixtures__/test-email.svelte'; 4 | 5 | describe('End-to-End Email Rendering', () => { 6 | it('should render a complete email with all Tailwind classes inlined', async () => { 7 | const renderer = new Renderer(); 8 | const html = await renderer.render(TestEmail); 9 | 10 | // Snapshot captures: 11 | // - All Tailwind classes converted to inline styles 12 | // - Proper HTML structure with email-safe DOCTYPE 13 | // - All components rendered correctly 14 | // - Mixed inline styles and Tailwind classes handled properly 15 | expect(html).toMatchSnapshot(); 16 | }); 17 | 18 | it('should handle custom Tailwind config (bg-brand color)', async () => { 19 | const renderer = new Renderer({ 20 | theme: { 21 | extend: { 22 | colors: { 23 | brand: '#ff3e00' 24 | } 25 | } 26 | } 27 | }); 28 | const html = await renderer.render(TestEmail); 29 | 30 | // The bg-brand class should use the custom color from config 31 | expect(html).toMatchSnapshot(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/extract-rules-per-class.ts: -------------------------------------------------------------------------------- 1 | import type { Root, Rule } from 'postcss'; 2 | import { isRuleInlinable } from './is-rule-inlinable.js'; 3 | 4 | function unescapeClassName(name: string): string { 5 | return name.replace(/\\(.)/g, '$1'); 6 | } 7 | 8 | export function extractRulesPerClass(root: Root, classes: string[]) { 9 | const classSet = new Set(classes); 10 | 11 | const inlinableRules = new Map(); 12 | const nonInlinableRules = new Map(); 13 | 14 | root.walkRules((rule) => { 15 | // Extract class names from selector using regex 16 | // The regex matches class names including escaped characters (like \: or \/) 17 | // Note: \\. must come FIRST in the alternation to properly match escapes 18 | const classMatches = rule.selector.matchAll(/\.((?:\\.|[^\s.:>+~[#,])+)/g); 19 | const selectorClasses = [...classMatches].map((m) => unescapeClassName(m[1])); 20 | 21 | const targetMap = isRuleInlinable(rule) ? inlinableRules : nonInlinableRules; 22 | 23 | for (const className of selectorClasses) { 24 | if (classSet.has(className)) { 25 | targetMap.set(className, rule); 26 | } 27 | } 28 | }); 29 | 30 | return { 31 | inlinable: inlinableRules, 32 | nonInlinable: nonInlinableRules 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/sanitize-non-inlinable-rules.ts: -------------------------------------------------------------------------------- 1 | import type { Root } from 'postcss'; 2 | import { sanitizeClassName } from '../compatibility/sanitize-class-name.js'; 3 | import { isRuleInlinable } from './is-rule-inlinable.js'; 4 | 5 | /** 6 | * This function goes through a few steps to ensure the best email client support and 7 | * to ensure that media queries and pseudo classes are applied correctly alongside 8 | * the inline styles. 9 | * 10 | * What it does: 11 | * 1. Converts all declarations in all rules into important ones 12 | * 2. Sanitizes class selectors of all non-inlinable rules 13 | */ 14 | export function sanitizeNonInlinableRules(root: Root) { 15 | root.walkRules((rule) => { 16 | if (!isRuleInlinable(rule)) { 17 | // Sanitize class names in selector 18 | // The regex matches class names including escaped characters (like \: or \/) 19 | // Note: \\. must come FIRST in the alternation to properly match escapes 20 | rule.selector = rule.selector.replace(/\.((?:\\.|[^\s.:>+~[#,])+)/g, (match, className) => { 21 | const unescaped = className.replace(/\\(.)/g, '$1'); 22 | return '.' + sanitizeClassName(unescaped); 23 | }); 24 | 25 | // Make all declarations important 26 | rule.walkDecls((decl) => { 27 | decl.important = true; 28 | }); 29 | } 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/is-rule-inlinable.ts: -------------------------------------------------------------------------------- 1 | import type { AtRule, Rule } from 'postcss'; 2 | 3 | // At-rules that prevent inlining when found inside a rule (CSS nesting) 4 | const NON_INLINABLE_AT_RULES = new Set(['media', 'supports', 'container', 'document']); 5 | 6 | export function isRuleInlinable(rule: Rule): boolean { 7 | // Check if rule CONTAINS a conditional at-rule (for CSS nesting like Tailwind v4) 8 | // e.g., .sm\:bg-blue-300 { @media (width >= 40rem) { ... } } 9 | let hasAtRuleInside = false; 10 | rule.walk((node) => { 11 | if (node.type === 'atrule') { 12 | hasAtRuleInside = true; 13 | return false; // Stop walking 14 | } 15 | }); 16 | 17 | if (hasAtRuleInside) { 18 | return false; 19 | } 20 | 21 | // Check if rule is INSIDE a conditional at-rule (media query, etc.) 22 | // Note: @layer is just a grouping mechanism, it doesn't prevent inlining 23 | let parent = rule.parent; 24 | while (parent && parent.type !== 'root') { 25 | if (parent.type === 'atrule') { 26 | const atRule = parent as AtRule; 27 | if (NON_INLINABLE_AT_RULES.has(atRule.name)) { 28 | return false; 29 | } 30 | } 31 | parent = parent.parent; 32 | } 33 | 34 | // Check for pseudo selectors in the selector string 35 | // Matches :hover, ::before, :nth-child(), etc. 36 | const hasPseudoSelector = /::?[\w-]+(\([^)]*\))?/.test(rule.selector); 37 | 38 | return !hasPseudoSelector; 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/__snapshots__/extract-rules-per-class.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`extractRulesPerClass() > handles a mix of inlinable and non-inlinable utilities 1`] = ` 4 | { 5 | "bg-red-500": ".bg-red-500 { 6 | background-color: var(--color-red-500); 7 | }", 8 | "text-center": ".text-center { 9 | text-align: center; 10 | }", 11 | "w-full": ".w-full { 12 | width: 100%; 13 | }", 14 | } 15 | `; 16 | 17 | exports[`extractRulesPerClass() > handles a mix of inlinable and non-inlinable utilities 2`] = ` 18 | { 19 | "lg:w-1/2": ".lg\\:w-1\\/2 { 20 | @media (width >= 64rem) { 21 | width: calc(1/2 * 100%); 22 | } 23 | }", 24 | } 25 | `; 26 | 27 | exports[`extractRulesPerClass() > handles non-inlinable utilities 1`] = `{}`; 28 | 29 | exports[`extractRulesPerClass() > handles non-inlinable utilities 2`] = ` 30 | { 31 | "lg:w-1/2": ".lg\\:w-1\\/2 { 32 | @media (width >= 64rem) { 33 | width: calc(1/2 * 100%); 34 | } 35 | }", 36 | } 37 | `; 38 | 39 | exports[`extractRulesPerClass() > works with just inlinable utilities 1`] = ` 40 | { 41 | "bg-red-500": ".bg-red-500 { 42 | background-color: var(--color-red-500); 43 | }", 44 | "text-center": ".text-center { 45 | text-align: center; 46 | }", 47 | } 48 | `; 49 | 50 | exports[`extractRulesPerClass() > works with just inlinable utilities 2`] = `{}`; 51 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/sanitize-non-inlinable-rules.spec.ts: -------------------------------------------------------------------------------- 1 | import { setupTailwind } from '../tailwindcss/setup-tailwind.js'; 2 | import { sanitizeNonInlinableRules } from './sanitize-non-inlinable-rules.js'; 3 | import { expect, describe, it } from 'vitest'; 4 | 5 | describe('sanitizeNonInlinableRules()', () => { 6 | it('inlines rules that can be inlined', async () => { 7 | const tailwind = await setupTailwind({}); 8 | tailwind.addUtilities(['bg-gray-900', 'text-red-300', 'text-lg']); 9 | const stylesheet = tailwind.getStyleSheet(); 10 | 11 | sanitizeNonInlinableRules(stylesheet); 12 | expect(stylesheet.toString()).toMatchSnapshot(); 13 | }); 14 | 15 | it('handles CSS nesting in hover pseudo styles', async () => { 16 | const tailwind = await setupTailwind({}); 17 | tailwind.addUtilities([ 18 | 'hover:text-sky-600', 19 | 'sm:focus:outline-none', 20 | 'md:hover:bg-gray-100', 21 | 'lg:focus:underline' 22 | ]); 23 | 24 | const stylesheet = tailwind.getStyleSheet(); 25 | 26 | sanitizeNonInlinableRules(stylesheet); 27 | expect(stylesheet.toString()).toMatchSnapshot(); 28 | }); 29 | 30 | it('supports basic media query rules', async () => { 31 | const tailwind = await setupTailwind({}); 32 | tailwind.addUtilities(['sm:mx-auto', 'sm:max-w-lg', 'sm:rounded-lg', 'md:px-10', 'md:py-12']); 33 | const stylesheet = tailwind.getStyleSheet(); 34 | 35 | sanitizeNonInlinableRules(stylesheet); 36 | 37 | expect(stylesheet.toString()).toMatchSnapshot(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { includeIgnoreFile } from '@eslint/compat'; 4 | import js from '@eslint/js'; 5 | import svelte from 'eslint-plugin-svelte'; 6 | import { defineConfig } from 'eslint/config'; 7 | import globals from 'globals'; 8 | import ts from 'typescript-eslint'; 9 | import svelteConfig from './svelte.config.js'; 10 | 11 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 12 | 13 | export default defineConfig( 14 | includeIgnoreFile(gitignorePath), 15 | js.configs.recommended, 16 | ...ts.configs.recommended, 17 | ...svelte.configs.recommended, 18 | prettier, 19 | ...svelte.configs.prettier, 20 | { 21 | languageOptions: { 22 | globals: { ...globals.browser, ...globals.node } 23 | }, 24 | rules: { 25 | // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. 26 | // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors 27 | 'no-undef': 'off', 28 | '@typescript-eslint/no-explicit-any': 'off' 29 | } 30 | }, 31 | { 32 | files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], 33 | languageOptions: { 34 | parserOptions: { 35 | projectService: true, 36 | extraFileExtensions: ['.svelte'], 37 | parser: ts.parser, 38 | svelteConfig 39 | } 40 | } 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /src/lib/render/utils/html/walk.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultTreeAdapterTypes as AST } from 'parse5'; 2 | 3 | type WalkableNode = AST.Document | AST.Element | AST.DocumentFragment | AST.Template; 4 | type AnyNode = AST.ChildNode; 5 | 6 | /** 7 | * Walk through an HTML AST and transform nodes using a callback function. 8 | * 9 | * @param ast - The root node to start walking from 10 | * @param callback - A function that receives each node and returns either: 11 | * - The same node (no change) 12 | * - A modified node (replace) 13 | * - null (remove the node) 14 | * @returns The transformed AST 15 | */ 16 | export function walk( 17 | ast: T, 18 | callback: (node: AnyNode) => AnyNode | null 19 | ): T { 20 | // Check if this node has childNodes 21 | if (!('childNodes' in ast) || !ast.childNodes) { 22 | return ast; 23 | } 24 | 25 | // Iterate backwards to handle splicing correctly 26 | for (let i = ast.childNodes.length - 1; i >= 0; i--) { 27 | const node = ast.childNodes[i]; 28 | const newNode = callback(node); 29 | 30 | if (newNode === null) { 31 | // Remove the node 32 | ast.childNodes.splice(i, 1); 33 | } else { 34 | // Replace the node if changed 35 | ast.childNodes[i] = newNode; 36 | 37 | // Recursively walk child nodes if they exist 38 | if ( 39 | 'childNodes' in newNode && 40 | Array.isArray(newNode.childNodes) && 41 | newNode.childNodes.length > 0 42 | ) { 43 | walk(newNode as WalkableNode, callback); 44 | } 45 | } 46 | } 47 | 48 | return ast; 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/render/utils/tailwindcss/pixel-based-preset.ts: -------------------------------------------------------------------------------- 1 | import type { TailwindConfig } from '$lib/render/index.js'; 2 | 3 | export const pixelBasedPreset: TailwindConfig = { 4 | theme: { 5 | extend: { 6 | fontSize: { 7 | xs: ['12px', { lineHeight: '16px' }], 8 | sm: ['14px', { lineHeight: '20px' }], 9 | base: ['16px', { lineHeight: '24px' }], 10 | lg: ['18px', { lineHeight: '28px' }], 11 | xl: ['20px', { lineHeight: '28px' }], 12 | '2xl': ['24px', { lineHeight: '32px' }], 13 | '3xl': ['30px', { lineHeight: '36px' }], 14 | '4xl': ['36px', { lineHeight: '36px' }], 15 | '5xl': ['48px', { lineHeight: '1' }], 16 | '6xl': ['60px', { lineHeight: '1' }], 17 | '7xl': ['72px', { lineHeight: '1' }], 18 | '8xl': ['96px', { lineHeight: '1' }], 19 | '9xl': ['144px', { lineHeight: '1' }] 20 | }, 21 | spacing: { 22 | px: '1px', 23 | 0: '0', 24 | 0.5: '2px', 25 | 1: '4px', 26 | 1.5: '6px', 27 | 2: '8px', 28 | 2.5: '10px', 29 | 3: '12px', 30 | 3.5: '14px', 31 | 4: '16px', 32 | 5: '20px', 33 | 6: '24px', 34 | 7: '28px', 35 | 8: '32px', 36 | 9: '36px', 37 | 10: '40px', 38 | 11: '44px', 39 | 12: '48px', 40 | 14: '56px', 41 | 16: '64px', 42 | 20: '80px', 43 | 24: '96px', 44 | 28: '112px', 45 | 32: '128px', 46 | 36: '144px', 47 | 40: '160px', 48 | 44: '176px', 49 | 48: '192px', 50 | 52: '208px', 51 | 56: '224px', 52 | 60: '240px', 53 | 64: '256px', 54 | 72: '288px', 55 | 80: '320px', 56 | 96: '384px' 57 | } 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/lib/components/Button.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 | 46 | 47 | {@html ``} 48 | 49 | 50 | 51 | {@render children?.()} 52 | 53 | 54 | 55 | {@html ``} 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/lib/render/utils/tailwindcss/__snapshots__/setup-tailwind.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`setupTailwind() and addUtilities() 1`] = ` 4 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 5 | @layer theme, base, components, utilities; 6 | @layer theme { 7 | :root, :host { 8 | --color-red-500: oklch(63.7% 0.237 25.331); 9 | --color-blue-300: oklch(80.9% 0.105 251.813); 10 | --color-slate-900: oklch(20.8% 0.042 265.755); 11 | } 12 | } 13 | @layer utilities { 14 | .bg-slate-900 { 15 | background-color: var(--color-slate-900); 16 | } 17 | .text-red-500 { 18 | color: var(--color-red-500); 19 | } 20 | .sm\\:bg-blue-300 { 21 | @media (width >= 40rem) { 22 | background-color: var(--color-blue-300); 23 | } 24 | } 25 | } 26 | " 27 | `; 28 | 29 | exports[`setupTailwind() and addUtilities() 2`] = ` 30 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 31 | @layer theme, base, components, utilities; 32 | @layer theme { 33 | :root, :host { 34 | --color-red-100: oklch(93.6% 0.032 17.717); 35 | --color-red-500: oklch(63.7% 0.237 25.331); 36 | --color-blue-300: oklch(80.9% 0.105 251.813); 37 | --color-slate-900: oklch(20.8% 0.042 265.755); 38 | } 39 | } 40 | @layer utilities { 41 | .bg-red-100 { 42 | background-color: var(--color-red-100); 43 | } 44 | .bg-slate-900 { 45 | background-color: var(--color-slate-900); 46 | } 47 | .text-red-500 { 48 | color: var(--color-red-500); 49 | } 50 | .sm\\:bg-blue-300 { 51 | @media (width >= 40rem) { 52 | background-color: var(--color-blue-300); 53 | } 54 | } 55 | } 56 | " 57 | `; 58 | -------------------------------------------------------------------------------- /src/lib/preview/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: oklch(98.5% 0.001 106.423); 3 | --foreground: oklch(21.6% 0.006 56.043); 4 | --card: oklch(1 0 0); 5 | --card-foreground: oklch(0.147 0.004 49.25); 6 | --popover: oklch(1 0 0); 7 | --popover-foreground: oklch(0.147 0.004 49.25); 8 | --primary: oklch(0.216 0.006 56.043); 9 | --primary-foreground: oklch(0.985 0.001 106.423); 10 | --secondary: oklch(0.958 0.003 48.717); 11 | --secondary-foreground: oklch(0.454 0.01 67.558); 12 | --muted: oklch(0.9483 0.0061 67.75); 13 | --muted-foreground: oklch(0.4761 0.021783 55.8952); 14 | --accent: oklch(0.97 0.001 106.424); 15 | --accent-foreground: oklch(0.216 0.006 56.043); 16 | --destructive: oklch(0.577 0.245 27.325); 17 | --border: oklch(0.923 0.003 48.717); 18 | --input: oklch(0.923 0.003 48.717); 19 | --ring: oklch(0.709 0.01 56.259); 20 | --svelte: #f73b01; 21 | } 22 | 23 | .dark { 24 | --background: oklch(14.7% 0.004 49.25); 25 | --foreground: oklch(97% 0.001 106.424); 26 | --card: oklch(0.216 0.006 56.043); 27 | --card-foreground: oklch(0.985 0.001 106.423); 28 | --popover: oklch(0.216 0.006 56.043); 29 | --popover-foreground: oklch(0.985 0.001 106.423); 30 | --primary: oklch(0.923 0.003 48.717); 31 | --primary-foreground: oklch(0.216 0.006 56.043); 32 | --secondary: oklch(0.216 0.006 56.043); 33 | --secondary-foreground: oklch(0.709 0.01 56.259); 34 | --muted: oklch(0.268 0.007 34.298); 35 | --muted-foreground: oklch(0.7348 0.0326 67.28); 36 | --accent: oklch(0.268 0.007 34.298); 37 | --accent-foreground: oklch(0.985 0.001 106.423); 38 | --destructive: oklch(0.704 0.191 22.216); 39 | --border: oklch(1 0 0 / 10%); 40 | --input: oklch(1 0 0 / 15%); 41 | --ring: oklch(0.553 0.013 58.071); 42 | } 43 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-vercel'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | import { mdsvex, escapeSvelte } from 'mdsvex'; 4 | import { createHighlighter } from 'shiki'; 5 | import rehypeSlug from 'rehype-slug'; 6 | import rehypeAutolinkHeadings from 'rehype-autolink-headings'; 7 | 8 | const theme = 'vesper'; 9 | const highlighter = await createHighlighter({ 10 | themes: [theme], 11 | langs: ['javascript', 'typescript', 'svelte', 'bash'] 12 | }); 13 | 14 | /** @type {import('mdsvex').MdsvexOptions} */ 15 | const mdsvexOptions = { 16 | rehypePlugins: [ 17 | rehypeSlug, 18 | [ 19 | rehypeAutolinkHeadings, 20 | { 21 | properties: { class: 'heading-link', title: 'Link to this heading' } 22 | } 23 | ] 24 | ], 25 | highlight: { 26 | highlighter: async (code, lang = 'text') => { 27 | const html = escapeSvelte(highlighter.codeToHtml(code, { lang, theme })); 28 | return `{@html \`${html}\` }`; 29 | } 30 | } 31 | }; 32 | 33 | /** @type {import('@sveltejs/kit').Config} */ 34 | const config = { 35 | extensions: ['.svelte', '.svx', '.md'], 36 | // Consult https://svelte.dev/docs/kit/integrations 37 | // for more information about preprocessors 38 | preprocess: [mdsvex({ extensions: ['.svx', '.md'], ...mdsvexOptions }), vitePreprocess()], 39 | 40 | kit: { 41 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 42 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 43 | // See https://svelte.dev/docs/kit/adapters for more information about adapters. 44 | adapter: adapter() 45 | } 46 | }; 47 | 48 | export default config; 49 | -------------------------------------------------------------------------------- /src/routes/docs/render/+page.md: -------------------------------------------------------------------------------- 1 | # Renderer API 2 | 3 | Render Svelte email templates to production-ready HTML and plain text using the utilities in `better-svelte-email/render`. 4 | 5 | ```ts 6 | import Renderer, { toPlainText, type RenderOptions } from 'better-svelte-email/render'; 7 | ``` 8 | 9 | ## Renderer 10 | 11 | ### Constructor 12 | 13 | `const renderer = new Renderer(tailwindConfig?)` 14 | 15 | - `tailwindConfig?` — Partial Tailwind config. Use it to extend the default theme, add plugins, or tweak core options. 16 | 17 | ### renderer.render 18 | 19 | `await renderer.render(component, options?)` 20 | 21 | - `component` — The compiled Svelte component to render (e.g. `WelcomeEmail`). 22 | - `options?` — Object with the same shape as `RenderOptions` (see below). 23 | - Returns a `Promise` containing email-safe HTML with Tailwind utilities inlined and necessary media queries injected into ``. 24 | 25 | ```ts 26 | const html = await renderer.render(WelcomeEmail, { 27 | props: { name: 'Ada' } 28 | }); 29 | ``` 30 | 31 | ## RenderOptions 32 | 33 | `type RenderOptions = { props?: Record; context?: Map; idPrefix?: string; }` 34 | 35 | - `props?` — Props forwarded to the Svelte component. Slot and event props are omitted automatically. 36 | - `context?` — Custom context map that becomes available through Svelte’s `getContext`. 37 | - `idPrefix?` — Prefix appended to generated element ids to avoid collisions when embedding multiple renders. 38 | 39 | ## Plain text output 40 | 41 | `toPlainText(markup: string)` 42 | 43 | - Strips non-readable markup (images, preview text) and returns a plain text version of the rendered HTML. 44 | - Helpful for adding the `text` field when sending emails via providers like Resend or SendGrid. 45 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/extract-rules-per-class.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from 'postcss'; 2 | import { setupTailwind } from '../tailwindcss/setup-tailwind.js'; 3 | import { extractRulesPerClass } from './extract-rules-per-class.js'; 4 | import { expect, describe, it } from 'vitest'; 5 | 6 | describe('extractRulesPerClass()', async () => { 7 | function convertToComparable(map: Map): Record { 8 | return Object.fromEntries([...map.entries()].map(([k, v]) => [k, v.toString()])); 9 | } 10 | 11 | it('works with just inlinable utilities', async () => { 12 | const tailwind = await setupTailwind({}); 13 | const classes = ['text-center', 'bg-red-500']; 14 | tailwind.addUtilities(classes); 15 | 16 | const stylesheet = tailwind.getStyleSheet(); 17 | 18 | const { inlinable, nonInlinable } = extractRulesPerClass(stylesheet, classes); 19 | 20 | expect(convertToComparable(inlinable)).toMatchSnapshot(); 21 | expect(convertToComparable(nonInlinable)).toMatchSnapshot(); 22 | }); 23 | 24 | it('handles non-inlinable utilities', async () => { 25 | const tailwind = await setupTailwind({}); 26 | const classes = ['lg:w-1/2']; 27 | tailwind.addUtilities(classes); 28 | 29 | const stylesheet = tailwind.getStyleSheet(); 30 | 31 | const { inlinable, nonInlinable } = extractRulesPerClass(stylesheet, classes); 32 | 33 | expect(convertToComparable(inlinable)).toMatchSnapshot(); 34 | expect(convertToComparable(nonInlinable)).toMatchSnapshot(); 35 | }); 36 | 37 | it('handles a mix of inlinable and non-inlinable utilities', async () => { 38 | const tailwind = await setupTailwind({}); 39 | const classes = [ 40 | 'text-center', 41 | 'bg-red-500', 42 | 'some-other-class', // should be ignored 43 | 'w-full', 44 | 'lg:w-1/2' 45 | ]; 46 | tailwind.addUtilities(classes); 47 | 48 | const stylesheet = tailwind.getStyleSheet(); 49 | const { inlinable, nonInlinable } = extractRulesPerClass(stylesheet, classes); 50 | expect(convertToComparable(inlinable)).toMatchSnapshot(); 51 | expect(convertToComparable(nonInlinable)).toMatchSnapshot(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/make-inline-styles-for.ts: -------------------------------------------------------------------------------- 1 | import type { Rule, Declaration } from 'postcss'; 2 | import valueParser from 'postcss-value-parser'; 3 | import type { CustomProperties } from './get-custom-properties.js'; 4 | 5 | export function makeInlineStylesFor( 6 | inlinableRules: Rule[], 7 | customProperties: CustomProperties 8 | ): string { 9 | let styles = ''; 10 | 11 | // Collect local variable declarations 12 | const localVariableDeclarations = new Map(); 13 | for (const rule of inlinableRules) { 14 | rule.walkDecls((decl) => { 15 | if (decl.prop.startsWith('--')) { 16 | localVariableDeclarations.set(decl.prop, decl); 17 | } 18 | }); 19 | } 20 | 21 | // Process rules and resolve variables 22 | for (const rule of inlinableRules) { 23 | rule.walkDecls((decl) => { 24 | // Skip variable declarations 25 | if (decl.prop.startsWith('--')) return; 26 | 27 | let value = decl.value; 28 | 29 | // Resolve var() references 30 | if (value.includes('var(')) { 31 | const parsed = valueParser(value); 32 | 33 | parsed.walk((node) => { 34 | if (node.type === 'function' && node.value === 'var') { 35 | const varNameNode = node.nodes[0]; 36 | const variableName = varNameNode ? valueParser.stringify(varNameNode).trim() : ''; 37 | 38 | if (variableName) { 39 | // Check local declarations first 40 | const localDef = localVariableDeclarations.get(variableName); 41 | if (localDef) { 42 | node.type = 'word'; 43 | node.value = localDef.value; 44 | node.nodes = []; 45 | } else { 46 | // Check custom properties (from @property rules) 47 | const customProp = customProperties.get(variableName); 48 | if (customProp?.initialValue) { 49 | node.type = 'word'; 50 | node.value = customProp.initialValue.value; 51 | node.nodes = []; 52 | } 53 | } 54 | } 55 | } 56 | }); 57 | 58 | value = valueParser.stringify(parsed.nodes); 59 | } 60 | 61 | const important = decl.important ? '!important' : ''; 62 | styles += `${decl.prop}: ${value} ${important};`; 63 | }); 64 | } 65 | 66 | return styles; 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/preview/email-tree.ts: -------------------------------------------------------------------------------- 1 | export type EmailTreeEntry = FileEntry | DirectoryEntry; 2 | 3 | export type FileEntry = { 4 | type: 'file'; 5 | name: string; 6 | path: string; 7 | }; 8 | 9 | export type DirectoryEntry = { 10 | type: 'directory'; 11 | name: string; 12 | path: string; 13 | items: EmailTreeEntry[]; 14 | }; 15 | 16 | export function buildEmailTree(paths: readonly string[] | null | undefined): EmailTreeEntry[] { 17 | if (!paths || paths.length === 0) { 18 | return []; 19 | } 20 | 21 | const root: EmailTreeEntry[] = []; 22 | const directoryMap = new Map(); 23 | 24 | for (const rawPath of paths) { 25 | if (!rawPath) continue; 26 | 27 | const normalized = normalizePath(rawPath); 28 | const segments = normalized.split('/').filter(Boolean); 29 | if (segments.length === 0) continue; 30 | 31 | let parentChildren = root; 32 | let parentPath = ''; 33 | 34 | segments.forEach((segment, index) => { 35 | const currentPath = parentPath ? `${parentPath}/${segment}` : segment; 36 | const isFile = index === segments.length - 1; 37 | 38 | if (isFile) { 39 | parentChildren.push({ 40 | type: 'file', 41 | name: segment, 42 | path: normalized 43 | }); 44 | } else { 45 | let directory = directoryMap.get(currentPath); 46 | if (!directory) { 47 | directory = { 48 | type: 'directory', 49 | name: segment, 50 | path: currentPath, 51 | items: [] 52 | }; 53 | directoryMap.set(currentPath, directory); 54 | parentChildren.push(directory); 55 | } 56 | parentChildren = directory.items; 57 | } 58 | 59 | parentPath = currentPath; 60 | }); 61 | } 62 | 63 | return sortTree(root); 64 | } 65 | 66 | function normalizePath(path: string): string { 67 | return path.replace(/^[./]+/, '').replace(/\/+/g, '/'); 68 | } 69 | 70 | function sortTree(nodes: EmailTreeEntry[]): EmailTreeEntry[] { 71 | nodes.sort((a, b) => { 72 | if (a.type === b.type) { 73 | return a.name.localeCompare(b.name); 74 | } 75 | return a.type === 'directory' ? -1 : 1; 76 | }); 77 | 78 | for (const node of nodes) { 79 | if (node.type === 'directory') { 80 | sortTree(node.items); 81 | } 82 | } 83 | 84 | return nodes; 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/render/utils/tailwindcss/setup-tailwind.ts: -------------------------------------------------------------------------------- 1 | import postcss, { type Root } from 'postcss'; 2 | import { compile } from 'tailwindcss'; 3 | import type { TailwindConfig } from '$lib/render/index.js'; 4 | import indexCss from './tailwind-stylesheets/index.js'; 5 | import preflightCss from './tailwind-stylesheets/preflight.js'; 6 | import themeCss from './tailwind-stylesheets/theme.js'; 7 | import utilitiesCss from './tailwind-stylesheets/utilities.js'; 8 | 9 | export type TailwindSetup = Awaited>; 10 | 11 | export async function setupTailwind(config: TailwindConfig) { 12 | const baseCss = ` 13 | @layer theme, base, components, utilities; 14 | @import "tailwindcss/theme.css" layer(theme); 15 | @import "tailwindcss/utilities.css" layer(utilities); 16 | @config; 17 | `; 18 | const compiler = await compile(baseCss, { 19 | async loadModule(id, base, resourceHint) { 20 | if (resourceHint === 'config') { 21 | return { 22 | path: id, 23 | base: base, 24 | module: config 25 | }; 26 | } 27 | 28 | throw new Error(`NO-OP: should we implement support for ${resourceHint}?`); 29 | }, 30 | polyfills: 0, // All 31 | async loadStylesheet(id, base) { 32 | if (id === 'tailwindcss') { 33 | return { 34 | base, 35 | path: 'tailwindcss/index.css', 36 | content: indexCss 37 | }; 38 | } 39 | 40 | if (id === 'tailwindcss/preflight.css') { 41 | return { 42 | base, 43 | path: id, 44 | content: preflightCss 45 | }; 46 | } 47 | 48 | if (id === 'tailwindcss/theme.css') { 49 | return { 50 | base, 51 | path: id, 52 | content: themeCss 53 | }; 54 | } 55 | 56 | if (id === 'tailwindcss/utilities.css') { 57 | return { 58 | base, 59 | path: id, 60 | content: utilitiesCss 61 | }; 62 | } 63 | 64 | throw new Error('stylesheet not supported, you can only import the ones from tailwindcss'); 65 | } 66 | }); 67 | 68 | let css: string = baseCss; 69 | 70 | return { 71 | addUtilities: function addUtilities(candidates: string[]): void { 72 | css = compiler.build(candidates); 73 | }, 74 | getStyleSheet: function getStyleSheet(): Root { 75 | return postcss.parse(css); 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/render/utils/tailwindcss/add-inlined-styles-to-element.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from 'postcss'; 2 | import { sanitizeClassName } from '$lib/render/utils/compatibility/sanitize-class-name.js'; 3 | import type { CustomProperties } from '$lib/render/utils/css/get-custom-properties.js'; 4 | import { makeInlineStylesFor } from '$lib/render/utils/css/make-inline-styles-for.js'; 5 | import type { AST } from '$lib/render/index.js'; 6 | import { combineStyles } from '$lib/utils/index.js'; 7 | 8 | export function addInlinedStylesToElement( 9 | element: AST.Element, 10 | inlinableRules: Map, 11 | nonInlinableRules: Map, 12 | customProperties: CustomProperties, 13 | unknownClasses: string[] 14 | ): AST.Element { 15 | const classAttr = element.attrs?.find((attr) => attr.name === 'class'); 16 | if (classAttr && classAttr.value) { 17 | const classes = classAttr.value.split(/\s+/).filter(Boolean); 18 | 19 | const residualClasses: string[] = []; 20 | 21 | const rules: Rule[] = []; 22 | for (const className of classes) { 23 | const rule = inlinableRules.get(className); 24 | if (rule) { 25 | rules.push(rule); 26 | } else { 27 | residualClasses.push(className); 28 | } 29 | } 30 | 31 | const styles = makeInlineStylesFor(rules, customProperties); 32 | const styleAttr = element.attrs?.find((attr) => attr.name === 'style'); 33 | const newStyles = combineStyles(styleAttr?.value ?? '', styles); 34 | 35 | if (styleAttr) { 36 | element.attrs = element.attrs?.map((attr) => { 37 | if (attr.name === 'style') { 38 | return { ...attr, value: newStyles }; 39 | } 40 | return attr; 41 | }); 42 | } else { 43 | element.attrs = [...element.attrs, { name: 'style', value: newStyles }]; 44 | } 45 | 46 | if (residualClasses.length > 0) { 47 | element.attrs = element.attrs?.map((attr) => { 48 | if (attr.name === 'class') { 49 | return { 50 | ...attr, 51 | value: residualClasses 52 | .map((className) => { 53 | if (nonInlinableRules.has(className)) { 54 | return sanitizeClassName(className); 55 | } else { 56 | if (!unknownClasses.includes(className)) { 57 | unknownClasses.push(className); 58 | } 59 | 60 | return className; 61 | } 62 | }) 63 | .join(' ') 64 | }; 65 | } 66 | return attr; 67 | }); 68 | } else { 69 | element.attrs = element.attrs?.filter((attr) => attr.name !== 'class'); 70 | } 71 | } 72 | 73 | return element; 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { toPlainText } from '$lib/render/index.js'; 2 | 3 | /** 4 | * Convert a style object to a CSS string 5 | * @param style - Object containing CSS properties 6 | * @returns CSS string with properties 7 | */ 8 | export function styleToString(style: Record): string { 9 | return Object.entries(style) 10 | .filter(([, value]) => value !== undefined && value !== null && value !== '') 11 | .map(([key, value]) => { 12 | // Convert camelCase to kebab-case 13 | const cssKey = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); 14 | return `${cssKey}:${value}`; 15 | }) 16 | .join(';'); 17 | } 18 | 19 | /** 20 | * Convert pixels to points for email clients 21 | * @param px - Pixel value as string 22 | * @returns Point value as string 23 | */ 24 | export function pxToPt(px: string | number): string { 25 | const value = typeof px === 'string' ? parseFloat(px) : px; 26 | return `${Math.round(value * 0.75)}pt`; 27 | } 28 | 29 | export type Margin = { 30 | m?: string; 31 | mx?: string; 32 | my?: string; 33 | mt?: string; 34 | mr?: string; 35 | mb?: string; 36 | ml?: string; 37 | }; 38 | 39 | /** 40 | * Convert margin props to a CSS style object 41 | * @param props - Margin properties object with shorthand notation (m, mx, my, mt, mr, mb, ml) 42 | * @returns Style object with margin properties in pixels 43 | */ 44 | export function withMargin(props: Margin) { 45 | const margins = [ 46 | withSpace(props.m, ['margin']), 47 | withSpace(props.mx, ['marginLeft', 'marginRight']), 48 | withSpace(props.my, ['marginTop', 'marginBottom']), 49 | withSpace(props.mt, ['marginTop']), 50 | withSpace(props.mr, ['marginRight']), 51 | withSpace(props.mb, ['marginBottom']), 52 | withSpace(props.ml, ['marginLeft']) 53 | ]; 54 | 55 | return Object.assign({}, ...margins); 56 | } 57 | 58 | function withSpace(value: string | undefined, properties: string[]) { 59 | return properties.reduce((styles, property) => { 60 | if (value) { 61 | return { ...styles, [property]: `${value}px` }; 62 | } 63 | return styles; 64 | }, {}); 65 | } 66 | 67 | /** 68 | * Combine multiple styles into a single string 69 | * @param styles - Array of style strings 70 | * @returns Combined style string 71 | */ 72 | export function combineStyles(...styles: (string | undefined | null)[]) { 73 | return styles 74 | .filter(Boolean) 75 | .map((s) => 76 | s 77 | ?.split(';') 78 | .map((s) => s.trim()) 79 | .filter(Boolean) 80 | ) 81 | .flat() 82 | .join(';'); 83 | } 84 | 85 | /** 86 | * Render HTML as plain text 87 | * @deprecated Use toPlainText from `better-svelte-email/render` instead 88 | * @param markup - HTML string 89 | * @returns Plain text string 90 | */ 91 | export function renderAsPlainText(markup: string) { 92 | return toPlainText(markup); 93 | } 94 | -------------------------------------------------------------------------------- /src/routes/docs/components/+page.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | Better Svelte Email provides Svelte components with sensible defaults for building email layouts. They all forward regular HTML attributes, merge inline styles, and take props tuned for email-friendly rendering. 4 | 5 | ## Quick start 6 | 7 | ```svelte 8 | 26 | ``` 27 | 28 | Use `Html`, `Head`, and `Body` at the root, then mix layout and content components for the rest of your email. 29 | 30 | ## Document shell 31 | 32 | ### Html 33 | 34 | - `lang? = 'en'` — language attribute. 35 | - `dir? = 'ltr'` — text direction. 36 | - Accepts all `` attributes plus a default slot for nested components. 37 | 38 | ### Head 39 | 40 | - Default slot for meta tags, styles, or fonts. 41 | - No custom props beyond standard `` children. 42 | 43 | ### Body 44 | 45 | - Default slot for your email content. 46 | - Accepts all `` attributes (`class`, `style`, etc.). 47 | 48 | ### Preview 49 | 50 | - `preview: string` (required) — text shown in inbox previews, trimmed to 150 characters. 51 | - Forwards standard `
` attributes. 52 | 53 | ## Layout 54 | 55 | ### Container 56 | 57 | - Default slot for inner sections. 58 | - Merges `style` with a max-width of `37.5em`; accepts all `` attributes. 59 | 60 | ### Section 61 | 62 | - Wrapper around content blocks. 63 | - Accepts all `
` attributes and a default slot. 64 | 65 | ### Row 66 | 67 | - Groups columns horizontally. 68 | - Default slot for `Column` components, plus `
` attributes. 69 | 70 | ### Column 71 | 72 | - Wraps content inside a table cell. 73 | - Accepts all `
` attributes, including `align`, `width`, and `style`. 74 | 75 | ## Typography 76 | 77 | ### Heading 78 | 79 | - `as? = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'` — element to render. 80 | - Margin shorthands: `m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml`. 81 | - Default slot for heading text; forwards remaining `` attributes. 82 | 83 | ### Text 84 | 85 | - `as? = string` — element type, defaults to `

`. 86 | - Default slot for body copy and all `

` attributes. 87 | - Merges `style` with default font size/line height. 88 | 89 | ## Links and buttons 90 | 91 | ### Link 92 | 93 | - `href: string` (required). 94 | - `target? = '_blank'`. 95 | - Default slot for link text; all other anchor attributes pass through. 96 | 97 | ### Button 98 | 99 | - `href? = '#'`. 100 | - `target? = '_blank'`. 101 | - `pX? = 0`, `pY? = 0` — horizontal and vertical padding in pixels. 102 | - Default slot for button content; forwards remaining `` attributes. 103 | 104 | ## Media and dividers 105 | 106 | ### Img 107 | 108 | - `src: string`, `alt: string`, `width: string`, `height: string` (all required). 109 | - Forwards additional `` attributes and merges custom styles. 110 | 111 | ### Hr 112 | 113 | - Accepts all `


` attributes and merges any styles you provide. 114 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/__snapshots__/sanitize-non-inlinable-rules.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`sanitizeNonInlinableRules() > handles CSS nesting in hover pseudo styles 1`] = ` 4 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 5 | @layer theme, base, components, utilities; 6 | @layer theme { 7 | :root, :host { 8 | --color-sky-600: oklch(58.8% 0.158 241.966) !important; 9 | --color-gray-100: oklch(96.7% 0.003 264.542) !important; 10 | } 11 | } 12 | @layer utilities { 13 | .hover_text-sky-600 { 14 | &:hover { 15 | @media (hover: hover) { 16 | color: var(--color-sky-600) !important; 17 | } 18 | } 19 | } 20 | .sm_focus_outline-none { 21 | @media (width >= 40rem) { 22 | &:focus { 23 | --tw-outline-style: none !important; 24 | outline-style: none !important; 25 | } 26 | } 27 | } 28 | .md_hover_bg-gray-100 { 29 | @media (width >= 48rem) { 30 | &:hover { 31 | @media (hover: hover) { 32 | background-color: var(--color-gray-100) !important; 33 | } 34 | } 35 | } 36 | } 37 | .lg_focus_underline { 38 | @media (width >= 64rem) { 39 | &:focus { 40 | text-decoration-line: underline !important; 41 | } 42 | } 43 | } 44 | } 45 | " 46 | `; 47 | 48 | exports[`sanitizeNonInlinableRules() > inlines rules that can be inlined 1`] = ` 49 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 50 | @layer theme, base, components, utilities; 51 | @layer theme { 52 | :root, :host { 53 | --color-red-300: oklch(80.8% 0.114 19.571) !important; 54 | --color-gray-900: oklch(21% 0.034 264.665) !important; 55 | --text-lg: 1.125rem !important; 56 | --text-lg--line-height: calc(1.75 / 1.125) !important; 57 | } 58 | } 59 | @layer utilities { 60 | .bg-gray-900 { 61 | background-color: var(--color-gray-900); 62 | } 63 | .text-lg { 64 | font-size: var(--text-lg); 65 | line-height: var(--tw-leading, var(--text-lg--line-height)); 66 | } 67 | .text-red-300 { 68 | color: var(--color-red-300); 69 | } 70 | } 71 | " 72 | `; 73 | 74 | exports[`sanitizeNonInlinableRules() > supports basic media query rules 1`] = ` 75 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 76 | @layer theme, base, components, utilities; 77 | @layer theme { 78 | :root, :host { 79 | --spacing: 0.25rem !important; 80 | --container-lg: 32rem !important; 81 | --radius-lg: 0.5rem !important; 82 | } 83 | } 84 | @layer utilities { 85 | .sm_mx-auto { 86 | @media (width >= 40rem) { 87 | margin-inline: auto !important; 88 | } 89 | } 90 | .sm_max-w-lg { 91 | @media (width >= 40rem) { 92 | max-width: var(--container-lg) !important; 93 | } 94 | } 95 | .sm_rounded-lg { 96 | @media (width >= 40rem) { 97 | border-radius: var(--radius-lg) !important; 98 | } 99 | } 100 | .md_px-10 { 101 | @media (width >= 48rem) { 102 | padding-inline: calc(var(--spacing) * 10) !important; 103 | } 104 | } 105 | .md_py-12 { 106 | @media (width >= 48rem) { 107 | padding-block: calc(var(--spacing) * 12) !important; 108 | } 109 | } 110 | } 111 | " 112 | `; 113 | -------------------------------------------------------------------------------- /src/routes/docs/migrating-to-v1/+page.md: -------------------------------------------------------------------------------- 1 | # Migrating to v1 2 | 3 | If you are using v0.x.x of Better Svelte Email, you can migrate to v1.x.x by following these steps. 4 | 5 | ## Update your dependencies 6 | 7 | ```bash 8 | npm install better-svelte-email@latest 9 | ``` 10 | 11 | If you are using tailwind: 12 | 13 | ```bash 14 | npm install tailwindcss@latest 15 | ``` 16 | 17 | ## New Renderer class 18 | 19 | Previously, you would add the `betterSvelteEmailPreprocessor` to your `svelte.config.js` file, this is now deprecated, you will need to remove it to make the new version work. 20 | 21 | To render email in v1, you will need to replace the `render` function from `svelte/server` with the new `Renderer` class. 22 | 23 | ```typescript 24 | import { Renderer } from 'better-svelte-email/render'; 25 | 26 | const { renderer } = new Renderer(); 27 | 28 | const html = await render(emailComponent); 29 | ``` 30 | 31 | ## Tailwind classes 32 | 33 | Since v1 now uses tailwindcss v4, you will need to update all your tailwind classes to the new syntax. See the [tailwindcss v4 migration guide](https://tailwindcss.com/docs/upgrade-guide) for more information. 34 | 35 | Another change is that you can now use inline classes in your email templates: 36 | 37 | ```svelte 38 | Hello 39 | World 40 | ``` 41 | 42 | This works too with the `style` prop. 43 | 44 | ## Plain text rendering 45 | 46 | The `renderAsPlainText` function has been deprecated in favor of the `toPlainText` function from the `better-svelte-email/render` package. 47 | 48 | ```typescript 49 | import { toPlainText } from 'better-svelte-email/render'; 50 | 51 | const plainText = toPlainText(html); 52 | ``` 53 | 54 | ## Email Preview 55 | 56 | In v1, the preview system now uses a route-based approach instead. For that to work, you will need to move your `+page.svelte` and `+page.server.ts` files from the `src/routes/email-preview` directory to the `src/routes/email-preview/[...email]` directory. 57 | 58 | ``` 59 | // Before 60 | src/routes/email-preview 61 | ├── +page.svelte 62 | └── +page.server.ts 63 | // After 64 | src/routes/email-preview/[...email] 65 | ├── +page.svelte 66 | └── +page.server.ts 67 | ``` 68 | 69 | In the `+page.server.ts` file, the `createEmail` action is now a function that needs to be called like the `sendEmail` function: 70 | 71 | ```typescript 72 | // src/routes/email-preview/[...email]/+page.server.ts 73 | export const actions = { 74 | // Before 75 | ...createEmail, 76 | ...sendEmail() 77 | // After 78 | ...createEmail(), 79 | ...sendEmail() 80 | }; 81 | ``` 82 | 83 | You will also need to update the `+page.svelte` like so: 84 | 85 | ```svelte 86 | 87 | 91 | 92 | 93 | ``` 94 | 95 | ### Using a tailwind config 96 | 97 | To use a custom tailwind config, you will need to pass an instance of the `Renderer` class to the `createEmail` and `sendEmail` functions: 98 | 99 | ```typescript 100 | // src/routes/email-preview/[...email]/+page.server.ts 101 | import { Renderer } from 'better-svelte-email/render'; 102 | 103 | const renderer = new Renderer(tailwindConfig); 104 | 105 | export const actions = { 106 | ...createEmail({ renderer }), 107 | ...sendEmail({ renderer }) 108 | }; 109 | ``` 110 | 111 | See the [email preview documentation](./email-preview) for more information. 112 | -------------------------------------------------------------------------------- /src/lib/components/__tests__/__fixtures__/test-email.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Responsive Heading 37 | 38 | 39 | Multiple utilities text 40 | 41 | 42 | Bold red text with margin 43 | 44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 | Column 1 52 | Left column content 53 | 54 | 55 | Column 2 56 | Right column content 57 | 58 | 59 |
60 | 61 |
62 | 63 | 64 | Logo 71 | 72 | 73 | 74 | Visit our website 75 | 76 | 77 | 78 | 85 | 86 | 87 | 93 | 94 | 95 | 96 | Nested Container 97 | 98 | This tests complex spacing, borders, and shadows 99 | 100 | 101 | 102 | 103 | Custom arbitrary values 104 | 105 | 106 | Text with empty style attribute 107 | 108 | 109 | Pure inline styles 110 |
111 | 112 | 113 | -------------------------------------------------------------------------------- /src/routes/docs/getting-started/+page.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Welcome to **Better Svelte Email**! This guide walks you through installation, configuration, and building your first email. 4 | 5 | ## Requirements 6 | 7 | - `svelte >= v5.14.3` 8 | - `tailwindcss >= v4` 9 | 10 | ## Installation 11 | 12 | Install the package: 13 | 14 | ```bash 15 | npm install better-svelte-email 16 | ``` 17 | 18 | ## Write your first email 19 | 20 | Create a new file at `src/lib/emails/welcome.svelte`. This example uses tailwind, but bare css works too. 21 | 22 | ```svelte 23 | 24 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | Welcome {name}! 47 | 48 | Better Svelte Email converts Svelte components into email-safe HTML. 49 | 50 | 51 | 59 | 67 | 68 |
69 |
70 | 71 | 72 | ``` 73 | 74 | ## Render and send it 75 | 76 | Render the email using the `Renderer` class and send it using your preferred email provider (resend in this example). 77 | 78 | ```typescript 79 | // src/routes/api/send-email/+server.ts 80 | import Renderer from 'better-svelte-email/render'; 81 | import { Resend } from 'resend'; 82 | import { env } from '$env/dynamic/private'; 83 | import WelcomeEmail from '$lib/emails/welcome.svelte'; 84 | 85 | const { render } = new Renderer(); 86 | const resend = new Resend(env.PRIVATE_RESEND_API_KEY); 87 | 88 | export async function POST({ request }) { 89 | const { name, email } = await request.json(); 90 | 91 | const html = await render(WelcomeEmail, { props: { name } }); 92 | 93 | // Send email using your preferred service (Resend, SendGrid, etc.) 94 | await resend.emails.send({ 95 | from: 'onboarding@resend.dev', 96 | to: email, 97 | subject: 'Welcome!', 98 | html 99 | }); 100 | 101 | return new Response('Email sent'); 102 | } 103 | ``` 104 | 105 | ## Plain text version 106 | 107 | You can also render the email as plain text using the `toPlainText` function for better accessibility. 108 | 109 | ```typescript 110 | import { toPlainText } from 'better-svelte-email/render'; 111 | 112 | const plainText = toPlainText(html); 113 | 114 | await resend.emails.send({ 115 | from: 'onboarding@resend.dev', 116 | to: email, 117 | subject: 'Welcome!', 118 | html, 119 | // Add the plain text version to the email 120 | text: plainText 121 | }); 122 | ``` 123 | 124 | ## Further configuration 125 | 126 | ### Tailwind configuration 127 | 128 | You can pass a Tailwind configuration object to the `Renderer` class. 129 | 130 | ```js 131 | const tailwindConfig = { 132 | theme: { extend: { colors: { brand: '#FF3E00' } } } 133 | }; 134 | const { render } = new Renderer({ tailwindConfig }); 135 | ``` 136 | 137 | ## Preview your emails 138 | 139 | Better Svelte Email provides an `EmailPreview` component that you can use to preview your emails in the browser. 140 | See the [Email Preview](./email-preview) section for a guide on how to use it. 141 | -------------------------------------------------------------------------------- /src/lib/emails/demo-email.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 | 58 | Better Svelte Email 59 | 60 | 61 | Welcome, {name}! 👋 62 | 63 | 64 | Create beautiful, responsive emails using Svelte components and Tailwind CSS. Better 65 | Svelte Email transforms your familiar workflow into email-safe HTML with full Tailwind 66 | support. 67 | 68 |
69 | 70 |
71 | 72 | Why Better Svelte Email? 73 | 74 | {#each features as feature, index} 75 | 76 | 77 | 78 | 79 | 80 | 81 | {feature.title} 82 | 83 | 84 | {feature.description} 85 | 86 | 87 | 88 | {/each} 89 |
90 | 91 |
92 | 100 |
101 | 102 |
103 | 104 |
105 | 106 | Check out the 107 | 111 | documentation 112 | 113 | to learn more,
or star on 114 | 118 | GitHub 119 | 120 | to support the project. 121 |
122 | 123 | Built with ❤️ by Konixy 126 | 127 |
128 |
129 | 130 | 131 | -------------------------------------------------------------------------------- /src/lib/emails/examples/vercel-invite-user.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 | 52 | 53 | 54 | 55 | 58 |
59 | Vercel Logo 66 |
67 | 68 | Join {teamName} on Vercel 69 | 70 | 71 | Hello {username}, 72 | 73 | 74 | {invitedByUsername} ( 78 | {invitedByEmail} 79 | ) has invited you to the {teamName} team on 80 | Vercel. 81 | 82 |
83 | 84 | 85 | {`${username}'s 92 | 93 | 94 | Arrow indicating invitation 100 | 101 | 102 | {`${teamName} 109 | 110 | 111 |
112 |
113 | 119 |
120 | 121 | or copy and paste this URL into your browser: 122 | 123 | {inviteLink} 124 | 125 | 126 |
127 | 128 | This invitation was intended for 129 | {username}. This invite was sent from 130 | {inviteFromIp} 131 | located in 132 | {inviteFromLocation}. If you were not expecting this 133 | invitation, you can ignore this email. If you are concerned about your account's safety, 134 | please reply to this email to get in touch with us. 135 | 136 |
137 | 138 | 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-svelte-email", 3 | "description": "Svelte email renderer with Tailwind support", 4 | "version": "1.1.0", 5 | "author": "Konixy", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/Konixy/better-svelte-email.git" 9 | }, 10 | "homepage": "https://better-svelte-email.konixy.fr", 11 | "peerDependencies": { 12 | "svelte": ">5.43.9", 13 | "@sveltejs/kit": ">=2" 14 | }, 15 | "peerDependenciesMeta": { 16 | "@sveltejs/kit": { 17 | "optional": true 18 | } 19 | }, 20 | "dependencies": { 21 | "html-to-text": "^9.0.5", 22 | "parse5": "^8.0.0", 23 | "postcss": "^8.5.6", 24 | "postcss-value-parser": "^4.2.0", 25 | "tailwindcss": "^4.1.17" 26 | }, 27 | "optionalDependencies": { 28 | "prettier": "^3.6.2", 29 | "resend": "^6.4.2", 30 | "shiki": "^3.15.0" 31 | }, 32 | "devDependencies": { 33 | "@eslint/compat": "^2.0.0", 34 | "@eslint/js": "^9.39.1", 35 | "@sveltejs/adapter-vercel": "^6.1.1", 36 | "@sveltejs/kit": "^2.48.5", 37 | "@sveltejs/package": "^2.5.4", 38 | "@sveltejs/vite-plugin-svelte": "^6.2.1", 39 | "@tailwindcss/vite": "^4.1.17", 40 | "@types/html-to-text": "^9.0.4", 41 | "@types/node": "22", 42 | "@vitest/coverage-v8": "^4.0.14", 43 | "eslint": "^9.39.1", 44 | "eslint-config-prettier": "^10.1.8", 45 | "eslint-plugin-svelte": "^3.13.0", 46 | "globals": "^16.5.0", 47 | "mdsvex": "^0.12.6", 48 | "mode-watcher": "^1.1.0", 49 | "prettier-plugin-svelte": "^3.4.0", 50 | "prettier-plugin-tailwindcss": "^0.7.1", 51 | "publint": "^0.3.15", 52 | "rehype-autolink-headings": "^7.1.0", 53 | "rehype-slug": "^6.0.0", 54 | "svelte": "5.45.2", 55 | "svelte-check": "^4.3.4", 56 | "tailwindcss-motion": "^1.1.1", 57 | "typescript": "^5.9.3", 58 | "typescript-eslint": "^8.47.0", 59 | "vite": "^7.2.2", 60 | "vitest": "^4.0.10" 61 | }, 62 | "exports": { 63 | ".": { 64 | "types": "./dist/index.d.ts", 65 | "svelte": "./dist/index.js", 66 | "import": "./dist/index.js", 67 | "default": "./dist/index.js" 68 | }, 69 | "./components": { 70 | "types": "./dist/components/index.d.ts", 71 | "import": "./dist/components/index.js", 72 | "default": "./dist/components/index.js" 73 | }, 74 | "./components/*": { 75 | "types": "./dist/components/*.svelte.d.ts", 76 | "svelte": "./dist/components/*.svelte" 77 | }, 78 | "./preview": { 79 | "types": "./dist/preview/index.d.ts", 80 | "import": "./dist/preview/index.js", 81 | "default": "./dist/preview/index.js" 82 | }, 83 | "./preview/EmailPreview.svelte": { 84 | "types": "./dist/preview/EmailPreview.svelte.d.ts", 85 | "svelte": "./dist/preview/EmailPreview.svelte" 86 | }, 87 | "./utils": { 88 | "types": "./dist/utils/index.d.ts", 89 | "import": "./dist/utils/index.js", 90 | "default": "./dist/utils/index.js" 91 | }, 92 | "./render": { 93 | "types": "./dist/render/index.d.ts", 94 | "import": "./dist/render/index.js", 95 | "default": "./dist/render/index.js" 96 | }, 97 | "./package.json": "./package.json" 98 | }, 99 | "files": [ 100 | "dist", 101 | "!dist/**/*.test.*", 102 | "!dist/**/*.spec.*", 103 | "!dist/**/__fixtures__", 104 | "!dist/emails" 105 | ], 106 | "keywords": [ 107 | "svelte", 108 | "svelte5", 109 | "email", 110 | "tailwind", 111 | "tailwindcss", 112 | "inline-styles", 113 | "responsive-email", 114 | "email-templates" 115 | ], 116 | "license": "MIT", 117 | "scripts": { 118 | "dev": "vite dev", 119 | "build": "bun run prepack && vite build", 120 | "preview": "vite preview", 121 | "package": "svelte-package", 122 | "package:watch": "nodemon -x \"bun run package\" -i dist -e ts,svelte", 123 | "prepare": "svelte-kit sync || echo ''", 124 | "prepack": "svelte-kit sync && svelte-package && publint", 125 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 126 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 127 | "format": "prettier --write .", 128 | "lint": "prettier --check . && eslint .", 129 | "test": "vitest run", 130 | "test:watch": "vitest", 131 | "test:ui": "vitest --ui", 132 | "test:coverage": "vitest run --coverage" 133 | }, 134 | "sideEffects": [ 135 | "**/*.css" 136 | ], 137 | "svelte": "./dist/index.js", 138 | "type": "module", 139 | "types": "./dist/index.d.ts" 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Better Svelte Email 3 |

Better Svelte Email

4 |

5 | Create beautiful emails in Svelte with first-class Tailwind support 6 |

7 |

8 | Website 9 | · 10 | GitHub 11 |

12 |

13 | 14 | Tests 15 | 16 | 17 | npm version 18 | 19 | 20 | GitHub stars 21 | 22 |

23 |

24 | 25 | ## Usage 26 | 27 | See the [documentation](https://better-svelte-email.konixy.fr/docs) for a complete guide on how to use Better Svelte Email. 28 | 29 | ## Features 30 | 31 | - **Tailwind v4 Support** - Transforms Tailwind classes to inline styles for email clients 32 | - **Built-in Email Preview** - Visual email preview and test sending 33 | - **TypeScript First** - Fully typed with comprehensive type definitions 34 | - **Well Tested** - >90% test coverage with unit and integration tests 35 | 36 | _See [Roadmap](./ROADMAP.md) for future features and planned improvements._ 37 | 38 | ## Why? 39 | 40 | Existing Svelte email solutions have significant limitations: 41 | 42 | - **svelte-email** hasn't been updated in over 2 years 43 | - **svelte-email-tailwind** suffers from stability issues and maintaining it is not sustainable anymore 44 | 45 | Better Svelte Email is a complete rewrite of [svelte-email-tailwind](https://github.com/steveninety/svelte-email-tailwind) inspired by [React Email](https://react.email/), providing the rock-solid foundation your email infrastructure needs. It brings the simplicity, reliability, and feature richness of [React Email](https://react.email/) to the Svelte ecosystem. 46 | 47 | ## Minimum Svelte Version 48 | 49 | The minimum supported Svelte version is 5.14.3. 50 | For older versions, you can use [`svelte-email-tailwind`](https://github.com/steveninety/svelte-email-tailwind). 51 | 52 | ## Supported Features 53 | 54 | ### ✅ Supported 55 | 56 | - All tailwindcss v4 utilities 57 | - Custom Tailwind classes (`bg-[#fff]`, `my:[40px]`, ...) 58 | - Dynamic Tailwind classes (`class={someVar}`) 59 | - Responsive breakpoints (`sm:`, `md:`, `lg:`, `xl:`, `2xl:`) 60 | - HTML elements and Svelte components 61 | - Nested components 62 | - All svelte features such as each blocks (`{#each}`) and if blocks (`{#if}`), and more 63 | - Custom Tailwind configurations 64 | 65 | ## Author 66 | 67 | Anatole Dufour ([@Konixy](https://github.com/Konixy)) 68 | 69 | ### Author of `svelte-email-tailwind` 70 | 71 | Steven Polak ([@steveninety](https://github.com/steveninety)) 72 | 73 | ### Authors of `react-email` 74 | 75 | Bu Kinoshita ([@bukinoshita](https://github.com/bukinoshita)) 76 | 77 | Zeno Rocha ([@zenorocha](https://github.com/zenorocha)) 78 | 79 | ## Development 80 | 81 | ### Running Tests 82 | 83 | ```bash 84 | bun run test 85 | ``` 86 | 87 | All tests must pass before pushing to main. The CI/CD pipeline will automatically run tests on every push and pull request. 88 | 89 | ### Building 90 | 91 | ```bash 92 | bun run build 93 | ``` 94 | 95 | ### Contributing 96 | 97 | Contributions are welcome! Please feel free to submit a Pull Request. 98 | 99 | To do so, you'll need to: 100 | 101 | 1. Fork the repository 102 | 2. Create a feature branch (`git checkout -b feat/amazing-feature`) 103 | 3. Make your changes 104 | 4. Run tests (`bun run test`) 105 | 5. Commit your changes using [conventional commits](https://www.conventionalcommits.org/): 106 | - `feat:` - New features 107 | - `fix:` - Bug fixes 108 | - `docs:` - Documentation changes 109 | - `test:` - Test additions/changes 110 | - `chore:` - Maintenance tasks 111 | 6. Push to your branch (`git push origin feat/amazing-feature`) 112 | 7. Open a Pull Request 113 | 114 | ## Acknowledgements 115 | 116 | Many components and logic were inspired by or adapted from [svelte-email-tailwind](https://github.com/steveninety/svelte-email-tailwind) and [react-email](https://react.email/). Huge thanks to the authors and contributors of these projects for their excellent work. 117 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/__snapshots__/resolve-all-css-variables.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`resolveAllCSSVariables > handles deeply nested var() functions with complex parentheses 1`] = ` 4 | ":root { 5 | --primary: blue; 6 | --secondary: red; 7 | --fallback: green; 8 | --size: 20px; 9 | } 10 | 11 | .box { 12 | color: blue; 13 | width: 20px; 14 | border: 1px solid black; 15 | --r: 100; 16 | --b: 10; 17 | background: rgb(100, 0, 10); 18 | }" 19 | `; 20 | 21 | exports[`resolveAllCSSVariables > handles nested var() functions in fallbacks 1`] = ` 22 | ":root { 23 | --fallback-width: 300px; 24 | } 25 | 26 | .box { 27 | width: 300px; 28 | height: 250px; 29 | }" 30 | `; 31 | 32 | exports[`resolveAllCSSVariables > ignores @layer (properties) defined for browser compatibility 1`] = ` 33 | "/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */ 34 | @layer properties; 35 | @layer theme, base, components, utilities; 36 | @layer theme { 37 | :root, :host { 38 | --color-red-500: oklch(63.7% 0.237 25.331); 39 | --color-blue-400: oklch(70.7% 0.165 254.624); 40 | --color-blue-600: oklch(54.6% 0.245 262.881); 41 | --color-gray-200: oklch(92.8% 0.006 264.531); 42 | --color-black: #000; 43 | --color-white: #fff; 44 | --spacing: 0.25rem; 45 | --text-sm: 0.875rem; 46 | --text-sm--line-height: calc(1.25 / 0.875); 47 | --radius-md: 0.375rem; 48 | } 49 | } 50 | @layer utilities { 51 | .mt-8 { 52 | margin-top: calc(0.25rem * 8); 53 | } 54 | .rounded-md { 55 | border-radius: 0.375rem; 56 | } 57 | .bg-blue-600 { 58 | background-color: oklch(54.6% 0.245 262.881); 59 | } 60 | .bg-red-500 { 61 | background-color: oklch(63.7% 0.237 25.331); 62 | } 63 | .bg-white { 64 | background-color: #fff; 65 | } 66 | .p-4 { 67 | padding: calc(0.25rem * 4); 68 | } 69 | .px-3 { 70 | padding-inline: calc(0.25rem * 3); 71 | } 72 | .py-2 { 73 | padding-block: calc(0.25rem * 2); 74 | } 75 | .text-sm { 76 | font-size: 0.875rem; 77 | line-height: calc(1.25 / 0.875); 78 | } 79 | .text-\\[14px\\] { 80 | font-size: 14px; 81 | } 82 | .leading-\\[24px\\] { 83 | --tw-leading: 24px; 84 | line-height: 24px; 85 | } 86 | .text-black { 87 | color: #000; 88 | } 89 | .text-blue-400 { 90 | color: oklch(70.7% 0.165 254.624); 91 | } 92 | .text-blue-600 { 93 | color: oklch(54.6% 0.245 262.881); 94 | } 95 | .text-gray-200 { 96 | color: oklch(92.8% 0.006 264.531); 97 | } 98 | .no-underline { 99 | text-decoration-line: none; 100 | } 101 | } 102 | @property --tw-leading { 103 | syntax: "*"; 104 | inherits: false; 105 | } 106 | @layer properties { 107 | @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { 108 | *, ::before, ::after, ::backdrop { 109 | --tw-leading: initial; 110 | } 111 | } 112 | } 113 | " 114 | `; 115 | 116 | exports[`resolveAllCSSVariables > keeps variable usages if it cant find their declaration 1`] = ` 117 | ".box { 118 | width: var(--width); 119 | }" 120 | `; 121 | 122 | exports[`resolveAllCSSVariables > uses fallback values when variable definition is not found 1`] = ` 123 | ".box { 124 | width: 150px; 125 | height: 200px; 126 | margin: 10px 20px; 127 | }" 128 | `; 129 | 130 | exports[`resolveAllCSSVariables > works for variables across different CSS layers 1`] = ` 131 | "@layer base { 132 | :root { 133 | --width: 100px; 134 | } 135 | } 136 | 137 | @layer utilities { 138 | .box { 139 | width: 100px; 140 | } 141 | }" 142 | `; 143 | 144 | exports[`resolveAllCSSVariables > works with a variable set in a layer, and used in another through a media query 1`] = ` 145 | "@layer theme { 146 | :root { 147 | --color-blue-300: blue; 148 | } 149 | } 150 | 151 | @layer utilities { 152 | .sm\\:bg-blue-300 { 153 | @media (width >= 40rem) { 154 | background-color: blue; 155 | } 156 | } 157 | }" 158 | `; 159 | 160 | exports[`resolveAllCSSVariables > works with multiple variables in the same declaration 1`] = ` 161 | ":root { 162 | --top: 101px; 163 | --bottom: 102px; 164 | --right: 103px; 165 | --left: 104px; 166 | } 167 | 168 | .box { 169 | margin: 101px 103px 102px 104px; 170 | }" 171 | `; 172 | 173 | exports[`resolveAllCSSVariables > works with simple css variables on a :root 1`] = ` 174 | ":root { 175 | --width: 100px; 176 | } 177 | 178 | .box { 179 | width: 100px; 180 | }" 181 | `; 182 | 183 | exports[`resolveAllCSSVariables > works with variables set in the same rule 1`] = ` 184 | ".box { 185 | --width: 200px; 186 | width: 200px; 187 | } 188 | 189 | @media (min-width: 1280px) { 190 | .xl\\:bg-green-500 { 191 | --tw-bg-opacity: 1; 192 | background-color: rgb(34 197 94 / 1) 193 | } 194 | } 195 | " 196 | `; 197 | -------------------------------------------------------------------------------- /src/lib/components/__tests__/rendering.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import Renderer from '$lib/render/index.js'; 3 | import Column from '../Column.svelte'; 4 | import Heading from '../Heading.svelte'; 5 | import Html from '../Html.svelte'; 6 | import Link from '../Link.svelte'; 7 | import Preview from '../Preview.svelte'; 8 | import Text from '../Text.svelte'; 9 | 10 | const testChildren = () => 'test'; 11 | 12 | describe('Component Unique Features', () => { 13 | describe('Html', () => { 14 | it('should render with default lang and dir attributes', async () => { 15 | const renderer = new Renderer(); 16 | const html = await renderer.render(Html, { props: {} }); 17 | expect(html).toMatchSnapshot(); 18 | }); 19 | 20 | it('should render with RTL direction', async () => { 21 | const renderer = new Renderer(); 22 | const html = await renderer.render(Html, { props: { lang: 'ar', dir: 'rtl' } }); 23 | expect(html).toMatchSnapshot(); 24 | }); 25 | }); 26 | 27 | describe('Text', () => { 28 | it('should render with custom element tag (as prop)', async () => { 29 | const renderer = new Renderer(); 30 | const html = await renderer.render(Text, { 31 | props: { as: 'h1', style: 'font-size:32px;', children: testChildren } 32 | }); 33 | expect(html).toMatchSnapshot(); 34 | }); 35 | }); 36 | 37 | describe('Column', () => { 38 | it('should handle colspan attribute', async () => { 39 | const renderer = new Renderer(); 40 | const html = await renderer.render(Column, { props: { colspan: 2, children: testChildren } }); 41 | expect(html).toMatchSnapshot(); 42 | }); 43 | 44 | it('should handle align attribute', async () => { 45 | const renderer = new Renderer(); 46 | const html = await renderer.render(Column, { 47 | props: { align: 'center', children: testChildren } 48 | }); 49 | expect(html).toMatchSnapshot(); 50 | }); 51 | }); 52 | 53 | describe('Heading', () => { 54 | it('should render different heading levels', async () => { 55 | const levels = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; 56 | const renderer = new Renderer(); 57 | 58 | for (const level of levels) { 59 | const html = await renderer.render(Heading, { 60 | props: { as: level, children: testChildren } 61 | }); 62 | expect(html).toMatchSnapshot(); 63 | } 64 | }); 65 | 66 | it('should apply margin shorthand (m)', async () => { 67 | const renderer = new Renderer(); 68 | const html = await renderer.render(Heading, { 69 | props: { as: 'h1', m: '20', children: testChildren } 70 | }); 71 | expect(html).toMatchSnapshot(); 72 | }); 73 | 74 | it('should apply horizontal margin (mx)', async () => { 75 | const renderer = new Renderer(); 76 | const html = await renderer.render(Heading, { 77 | props: { as: 'h2', mx: '16', children: testChildren } 78 | }); 79 | expect(html).toMatchSnapshot(); 80 | }); 81 | 82 | it('should apply vertical margin (my)', async () => { 83 | const renderer = new Renderer(); 84 | const html = await renderer.render(Heading, { 85 | props: { as: 'h3', my: '24', children: testChildren } 86 | }); 87 | expect(html).toMatchSnapshot(); 88 | }); 89 | 90 | it('should apply individual margin props', async () => { 91 | const renderer = new Renderer(); 92 | const html = await renderer.render(Heading, { 93 | props: { as: 'h1', mt: '10', mr: '15', mb: '20', ml: '25', children: testChildren } 94 | }); 95 | expect(html).toMatchSnapshot(); 96 | }); 97 | 98 | it('should combine margin props with inline styles', async () => { 99 | const renderer = new Renderer(); 100 | const html = await renderer.render(Heading, { 101 | props: { as: 'h2', style: 'color:blue;', my: '16', children: testChildren } 102 | }); 103 | expect(html).toMatchSnapshot(); 104 | }); 105 | }); 106 | 107 | describe('Link', () => { 108 | it('should have default target="_blank"', async () => { 109 | const renderer = new Renderer(); 110 | const html = await renderer.render(Link, { 111 | props: { href: 'https://example.com', children: testChildren } 112 | }); 113 | expect(html).toMatchSnapshot(); 114 | }); 115 | 116 | it('should allow custom target', async () => { 117 | const renderer = new Renderer(); 118 | const html = await renderer.render(Link, { 119 | props: { href: 'https://example.com', target: '_self', children: testChildren } 120 | }); 121 | expect(html).toMatchSnapshot(); 122 | }); 123 | }); 124 | 125 | describe('Preview', () => { 126 | it('should truncate preview text to max length', async () => { 127 | const longText = 'A'.repeat(200); 128 | const renderer = new Renderer(); 129 | const html = await renderer.render(Preview, { props: { preview: longText } }); 130 | expect(html).toMatchSnapshot(); 131 | }); 132 | 133 | it('should add whitespace padding for short text', async () => { 134 | const shortText = 'Short'; 135 | const renderer = new Renderer(); 136 | const html = await renderer.render(Preview, { props: { preview: shortText } }); 137 | expect(html).toMatchSnapshot(); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/routes/docs/+layout.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | Docs · Better Svelte Email 18 | 19 | 20 |
23 | 88 | 89 |
90 |
91 | {@render children()} 92 |
93 |
94 |
95 | 96 | 176 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Test, Publish and Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: read 14 | id-token: write 15 | 16 | jobs: 17 | test: 18 | name: Run Tests 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Setup Bun 28 | uses: oven-sh/setup-bun@v2 29 | with: 30 | bun-version: latest 31 | 32 | - name: Install dependencies 33 | run: bun install 34 | 35 | - name: Build project 36 | run: bun run build 37 | 38 | - name: Run tests 39 | run: bun run test 40 | 41 | publish: 42 | name: Publish to NPM 43 | runs-on: ubuntu-latest 44 | needs: test 45 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 46 | 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v4 50 | with: 51 | fetch-depth: 0 52 | fetch-tags: true 53 | 54 | - name: Setup Bun 55 | uses: oven-sh/setup-bun@v2 56 | with: 57 | bun-version: latest 58 | 59 | - name: Setup Node.js 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: lts/* 63 | registry-url: 'https://registry.npmjs.org' 64 | 65 | - name: Get current version 66 | id: current_version 67 | run: | 68 | VERSION=$(node -p "require('./package.json').version") 69 | echo "version=$VERSION" >> $GITHUB_OUTPUT 70 | echo "Current version: $VERSION" 71 | 72 | - name: Detect prerelease tag 73 | id: prerelease_tag 74 | run: | 75 | VERSION="${{ steps.current_version.outputs.version }}" 76 | 77 | # Check if version contains a prerelease identifier (e.g., -alpha, -beta, -rc) 78 | if [[ "$VERSION" =~ -([a-zA-Z]+)\. ]]; then 79 | TAG="${BASH_REMATCH[1]}" 80 | echo "tag=$TAG" >> $GITHUB_OUTPUT 81 | echo "is_prerelease=true" >> $GITHUB_OUTPUT 82 | echo "📦 Detected prerelease version: $VERSION → dist-tag: $TAG" 83 | else 84 | echo "tag=latest" >> $GITHUB_OUTPUT 85 | echo "is_prerelease=false" >> $GITHUB_OUTPUT 86 | echo "📦 Stable version: $VERSION → dist-tag: latest" 87 | fi 88 | 89 | - name: Check if version is published 90 | id: check_published 91 | run: | 92 | PACKAGE_NAME=$(node -p "require('./package.json').name") 93 | VERSION="${{ steps.current_version.outputs.version }}" 94 | 95 | if npm view "$PACKAGE_NAME@$VERSION" version 2>/dev/null; then 96 | echo "published=true" >> $GITHUB_OUTPUT 97 | echo "📦 Version $VERSION is already published to npm" 98 | else 99 | echo "published=false" >> $GITHUB_OUTPUT 100 | echo "✨ Version $VERSION is not published yet" 101 | fi 102 | 103 | - name: Install dependencies 104 | if: steps.check_published.outputs.published == 'false' 105 | run: bun install 106 | 107 | - name: Build package 108 | if: steps.check_published.outputs.published == 'false' 109 | run: bun run prepack 110 | 111 | - name: Publish to NPM 112 | if: steps.check_published.outputs.published == 'false' 113 | run: npm publish --provenance --access public --tag ${{ steps.prerelease_tag.outputs.tag }} 114 | env: 115 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 116 | 117 | - name: Skip publishing 118 | if: steps.check_published.outputs.published == 'true' 119 | run: | 120 | echo "⏭️ Skipping npm publish - version ${{ steps.current_version.outputs.version }} already published" 121 | 122 | outputs: 123 | version: ${{ steps.current_version.outputs.version }} 124 | should_release: ${{ steps.check_published.outputs.published == 'false' }} 125 | 126 | release: 127 | name: Create GitHub Release 128 | runs-on: ubuntu-latest 129 | needs: publish 130 | if: needs.publish.outputs.should_release == 'true' 131 | 132 | steps: 133 | - name: Checkout code 134 | uses: actions/checkout@v4 135 | with: 136 | fetch-depth: 0 137 | fetch-tags: true 138 | 139 | - name: Setup Node.js 140 | uses: actions/setup-node@v4 141 | with: 142 | node-version: lts/* 143 | 144 | - name: Create and push tag 145 | run: | 146 | git config user.name "github-actions[bot]" 147 | git config user.email "github-actions[bot]@users.noreply.github.com" 148 | git tag -a "v${{ needs.publish.outputs.version }}" -m "Release v${{ needs.publish.outputs.version }}" 149 | git push origin "v${{ needs.publish.outputs.version }}" 150 | env: 151 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 152 | 153 | - name: Generate changelog and create release 154 | run: npx changelogithub 155 | env: 156 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 157 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/resolve-all-css-variables.ts: -------------------------------------------------------------------------------- 1 | import type { Root, Declaration, Rule, AtRule, Container } from 'postcss'; 2 | import valueParser from 'postcss-value-parser'; 3 | 4 | interface VariableUse { 5 | declaration: Declaration; 6 | selector: string; 7 | inAtRule: boolean; 8 | atRuleSelector?: string; 9 | fallback?: string; 10 | variableName: string; 11 | raw: string; 12 | } 13 | 14 | export interface VariableDefinition { 15 | declaration: Declaration; 16 | selector: string; 17 | variableName: string; 18 | definition: string; 19 | } 20 | 21 | function getSelector(decl: Declaration): string { 22 | const parent = decl.parent; 23 | if (parent?.type === 'rule') { 24 | return (parent as Rule).selector; 25 | } 26 | return '*'; 27 | } 28 | 29 | function getAtRuleSelector(decl: Declaration): string | undefined { 30 | let parent = decl.parent; 31 | while (parent) { 32 | if (parent.type === 'atrule') { 33 | // Check if parent of atrule is a rule 34 | const atRuleParent = parent.parent; 35 | if (atRuleParent?.type === 'rule') { 36 | return (atRuleParent as Rule).selector; 37 | } 38 | } 39 | if (parent.type === 'rule') { 40 | return (parent as Rule).selector; 41 | } 42 | parent = parent.parent as Container | undefined; 43 | } 44 | return undefined; 45 | } 46 | 47 | function isInAtRule(decl: Declaration): boolean { 48 | let parent = decl.parent; 49 | while (parent) { 50 | if (parent.type === 'atrule') { 51 | return true; 52 | } 53 | parent = parent.parent as Container | undefined; 54 | } 55 | return false; 56 | } 57 | 58 | function isInPropertiesLayer(decl: Declaration): boolean { 59 | let parent = decl.parent; 60 | while (parent) { 61 | if (parent.type === 'atrule') { 62 | const atRule = parent as AtRule; 63 | if (atRule.name === 'layer' && atRule.params?.includes('properties')) { 64 | return true; 65 | } 66 | } 67 | parent = parent.parent as Container | undefined; 68 | } 69 | return false; 70 | } 71 | 72 | function doSelectorsIntersect(first: string, second: string): boolean { 73 | if (first === second) return true; 74 | 75 | // Check for universal selectors 76 | if (first.includes(':root') || second.includes(':root')) return true; 77 | if (first === '*' || second === '*') return true; 78 | 79 | return false; 80 | } 81 | 82 | export function resolveAllCssVariables(root: Root) { 83 | const variableDefinitions = new Set(); 84 | const variableUses: VariableUse[] = []; 85 | 86 | // First pass: collect variable definitions and uses 87 | root.walkDecls((decl) => { 88 | // Skip @layer (properties) { ... } to avoid variable resolution conflicts 89 | if (isInPropertiesLayer(decl)) { 90 | return; 91 | } 92 | 93 | if (decl.prop.startsWith('--')) { 94 | variableDefinitions.add({ 95 | declaration: decl, 96 | selector: getSelector(decl), 97 | variableName: decl.prop, 98 | definition: decl.value 99 | }); 100 | } else if (decl.value.includes('var(')) { 101 | const parseVariableUses = (value: string) => { 102 | const parsed = valueParser(value); 103 | 104 | parsed.walk((node) => { 105 | if (node.type === 'function' && node.value === 'var') { 106 | const varNameNode = node.nodes[0]; 107 | const varName = varNameNode ? valueParser.stringify(varNameNode).trim() : ''; 108 | 109 | // Find fallback (after the comma) 110 | let fallback: string | undefined; 111 | const commaIndex = node.nodes.findIndex((n) => n.type === 'div' && n.value === ','); 112 | if (commaIndex !== -1) { 113 | fallback = valueParser.stringify(node.nodes.slice(commaIndex + 1)).trim(); 114 | } 115 | 116 | const raw = valueParser.stringify(node); 117 | 118 | variableUses.push({ 119 | declaration: decl, 120 | selector: getSelector(decl), 121 | inAtRule: isInAtRule(decl), 122 | atRuleSelector: getAtRuleSelector(decl), 123 | fallback, 124 | variableName: varName, 125 | raw 126 | }); 127 | 128 | // If fallback contains var(), recursively parse those too 129 | if (fallback?.includes('var(')) { 130 | parseVariableUses(fallback); 131 | } 132 | } 133 | }); 134 | }; 135 | 136 | parseVariableUses(decl.value); 137 | } 138 | }); 139 | 140 | // Second pass: resolve variables 141 | for (const use of variableUses) { 142 | let hasReplaced = false; 143 | 144 | for (const definition of variableDefinitions) { 145 | if (use.variableName !== definition.variableName) { 146 | continue; 147 | } 148 | 149 | // Check if use is in an at-rule and definition is in a matching rule 150 | if ( 151 | use.inAtRule && 152 | use.atRuleSelector && 153 | doSelectorsIntersect(use.atRuleSelector, definition.selector) 154 | ) { 155 | use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.definition); 156 | hasReplaced = true; 157 | break; 158 | } 159 | 160 | // Check if both are in rules with matching selectors 161 | if (!use.inAtRule && doSelectorsIntersect(use.selector, definition.selector)) { 162 | use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.definition); 163 | hasReplaced = true; 164 | break; 165 | } 166 | } 167 | 168 | if (!hasReplaced && use.fallback) { 169 | use.declaration.value = use.declaration.value.replaceAll(use.raw, use.fallback); 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/routes/preview/[...email]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import type { PreviewData } from '$lib/preview/index.js'; 3 | import type { RequestEvent } from '@sveltejs/kit'; 4 | import Renderer, { type TailwindConfig } from '$lib/render/index.js'; 5 | import prettier from 'prettier/standalone'; 6 | import parserHtml from 'prettier/parser-html'; 7 | import { pixelBasedPreset } from '$lib/render/utils/tailwindcss/pixel-based-preset.js'; 8 | import fs from 'fs'; 9 | import path from 'path'; 10 | import { Resend } from 'resend'; 11 | 12 | const resend = new Resend(env.RESEND_API_KEY ?? 're_1234'); 13 | 14 | const tailwindConfig: TailwindConfig = { 15 | theme: { 16 | extend: { 17 | colors: { 18 | brand: '#FF3E00' 19 | } 20 | } 21 | }, 22 | presets: [pixelBasedPreset] 23 | }; 24 | 25 | const { render } = new Renderer(tailwindConfig); 26 | 27 | // Import all email components at build time using import.meta.glob 28 | // This creates a map of all email components that can be accessed at runtime 29 | const emailModules = import.meta.glob('/src/lib/emails/**/*.svelte', { eager: true }); 30 | 31 | /** 32 | * Vercel-compatible email list function that uses Vite's import.meta.glob 33 | * to statically analyze email files at build time instead of runtime fs access 34 | */ 35 | function emailListVercel(): PreviewData { 36 | const files = Object.keys(emailModules) 37 | .map((path) => { 38 | // Extract filename without extension from the full path 39 | // e.g., '/src/lib/emails/apple-receipt.svelte' -> 'apple-receipt' 40 | const match = path.match(/\/src\/lib\/emails\/(.+)\.svelte$/); 41 | return match ? match[1] : null; 42 | }) 43 | .filter((name): name is string => name !== null); 44 | 45 | if (files.length === 0) { 46 | return { files: null, path: '/src/lib/emails' }; 47 | } 48 | 49 | return { files, path: '/src/lib/emails' }; 50 | } 51 | 52 | /** 53 | * Vercel-compatible createEmail action that uses pre-imported email components 54 | * instead of dynamic runtime imports 55 | */ 56 | const createEmailVercel = { 57 | 'create-email': async (event: RequestEvent) => { 58 | try { 59 | const data = await event.request.formData(); 60 | const file = data.get('file'); 61 | const emailPath = data.get('path'); 62 | 63 | if (!file || !emailPath) { 64 | return { 65 | status: 400, 66 | body: { error: 'Missing file or path parameter' } 67 | }; 68 | } 69 | 70 | // Construct the full path to match the keys in emailModules 71 | const fullPath = `${emailPath}/${file}.svelte`; 72 | 73 | // Get the component from the pre-imported modules 74 | const module = emailModules[fullPath] as { default: any } | undefined; 75 | 76 | if (!module || !module.default) { 77 | throw new Error( 78 | `Failed to import email component '${file}' in '${emailPath}'. Make sure the file exists and includes the component.` 79 | ); 80 | } 81 | 82 | const emailComponent = module.default; 83 | 84 | // Render the component to HTML 85 | const html = await render(emailComponent); 86 | 87 | const source = fs.readFileSync( 88 | path.resolve(process.cwd(), path.relative('/', fullPath)), 89 | 'utf8' 90 | ); 91 | 92 | // Remove all HTML comments from the body before formatting 93 | const formattedHtml = await prettier.format(html, { 94 | parser: 'html', 95 | plugins: [parserHtml] 96 | }); 97 | 98 | return { 99 | body: formattedHtml, 100 | source 101 | }; 102 | } catch (error) { 103 | console.error('Error rendering email:', error); 104 | return { 105 | status: 500, 106 | error: { 107 | message: error instanceof Error ? error.message : 'Failed to render email' 108 | } 109 | }; 110 | } 111 | } 112 | }; 113 | 114 | const sendEmailVercel = { 115 | 'send-email': async (event: RequestEvent): Promise<{ success: boolean; error: any }> => { 116 | const data = await event.request.formData(); 117 | const emailPath = data.get('path'); 118 | const file = data.get('file'); 119 | 120 | if (!file || !emailPath) { 121 | return { 122 | success: false, 123 | error: { message: 'Missing file or path parameter' } 124 | }; 125 | } 126 | 127 | // Construct the full path to match the keys in emailModules 128 | const fullPath = `${emailPath}/${file}.svelte`; 129 | 130 | // Get the component from the pre-imported modules 131 | const module = emailModules[fullPath] as { default: any } | undefined; 132 | 133 | if (!module || !module.default) { 134 | throw new Error( 135 | `Failed to import email component '${file}' in '${emailPath}'. Make sure the file exists and includes the component.` 136 | ); 137 | } 138 | 139 | const emailComponent = module.default; 140 | 141 | const html = await render(emailComponent); 142 | 143 | const email = { 144 | from: 'svelte-email-tailwind ', 145 | to: `${data.get('to')}`, 146 | subject: `${data.get('component')} ${data.get('note') ? '| ' + data.get('note') : ''}`, 147 | html 148 | }; 149 | 150 | const sent = await resend.emails.send(email); 151 | 152 | if (sent && sent.error) { 153 | console.log('Error:', sent.error); 154 | return { success: false, error: sent.error }; 155 | } else { 156 | console.log('Email was sent successfully.'); 157 | return { success: true, error: null }; 158 | } 159 | } 160 | }; 161 | 162 | export function load() { 163 | const emails = emailListVercel(); 164 | return { emails }; 165 | } 166 | 167 | export const actions = { 168 | ...createEmailVercel, 169 | ...sendEmailVercel 170 | }; 171 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/resolve-calc-expressions.ts: -------------------------------------------------------------------------------- 1 | import type { Root } from 'postcss'; 2 | import valueParser from 'postcss-value-parser'; 3 | 4 | interface ParsedValue { 5 | value: number; 6 | unit: string; 7 | type: 'dimension' | 'number' | 'percentage'; 8 | } 9 | 10 | function parseValue(str: string): ParsedValue | null { 11 | const match = str.match(/^(-?[\d.]+)(%|[a-z]+)?$/i); 12 | if (match) { 13 | const value = parseFloat(match[1]); 14 | const unit = match[2] || ''; 15 | return { 16 | value, 17 | unit, 18 | type: unit === '%' ? 'percentage' : unit ? 'dimension' : 'number' 19 | }; 20 | } 21 | return null; 22 | } 23 | 24 | function formatValue(parsed: ParsedValue): string { 25 | return `${parsed.value}${parsed.unit}`; 26 | } 27 | 28 | /** 29 | * Splits a calc expression string into tokens (values and operators) 30 | * Handles both space-separated and non-space-separated expressions 31 | */ 32 | function tokenizeCalcExpression(expr: string): string[] { 33 | const tokens: string[] = []; 34 | let current = ''; 35 | 36 | for (let i = 0; i < expr.length; i++) { 37 | const char = expr[i]; 38 | 39 | if (char === '*' || char === '/') { 40 | if (current.trim()) { 41 | tokens.push(current.trim()); 42 | } 43 | tokens.push(char); 44 | current = ''; 45 | } else if (char === '+' || char === '-') { 46 | // Only treat as operator if not at start and previous char wasn't an operator 47 | // (to handle negative numbers and units like 1e-5) 48 | if (current.trim() && !/[eE]$/.test(current.trim())) { 49 | tokens.push(current.trim()); 50 | tokens.push(char); 51 | current = ''; 52 | } else { 53 | current += char; 54 | } 55 | } else if (char === ' ') { 56 | if (current.trim()) { 57 | // Check if next non-space char is an operator 58 | let nextNonSpace = i + 1; 59 | while (nextNonSpace < expr.length && expr[nextNonSpace] === ' ') { 60 | nextNonSpace++; 61 | } 62 | const nextChar = expr[nextNonSpace]; 63 | if (nextChar !== '*' && nextChar !== '/' && nextChar !== '+' && nextChar !== '-') { 64 | tokens.push(current.trim()); 65 | current = ''; 66 | } 67 | } 68 | } else { 69 | current += char; 70 | } 71 | } 72 | 73 | if (current.trim()) { 74 | tokens.push(current.trim()); 75 | } 76 | 77 | return tokens; 78 | } 79 | 80 | /** 81 | * Intentionally only resolves `*` and `/` operations without dealing with parenthesis, 82 | * because this is the only thing required to run Tailwind v4 83 | */ 84 | function evaluateCalcExpression(expr: string): string | null { 85 | const tokens = tokenizeCalcExpression(expr); 86 | 87 | if (tokens.length === 0) return null; 88 | if (tokens.length === 1) { 89 | const parsed = parseValue(tokens[0]); 90 | return parsed ? formatValue(parsed) : null; 91 | } 92 | 93 | // Process * and / operations (left to right) 94 | let i = 0; 95 | while (i < tokens.length) { 96 | const token = tokens[i]; 97 | if (token === '*' || token === '/') { 98 | const left = parseValue(tokens[i - 1]); 99 | const right = parseValue(tokens[i + 1]); 100 | 101 | if (left && right) { 102 | let resultValue: number; 103 | if (token === '*') { 104 | resultValue = left.value * right.value; 105 | } else { 106 | if (right.value === 0) resultValue = 0; 107 | else resultValue = left.value / right.value; 108 | } 109 | 110 | // Determine result type 111 | let resultUnit = ''; 112 | let resultType: ParsedValue['type'] = 'number'; 113 | 114 | if (left.type === 'dimension' && right.type === 'number') { 115 | resultUnit = left.unit; 116 | resultType = 'dimension'; 117 | } else if (left.type === 'number' && right.type === 'dimension') { 118 | resultUnit = right.unit; 119 | resultType = 'dimension'; 120 | } else if (left.type === 'dimension' && right.type === 'dimension') { 121 | if (token === '/') { 122 | resultType = 'number'; 123 | } else { 124 | resultUnit = left.unit; 125 | resultType = 'dimension'; 126 | } 127 | } else if (left.type === 'percentage' || right.type === 'percentage') { 128 | if (token === '/' && left.type === 'percentage' && right.type === 'percentage') { 129 | resultType = 'number'; 130 | } else { 131 | resultUnit = '%'; 132 | resultType = 'percentage'; 133 | } 134 | } 135 | 136 | // Replace the three tokens with the result 137 | const result = formatValue({ value: resultValue, unit: resultUnit, type: resultType }); 138 | tokens.splice(i - 1, 3, result); 139 | i = Math.max(0, i - 1); // Go back to check for more operations 140 | continue; 141 | } 142 | } 143 | i++; 144 | } 145 | 146 | if (tokens.length === 1) { 147 | return tokens[0]; 148 | } 149 | 150 | // If we still have multiple tokens, we couldn't fully evaluate 151 | return null; 152 | } 153 | 154 | export function resolveCalcExpressions(root: Root) { 155 | root.walkDecls((decl) => { 156 | if (!decl.value.includes('calc(')) return; 157 | 158 | const parsed = valueParser(decl.value); 159 | 160 | parsed.walk((node) => { 161 | if (node.type === 'function' && node.value === 'calc') { 162 | // Get the inner content of calc() 163 | const innerContent = valueParser.stringify(node.nodes); 164 | const result = evaluateCalcExpression(innerContent); 165 | 166 | if (result) { 167 | // Replace the function with the result 168 | node.type = 'word'; 169 | node.value = result; 170 | node.nodes = []; 171 | } 172 | } 173 | }); 174 | 175 | decl.value = valueParser.stringify(parsed.nodes); 176 | }); 177 | } 178 | -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | 10 | 13 | 15 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/lib/components/__tests__/__snapshots__/rendering.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Component Unique Features > Column > should handle align attribute 1`] = `""`; 4 | 5 | exports[`Component Unique Features > Column > should handle colspan attribute 1`] = `""`; 6 | 7 | exports[`Component Unique Features > Heading > should apply horizontal margin (mx) 1`] = `"

"`; 8 | 9 | exports[`Component Unique Features > Heading > should apply individual margin props 1`] = `"

"`; 10 | 11 | exports[`Component Unique Features > Heading > should apply margin shorthand (m) 1`] = `"

"`; 12 | 13 | exports[`Component Unique Features > Heading > should apply vertical margin (my) 1`] = `"

"`; 14 | 15 | exports[`Component Unique Features > Heading > should combine margin props with inline styles 1`] = `"

"`; 16 | 17 | exports[`Component Unique Features > Heading > should render different heading levels 1`] = `"

"`; 18 | 19 | exports[`Component Unique Features > Heading > should render different heading levels 2`] = `"

"`; 20 | 21 | exports[`Component Unique Features > Heading > should render different heading levels 3`] = `"

"`; 22 | 23 | exports[`Component Unique Features > Heading > should render different heading levels 4`] = `"

"`; 24 | 25 | exports[`Component Unique Features > Heading > should render different heading levels 5`] = `"
"`; 26 | 27 | exports[`Component Unique Features > Heading > should render different heading levels 6`] = `"
"`; 28 | 29 | exports[`Component Unique Features > Html > should render with RTL direction 1`] = `""`; 30 | 31 | exports[`Component Unique Features > Html > should render with default lang and dir attributes 1`] = `""`; 32 | 33 | exports[`Component Unique Features > Link > should allow custom target 1`] = `""`; 34 | 35 | exports[`Component Unique Features > Link > should have default target="_blank" 1`] = `""`; 36 | 37 | exports[`Component Unique Features > Preview > should add whitespace padding for short text 1`] = `""`; 38 | 39 | exports[`Component Unique Features > Preview > should truncate preview text to max length 1`] = `""`; 40 | 41 | exports[`Component Unique Features > Text > should render with custom element tag (as prop) 1`] = `"

"`; 42 | -------------------------------------------------------------------------------- /src/lib/render/index.ts: -------------------------------------------------------------------------------- 1 | import { render as svelteRender } from 'svelte/server'; 2 | import { parse, serialize, type DefaultTreeAdapterTypes } from 'parse5'; 3 | import postcss from 'postcss'; 4 | import { walk } from './utils/html/walk.js'; 5 | import { setupTailwind } from './utils/tailwindcss/setup-tailwind.js'; 6 | import type { Config } from 'tailwindcss'; 7 | import { sanitizeStyleSheet } from './utils/css/sanitize-stylesheet.js'; 8 | import { extractRulesPerClass } from './utils/css/extract-rules-per-class.js'; 9 | import { getCustomProperties } from './utils/css/get-custom-properties.js'; 10 | import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules.js'; 11 | import { addInlinedStylesToElement } from './utils/tailwindcss/add-inlined-styles-to-element.js'; 12 | import { isValidNode } from './utils/html/is-valid-node.js'; 13 | import { removeAttributesFunctions } from './utils/html/remove-attributes-functions.js'; 14 | import { convert } from 'html-to-text'; 15 | 16 | export type TailwindConfig = Omit; 17 | export type { DefaultTreeAdapterTypes as AST }; 18 | 19 | /** 20 | * Options for rendering a Svelte component 21 | */ 22 | export type RenderOptions = { 23 | props?: Omit, '$$slots' | '$$events'> | undefined; 24 | context?: Map; 25 | idPrefix?: string; 26 | }; 27 | 28 | /** 29 | * Email renderer that converts Svelte components to email-safe HTML with inlined Tailwind styles. 30 | * 31 | * @example 32 | * ```ts 33 | * import Renderer from 'better-svelte-email/renderer'; 34 | * import EmailComponent from './email.svelte'; 35 | * 36 | * const renderer = new Renderer({ 37 | * theme: { 38 | * extend: { 39 | * colors: { 40 | * brand: '#FF3E00' 41 | * } 42 | * } 43 | * } 44 | * }); 45 | * 46 | * const html = await renderer.render(EmailComponent, { 47 | * props: { name: 'John' } 48 | * }); 49 | * ``` 50 | */ 51 | export default class Renderer { 52 | private tailwindConfig: TailwindConfig; 53 | 54 | constructor(tailwindConfig: TailwindConfig = {}) { 55 | this.tailwindConfig = tailwindConfig; 56 | } 57 | 58 | /** 59 | * Renders a Svelte component to email-safe HTML with inlined Tailwind CSS. 60 | * 61 | * Automatically: 62 | * - Converts Tailwind classes to inline styles 63 | * - Injects media queries into `` for responsive classes 64 | * - Replaces DOCTYPE with XHTML 1.0 Transitional 65 | * - Removes comments and Svelte artifacts 66 | * 67 | * @param component - The Svelte component to render 68 | * @param options - Render options including props, context, and idPrefix 69 | * @returns Email-safe HTML string 70 | * 71 | * @example 72 | * ```ts 73 | * const html = await renderer.render(EmailComponent, { 74 | * props: { username: 'john_doe', resetUrl: 'https://...' } 75 | * }); 76 | * ``` 77 | */ 78 | render = async (component: any, options?: RenderOptions | undefined) => { 79 | const { body } = svelteRender(component, options); 80 | 81 | let ast = parse(body); 82 | ast = removeAttributesFunctions(ast); 83 | 84 | let classesUsed: string[] = []; 85 | const tailwindSetup = await setupTailwind(this.tailwindConfig); 86 | 87 | walk(ast, (node) => { 88 | if (isValidNode(node)) { 89 | const classAttr = node.attrs?.find((attr) => attr.name === 'class'); 90 | 91 | if (classAttr && classAttr.value) { 92 | const classes = classAttr.value.split(/\s+/).filter(Boolean); 93 | classesUsed = [...classesUsed, ...classes]; 94 | tailwindSetup.addUtilities(classes); 95 | } 96 | } 97 | 98 | return node; 99 | }); 100 | 101 | const styleSheet = tailwindSetup.getStyleSheet(); 102 | sanitizeStyleSheet(styleSheet); 103 | 104 | const { inlinable: inlinableRules, nonInlinable: nonInlinableRules } = extractRulesPerClass( 105 | styleSheet, 106 | classesUsed 107 | ); 108 | 109 | const customProperties = getCustomProperties(styleSheet); 110 | 111 | // Create a new Root for non-inline styles 112 | const nonInlineStyles = postcss.root(); 113 | for (const rule of nonInlinableRules.values()) { 114 | nonInlineStyles.append(rule.clone()); 115 | } 116 | sanitizeNonInlinableRules(nonInlineStyles); 117 | 118 | const hasNonInlineStylesToApply = nonInlinableRules.size > 0; 119 | let appliedNonInlineStyles = false; 120 | let hasHead = false; 121 | const unknownClasses: string[] = []; 122 | 123 | ast = walk(ast, (node) => { 124 | if (isValidNode(node)) { 125 | const elementWithInlinedStyles = addInlinedStylesToElement( 126 | node, 127 | inlinableRules, 128 | nonInlinableRules, 129 | customProperties, 130 | unknownClasses 131 | ); 132 | if (node.nodeName === 'head') { 133 | hasHead = true; 134 | } 135 | return elementWithInlinedStyles; 136 | } 137 | return node; 138 | }); 139 | 140 | let serialized = serialize(ast); 141 | 142 | if (unknownClasses.length > 0) { 143 | console.warn( 144 | `[better-svelte-email] You are using the following classes that were not recognized: ${unknownClasses.join(' ')}.` 145 | ); 146 | } 147 | 148 | if (hasHead && hasNonInlineStylesToApply) { 149 | appliedNonInlineStyles = true; 150 | serialized = serialized.replace( 151 | '', 152 | '' + '' 153 | ); 154 | } 155 | 156 | if (hasNonInlineStylesToApply && !appliedNonInlineStyles) { 157 | throw new Error( 158 | `You are trying to use the following Tailwind classes that cannot be inlined: ${Array.from( 159 | nonInlinableRules.keys() 160 | ).join(' ')}. 161 | For the media queries to work properly on rendering, they need to be added into a 169 | -------------------------------------------------------------------------------- /src/lib/render/utils/css/resolve-all-css-variables.spec.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import { resolveAllCssVariables } from './resolve-all-css-variables.js'; 3 | import { expect, describe, it } from 'vitest'; 4 | 5 | describe('resolveAllCSSVariables', () => { 6 | it('ignores @layer (properties) defined for browser compatibility', () => { 7 | const root = postcss.parse(`/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */ 8 | @layer properties; 9 | @layer theme, base, components, utilities; 10 | @layer theme { 11 | :root, :host { 12 | --color-red-500: oklch(63.7% 0.237 25.331); 13 | --color-blue-400: oklch(70.7% 0.165 254.624); 14 | --color-blue-600: oklch(54.6% 0.245 262.881); 15 | --color-gray-200: oklch(92.8% 0.006 264.531); 16 | --color-black: #000; 17 | --color-white: #fff; 18 | --spacing: 0.25rem; 19 | --text-sm: 0.875rem; 20 | --text-sm--line-height: calc(1.25 / 0.875); 21 | --radius-md: 0.375rem; 22 | } 23 | } 24 | @layer utilities { 25 | .mt-8 { 26 | margin-top: calc(var(--spacing) * 8); 27 | } 28 | .rounded-md { 29 | border-radius: var(--radius-md); 30 | } 31 | .bg-blue-600 { 32 | background-color: var(--color-blue-600); 33 | } 34 | .bg-red-500 { 35 | background-color: var(--color-red-500); 36 | } 37 | .bg-white { 38 | background-color: var(--color-white); 39 | } 40 | .p-4 { 41 | padding: calc(var(--spacing) * 4); 42 | } 43 | .px-3 { 44 | padding-inline: calc(var(--spacing) * 3); 45 | } 46 | .py-2 { 47 | padding-block: calc(var(--spacing) * 2); 48 | } 49 | .text-sm { 50 | font-size: var(--text-sm); 51 | line-height: var(--tw-leading, var(--text-sm--line-height)); 52 | } 53 | .text-\\[14px\\] { 54 | font-size: 14px; 55 | } 56 | .leading-\\[24px\\] { 57 | --tw-leading: 24px; 58 | line-height: 24px; 59 | } 60 | .text-black { 61 | color: var(--color-black); 62 | } 63 | .text-blue-400 { 64 | color: var(--color-blue-400); 65 | } 66 | .text-blue-600 { 67 | color: var(--color-blue-600); 68 | } 69 | .text-gray-200 { 70 | color: var(--color-gray-200); 71 | } 72 | .no-underline { 73 | text-decoration-line: none; 74 | } 75 | } 76 | @property --tw-leading { 77 | syntax: "*"; 78 | inherits: false; 79 | } 80 | @layer properties { 81 | @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { 82 | *, ::before, ::after, ::backdrop { 83 | --tw-leading: initial; 84 | } 85 | } 86 | } 87 | `); 88 | resolveAllCssVariables(root); 89 | expect(root.toString()).toMatchSnapshot(); 90 | }); 91 | 92 | it('works with simple css variables on a :root', () => { 93 | const root = postcss.parse(`:root { 94 | --width: 100px; 95 | } 96 | 97 | .box { 98 | width: var(--width); 99 | }`); 100 | resolveAllCssVariables(root); 101 | expect(root.toString()).toMatchSnapshot(); 102 | }); 103 | 104 | it('works for variables across different CSS layers', () => { 105 | const root = postcss.parse(`@layer base { 106 | :root { 107 | --width: 100px; 108 | } 109 | } 110 | 111 | @layer utilities { 112 | .box { 113 | width: var(--width); 114 | } 115 | }`); 116 | resolveAllCssVariables(root); 117 | expect(root.toString()).toMatchSnapshot(); 118 | }); 119 | 120 | it('works with multiple variables in the same declaration', () => { 121 | const root = postcss.parse(`:root { 122 | --top: 101px; 123 | --bottom: 102px; 124 | --right: 103px; 125 | --left: 104px; 126 | } 127 | 128 | .box { 129 | margin: var(--top) var(--right) var(--bottom) var(--left); 130 | }`); 131 | 132 | resolveAllCssVariables(root); 133 | expect(root.toString()).toMatchSnapshot(); 134 | }); 135 | 136 | it('keeps variable usages if it cant find their declaration', () => { 137 | const root = postcss.parse(`.box { 138 | width: var(--width); 139 | }`); 140 | resolveAllCssVariables(root); 141 | expect(root.toString()).toMatchSnapshot(); 142 | }); 143 | 144 | it('works with variables set in the same rule', () => { 145 | const root = postcss.parse(`.box { 146 | --width: 200px; 147 | width: var(--width); 148 | } 149 | 150 | @media (min-width: 1280px) { 151 | .xl\\:bg-green-500 { 152 | --tw-bg-opacity: 1; 153 | background-color: rgb(34 197 94 / var(--tw-bg-opacity)) 154 | } 155 | } 156 | `); 157 | resolveAllCssVariables(root); 158 | expect(root.toString()).toMatchSnapshot(); 159 | }); 160 | 161 | it('works with a variable set in a layer, and used in another through a media query', () => { 162 | const root = postcss.parse(`@layer theme { 163 | :root { 164 | --color-blue-300: blue; 165 | } 166 | } 167 | 168 | @layer utilities { 169 | .sm\\:bg-blue-300 { 170 | @media (width >= 40rem) { 171 | background-color: var(--color-blue-300); 172 | } 173 | } 174 | }`); 175 | resolveAllCssVariables(root); 176 | expect(root.toString()).toMatchSnapshot(); 177 | }); 178 | 179 | it('uses fallback values when variable definition is not found', () => { 180 | const root = postcss.parse(`.box { 181 | width: var(--undefined-width, 150px); 182 | height: var(--undefined-height, 200px); 183 | margin: var(--undefined-margin, 10px 20px); 184 | }`); 185 | resolveAllCssVariables(root); 186 | expect(root.toString()).toMatchSnapshot(); 187 | }); 188 | 189 | it('handles nested var() functions in fallbacks', () => { 190 | const root = postcss.parse(`:root { 191 | --fallback-width: 300px; 192 | } 193 | 194 | .box { 195 | width: var(--undefined-width, var(--fallback-width)); 196 | height: var(--undefined-height, var(--also-undefined, 250px)); 197 | }`); 198 | resolveAllCssVariables(root); 199 | expect(root.toString()).toMatchSnapshot(); 200 | }); 201 | 202 | it('handles deeply nested var() functions with complex parentheses', () => { 203 | const root = postcss.parse(`:root { 204 | --primary: blue; 205 | --secondary: red; 206 | --fallback: green; 207 | --size: 20px; 208 | } 209 | 210 | .box { 211 | color: var(--primary, var(--secondary, var(--fallback))); 212 | width: var(--size, calc(100px + var(--size, 20px))); 213 | border: var(--border-width, var(--border-style, var(--border-color, 1px solid black))); 214 | --r: 100; 215 | --b: 10; 216 | background: var(--bg-color, rgb(var(--r, 255), var(--g, 0), var(--b, 0))); 217 | }`); 218 | resolveAllCssVariables(root); 219 | expect(root.toString()).toMatchSnapshot(); 220 | }); 221 | 222 | it('handles selectors with asterisks in attribute selectors and pseudo-functions', () => { 223 | const root = postcss.parse(`* { 224 | --global-color: red; 225 | } 226 | 227 | input[type="*"]:hover { 228 | color: var(--global-color); 229 | } 230 | 231 | div:nth-child(2*n+1) { 232 | background: var(--global-color); 233 | } 234 | 235 | .test[data-attr="value*test"] { 236 | border-color: var(--global-color); 237 | } 238 | 239 | .universal-with-class-* { 240 | --class-color: blue; 241 | text-decoration: var(--class-color); 242 | } 243 | 244 | .normal { 245 | color: var(--class-color); 246 | }`); 247 | 248 | resolveAllCssVariables(root); 249 | const result = root.toString(); 250 | 251 | // Variables from universal selector (*) should resolve to other selectors with actual universal selector 252 | expect(result).toContain('input[type="*"]:hover'); 253 | expect(result).toContain('color: red'); 254 | expect(result).toContain('div:nth-child(2*n+1)'); 255 | expect(result).toContain('background: red'); 256 | expect(result).toContain('.test[data-attr="value*test"]'); 257 | expect(result).toContain('border-color: red'); 258 | 259 | // Variables from *.universal-with-class should resolve within the same selector 260 | expect(result).toContain('.universal-with-class-*'); 261 | expect(result).toContain('text-decoration: blue'); 262 | // .normal should NOT get the --class-color from .universal-with-class-* as selectors don't intersect 263 | expect(result).toContain('.normal'); 264 | }); 265 | }); 266 | -------------------------------------------------------------------------------- /src/lib/preview/EmailTreeNode.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 |
  • 47 | {#if isDirectory} 48 | 99 | {#if expanded && directoryItems.length > 0} 100 | 105 | {/if} 106 | {:else} 107 | 134 | {/if} 135 |
  • 136 | 137 | 271 | --------------------------------------------------------------------------------