├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── package.json
├── pnpm-lock.yaml
├── src
├── app.d.ts
├── app.html
├── emails
│ ├── apple-receipt-tailwind.svelte
│ ├── debug.svelte
│ ├── hello.svelte
│ ├── nested
│ │ └── hello-copy.svelte
│ ├── welcome-tailwind.svelte
│ └── welcome.svelte
├── lib
│ ├── components
│ │ ├── Body.svelte
│ │ ├── Button.svelte
│ │ ├── Column.svelte
│ │ ├── Container.svelte
│ │ ├── Custom.svelte
│ │ ├── Head.svelte
│ │ ├── Heading.svelte
│ │ ├── Hr.svelte
│ │ ├── Html.svelte
│ │ ├── Img.svelte
│ │ ├── Link.svelte
│ │ ├── Preview.svelte
│ │ ├── Row.svelte
│ │ ├── Section.svelte
│ │ └── Text.svelte
│ ├── index.ts
│ ├── preview
│ │ ├── PreviewInterface.svelte
│ │ └── index.ts
│ ├── utils
│ │ └── index.ts
│ └── vite
│ │ ├── index.ts
│ │ └── utils
│ │ ├── inline-tailwind.ts
│ │ ├── string-utils.ts
│ │ └── tailwind-utils.ts
└── routes
│ ├── +layout.svelte
│ ├── +page.server.ts
│ └── +page.svelte
├── static
├── airbnb-logo.png
├── airbnb-review-user.jpeg
├── apple-card-icon.png
├── apple-hbo-max-icon.jpeg
├── apple-logo.png
├── apple-wallet.png
├── favicon.ico
├── favicon.png
└── interface.jpg
├── svelte.config.js
├── tsconfig.json
└── vite.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: '@typescript-eslint/parser',
4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
5 | plugins: ['svelte3', '@typescript-eslint'],
6 | ignorePatterns: ['*.cjs'],
7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
8 | settings: {
9 | 'svelte3/typescript': () => require('typescript')
10 | },
11 | parserOptions: {
12 | sourceType: 'module',
13 | ecmaVersion: 2020
14 | },
15 | env: {
16 | browser: true,
17 | es2017: true,
18 | node: true
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | /dist
7 | .env
8 | .env.*
9 | !.env.example
10 | vite.config.js.timestamp-*
11 | vite.config.ts.timestamp-*
12 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100,
6 | "plugins": ["prettier-plugin-svelte"],
7 | "pluginSearchDirs": ["."],
8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
9 | }
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 2.1.1 (2025-17-02)
2 |
3 | ## Patch
4 |
5 | - Move Sveltekit from regular dependency back to dev dependency to fix SK v1 vs v2 type mismatches ([#22](https://github.com/steveninety/svelte-email-tailwind/issues/22))
6 |
7 | # 2.1.0 (2025-16-02)
8 |
9 | ## Minor Issues
10 |
11 | - chore: made function param of `svelteEmailTailwind` optional, since all keys of the object param are optional too ([[#16](https://github.com/steveninety/svelte-email-tailwind/issues/16)]).
12 | -fix: Removed 'pretty' dependency because it's no longer used in the render function since moving to Svelte's native `render`.
13 | - chore: deleted the html-to-text patch - no longer an issue in later version of Vite + documented the `renderAsPlainText` function ([#19](https://github.com/steveninety/svelte-email-tailwind/issues/19))
14 | - fix: renamed `Preview` to `PreviewInterface` in `/preview/PreviewInterface.svelte`, to distinguish the interface component from the email component that is also called 'Preview' ([#20](https://github.com/steveninety/svelte-email-tailwind/issues/20))
15 | - chore: updated the @sveltejs/package devDependency to V2 and adjusted the package.json accordingly. This should also fix some type issues around imports. ([#20](https://github.com/steveninety/svelte-email-tailwind/issues/20))
16 |
17 | # 2.0.1 (2024-11-10)
18 |
19 | ## Patch
20 |
21 | Move svelte-persisted-store from dev dep to regular dep
22 |
23 | # 2.0.0 (2024-11-10)
24 |
25 | ## BREAKING CHANGES
26 |
27 | Svelte 5 compatibility.
28 |
29 | # 1.1.0 (2024-11-10)
30 |
31 | ## Patch
32 |
33 | Update to the latest Svelte 4 version
34 |
35 | # 1.0.2 (2024-03-30)
36 |
37 | ## Patch
38 |
39 | Move Resend from dev dep to normal dep
40 |
41 | # 1.0.1 (2024-03-14)
42 |
43 | ## Patch
44 |
45 | Move tw-to-css from dev dep to normal dep
46 |
47 | # 1.0.0 (2024-03-14)
48 |
49 | ## Features
50 |
51 | - A Vite plugin to transform Tailwind classes on build-time.
52 | - Preview UI component has been added to the package (including corresponding server utility functions).
53 |
54 | ## BREAKING CHANGES
55 |
56 | - The renderTailwind() function is now obsolete and has been removed. Use the Vite plugin instead.
57 | - The renderSvelte() function is replaced by Svelte's native render() function. Use the renderAsPlainText() function to turn a rendered component's html to plain text.
58 | - The `` component is required to use Tailwind classes on custom html.
59 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Carsten Lebek
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Svelte Email Tailwind
2 | Develop emails easily in Svelte using Tailwind.
3 |
4 |
7 |
8 | # Introduction
9 |
10 | SVELTE 5 COMPATIBLE since version 2.0.0!
11 |
12 | `svelte-email-tailwind` enables you to code, preview and test-send email templates with Svelte and Tailwind classes and render them to HTML or plain text.
13 |
14 | - This package adds a Tailwind post-processor to the original [svelte-email](https://github.com/carstenlebek/svelte-email) package.
15 | - Tailwind classes are converted to inline styles on built-time using a Vite plugin.
16 | - In earlier versions, this process took place every time an email was sent (not very efficient).
17 | - This package also provides a Svelte preview component, including utility functions for the server (SvelteKit only).
18 |
19 | # Installation
20 |
21 | Install the package to your existing Svelte + Vite or SvelteKit project:
22 |
23 | ```bash title="npm"
24 | npm install svelte-email-tailwind
25 | ```
26 |
27 | ```bash title="pnpm"
28 | pnpm install svelte-email-tailwind
29 | ```
30 |
31 | # Getting started
32 |
33 | ## 1. Configure Vite
34 |
35 | Import the svelteEmailTailwind Vite plugin, and pass it into the config's `plugins` array.
36 |
37 | `vite.config.ts`
38 |
39 | ```ts
40 | import { sveltekit } from '@sveltejs/kit/vite';
41 | import type { UserConfig } from 'vite';
42 | import svelteEmailTailwind from 'svelte-email-tailwind/vite';
43 |
44 | const config: UserConfig = {
45 | plugins: [
46 | sveltekit(),
47 | svelteEmailTailwind() // processes .svelte files inside the default '/src/lib/emails' folder
48 | ]
49 | };
50 |
51 | export default config;
52 | ```
53 |
54 | Optional configurations:
55 |
56 | - Provide a Tailwind config;
57 | - Provide a custom path to your email folder.
58 |
59 | ```js
60 | import { sveltekit } from '@sveltejs/kit/vite';
61 | import type { UserConfig } from 'vite';
62 | import type { TailwindConfig } from 'tw-to-css';
63 | import svelteEmailTailwind from 'svelte-email-tailwind/vite';
64 |
65 | const emailTwConfig: TailwindConfig = {
66 | theme: {
67 | screens: {
68 | md: { max: '767px' },
69 | sm: { max: '475px' }
70 | },
71 | extend: {
72 | colors: {
73 | brand: 'rgb(255, 62, 0)'
74 | }
75 | }
76 | }
77 | };
78 |
79 | const config: UserConfig = {
80 | plugins: [
81 | sveltekit(),
82 | svelteEmailTailwind({
83 | tailwindConfig: emailTwConfig,
84 | pathToEmailFolder: '/src/lib/components/emails' // defaults to '/src/lib/emails'
85 | })
86 | ]
87 | };
88 |
89 | export default config;
90 | ```
91 |
92 | ## 2. Create an email using Svelte
93 |
94 | `src/lib/emails/Hello.svelte`
95 |
96 | ```svelte
97 |
102 |
103 |
104 |
25 | %sveltekit.body%
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/emails/apple-receipt-tailwind.svelte:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | Receipt
42 |
43 |
44 |
45 |
46 |
47 | Save 3% on all your Apple purchases with Apple Card.
48 | 1{' '}
49 | Apply and use in minutes
50 | 2
51 |
52 |
53 |
54 |
57 |
58 |
59 |
60 |
61 |
62 | APPLE ID
63 |
64 | {props.email}
65 |
66 |
67 |
68 |
69 | DATE
70 |
71 | Jul 20, 2023
72 |
73 |
74 |
75 |
76 | ORDER ID
77 |
78 |
81 | ML4F5L8522
82 |
83 |
84 |
85 |
86 |
87 | DOCUMENT NO.
88 |
89 | 121565300446
90 |
91 |
92 |
93 |
94 |
97 |
98 |
99 |
100 | BILLED TO
101 |
102 | Visa .... 7461 (Apple Pay)
103 |
104 | Zeno Rocha
105 |
106 | 2125 Chestnut St
107 |
108 | San Francisco, CA 94123
109 |
110 | USA
111 |
112 |
113 |
114 |
115 |
116 |
117 |
120 |
121 |
122 |
129 |
130 |
131 |
132 | HBO Max: Stream TV & Movies
133 |
134 | HBO Max Ad-Free (Monthly)
135 |
136 | Renews Aug 20, 2023
137 |
138 |
143 | Write a Review
144 |
145 | |
146 |
147 |
152 | Report a Problem
153 |
154 |
155 |
156 |
157 | $14.99
158 |
159 |
160 |
161 |
162 |
163 | TOTAL
164 |
165 |
166 |
167 | $14.99
170 |
171 |
172 |
173 |
174 |
175 |
176 | Save 3% on all your Apple purchases.
177 |
181 |
188 | Apply and use in minutes
191 |
192 |
193 |
194 |
195 |
196 | 1. 3% savings is earned as Daily Cash and is transferred to your Apple Cash card when
197 | transactions post to your Apple Card account. If you do not have an Apple Cash card, Daily
198 | Cash can be applied by you as a credit on your statement balance. 3% is the total amount of
199 | Daily Cash earned for these purchases. See the Apple Card Customer Agreement for more
200 | details on Daily Cash and qualifying transactions.
201 |
202 |
203 | 2. Subject to credit approval.
204 |
205 |
206 | To access and use all the features of Apple Card, you must add Apple Card to Wallet on an iPhone
207 | or iPad with iOS or iPadOS 13.2 or later. Update to the latest version of iOS or iPadOS by going
208 | to Settings > General > Software Update. Tap Download and Install.
209 |
210 |
211 | Available for qualifying applicants in the United States.
212 |
213 |
214 | Apple Card is issued by Goldman Sachs Bank USA, Salt Lake City Branch.
215 |
216 |
217 | If you reside in the US territories, please call Goldman Sachs at 877-255-5923 with questions
218 | about Apple Card.
219 |
220 |
221 | Privacy: We use a
222 |
223 | {' '}
224 | Subscriber ID{' '}
225 |
226 | to provide reports to developers.
227 |
228 |
229 | Get help with subscriptions and purchases.
230 |
234 | Visit Apple Support.
235 |
236 |
237 |
238 | Learn how to{' '}
239 |
242 | manage your password preferences
243 | {' '}
244 | for iTunes, Apple Books, and App Store purchases.
245 |
246 |
247 |
248 | You have the option to stop receiving email receipts for your subscription renewals. If you have
249 | opted out, you can still view your receipts in your account under Purchase History. To manage
250 | receipts or to opt in again, go to{' '}
251 |
254 | Account Settings.
255 |
256 |
257 |
264 |
265 |
266 | Account Settings
267 | {' '}
268 | •{' '}
269 | Terms of Sale{' '}
270 | •{' '}
271 |
272 | Privacy Policy{' '}
273 |
274 |
275 |
276 | Copyright © 2023 Apple Inc.
{' '}
277 | All rights reserved
278 |
279 |
280 |
281 |
282 |
--------------------------------------------------------------------------------
/src/emails/debug.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
10 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/emails/hello.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Hello, {name}!
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/emails/nested/hello-copy.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Hello, {name}!
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/emails/welcome-tailwind.svelte:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
46 | 2.5
47 |
52 | {#each purchased_products as value}
53 | {#if value.length}
54 | {#each value as field}
55 | {field}
56 | {/each}
57 | {:else}
58 | {value}
59 | {/if}
60 | {/each}
61 | {purchased_products[0]}
62 | {name.firstName.value}
63 | welcome to svelte-email
64 |
65 |
66 |
67 |
68 | A Svelte component library for building responsive emails
69 |
70 |
76 | Happy coding!
77 |
78 |
79 |
80 | Carsten Lebek
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/src/emails/welcome.svelte:
--------------------------------------------------------------------------------
1 |
54 |
55 |
56 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/lib/components/Button.svelte:
--------------------------------------------------------------------------------
1 |
60 |
61 |
68 |
69 | {@html ``}
70 |
71 |
72 |
73 |
74 |
75 | {@html ``}
76 |
77 |
78 |
--------------------------------------------------------------------------------
/src/lib/components/Column.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 | |
26 |
--------------------------------------------------------------------------------
/src/lib/components/Container.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
32 |
33 |
34 |
35 |
36 | |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/lib/components/Custom.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/lib/components/Head.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/Heading.svelte:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/lib/components/Hr.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/lib/components/Html.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 | {@html doctype}
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/lib/components/Img.svelte:
--------------------------------------------------------------------------------
1 |
34 |
35 |
44 |
--------------------------------------------------------------------------------
/src/lib/components/Link.svelte:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/lib/components/Preview.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
28 | {preview}
29 |
30 | {renderWhiteSpace(preview)}
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/lib/components/Row.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/lib/components/Section.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/lib/components/Text.svelte:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | import Body from './components/Body.svelte';
2 | import Button from './components/Button.svelte';
3 | import Column from './components/Column.svelte';
4 | import Container from './components/Container.svelte';
5 | import Head from './components/Head.svelte';
6 | import Heading from './components/Heading.svelte';
7 | import Hr from './components/Hr.svelte';
8 | import Html from './components/Html.svelte';
9 | import Img from './components/Img.svelte';
10 | import Link from './components/Link.svelte';
11 | import Preview from './components/Preview.svelte';
12 | import Section from './components/Section.svelte';
13 | import Text from './components/Text.svelte';
14 | import Row from './components/Row.svelte';
15 | import Custom from './components/Custom.svelte';
16 | import { renderAsPlainText } from './utils';
17 |
18 | export {
19 | Body,
20 | Button,
21 | Column,
22 | Container,
23 | Head,
24 | Heading,
25 | Hr,
26 | Html,
27 | Img,
28 | Link,
29 | Preview,
30 | Section,
31 | Text,
32 | Row,
33 | Custom,
34 | renderAsPlainText
35 | };
36 |
--------------------------------------------------------------------------------
/src/lib/preview/PreviewInterface.svelte:
--------------------------------------------------------------------------------
1 |
83 |
84 |
85 |
86 |
87 |
104 | {#if form1Expanded && !form2Expanded}
105 |
167 | {/if}
168 |
222 |
240 | {#if form2Expanded && !form1Expanded}
241 |
335 | {/if}
336 |
337 |
338 |
344 | {#if !$fileSelected && !loadingFile}
345 |
Select an email
346 | {:else if uiActive !== 'code'}
347 |
348 | {w}px × {h}px
349 |
350 | {/if}
351 | {#if uiActive === 'code'}
352 |
353 | {@html code}
354 |
355 | {:else}
356 |
364 | {/if}
365 |
366 |
367 |
368 |
369 |
370 |
721 |
--------------------------------------------------------------------------------
/src/lib/preview/index.ts:
--------------------------------------------------------------------------------
1 | import type { RequestEvent } from '@sveltejs/kit';
2 | import { Resend } from 'resend';
3 | import { renderAsPlainText } from '../utils';
4 | import fs from 'fs';
5 | import { render } from 'svelte/server';
6 |
7 | /**
8 | * Import all Svelte email components file paths.
9 | * Create a list containing all Svelte email component file names.
10 | * Return this list to the client.
11 | */
12 | export type PreviewData = {
13 | files: string[] | null;
14 | path: string | null;
15 | };
16 |
17 | type Props = {
18 | path?: string;
19 | root?: string;
20 | }
21 |
22 | export const emailList = ({
23 | path = '/src/lib/emails',
24 | root
25 | }: Props = {}): PreviewData => {
26 | if (!root) {
27 | const calledFromPath = calledFrom();
28 |
29 | if (!calledFromPath) {
30 | throw new Error(
31 | 'Could not determine the root path of your project. Please pass in the root param manually (e.g. "/home/my-repos/my-project")'
32 | );
33 | }
34 |
35 | root = calledFromPath.substring(calledFromPath.indexOf('/'), calledFromPath.indexOf('/src'));
36 | }
37 |
38 | const files = createEmailComponentList(path, getFiles(root + path));
39 |
40 | if (!files.length) {
41 | return { files: null, path: null };
42 | }
43 |
44 | return { files, path };
45 | };
46 |
47 | /**
48 | *
49 | * Imports the requested svelte template.
50 | * Renders the template into html.
51 | */
52 | export const createEmail = {
53 | 'create-email': async (event: RequestEvent) => {
54 | const data = await event.request.formData();
55 | const file = data.get('file');
56 | const path = data.get('path');
57 |
58 | const getEmailComponent = async () => {
59 | try {
60 | return (await import(/* @vite-ignore */ `${path}/${file}.svelte`)).default;
61 | } catch (e) {
62 | throw new Error(
63 | `Failed to import the selected email component '${file}'. Make sure to include the
105 |
106 | Hello, {name}!
107 |
108 |
109 |
110 |
111 | ```
112 |
113 | ## 3. Render & send an email
114 |
115 | This example uses [Resend](https://resend.com/docs/send-with-nodejs) to send the email. You can use any other email service provider (Nodemailer, SendGrid, Postmark, AWS SES...).
116 |
117 | `src/routes/emails/hello/+server.ts`
118 |
119 | ```ts
120 | import { render } from 'svelte/server';
121 | // import { renderAsPlainText } from 'svelte-email-tailwind';
122 | import type { ComponentProps } from 'svelte';
123 | import type HelloProps from 'src/lib/emails/Hello.svelte';
124 | import Hello from 'src/lib/emails/Hello.svelte';
125 | import { PRIVATE_RESEND_API_KEY } from '$env/static/private';
126 | import { Resend } from 'resend';
127 |
128 | const componentProps: ComponentProps = {
129 | name: 'Steven'
130 | };
131 |
132 | const { html } = render(Hello, { props: componentProps });
133 | // Alternatively, render your email as plain text:
134 | // const plainText = renderAsPlainText(html);
135 |
136 | const resend = new Resend(PRIVATE_RESEND_API_KEY);
137 |
138 | // Send the email using your provider of choice.
139 | resend.emails.send({
140 | from: 'you@example.com',
141 | to: 'user@gmail.com',
142 | subject: 'Hello',
143 | html: html
144 | // Or send your plain text:
145 | // html: plainText
146 | });
147 | ```
148 |
149 | # Previewing & test-sending emails in development (SvelteKit)
150 |
151 | Using a designated route, you can preview all your dynamically retrieved email components.
152 | This means you'll be able to preview your emails with the exact markup that eventually lands an inbox (unless of course, the email provider manipulates it behind the scenes).
153 |
154 | 
155 |
156 | To get started...
157 |
158 | ## 1. Configure a route
159 |
160 | Import the PreviewInterface component and pass in the server data as a prop. Customize the email address.
161 |
162 | `src/routes/email-previews/+page.svelte`
163 |
164 | ```svelte
165 |
169 |
170 |
171 | ```
172 |
173 | ## 2. Configure the server for this route
174 |
175 | Return the email component file list from SvelteKit's `load` function using the `emailList` function.
176 | In SvelteKit's `form actions`, pass in `createEmail` (loads files from the server), and `sendEmail` (sends test-emails).
177 |
178 | `src/routes/email-previews/+page.server.ts`
179 |
180 | ```ts
181 | import { createEmail, emailList, sendEmail } from 'svelte-email-tailwind/preview';
182 | import { PRIVATE_RESEND_API_KEY } from '$env/static/private';
183 |
184 | export async function load() {
185 | // return the list of email components
186 | return emailList();
187 | }
188 |
189 | export const actions = {
190 | // Pass in the two actions. Provide your Resend API key.
191 | ...createEmail,
192 | ...sendEmail({ resendApiKey: PRIVATE_RESEND_API_KEY })
193 | };
194 | ```
195 |
196 | Optional configurations:
197 |
198 | - Provide a custom path to your email components;
199 | - Provide a custom function to send the email using a different provider.
200 |
201 | ```ts
202 | import {
203 | createEmail,
204 | emailList,
205 | sendEmail,
206 | SendEmailFunction
207 | } from 'svelte-email-tailwind/preview';
208 | import nodemailer from 'nodemailer';
209 |
210 | export async function load() {
211 | // Customize the path to your email components.
212 | return emailList({ path: '/src/lib/components/emails' });
213 | }
214 |
215 | // Make sure your custom 'send email' function is of type 'SendEmailFunction'.
216 | const sendUsingNodemailer: typeof SendEmailFunction = async ({ from, to, subject, html }) => {
217 | const transporter = nodemailer.createTransport({
218 | host: 'smtp.ethereal.email',
219 | port: 587,
220 | secure: false,
221 | auth: {
222 | user: 'my_user',
223 | pass: 'my_password'
224 | }
225 | });
226 |
227 | const sent = await transporter.sendMail({ from, to, subject, html });
228 |
229 | if (sent.error) {
230 | return { success: false, error: sent.error };
231 | } else {
232 | return { success: true };
233 | }
234 | };
235 |
236 | export const actions = {
237 | ...createEmail,
238 | // Pass in your custom 'send email' function.
239 | ...sendEmail({ customSendEmailFunction: sendUsingNodemailer })
240 | };
241 | ```
242 |
243 | ## 3. Start developing your emails via the route you've chosen.
244 |
245 | Example: http://localhost:5173/email-previews
246 |
247 | # Components
248 |
249 | A set of standard components to help you build amazing emails without having to deal with the mess of creating table-based layouts and maintaining archaic markup.
250 |
251 | - HTML
252 | - Head
253 | - Heading
254 | - Button
255 | - Link
256 | - Img
257 | - Hr
258 | - Text
259 | - Container
260 | - Preview
261 | - Body
262 | - Column
263 | - Section
264 | - Row
265 | - Custom
266 |
267 | # HEADS UP! (Limitations & Syntax requirements)
268 |
269 | ## Limitations & Syntax requirements
270 |
271 | - Always include the `` component.
272 | - For now, class attribute/prop interpolation/variable references will not work (this won't work: `class={someTwClassName}`, `class={`${someTwClassName} w-full`}`, this will work: `class="w-full"`).
273 | - When using arbitrary Tailwind classes that use multiple values, separate them using underscores (example: p-[0_30px_12px_5px]).
274 | - In Svelte email components, stick to the designated components if you use Tailwind classes. If you need custom HTML, use the `` component and the "as" property to define the tag. This component defaults to a `
`. Tailwind classes on regular html nodes will not be processed.
275 | - There are ultra-rare cases where the text inside your email component results in syntax errors under the hood. This could happen when you're using code characters such as brackets, or certain strings that break the Vite script. This would require you to change up your text content.
276 |
277 | ## Ignore "node_invalid_placement_ssr" warnings
278 |
279 | `node_invalid_placement_ssr: `` (src/lib/components/Html.svelte:12:0) needs a valid parent element
280 |
281 | This can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.`
282 |
283 | You can ignore these warnings, because Svelte thinks you're building for the web and doesn't know you're building emails - so the warnings are not applicable.
284 |
285 | # Author
286 |
287 | - Steven Polak
288 |
289 | ## Author of the original Svelte Email package
290 |
291 | - Carsten Lebek ([@carstenlebek](https://twitter.com/carstenlebek1))
292 |
293 | ### Authors of the original project [react-email](https://github.com/resendlabs/react-email)
294 |
295 | - Bu Kinoshita ([@bukinoshita](https://twitter.com/bukinoshita))
296 | - Zeno Rocha ([@zenorocha](https://twitter.com/zenorocha))
297 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "svelte-email-tailwind",
3 | "version": "2.1.1",
4 | "description": "Build emails with Svelte 5 and Tailwind",
5 | "author": {
6 | "name": "Steven Polak"
7 | },
8 | "contributors": [
9 | "Carsten Lebek"
10 | ],
11 | "license": "MIT",
12 | "keywords": [
13 | "svelte",
14 | "email",
15 | "tailwind",
16 | "sveltekit",
17 | "resend"
18 | ],
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/steveninety/svelte-email-tailwind.git",
22 | "homepage": "https://github.com/steveninety/svelte-email-tailwind#readme"
23 | },
24 | "scripts": {
25 | "dev": "vite dev",
26 | "package": "svelte-kit sync && svelte-package",
27 | "build": "vite build",
28 | "prepublishOnly": "echo 'Did you mean to publish `./package/`, instead of `./`?' && exit 1",
29 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
30 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
31 | "lint": "prettier --plugin-search-dir . --check . && eslint .",
32 | "format": "prettier --plugin-search-dir . --write ."
33 | },
34 | "devDependencies": {
35 | "@sveltejs/adapter-auto": "^2.1.1",
36 | "@sveltejs/kit": "^1.30.4",
37 | "@sveltejs/package": "^2.3.10",
38 | "@types/html-to-text": "^9.0.4",
39 | "@typescript-eslint/eslint-plugin": "^5.62.0",
40 | "@typescript-eslint/parser": "^5.62.0",
41 | "autoprefixer": "^10.4.16",
42 | "clsx": "^1.2.1",
43 | "csstype": "^3.1.2",
44 | "eslint": "^8.53.0",
45 | "eslint-config-prettier": "^8.10.0",
46 | "eslint-plugin-svelte3": "^4.0.0",
47 | "postcss": "^8.4.31",
48 | "prettier": "^2.8.8",
49 | "prettier-plugin-svelte": "^2.10.1",
50 | "svelte-check": "^3.6.0",
51 | "tslib": "^2.6.2",
52 | "typescript": "^4.9.5",
53 | "vite": "^4.5.0",
54 | "vite-plugin-inspect": "^0.8.3"
55 | },
56 | "type": "module",
57 | "dependencies": {
58 | "@sveltejs/vite-plugin-svelte": "^4.0.0",
59 | "html-to-text": "^9.0.5",
60 | "resend": "2.0.0",
61 | "svelte": "^5",
62 | "svelte-persisted-store": "^0.12.0",
63 | "tw-to-css": "0.0.12"
64 | },
65 | "files": [
66 | "dist"
67 | ],
68 | "exports": {
69 | ".": {
70 | "types": "./dist/index.d.ts",
71 | "svelte": "./dist/index.js",
72 | "default": "./dist/index.js"
73 | },
74 | "./package.json": "./package.json",
75 | "./components/Body.svelte": "./dist/components/Body.svelte",
76 | "./components/Button.svelte": "./dist/components/Button.svelte",
77 | "./components/Column.svelte": "./dist/components/Column.svelte",
78 | "./components/Container.svelte": "./dist/components/Container.svelte",
79 | "./components/Custom.svelte": "./dist/components/Custom.svelte",
80 | "./components/Head.svelte": "./dist/components/Head.svelte",
81 | "./components/Heading.svelte": "./dist/components/Heading.svelte",
82 | "./components/Hr.svelte": "./dist/components/Hr.svelte",
83 | "./components/Html.svelte": "./dist/components/Html.svelte",
84 | "./components/Img.svelte": "./dist/components/Img.svelte",
85 | "./components/Link.svelte": "./dist/components/Link.svelte",
86 | "./components/Preview.svelte": "./dist/components/Preview.svelte",
87 | "./components/Row.svelte": "./dist/components/Row.svelte",
88 | "./components/Section.svelte": "./dist/components/Section.svelte",
89 | "./components/Text.svelte": "./dist/components/Text.svelte",
90 | "./preview": "./dist/preview/index.js",
91 | "./preview/PreviewInterface.svelte": {
92 | "types": "./dist/preview/PreviewInterface.svelte.d.ts",
93 | "svelte": "./dist/preview/PreviewInterface.svelte"
94 | },
95 | "./utils": "./dist/utils/index.js",
96 | "./vite": "./dist/vite/index.js"
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // See https://kit.svelte.dev/docs/types#the-app-namespace
5 | // for information about these interfaces
6 | declare namespace App {
7 | // interface Locals {}
8 | // interface Platform {}
9 | // interface Session {}
10 | // interface Stuff {}
11 | }
12 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
9 |
10 |
11 |
21 |
22 | %sveltekit.head%
23 |
24 |
33 |
34 |
9 |
57 |
58 |
59 |
60 |
66 | {firstName}, welcome to svelte-email
67 |
68 | A Svelte component library for building responsive emails
69 |
70 |
71 |
74 |
75 | Happy coding!
76 |
77 |
78 | Carsten Lebek
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/src/lib/components/Body.svelte:
--------------------------------------------------------------------------------
1 |
19 |
20 |
component and to follow the syntax guidelines at the bottom of the README.`
64 | );
65 | }
66 | };
67 |
68 | const emailComponent = await getEmailComponent();
69 | // render().html is deprecated but works for now
70 | // todo: compose the html from render().head and render().body
71 | const { html } = render(emailComponent);
72 | const text = renderAsPlainText(html);
73 |
74 | return { html, text };
75 | }
76 | };
77 |
78 | export declare const SendEmailFunction: (
79 | { from, to, subject, html }: { from: string; to: string; subject: string; html: string },
80 | resendApiKey?: string
81 | ) => Promise<{ success: boolean; error?: any }>;
82 |
83 | const defaultSendEmailFunction: typeof SendEmailFunction = async (
84 | { from, to, subject, html },
85 | resendApiKey
86 | ) => {
87 | // stringify api key to comment out temp
88 | const resend = new Resend(resendApiKey);
89 | const email = { from, to, subject, html };
90 | const resendReq = await resend.emails.send(email);
91 |
92 | if (resendReq.error) {
93 | return { success: false, error: resendReq.error };
94 | } else {
95 | return { success: true, error: null };
96 | }
97 | };
98 |
99 | /**
100 | * Sends the email using the submitted form data.
101 | */
102 | export const sendEmail = ({
103 | customSendEmailFunction,
104 | resendApiKey
105 | }: {
106 | customSendEmailFunction?: typeof SendEmailFunction;
107 | resendApiKey?: string;
108 | }) => {
109 | return {
110 | 'send-email': async (event: RequestEvent): Promise<{ success: boolean; error: any }> => {
111 | const data = await event.request.formData();
112 |
113 | const email = {
114 | from: 'svelte-email-tailwind ',
115 | to: `${data.get('to')}`,
116 | subject: `${data.get('component')} ${data.get('note') ? '| ' + data.get('note') : ''}`,
117 | html: `${data.get('html')}`
118 | };
119 |
120 | let sent: { success: boolean; error?: any } = { success: false, error: null };
121 |
122 | if (!customSendEmailFunction && resendApiKey) {
123 | sent = await defaultSendEmailFunction(email, resendApiKey);
124 | } else if (customSendEmailFunction) {
125 | sent = await customSendEmailFunction(email);
126 | } else if (!customSendEmailFunction && !resendApiKey) {
127 | const error = {
128 | message:
129 | 'Please pass your Resend API key into the "sendEmail" form action, or provide a custom function.'
130 | };
131 | return { success: false, error };
132 | }
133 |
134 | if (sent && sent.error) {
135 | console.log('Error:', sent.error);
136 | return { success: false, error: sent.error };
137 | } else {
138 | console.log('Email was sent successfully.');
139 | return { success: true, error: null };
140 | }
141 | }
142 | };
143 | };
144 |
145 | function calledFrom() {
146 | return (
147 | new Error().stack // Access the call stack from the Error object
148 | // Split the call stack into lines and extract the third line
149 | ?.split('\n')[3]
150 | );
151 | }
152 |
153 | // Recursive function to get files
154 | function getFiles(dir: string, files: string[] = []) {
155 | // Get an array of all files and directories in the passed directory using fs.readdirSync
156 | const fileList = fs.readdirSync(dir);
157 | // Create the full path of the file/directory by concatenating the passed directory and file/directory name
158 | for (const file of fileList) {
159 | const name = `${dir}/${file}`;
160 | // Check if the current file/directory is a directory using fs.statSync
161 | if (fs.statSync(name).isDirectory()) {
162 | // If it is a directory, recursively call the getFiles function with the directory path and the files array
163 | getFiles(name, files);
164 | } else {
165 | // If it is a file, push the full path to the files array
166 | files.push(name);
167 | }
168 | }
169 | return files;
170 | }
171 |
172 | /**
173 | *
174 | * Creates an array of names from the record of svelte email component file paths
175 | */
176 | function createEmailComponentList(root: string, paths: string[]) {
177 | const emailComponentList: string[] = [];
178 |
179 | paths.forEach((path) => {
180 | if (path.includes(`.svelte`)) {
181 | const fileName = path.substring(
182 | path.indexOf(root) + root.length + 1,
183 | path.indexOf('.svelte')
184 | );
185 | emailComponentList.push(fileName);
186 | }
187 | });
188 |
189 | return emailComponentList;
190 | }
191 |
--------------------------------------------------------------------------------
/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { convert } from 'html-to-text';
2 |
3 | export const renderAsPlainText = (markup: string) => {
4 | return convert(markup, {
5 | selectors: [
6 | { selector: 'img', format: 'skip' },
7 | { selector: '#__svelte-email-preview', format: 'skip' }
8 | ]
9 | });
10 | };
11 |
12 | export const copyTextToClipboard = async (text: string) => {
13 | try {
14 | await navigator.clipboard.writeText(text);
15 | } catch {
16 | throw new Error('Not able to copy');
17 | }
18 | };
19 |
20 | export const pxToPt = (px: string): number | null =>
21 | isNaN(Number(px)) ? null : (parseInt(px, 10) * 3) / 4;
22 |
23 | export interface Margin {
24 | m?: string;
25 | mx?: string;
26 | my?: string;
27 | mt?: string;
28 | mr?: string;
29 | mb?: string;
30 | ml?: string;
31 | }
32 |
33 | export const withMargin = (props: Margin) =>
34 | [
35 | withSpace(props.m, ['margin']),
36 | withSpace(props.mx, ['marginLeft', 'marginRight']),
37 | withSpace(props.my, ['marginTop', 'marginBottom']),
38 | withSpace(props.mt, ['marginTop']),
39 | withSpace(props.mr, ['marginRight']),
40 | withSpace(props.mb, ['marginBottom']),
41 | withSpace(props.ml, ['marginLeft'])
42 | ].filter((s) => Object.keys(s).length)[0];
43 |
44 | const withSpace = (value: string | undefined, properties: string[]) => {
45 | return properties.reduce((styles, property) => {
46 | if (value) {
47 | return { ...styles, [property]: `${value}px` };
48 | }
49 | return styles;
50 | }, {});
51 | };
52 |
53 | // https://stackoverflow.com/a/61410824
54 |
55 | export const styleToString = (style: Record) => {
56 | return Object.keys(style).reduce(
57 | (acc, key) =>
58 | acc +
59 | key
60 | .split(/(?=[A-Z])/)
61 | .join('-')
62 | .toLowerCase() +
63 | ':' +
64 | style[key] +
65 | '; ',
66 | ''
67 | );
68 | };
69 |
70 | export const unreachable = (
71 | condition: never,
72 | message = `Entered unreachable code. Received '${condition}'.`
73 | ): never => {
74 | throw new TypeError(message);
75 | };
76 |
--------------------------------------------------------------------------------
/src/lib/vite/index.ts:
--------------------------------------------------------------------------------
1 | import type { TailwindConfig } from 'tw-to-css';
2 | import { inlineTailwind } from './utils/inline-tailwind.js';
3 |
4 | interface Options {
5 | tailwindConfig?: TailwindConfig;
6 | pathToEmailFolder?: string;
7 | }
8 |
9 | export default function svelteEmailTailwind(options: Options = {}) {
10 | return {
11 | name: 'vite:inline-tw',
12 | async transform(src: string, id: string) {
13 | if (id.includes(options.pathToEmailFolder ?? '/src/lib/emails') && id.includes('.svelte')) {
14 | return { code: inlineTailwind(src, id, options.tailwindConfig) };
15 | }
16 | }
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/src/lib/vite/utils/inline-tailwind.ts:
--------------------------------------------------------------------------------
1 | import { tailwindToCSS, type TailwindConfig } from 'tw-to-css';
2 | import { matchSingleKeyChar, matchMultiKeyBracket, substituteText } from './string-utils.js';
3 | import { classesToStyles, cleanCss, getMediaQueryCss } from './tailwind-utils.js';
4 |
5 | export function inlineTailwind(
6 | rawSvelteCode: string,
7 | filepath: string,
8 | tailwindConfig?: TailwindConfig
9 | ) {
10 | let code = rawSvelteCode;
11 |
12 | // If Tailwind was used, proceed to process the Tailwind classes
13 | const { twi } = tailwindToCSS({ config: tailwindConfig });
14 |
15 | // grab all tw classes from the code
16 | const twCss = twi(code, {
17 | merge: false,
18 | ignoreMediaQueries: false
19 | });
20 |
21 | // further process the tailwind css
22 | const twClean = cleanCss(twCss);
23 |
24 | // replace props and head
25 | const { code: codeNewProps, classesNotFound } = substituteProps(code, twClean);
26 |
27 | const codeNewHead = substituteHead(codeNewProps, twClean);
28 |
29 | if (classesNotFound?.length) {
30 | console.warn(
31 | 'WARNING (svelte-email-tailwind): Some classes were not identified as valid Tailwind classes:',
32 | classesNotFound,
33 | `Source: ${filepath}`
34 | );
35 | }
36 |
37 | return codeNewHead;
38 | }
39 |
40 | function substituteProps(
41 | code: string,
42 | twClean: string
43 | ): { code: string; classesNotFound?: string[] } {
44 | // Identify a pattern that matches the props on every node.
45 |
46 | /**
47 | * Svelte 4 pattern: `$$result, { props }, {}, { default: () => ... }`
48 | * Svelte 5 pattern: `$$payload, { props, children: ($$payload) => ... }`,
49 | * So now the props are inbetween `$$payload, {` and `children:`
50 | * (With the exception of he deepest node in a branch, which has no children.)
51 | */
52 |
53 | // `$$payload, {`
54 | const regexStart = /\$\$payload,\s*{/g;
55 |
56 | let matchStart;
57 | let count = 0;
58 | let classesNotFound: string[] = [];
59 |
60 | // Loop all nodes: keep going as long as we find the `$$payload, {` pattern
61 | while ((matchStart = regexStart.exec(code)) !== null) {
62 | count++;
63 |
64 | const startIndex = regexStart.lastIndex - 1;
65 | const codeSliced = code.substring(startIndex);
66 | const upToChildrenIndex = codeSliced.indexOf('children: (');
67 | const matchingBracketIndex = matchMultiKeyBracket(codeSliced);
68 |
69 | // Some nodes have no children, so the matched 'children: (' will be from another node, with the result being that the prop string will be too long.
70 | // In that case we need another way to find the end of the props
71 | // We do that by matching the opening bracket from regexStart (`$$payload, {`)
72 | // As a side note, we can't take the matching bracket for nodes WITH children,
73 | // because the substring up to the closing bracket includes an ENTIRE branch of child nodes AND their props, defeating the point of trying to isolate props per child.
74 | // What if it's the last node in the last branch?
75 | // There won't be any later siblings with children, so upToChildrenIndex will be -1
76 | const hasNoChildren = matchingBracketIndex < upToChildrenIndex || upToChildrenIndex === -1;
77 | const endIndex = hasNoChildren ? matchingBracketIndex : upToChildrenIndex;
78 |
79 | if (endIndex === -1) {
80 | console.log(
81 | `Something went wrong while selecting prop #${count} (no closing bracket was found).`
82 | );
83 | return { code };
84 | }
85 |
86 | const propsStringRaw = codeSliced.substring(0, endIndex);
87 | const propsStringClean = propsStringRaw.replace(/\s{2,}/g, ' ').trim(); // remove all excess whitespace
88 |
89 | // skip empty props and props without a class key
90 | if (propsStringClean !== '{}' && propsStringClean.includes('class:')) {
91 | const { notFound, propsObj } = convertKvs(propsStringClean, twClean);
92 |
93 | classesNotFound = [...classesNotFound, ...notFound];
94 |
95 | // console.log(count)
96 | // console.log('INPUT:', propsStringClean)
97 | // console.log('OUTPUT:', propsObj);
98 | // console.log(" ")
99 |
100 | if (propsObj.replace(/\s+/g, '') === '{}') {
101 | // don't transform the code if propsObj is empty, to avoid adding in ` ,` which results in invalid js syntax
102 | } else {
103 | // replace old props obj for the new one
104 | code = substituteText(
105 | code,
106 | startIndex,
107 | propsStringRaw,
108 | // If no children, include the closing bracket ` }` to mark end of node
109 | // else, exlude it and append ` ,` (end of child already includes the closing bracket)
110 | // One exception is when 'class' is the only prop and is empty (or ends up empty after taking out the tw-classes)...
111 | // Because then we end up with invalid syntax
112 | // like `Head($$payload, { , children:`, should be `Head($$payload, { children:`
113 | // Solution is to skip transformation if propsObj is empty
114 | hasNoChildren ? propsObj : propsObj.slice(0, -2) + ', '
115 | );
116 | }
117 | }
118 | }
119 | return { code, classesNotFound };
120 | }
121 |
122 | function convertKvs(input: string, twClean: string) {
123 | let objString = '';
124 | let classString = '';
125 | let styleString = '';
126 | let notFound: string[] = [];
127 |
128 | findKvs(input);
129 |
130 | if (classString.length > 0) {
131 | const { tw, classesNotFound } = classesToStyles(classString.replaceAll('"', ''), twClean);
132 | notFound = classesNotFound;
133 |
134 | if (tw.class) {
135 | classString = `"${classString.replaceAll('"', '')} ${tw.class}"`;
136 | objString = objString.length
137 | ? `${objString}, class: ${classString}`
138 | : `class: ${classString}`;
139 | }
140 |
141 | if (tw.style && styleString.length) {
142 | styleString = `${styleString.replaceAll('"', '')};${tw.style}`;
143 | objString = `${objString}, styleString: "${styleString}"`;
144 | } else if (tw.style && !styleString.length) {
145 | styleString = tw.style;
146 | objString = objString.length
147 | ? `${objString}, styleString: "${styleString}"`
148 | : `styleString: "${styleString}"`;
149 | }
150 | }
151 |
152 | return {
153 | notFound,
154 | propsObj: `{ ${objString} }`
155 | };
156 |
157 | function findKvs(input: string) {
158 | // base case is empty string,
159 | // but an ugly safety measure is to set it at 2
160 | if (input.length <= 2) {
161 | return;
162 | }
163 | // a = kv without '{ ' or ', '
164 | const a = input.replace(/\s{2,}/g, ' ').trim();
165 | // b = starting index of `key: `
166 | const b =
167 | a.search(/(\b\w+\b)(: )/g) >= 0
168 | ? a.search(/(\b\w+\b)(: )/g)
169 | : // if no whole word match...
170 | // ...then account for keys that got wrapped in double quotes
171 | // (because of dashes, such as data-attributes)
172 | a.search(/"([^"\\]+(?:\\.[^"\\]*)*)"(: )/g);
173 | // c = string starting at key
174 | const c = a.substring(b);
175 | // d = index of k/v separator `:`
176 | const d = c.search(/(: )/g);
177 | // e = value
178 | const e = c.substring(d + 2);
179 | // f = starting index of value
180 | const f = e.at(0);
181 |
182 | const kv = {
183 | key: c.substring(0, d),
184 | value: c
185 | .substring(d + 2, d + 2 + matchSingleKeyChar(f, e) + 1)
186 | // normalize the used quotation marks
187 | .replaceAll(`'`, `"`)
188 | };
189 |
190 | if (kv.key === 'class') {
191 | classString = kv.value;
192 | } else if (kv.key === 'styleString') {
193 | styleString = kv.value;
194 | } else {
195 | objString = objString + `${objString.length > 0 ? ', ' : ''}` + `${kv.key}: ${kv.value}`;
196 | }
197 |
198 | // remove the found kv from the beginning of the string and traverse
199 | // The "+ 2" comes from ": " and ", "
200 | input = a.substring(kv.key.length + 2 + kv.value.length + 2);
201 |
202 | findKvs(input);
203 | }
204 | }
205 |
206 | function substituteHead(code: string, twClean: string) {
207 | // 3. Handle responsive head styles
208 |
209 | const headStyle = ``;
210 |
211 | // const hasResponsiveStyles = /@media[^{]+\{(?[\s\S]+?)\}\s*\}/gm.test(headStyle)
212 | const startStringPre = 'Head($$payload, {';
213 | const iS = code.indexOf(startStringPre);
214 |
215 | if (iS === -1) {
216 | throw new Error('Missing component!');
217 | }
218 |
219 | const stringAfterStart = code.substring(iS);
220 | const stringToMatchBeforeHeadContent = '$$payload.out += `';
221 | const indexStartHeadContent =
222 | stringAfterStart.indexOf(stringToMatchBeforeHeadContent) +
223 | stringToMatchBeforeHeadContent.length;
224 | // head-body-tail terminology:
225 | // head = up to Head content
226 | // body = Head content
227 | // tail = after Head content
228 | const head = iS + indexStartHeadContent;
229 | const tail = code.indexOf('`', head);
230 | const body = code.substring(head, tail) + headStyle;
231 |
232 | const transformedCode = `${code.substring(0, head) + body + '`' + code.substring(tail + 1)}`;
233 |
234 | return transformedCode;
235 | }
236 |
--------------------------------------------------------------------------------
/src/lib/vite/utils/string-utils.ts:
--------------------------------------------------------------------------------
1 | export function matchMultiKeyBracket(input: string): number {
2 | const splitInit = input.split(/([{}])/);
3 |
4 | let totals = 0
5 |
6 | const brackets = { open: 0, close: 0 }
7 | type Split = {
8 | text: string
9 | length: number
10 | index: number
11 | matched?: number
12 | }
13 | const split: Split[] = []
14 |
15 | splitInit.forEach((item, i) => {
16 | split[i] = {
17 | text: item,
18 | length: item.length,
19 | index: 0,
20 | }
21 | totals = totals + item.length
22 | split[i].index = totals
23 | if (item === "{") {
24 | brackets.open = brackets.open + 1
25 | } else if (item === "}") {
26 | brackets.close = brackets.close + 1
27 | }
28 | })
29 |
30 | // This is the bracket we care about!
31 | const firstOpen: number = split.findIndex(item => item.text === "{")
32 | // This is the bracket we're currently trying to match (-1 if currently none)
33 | let currentOpen = -1
34 | const unmatched: number[] = []
35 | let prevUnmatched: number
36 |
37 | const foundMatch = split.some((item, i) => {
38 | if (item.text === "{") {
39 | if (currentOpen >= 0) {
40 | // after finding a match ("}"), currentOpen = -1, meaning we restart the search
41 | // otherwise, we've run into consecutive "{"
42 | // console.log(`[${i}] It's an open again... Prev open (${currentOpen}) is unmatched. New open is ${i}.`)
43 | // so we set the currentOpen to not-matched
44 | // split2[currentOpen].matched = false
45 | // and push it into an array of non-matched open-brackets
46 | unmatched.push(currentOpen)
47 | // and the most recent not-matched is the last item in that array
48 | prevUnmatched = unmatched[unmatched.length - 1]
49 | }
50 | // set current item as the new open bracket we're trying to match
51 | currentOpen = i
52 | }
53 |
54 | if (item.text === "}") {
55 | if (currentOpen === -1) {
56 | // console.log(`[${i}] It's a close again... Match ${i} to Prev unmatched (${prevUnmatched}).`)
57 | // if we find 2 consecutive "}"...
58 | // this one can be matched to the previously non-matched "{"
59 | split[prevUnmatched].matched = i
60 | split[i].matched = prevUnmatched
61 | // and it can be taken off the unmatched array...
62 | unmatched.pop()
63 | // ...so that the most recent not-matched is updated to the new last item.
64 | prevUnmatched = unmatched[unmatched.length - 1]
65 | } else {
66 | // else if it's the first "}" we encounter since matching a pair...
67 | // ...it's matched to the current "{" that we're trying to match
68 | split[currentOpen].matched = i
69 | split[i].matched = currentOpen
70 | }
71 | // console.log(`[${i}] Close found at i=${i}. Match: ${currentOpen}`)
72 |
73 | currentOpen = -1
74 | }
75 |
76 | // and finally, we've found the matching closing bracket of the first opening bracket!
77 | return typeof split[firstOpen].matched === 'number'
78 | })
79 |
80 | if (foundMatch) {
81 | // find first opening bracket's match, and return the index thereof.
82 | return split[split[firstOpen].matched].index
83 | } else {
84 | return -1
85 | }
86 | }
87 |
88 | export function matchSingleKeyChar(char: string | undefined, input: string) {
89 | if (!char) return 0
90 | // values that start with a letter or number,
91 | // then end at ", " or " }"
92 | if (
93 | (/^[a-zA-Z]+$/).test(char)
94 | // @ts-ignore
95 | || !isNaN(char)
96 | ) {
97 | // KV ends either with a comma if more KVs, or just the object's closing bracket.
98 | return input.search(",") > 0 ? input.search(",") - 1 : input.search(" }") - 1
99 | }
100 | // (???) else it can only be { [ ` ' " (???)
101 | const charMatch: { char: string, regexp: RegExp } = {
102 | char: '',
103 | regexp: /''/g
104 | }
105 |
106 | switch (char) {
107 | case "{":
108 | charMatch.char = "}"
109 | charMatch.regexp = /\}/g
110 | break;
111 | case "[":
112 | charMatch.char = "]"
113 | charMatch.regexp = /\]/g
114 | break;
115 | case "'":
116 | charMatch.char = "'"
117 | charMatch.regexp = new RegExp("\\'", 'g')
118 | break;
119 | case "`":
120 | charMatch.char = "`"
121 | charMatch.regexp = new RegExp("\\`", 'g')
122 | break
123 | case `"`:
124 | charMatch.char = `"`
125 | charMatch.regexp = new RegExp(`\\"`, 'g')
126 | break
127 | }
128 |
129 | const firstClose = input.indexOf(charMatch.char)
130 | const openCount = input.substring(0, firstClose + 1).match(charMatch.regexp)?.length
131 | if (!openCount) {
132 | // Would be odd, but if no openining char... match value up to next key or end of obj
133 | return input.search(",") > 0 ? input.search(",") - 1 : input.search(" }") - 1
134 | }
135 | const closingBracketMatches = input.matchAll(charMatch.regexp)
136 |
137 | let match: number = -1
138 |
139 | const closingBrackets = Array.from(closingBracketMatches, (m) => {
140 | return {
141 | start: m.index,
142 | //@ts-ignore
143 | end: m.index + m[0].length,
144 | length: m[0].length
145 | }
146 | })
147 |
148 | if (openCount >= 2) {
149 | // for every additional '{' inbetween, find the next '}'
150 | closingBrackets.forEach((bracket, i) => {
151 | // find matching closing bracket
152 | if (i === openCount - 1) {
153 | match = bracket.end
154 | // return bracket.end
155 | }
156 | })
157 | } else if (openCount === 1) {
158 | if (char === '`' || char === '"' || char === "'") {
159 |
160 | // find second occurrence of quotation mark
161 | const afterMatch = input.substring(
162 | input.indexOf(
163 | charMatch.char,
164 | input.indexOf(charMatch.char) + 1
165 | ) + 1
166 | )
167 |
168 | if (afterMatch.substring(0, 2) === ', ' || afterMatch.substring(0, 2) === ' }') {
169 | // find 2nd occurrence of quotation mark
170 | match = input.indexOf(charMatch.char, input.indexOf(charMatch.char) + 1)
171 | } else {
172 | // end at next occurrence of ', ' or ' }'
173 | // because in svelte, href="mailto:{email}" -> href: "mailto:" + {email}
174 | // and mailto:" will be falsely identified as a key,
175 | // which you can tell by the closing quote not being followed by a comma (next key) or closing bracket (end of obj)
176 | const shift =
177 | afterMatch.indexOf(', ') >= 0
178 | ? afterMatch.indexOf(', ')
179 | : afterMatch.indexOf(' }')
180 |
181 | match = input.indexOf(charMatch.char, input.indexOf(charMatch.char) + 1) + (shift + 1)
182 | }
183 | } else if (char === '[' || char === '{') {
184 | // find 1st occurrence of closing bracket
185 | match = input.indexOf(charMatch.char)
186 | }
187 | }
188 |
189 | return match
190 | }
191 |
192 |
193 | export const substituteText = (text: string, start: number, oldPart: string, newPart: string): string => {
194 | return text.substring(0, start)
195 | + newPart
196 | + text.substring(start + oldPart.length)
197 | }
198 |
199 |
--------------------------------------------------------------------------------
/src/lib/vite/utils/tailwind-utils.ts:
--------------------------------------------------------------------------------
1 | export function classesToStyles(classString: string, twClean: string) {
2 | // 3. transform tw classes to styles
3 | const cssMap = makeCssMap(twClean)
4 | const cleanRegex = /[:#\!\-[\]\/\.%]+/g
5 | // Replace all non-alphanumeric characters with underscores
6 | const cleanTailwindClasses = classString.replace(cleanRegex, '_').replaceAll('"', '')
7 |
8 | type Conversion = {
9 | original: string
10 | cleaned: string
11 | }
12 |
13 | const conversion = classString.split(' ').map((className: string): Conversion => {
14 | return {
15 | original: className,
16 | cleaned: className.replace(cleanRegex, '_').replaceAll('"', '')
17 | }
18 | })
19 |
20 | // Convert tailwind classes to css styles
21 | const classesNotFound: string[] = []
22 |
23 | // Keep only the responsive classes (styled later in the doc's )
24 | const breakpointClasses = classString
25 | .split(' ')
26 | // filter '.sm:' '.lg:' etc.
27 | .filter((className: string) => className.search(/^.{2}:/) !== -1)
28 |
29 | const tailwindStyles = cleanTailwindClasses
30 | .split(' ')
31 | .map((className: string) => {
32 | // if class was identified as tw class
33 | if (cssMap[`.${className}`]) {
34 | return cssMap[`.${className}`]
35 | // else if non-found class was not a breakpoint class, it was truly not found
36 | } else {
37 | if (
38 | !breakpointClasses.find(item => {
39 | return item.replace(cleanRegex, '_').replaceAll('"', '') === className
40 | })
41 | ) {
42 | // store to later warn developer about it
43 | const match = conversion.find(obj => obj.cleaned === className)
44 | if (match) classesNotFound.push(match.original)
45 | }
46 | return
47 | }
48 | })
49 | .filter(className => className !== undefined)
50 | .join(';')
51 |
52 | // Merge the pre-existing styles with the tailwind styles
53 | if (breakpointClasses.length > 0) {
54 | let responsiveClasses = ''
55 |
56 | for (const string of breakpointClasses) {
57 | // ...and add back the newly formatted responsive classes
58 | responsiveClasses = responsiveClasses.length
59 | ? responsiveClasses + ' ' + string.replace(cleanRegex, '_')
60 | : string.replace(cleanRegex, '_')
61 | }
62 |
63 | return {
64 | classesNotFound,
65 | tw: {
66 | class: responsiveClasses,
67 | style: tailwindStyles
68 | }
69 | }
70 | } else {
71 | return {
72 | classesNotFound,
73 | tw: {
74 | style: tailwindStyles
75 | }
76 | }
77 | }
78 | }
79 |
80 |
81 | export const cleanCss = (css: string) => {
82 | let newCss = css
83 | .replace(/\\/g, '')
84 | // find all css selectors and look ahead for opening and closing curly braces
85 | .replace(/[.\!\#\w\d\\:\-\[\]\/\.%\(\))]+(?=\s*?{[^{]*?\})\s*?{/g, (m) => {
86 | return m.replace(/(?<=.)[:#\!\-[\\\]\/\.%]+/g, '_');
87 | })
88 | .replace(/font-family(?[^;\r\n]+)/g, (m, value) => {
89 | return `font-family${value.replace(/['"]+/g, '')}`;
90 | });
91 | return newCss;
92 | }
93 |
94 | export const makeCssMap = (css: string) => {
95 | const cssNoMedia = css.replace(/@media[^{]+\{(?[\s\S]+?)\}\s*\}/gm, '');
96 | const cssMap = cssNoMedia.split('}').reduce((acc, cur) => {
97 | const [key, value] = cur.split('{');
98 | if (key && value) {
99 | acc[key] = value;
100 | }
101 | return acc;
102 | }, {} as Record);
103 |
104 | return cssMap;
105 | }
106 |
107 | export const getMediaQueryCss = (css: string) => {
108 | const mediaQueryRegex = /@media[^{]+\{(?[\s\S]+?)\}\s*\}/gm;
109 | return (
110 | css
111 | .replace(mediaQueryRegex, (m) => {
112 | return m.replace(/([^{]+\{)([\s\S]+?)(\}\s*\})/gm, (_, start, content, end) => {
113 | const newContent = (content as string).replace(
114 | /(?:[\s\r\n]*)?(?[\w-]+)\s*:\s*(?[^};\r\n]+)/gm,
115 | (_, prop, value) => {
116 | return `${prop}: ${value} !important;`;
117 | }
118 | );
119 | return `${start}${newContent}${end}`;
120 | });
121 | })
122 | // only return media queries
123 | .match(/@media\s*([^{]+)\{([^{}]*\{[^{}]*\})*[^{}]*\}/g)
124 | ?.join('') ?? ''
125 | );
126 | }
127 |
128 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/routes/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { createEmail, emailList, sendEmail, SendEmailFunction } from '$lib/preview';
2 | import { Resend } from 'resend';
3 |
4 | export async function load() {
5 | return emailList({ path: '/src/emails' })
6 | }
7 |
8 | const send: typeof SendEmailFunction = async ({ from, to, subject, html }) => {
9 | const resend = new Resend('PRIVATE_RESEND_API_KEY');
10 | const sent = await resend.emails.send({ from, to, subject, html });
11 |
12 | if (sent.error) {
13 | return { success: false, error: sent.error }
14 | } else {
15 | return { success: true }
16 | }
17 | }
18 |
19 | export const actions = {
20 | ...createEmail,
21 | ...sendEmail({ resendApiKey: 'PRIVATE_RESEND_API_KEY' })
22 | }
23 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/static/airbnb-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/airbnb-logo.png
--------------------------------------------------------------------------------
/static/airbnb-review-user.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/airbnb-review-user.jpeg
--------------------------------------------------------------------------------
/static/apple-card-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/apple-card-icon.png
--------------------------------------------------------------------------------
/static/apple-hbo-max-icon.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/apple-hbo-max-icon.jpeg
--------------------------------------------------------------------------------
/static/apple-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/apple-logo.png
--------------------------------------------------------------------------------
/static/apple-wallet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/apple-wallet.png
--------------------------------------------------------------------------------
/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/favicon.ico
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/favicon.png
--------------------------------------------------------------------------------
/static/interface.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steveninety/svelte-email-tailwind/6a6087850913e8a26f98281dae9ad719e4547644/static/interface.jpg
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-auto';
2 | import { vitePreprocess } from '@sveltejs/kit/vite';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
7 | // for more information about preprocessors
8 | preprocess: vitePreprocess(),
9 | extensions: ['.svelte', '.md'],
10 | kit: {
11 | adapter: adapter(),
12 | // alias: {
13 | // $lib: "src/lib"
14 | // }
15 | },
16 | // exclude: '*.js'
17 | };
18 |
19 | export default config;
20 |
--------------------------------------------------------------------------------
/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 | }
13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
14 | //
15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
16 | // from the referenced tsconfig.json - TypeScript does not merge them in
17 | }
18 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import Inspect from 'vite-plugin-inspect'
3 | import type { UserConfig } from 'vite';
4 | import type { TailwindConfig } from 'tw-to-css';
5 | import svelteEmailTailwind from './src/lib/vite';
6 |
7 | const emailTwConfig: TailwindConfig = {
8 | theme: {
9 | screens: {
10 | md: { max: '767px' },
11 | sm: { max: '475px' }
12 | },
13 | extend: {
14 | colors: {
15 | brand: 'rgb(255, 62, 0)'
16 | }
17 | }
18 | }
19 | }
20 |
21 | const config: UserConfig = {
22 | plugins: [
23 | sveltekit(),
24 | Inspect(),
25 | svelteEmailTailwind({
26 | tailwindConfig: emailTwConfig,
27 | pathToEmailFolder: '/src/emails'
28 | })
29 | ],
30 | // server: { hmr: false }
31 | };
32 |
33 | export default config;
34 |
--------------------------------------------------------------------------------