better-svelte-email 1.0.0-beta.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,10 +22,13 @@
22
22
  </p>
23
23
  </p>
24
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
+
25
29
  ## Features
26
30
 
27
- - **Stable & Future-Proof** - Uses Svelte's public preprocessor API
28
- - **Tailwind CSS Support** - Transforms Tailwind classes to inline styles for email clients
31
+ - **Tailwind v4 Support** - Transforms Tailwind classes to inline styles for email clients
29
32
  - **Built-in Email Preview** - Visual email preview and test sending
30
33
  - **TypeScript First** - Fully typed with comprehensive type definitions
31
34
  - **Well Tested** - Extensive test coverage with unit and integration tests
@@ -39,191 +42,7 @@ Existing Svelte email solutions have significant limitations:
39
42
  - **svelte-email** hasn't been updated in over 2 years
40
43
  - **svelte-email-tailwind** suffers from stability issues and maintaining it is not sustainable anymore
41
44
 
42
- Better Svelte Email is a complete rewrite built on Svelte's official preprocessor API, 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.
43
-
44
- ## Quick Start
45
-
46
- ### 1. Install the package
47
-
48
- ```bash
49
- npm i -D better-svelte-email
50
- # or
51
- bun add -D better-svelte-email
52
- # or
53
- pnpm add -D better-svelte-email
54
- ```
55
-
56
- ### 2. Configure the Preprocessor
57
-
58
- Add the preprocessor to your `svelte.config.js`:
59
-
60
- ```javascript
61
- import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
62
- import { betterSvelteEmailPreprocessor } from 'better-svelte-email';
63
-
64
- /** @type {import('@sveltejs/kit').Config} */
65
- const config = {
66
- preprocess: [vitePreprocess(), betterSvelteEmailPreprocessor()],
67
- kit: {
68
- adapter: adapter()
69
- }
70
- };
71
-
72
- export default config;
73
- ```
74
-
75
- ### 3. Create Email Components
76
-
77
- Create your email templates in `src/lib/emails/`:
78
-
79
- ```svelte
80
- <!-- src/lib/emails/welcome.svelte -->
81
- <script>
82
- import { Html, Head, Body, Preview, Container, Text, Button } from 'better-svelte-email';
83
-
84
- let { name = 'User' } = $props();
85
- </script>
86
-
87
- <Html>
88
- <Head />
89
- <Body class="bg-gray-100">
90
- <Preview preview="Welcome Email" />
91
- <Container class="mx-auto p-8">
92
- <Text class="mb-4 text-2xl font-bold">
93
- Welcome, {name}!
94
- </Text>
95
-
96
- <Button
97
- href="https://example.com"
98
- class="rounded bg-orange-600 px-6 py-3 text-white sm:text-sm"
99
- >
100
- Get Started
101
- </Button>
102
- </Container>
103
- </Body>
104
- </Html>
105
- ```
106
-
107
- ### 4. Render and Send
108
-
109
- ```typescript
110
- // src/routes/api/send-email/+server.ts
111
- import { render } from 'svelte/server';
112
- import WelcomeEmail from '$lib/emails/welcome.svelte';
113
-
114
- export async function POST({ request }) {
115
- const { name, email } = await request.json();
116
-
117
- // Render email (preprocessor already ran at build time!)
118
- const result = render(WelcomeEmail, { props: { name } });
119
-
120
- // Send email using your preferred service (Resend, SendGrid, etc.)
121
- // await resend.emails.send({
122
- // from: 'noreply@example.com',
123
- // to: email,
124
- // subject: 'Welcome!',
125
- // html: result.body
126
- // });
127
-
128
- return new Response('Sent');
129
- }
130
- ```
131
-
132
- ## Email Preview Component
133
-
134
- Better Svelte Email includes a built-in preview component for visually developing and testing your email templates during development.
135
-
136
- ### Setup
137
-
138
- Create a preview route in your SvelteKit app:
139
-
140
- ```svelte
141
- <!-- src/routes/preview/+page.svelte -->
142
- <script lang="ts">
143
- import { EmailPreview } from 'better-svelte-email/preview';
144
-
145
- let { data } = $props();
146
- </script>
147
-
148
- <EmailPreview emailList={data.emails} />
149
- ```
150
-
151
- ```typescript
152
- // src/routes/preview/+page.server.ts
153
- import { emailList, createEmail, sendEmail } from 'better-svelte-email/preview';
154
- import { env } from '$env/dynamic/private';
155
-
156
- export function load() {
157
- const emails = emailList({
158
- path: '/src/lib/emails' // optional, defaults to '/src/lib/emails'
159
- });
160
-
161
- return { emails };
162
- }
163
-
164
- export const actions = {
165
- ...createEmail,
166
- ...sendEmail({ resendApiKey: env.RESEND_API_KEY })
167
- };
168
- ```
169
-
170
- ### Features
171
-
172
- - **HTML Source View** - Inspect the generated HTML with syntax highlighting
173
- - **Copy to Clipboard** - Quickly copy the rendered HTML
174
- - **Test Email Sending** - Send test emails directly from the preview UI using Resend
175
- - **Template List** - Browse all your email templates in one place
176
-
177
- ### Environment Variables
178
-
179
- To enable test email sending, add your Resend API key to your `.env` file:
180
-
181
- ```env
182
- RESEND_API_KEY=re_your_api_key_here
183
- ```
184
-
185
- Get your API key from [Resend](https://resend.com/).
186
-
187
- ### Custom Email Provider
188
-
189
- If you prefer to use a different email provider, you can pass a custom send function:
190
-
191
- ```typescript
192
- export const actions = {
193
- ...createEmail,
194
- ...sendEmail({
195
- customSendEmailFunction: async ({ from, to, subject, html }) => {
196
- // Use your preferred email service (SendGrid, Mailgun, etc.)
197
- try {
198
- await yourEmailService.send({ from, to, subject, html });
199
- return { success: true };
200
- } catch (error) {
201
- return { success: false, error };
202
- }
203
- }
204
- })
205
- };
206
- ```
207
-
208
- ## Configuration
209
-
210
- Here are the available options:
211
-
212
- ```javascript
213
- betterSvelteEmailPreprocessor({
214
- pathToEmailFolder: '/src/lib/emails',
215
- debug: false,
216
- tailwindConfig: {
217
- theme: {
218
- extend: {
219
- colors: {
220
- brand: '#FF3E00'
221
- }
222
- }
223
- }
224
- }
225
- });
226
- ```
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.
227
46
 
228
47
  ## Minimum Svelte Version
229
48
 
@@ -234,28 +53,29 @@ For older versions, you can use [`svelte-email-tailwind`](https://github.com/ste
234
53
 
235
54
  ### ✅ Supported
236
55
 
237
- - Static Tailwind classes
238
- - Custom Tailwind classes (`bg-[#fff]`, `my:[40px]`, ...)
239
- - All standard Tailwind (v3) utilities (colors, spacing, typography, etc.)
240
- - Responsive breakpoints (`sm:`, `md:`, `lg:`, `xl:`, `2xl:`)
241
- - HTML elements and Svelte components
242
- - Nested components
243
- - Conditional blocks (`{#if}`)
244
- - Each blocks (`{#each}`)
245
- - ✅ Custom Tailwind configurations
246
-
247
- ### ❌ Not Supported (Yet) (See [Roadmap](./ROADMAP.md))
248
-
249
- - ❌ Tailwind v4
250
- - ❌ CSS Object (`style={{ color: 'red' }}`)
251
- - ❌ Dynamic class expressions (`class={someVar}`)
252
- - ❌ Arbitrary values in responsive classes (`sm:[color:red]`)
253
- - ❌ Container queries
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
254
64
 
255
65
  ## Author
256
66
 
257
67
  Anatole Dufour ([@Konixy](https://github.com/Konixy))
258
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
+
259
79
  ## Development
260
80
 
261
81
  ### Running Tests
@@ -1,5 +1,4 @@
1
- // Email Components for better-svelte-email
2
- // These components work with the preprocessor's styleString prop
1
+ // Email components for better-svelte-email
3
2
  export { default as Body } from './Body.svelte';
4
3
  export { default as Button } from './Button.svelte';
5
4
  export { default as Column } from './Column.svelte';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,2 @@
1
1
  export * from './components/index.js';
2
- export { betterSvelteEmailPreprocessor } from './preprocessor/index.js';
3
- export type { PreprocessorOptions, ComponentTransform } from './preprocessor/index.js';
4
- export { default as Renderer, type TailwindConfig, type RenderOptions } from './render/index.js';
5
- export * from './utils/index.js';
2
+ export { default as Renderer, toPlainText, type TailwindConfig, type RenderOptions } from './render/index.js';
package/dist/index.js CHANGED
@@ -1,8 +1,4 @@
1
1
  // Export email components
2
2
  export * from './components/index.js';
3
- // Export the preprocessor
4
- export { betterSvelteEmailPreprocessor } from './preprocessor/index.js';
5
3
  // Export renderer
6
- export { default as Renderer } from './render/index.js';
7
- // Export utilities
8
- export * from './utils/index.js';
4
+ export { default as Renderer, toPlainText } from './render/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-svelte-email",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0",
4
4
  "author": "Konixy",
5
5
  "repository": {
6
6
  "type": "git",
@@ -63,13 +63,8 @@
63
63
  ".": {
64
64
  "types": "./dist/index.d.ts",
65
65
  "svelte": "./dist/index.js",
66
- "import": "./dist/preprocessor/index.js",
67
- "default": "./dist/preprocessor/index.js"
68
- },
69
- "./preprocessor": {
70
- "types": "./dist/preprocessor/index.d.ts",
71
- "import": "./dist/preprocessor/index.js",
72
- "default": "./dist/preprocessor/index.js"
66
+ "import": "./dist/index.js",
67
+ "default": "./dist/index.js"
73
68
  },
74
69
  "./components": {
75
70
  "types": "./dist/components/index.d.ts",
@@ -89,9 +84,6 @@
89
84
  "types": "./dist/preview/EmailPreview.svelte.d.ts",
90
85
  "svelte": "./dist/preview/EmailPreview.svelte"
91
86
  },
92
- "./preview/theme.css": {
93
- "default": "./dist/preview/theme.css"
94
- },
95
87
  "./utils": {
96
88
  "types": "./dist/utils/index.d.ts",
97
89
  "import": "./dist/utils/index.js",
@@ -1,9 +0,0 @@
1
- import type { MediaQueryStyle } from './types.js';
2
- /**
3
- * Inject media query styles into the <Head> component
4
- */
5
- export declare function injectMediaQueries(source: string, mediaQueries: MediaQueryStyle[]): {
6
- code: string;
7
- success: boolean;
8
- error?: string;
9
- };
@@ -1,57 +0,0 @@
1
- import MagicString from 'magic-string';
2
- import { findHeadComponent } from './parser.js';
3
- /**
4
- * Inject media query styles into the <Head> component
5
- */
6
- export function injectMediaQueries(source, mediaQueries) {
7
- if (mediaQueries.length === 0) {
8
- // No media queries to inject
9
- return { code: source, success: true };
10
- }
11
- // Find the Head component
12
- const headInfo = findHeadComponent(source);
13
- if (!headInfo.found || headInfo.insertPosition === null) {
14
- return {
15
- code: source,
16
- success: false,
17
- error: 'No <Head> component found. Media queries cannot be injected.'
18
- };
19
- }
20
- // Generate the style tag content
21
- const styleContent = generateStyleTag(mediaQueries);
22
- // Use MagicString for surgical insertion
23
- const s = new MagicString(source);
24
- // Check if Head is self-closing and convert it
25
- const headStart = source.lastIndexOf('<Head', headInfo.insertPosition);
26
- const headSegment = source.substring(headStart, headInfo.insertPosition + 10);
27
- if (headSegment.includes('/>')) {
28
- // Self-closing: convert to non-self-closing
29
- // Check if there's a space before />
30
- const spaceBeforeSelfClose = source[headInfo.insertPosition - 1] === ' ';
31
- const replaceStart = spaceBeforeSelfClose
32
- ? headInfo.insertPosition - 1
33
- : headInfo.insertPosition;
34
- // Replace [space]?/> with >
35
- s.overwrite(replaceStart, headInfo.insertPosition + 2, '>');
36
- // Insert style content
37
- s.appendLeft(headInfo.insertPosition + 2, styleContent);
38
- // Add closing tag
39
- s.appendLeft(headInfo.insertPosition + 2, '</Head>');
40
- }
41
- else {
42
- // Already has closing tag, just insert content
43
- s.appendLeft(headInfo.insertPosition, styleContent);
44
- }
45
- return {
46
- code: s.toString(),
47
- success: true
48
- };
49
- }
50
- /**
51
- * Generate <style> tag with all media queries
52
- */
53
- function generateStyleTag(mediaQueries) {
54
- // Combine all media queries
55
- const allQueries = mediaQueries.map((mq) => mq.rules).join('\n');
56
- return `\n\t<style>\n\t\t${allQueries}\n\t</style>\n`;
57
- }
@@ -1,44 +0,0 @@
1
- import type { PreprocessorGroup } from 'svelte/compiler';
2
- import type { PreprocessorOptions, ComponentTransform } from './types.js';
3
- /**
4
- * Svelte 5 preprocessor for transforming Tailwind classes in email components
5
- *
6
- * @deprecated The preprocessor approach is deprecated. Use the `Renderer` class instead for better performance and flexibility.
7
- *
8
- * @example
9
- * ```javascript
10
- * // Old (deprecated):
11
- * // svelte.config.js
12
- * import { betterSvelteEmailPreprocessor } from 'better-svelte-email/preprocessor';
13
- *
14
- * export default {
15
- * preprocess: [
16
- * vitePreprocess(),
17
- * betterSvelteEmailPreprocessor({
18
- * pathToEmailFolder: '/src/lib/emails',
19
- * tailwindConfig: { ... }
20
- * })
21
- * ]
22
- * };
23
- *
24
- * // New (recommended):
25
- * import Renderer from 'better-svelte-email/renderer';
26
- * import EmailComponent from './email.svelte';
27
- *
28
- * const renderer = new Renderer({
29
- * theme: {
30
- * extend: {
31
- * colors: { brand: '#FF3E00' }
32
- * }
33
- * }
34
- * });
35
- *
36
- * const html = await renderer.render(EmailComponent, {
37
- * props: { name: 'John' }
38
- * });
39
- * ```
40
- *
41
- * Reference: https://svelte.dev/docs/svelte/svelte-compiler#preprocess
42
- */
43
- export declare function betterSvelteEmailPreprocessor(options?: PreprocessorOptions): PreprocessorGroup;
44
- export type { PreprocessorOptions, ComponentTransform };
@@ -1,227 +0,0 @@
1
- import MagicString from 'magic-string';
2
- import { parseAttributes } from './parser.js';
3
- import { createTailwindConverter, transformTailwindClasses, generateMediaQueries, sanitizeClassName } from './transformer.js';
4
- import { injectMediaQueries } from './head-injector.js';
5
- import path from 'path';
6
- /**
7
- * Svelte 5 preprocessor for transforming Tailwind classes in email components
8
- *
9
- * @deprecated The preprocessor approach is deprecated. Use the `Renderer` class instead for better performance and flexibility.
10
- *
11
- * @example
12
- * ```javascript
13
- * // Old (deprecated):
14
- * // svelte.config.js
15
- * import { betterSvelteEmailPreprocessor } from 'better-svelte-email/preprocessor';
16
- *
17
- * export default {
18
- * preprocess: [
19
- * vitePreprocess(),
20
- * betterSvelteEmailPreprocessor({
21
- * pathToEmailFolder: '/src/lib/emails',
22
- * tailwindConfig: { ... }
23
- * })
24
- * ]
25
- * };
26
- *
27
- * // New (recommended):
28
- * import Renderer from 'better-svelte-email/renderer';
29
- * import EmailComponent from './email.svelte';
30
- *
31
- * const renderer = new Renderer({
32
- * theme: {
33
- * extend: {
34
- * colors: { brand: '#FF3E00' }
35
- * }
36
- * }
37
- * });
38
- *
39
- * const html = await renderer.render(EmailComponent, {
40
- * props: { name: 'John' }
41
- * });
42
- * ```
43
- *
44
- * Reference: https://svelte.dev/docs/svelte/svelte-compiler#preprocess
45
- */
46
- export function betterSvelteEmailPreprocessor(options = {}) {
47
- const { tailwindConfig, pathToEmailFolder = '/src/lib/emails', debug = true } = options;
48
- // Initialize Tailwind converter once (performance optimization)
49
- const tailwindConverter = createTailwindConverter(tailwindConfig);
50
- // Return a Svelte 5 PreprocessorGroup
51
- return {
52
- name: 'better-svelte-email',
53
- /**
54
- * The markup preprocessor transforms the template/HTML portion
55
- * This is where we extract and transform Tailwind classes
56
- */
57
- markup({ content, filename }) {
58
- // Only process .svelte files in the configured email folder
59
- if (!filename || !filename.includes(pathToEmailFolder)) {
60
- // Return undefined to skip processing
61
- return;
62
- }
63
- if (!filename.endsWith('.svelte')) {
64
- return;
65
- }
66
- try {
67
- // Process the email component
68
- const result = processEmailComponent(content, filename, tailwindConverter, tailwindConfig);
69
- // Log warnings if debug mode is enabled
70
- if (result.warnings.length > 0) {
71
- if (debug) {
72
- console.warn(`[better-svelte-email] Warnings for ${path.relative(process.cwd(), filename)}:\n`, result.warnings.join('\n'));
73
- }
74
- }
75
- // Return the transformed code
76
- // The preprocessor API expects { code: string } or { code: string, map: SourceMap }
77
- return {
78
- code: result.transformedCode
79
- // Note: Source maps could be added here via MagicString's generateMap()
80
- };
81
- }
82
- catch (error) {
83
- console.error(`[better-svelte-email] Error processing ${filename}:`, error);
84
- // On error, return undefined to use original content
85
- // This prevents breaking the build for non-email files
86
- return;
87
- }
88
- }
89
- };
90
- }
91
- /**
92
- * Process a single email component
93
- */
94
- function processEmailComponent(source, _filename, tailwindConverter, tailwindConfig) {
95
- const warnings = [];
96
- let transformedCode = source;
97
- const allMediaQueries = [];
98
- // Step 1: Parse and find all class attributes
99
- const attributes = parseAttributes(source);
100
- if (attributes.length === 0) {
101
- // No classes to transform
102
- return {
103
- originalCode: source,
104
- transformedCode: source,
105
- mediaQueries: [],
106
- hasHead: false,
107
- warnings: []
108
- };
109
- }
110
- // Step 2: Transform each class attribute
111
- const s = new MagicString(transformedCode);
112
- // Process in reverse order to maintain correct positions
113
- const sortedAttributes = [...attributes].sort((a, b) => b.class.start - a.class.start);
114
- for (const attr of sortedAttributes) {
115
- if (!attr.class.isStatic) {
116
- // Skip dynamic classes for now
117
- warnings.push(`Dynamic class expression detected in ${attr.class.elementName}. ` +
118
- `Only static classes can be transformed at build time.`);
119
- continue;
120
- }
121
- // Transform the classes
122
- const transformed = transformTailwindClasses(attr.class.raw, tailwindConverter);
123
- // Collect warnings about invalid classes
124
- if (transformed.invalidClasses.length > 0) {
125
- warnings.push(`Invalid Tailwind classes in ${attr.class.elementName}: ${transformed.invalidClasses.join(', ')}`);
126
- }
127
- // Generate media queries for responsive classes
128
- if (transformed.responsiveClasses.length > 0) {
129
- const mediaQueries = generateMediaQueries(transformed.responsiveClasses, tailwindConverter, tailwindConfig);
130
- allMediaQueries.push(...mediaQueries);
131
- }
132
- // Build the new attribute value
133
- const newAttributes = buildNewAttributes(transformed.inlineStyles, transformed.responsiveClasses, attr.style?.raw);
134
- // Remove the already existing style attribute if it exists
135
- if (attr.style) {
136
- removeStyleAttribute(s, attr.style);
137
- }
138
- // Replace the class attribute with new attributes
139
- replaceClassAttribute(s, attr.class, newAttributes);
140
- }
141
- transformedCode = s.toString();
142
- // Step 3: Inject media queries into <Head>
143
- if (allMediaQueries.length > 0) {
144
- const injectionResult = injectMediaQueries(transformedCode, allMediaQueries);
145
- if (!injectionResult.success) {
146
- warnings.push(injectionResult.error || 'Failed to inject media queries');
147
- }
148
- else {
149
- transformedCode = injectionResult.code;
150
- }
151
- }
152
- return {
153
- originalCode: source,
154
- transformedCode,
155
- mediaQueries: allMediaQueries,
156
- hasHead: allMediaQueries.length > 0,
157
- warnings
158
- };
159
- }
160
- /**
161
- * Build new attribute string from transformation result
162
- */
163
- function buildNewAttributes(inlineStyles, responsiveClasses, existingStyles) {
164
- const parts = [];
165
- // Add responsive classes if any
166
- if (responsiveClasses.length > 0) {
167
- const sanitizedClasses = responsiveClasses.map(sanitizeClassName);
168
- parts.push(`class="${sanitizedClasses.join(' ')}"`);
169
- }
170
- // Add inline styles if any
171
- if (inlineStyles) {
172
- // Escape quotes in styles
173
- const escapedStyles = inlineStyles.replace(/"/g, '&quot;');
174
- const withExisting = escapedStyles + (existingStyles ? existingStyles : '');
175
- parts.push(`style="${withExisting}"`);
176
- }
177
- return parts.join(' ');
178
- }
179
- /**
180
- * Replace class attribute with new attributes using MagicString
181
- */
182
- function replaceClassAttribute(s, classAttr, newAttributes) {
183
- // We need to replace the entire class="..." portion
184
- // The positions from AST are for the value, not the attribute
185
- // So we need to search backwards for class="
186
- // Find the start of the attribute (look for class=")
187
- const beforeAttr = s.original.substring(0, classAttr.start);
188
- const attrStartMatch = beforeAttr.lastIndexOf('class="');
189
- if (attrStartMatch === -1) {
190
- console.warn('Could not find class attribute start position');
191
- return;
192
- }
193
- // Find the end of the attribute (closing quote)
194
- const afterValue = s.original.substring(classAttr.end);
195
- const quotePos = afterValue.indexOf('"');
196
- if (quotePos === -1) {
197
- console.warn('Could not find class attribute end position');
198
- return;
199
- }
200
- const fullAttrStart = attrStartMatch;
201
- const fullAttrEnd = classAttr.end + quotePos + 1;
202
- // Replace the entire class="..." with our new attributes
203
- if (newAttributes) {
204
- s.overwrite(fullAttrStart, fullAttrEnd, newAttributes);
205
- }
206
- else {
207
- // No attributes to add - remove the class attribute entirely
208
- // Also remove any extra whitespace
209
- let removeStart = fullAttrStart;
210
- let removeEnd = fullAttrEnd;
211
- // Check if there's a space before
212
- if (s.original[removeStart - 1] === ' ') {
213
- removeStart--;
214
- }
215
- // Check if there's a space after
216
- if (s.original[removeEnd] === ' ') {
217
- removeEnd++;
218
- }
219
- s.remove(removeStart, removeEnd);
220
- }
221
- }
222
- /**
223
- * Remove style attribute with MagicString
224
- */
225
- function removeStyleAttribute(s, styleAttr) {
226
- s.remove(styleAttr.start, styleAttr.end);
227
- }
@@ -1,17 +0,0 @@
1
- import type { ClassAttribute, StyleAttribute } from './types.js';
2
- /**
3
- * Parse Svelte 5 source code and extract all class attributes
4
- * Reference: https://svelte.dev/docs/svelte/svelte-compiler#parse
5
- */
6
- export declare function parseAttributes(source: string): {
7
- class: ClassAttribute;
8
- style?: StyleAttribute;
9
- }[];
10
- /**
11
- * Find the <Head> component in Svelte 5 AST
12
- * Returns the position where we should inject styles
13
- */
14
- export declare function findHeadComponent(source: string): {
15
- found: boolean;
16
- insertPosition: number | null;
17
- };
@@ -1,315 +0,0 @@
1
- import { parse } from 'svelte/compiler';
2
- /**
3
- * Parse Svelte 5 source code and extract all class attributes
4
- * Reference: https://svelte.dev/docs/svelte/svelte-compiler#parse
5
- */
6
- export function parseAttributes(source) {
7
- const attributes = [];
8
- try {
9
- // Parse the Svelte file into an AST
10
- // Svelte 5 parse returns a Root node with modern AST structure
11
- const ast = parse(source);
12
- // Walk the html fragment (template portion) of the AST
13
- if (ast.html && ast.html.children) {
14
- for (const child of ast.html.children) {
15
- walkNode(child, attributes, source);
16
- }
17
- }
18
- }
19
- catch (error) {
20
- console.error('Failed to parse Svelte file:', error);
21
- throw error;
22
- }
23
- return attributes;
24
- }
25
- /**
26
- * Recursively walk Svelte 5 AST nodes to find class attributes
27
- */
28
- function walkNode(node, attributes, source) {
29
- if (!node)
30
- return;
31
- // Svelte 5 AST structure:
32
- // - Element: HTML elements like <div>, <button>
33
- // - InlineComponent: Custom components like <Button>, <Head>
34
- // - SlotElement: <svelte:element> and other svelte: elements
35
- if (node.type === 'Element' ||
36
- node.type === 'InlineComponent' ||
37
- node.type === 'SlotElement' ||
38
- node.type === 'Component') {
39
- const elementName = node.name || 'unknown';
40
- // Look for class and style attribute in Svelte 5 AST
41
- const classAttr = node.attributes?.find((attr) => attr.type === 'Attribute' && attr.name === 'class');
42
- const styleAttr = node.attributes?.find((attr) => attr.type === 'Attribute' && attr.name === 'style');
43
- if (classAttr && classAttr.value) {
44
- // Extract class value
45
- const extractedClass = extractClassValue(classAttr, source);
46
- let extractedStyle = null;
47
- if (styleAttr && styleAttr.value) {
48
- extractedStyle = extractStyleValue(styleAttr, source);
49
- }
50
- if (extractedClass) {
51
- attributes.push({
52
- class: {
53
- raw: extractedClass.value,
54
- start: extractedClass.start,
55
- end: extractedClass.end,
56
- elementName,
57
- isStatic: extractedClass.isStatic
58
- },
59
- style: extractedStyle
60
- ? {
61
- raw: extractedStyle.value,
62
- start: extractedStyle.start,
63
- end: extractedStyle.end,
64
- elementName
65
- }
66
- : undefined
67
- });
68
- }
69
- }
70
- }
71
- // Recursively process children
72
- if (node.children) {
73
- for (const child of node.children) {
74
- walkNode(child, attributes, source);
75
- }
76
- }
77
- // Handle conditional blocks (#if, #each, etc.)
78
- if (node.consequent) {
79
- if (node.consequent.children) {
80
- for (const child of node.consequent.children) {
81
- walkNode(child, attributes, source);
82
- }
83
- }
84
- }
85
- if (node.alternate) {
86
- if (node.alternate.children) {
87
- for (const child of node.alternate.children) {
88
- walkNode(child, attributes, source);
89
- }
90
- }
91
- }
92
- // Handle #each blocks
93
- if (node.body) {
94
- if (node.body.children) {
95
- for (const child of node.body.children) {
96
- walkNode(child, attributes, source);
97
- }
98
- }
99
- }
100
- }
101
- /**
102
- * Extract the actual class value from a Svelte 5 attribute node
103
- */
104
- function extractClassValue(classAttr, source) {
105
- // Svelte 5 attribute value formats:
106
- // 1. Static string: class="text-red-500"
107
- // → value: [{ type: 'Text', data: 'text-red-500' }]
108
- //
109
- // 2. Expression: class={someVar}
110
- // → value: [{ type: 'ExpressionTag', expression: {...} }]
111
- //
112
- // 3. Mixed: class="static {dynamic} more"
113
- // → value: [{ type: 'Text' }, { type: 'ExpressionTag' }, { type: 'Text' }]
114
- if (!classAttr.value || classAttr.value.length === 0) {
115
- return null;
116
- }
117
- // Check if entirely static (only Text nodes)
118
- const hasOnlyText = classAttr.value.every((v) => v.type === 'Text');
119
- if (hasOnlyText) {
120
- // Fully static - we can safely transform this
121
- const textContent = classAttr.value.map((v) => v.data || '').join('');
122
- const start = classAttr.value[0].start;
123
- const end = classAttr.value[classAttr.value.length - 1].end;
124
- return {
125
- value: textContent,
126
- start,
127
- end,
128
- isStatic: true
129
- };
130
- }
131
- // Check if entirely dynamic (only ExpressionTag or MustacheTag)
132
- const hasOnlyExpression = classAttr.value.length === 1 &&
133
- (classAttr.value[0].type === 'ExpressionTag' || classAttr.value[0].type === 'MustacheTag');
134
- if (hasOnlyExpression) {
135
- // Fully dynamic - cannot transform at build time
136
- const exprNode = classAttr.value[0];
137
- const expressionCode = source.substring(exprNode.start, exprNode.end);
138
- return {
139
- value: expressionCode,
140
- start: exprNode.start,
141
- end: exprNode.end,
142
- isStatic: false
143
- };
144
- }
145
- // Mixed content (both Text and ExpressionTag)
146
- // Extract only the static Text portions for partial transformation
147
- let combinedValue = '';
148
- const start = classAttr.value[0].start;
149
- const end = classAttr.value[classAttr.value.length - 1].end;
150
- let hasStaticContent = false;
151
- for (const part of classAttr.value) {
152
- if (part.type === 'Text' && part.data) {
153
- combinedValue += part.data + ' ';
154
- hasStaticContent = true;
155
- }
156
- // Skip ExpressionTag nodes
157
- }
158
- if (hasStaticContent) {
159
- return {
160
- value: combinedValue.trim(),
161
- start,
162
- end,
163
- isStatic: false // Mixed is not fully static
164
- };
165
- }
166
- return null;
167
- }
168
- /**
169
- * Extract the actual style value from a Svelte 5 attribute node
170
- */
171
- function extractStyleValue(styleAttr, source) {
172
- // Svelte 5 attribute value formats:
173
- // 1. Static string: style="color: red;"
174
- // → value: [{ type: 'Text', data: 'color: red;' }]
175
- //
176
- // 2. Expression: style={someVar}
177
- // → value: [{ type: 'ExpressionTag', expression: {...} }]
178
- //
179
- // 3. Mixed: style="color: red; {dynamicStyle}"
180
- // → value: [{ type: 'Text' }, { type: 'ExpressionTag' }]
181
- if (!styleAttr.value || styleAttr.value.length === 0) {
182
- return null;
183
- }
184
- // Check if entirely static (only Text nodes)
185
- const hasOnlyText = styleAttr.value.every((v) => v.type === 'Text');
186
- if (hasOnlyText) {
187
- // Fully static - we can extract this
188
- const textContent = styleAttr.value.map((v) => v.data || '').join('');
189
- return {
190
- value: textContent,
191
- start: styleAttr.start,
192
- end: styleAttr.end
193
- };
194
- }
195
- // Check if entirely dynamic (only ExpressionTag or MustacheTag)
196
- const hasOnlyExpression = styleAttr.value.length === 1 &&
197
- (styleAttr.value[0].type === 'ExpressionTag' || styleAttr.value[0].type === 'MustacheTag');
198
- if (hasOnlyExpression) {
199
- // Fully dynamic - extract the expression code
200
- const exprNode = styleAttr.value[0];
201
- const expressionCode = source.substring(exprNode.start, exprNode.end);
202
- return {
203
- value: expressionCode,
204
- start: exprNode.start,
205
- end: exprNode.end
206
- };
207
- }
208
- // Mixed content (both Text and ExpressionTag)
209
- // Extract the full content including dynamic parts
210
- const start = styleAttr.value[0].start;
211
- const end = styleAttr.value[styleAttr.value.length - 1].end;
212
- const fullContent = source.substring(start, end);
213
- return {
214
- value: fullContent,
215
- start: styleAttr.start,
216
- end: styleAttr.end
217
- };
218
- }
219
- /**
220
- * Find the <Head> component in Svelte 5 AST
221
- * Returns the position where we should inject styles
222
- */
223
- export function findHeadComponent(source) {
224
- try {
225
- const ast = parse(source);
226
- // Find Head component in the AST
227
- if (ast.html && ast.html.children) {
228
- for (const child of ast.html.children) {
229
- const headInfo = findHeadInNode(child, source);
230
- if (headInfo)
231
- return headInfo;
232
- }
233
- }
234
- return { found: false, insertPosition: null };
235
- }
236
- catch {
237
- return { found: false, insertPosition: null };
238
- }
239
- }
240
- /**
241
- * Recursively search for Head component in Svelte 5 AST
242
- */
243
- function findHeadInNode(node, source) {
244
- if (!node)
245
- return null;
246
- // Check if this is the Head component (InlineComponent type in Svelte 5)
247
- if ((node.type === 'InlineComponent' || node.type === 'Component') && node.name === 'Head') {
248
- // Svelte 5: Find the best insertion point for styles
249
- // If Head has children, insert before first child
250
- if (node.children && node.children.length > 0) {
251
- return {
252
- found: true,
253
- insertPosition: node.children[0].start
254
- };
255
- }
256
- // No children - need to insert before closing tag
257
- // Find where the opening tag ends
258
- const headStart = node.start;
259
- const headEnd = node.end;
260
- const headContent = source.substring(headStart, headEnd);
261
- // Self-closing: <Head />
262
- if (headContent.includes('/>')) {
263
- // Convert to non-self-closing by inserting before />
264
- const selfClosingPos = source.indexOf('/>', headStart);
265
- return {
266
- found: true,
267
- insertPosition: selfClosingPos
268
- };
269
- }
270
- // Regular closing tag: <Head></Head> or <Head>...</Head>
271
- const closingTagPos = source.indexOf('</Head>', headStart);
272
- if (closingTagPos !== -1) {
273
- return {
274
- found: true,
275
- insertPosition: closingTagPos
276
- };
277
- }
278
- // Fallback: insert right after opening tag
279
- const openingTagEnd = source.indexOf('>', headStart);
280
- if (openingTagEnd !== -1) {
281
- return {
282
- found: true,
283
- insertPosition: openingTagEnd + 1
284
- };
285
- }
286
- }
287
- // Search recursively through the AST
288
- if (node.children) {
289
- for (const child of node.children) {
290
- const found = findHeadInNode(child, source);
291
- if (found)
292
- return found;
293
- }
294
- }
295
- // Check conditional branches
296
- if (node.consequent) {
297
- if (node.consequent.children) {
298
- for (const child of node.consequent.children) {
299
- const found = findHeadInNode(child, source);
300
- if (found)
301
- return found;
302
- }
303
- }
304
- }
305
- if (node.alternate) {
306
- if (node.alternate.children) {
307
- for (const child of node.alternate.children) {
308
- const found = findHeadInNode(child, source);
309
- if (found)
310
- return found;
311
- }
312
- }
313
- }
314
- return null;
315
- }
@@ -1,18 +0,0 @@
1
- import { type TailwindConfig } from 'tw-to-css';
2
- import type { TransformResult, MediaQueryStyle } from './types.js';
3
- /**
4
- * Initialize Tailwind converter with config
5
- */
6
- export declare function createTailwindConverter(config?: TailwindConfig): typeof import("tw-to-css").twi;
7
- /**
8
- * Transform Tailwind classes to inline styles and responsive classes
9
- */
10
- export declare function transformTailwindClasses(classString: string, tailwindConverter: ReturnType<typeof createTailwindConverter>): TransformResult;
11
- /**
12
- * Generate media query CSS for responsive classes
13
- */
14
- export declare function generateMediaQueries(responsiveClasses: string[], tailwindConverter: ReturnType<typeof createTailwindConverter>, tailwindConfig?: TailwindConfig): MediaQueryStyle[];
15
- /**
16
- * Sanitize class names for use in CSS (replace special characters)
17
- */
18
- export declare function sanitizeClassName(className: string): string;
@@ -1,158 +0,0 @@
1
- import { tailwindToCSS } from 'tw-to-css';
2
- /**
3
- * Initialize Tailwind converter with config
4
- */
5
- export function createTailwindConverter(config) {
6
- const { twi } = tailwindToCSS({ config });
7
- return twi;
8
- }
9
- /**
10
- * Transform Tailwind classes to inline styles and responsive classes
11
- */
12
- export function transformTailwindClasses(classString, tailwindConverter) {
13
- // Split classes
14
- const classes = classString.trim().split(/\s+/).filter(Boolean);
15
- // Separate responsive from non-responsive classes
16
- const responsiveClasses = [];
17
- const nonResponsiveClasses = [];
18
- for (const cls of classes) {
19
- // Responsive classes have format: sm:, md:, lg:, xl:, 2xl:
20
- if (/^(sm|md|lg|xl|2xl):/.test(cls)) {
21
- responsiveClasses.push(cls);
22
- }
23
- else {
24
- nonResponsiveClasses.push(cls);
25
- }
26
- }
27
- // Convert non-responsive classes to CSS
28
- let inlineStyles = '';
29
- const invalidClasses = [];
30
- if (nonResponsiveClasses.length > 0) {
31
- const classesStr = nonResponsiveClasses.join(' ');
32
- try {
33
- // Generate CSS from Tailwind classes
34
- const css = tailwindConverter(classesStr, {
35
- merge: false,
36
- ignoreMediaQueries: true
37
- });
38
- // Extract styles from CSS
39
- const styles = extractStylesFromCSS(css, nonResponsiveClasses);
40
- inlineStyles = styles.validStyles;
41
- invalidClasses.push(...styles.invalidClasses);
42
- }
43
- catch (error) {
44
- console.warn('Failed to convert Tailwind classes:', error);
45
- }
46
- }
47
- return {
48
- inlineStyles,
49
- responsiveClasses,
50
- invalidClasses
51
- };
52
- }
53
- /**
54
- * Extract CSS properties from generated CSS
55
- * Handles the format: .classname { prop: value; }
56
- */
57
- function extractStylesFromCSS(css, originalClasses) {
58
- const invalidClasses = [];
59
- const styleProperties = [];
60
- // Remove media queries (we handle those separately)
61
- const cssWithoutMedia = css.replace(/@media[^{]+\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
62
- // Create a map of class name -> CSS rules
63
- const classMap = new Map();
64
- // Match .classname { rules }
65
- const classRegex = /\.([^\s{]+)\s*\{([^}]+)\}/g;
66
- let match;
67
- while ((match = classRegex.exec(cssWithoutMedia)) !== null) {
68
- const className = match[1];
69
- const rules = match[2].replace(/\\/g, '').trim();
70
- // Normalize class name (tw-to-css might transform special chars)
71
- const normalizedClass = className.replace(/\\/g, '').replace(/[:#\-[\]/.%!_]+/g, '_');
72
- classMap.set(normalizedClass, rules);
73
- }
74
- // For each original class, try to find its CSS
75
- for (const originalClass of originalClasses) {
76
- // Normalize the original class name to match what tw-to-css produces
77
- const normalized = originalClass.replace(/[:#\-[\]/.%!_]+/g, '_');
78
- if (classMap.has(normalized)) {
79
- const rules = classMap.get(normalized);
80
- // Ensure rules end with semicolon for proper concatenation
81
- const rulesWithSemicolon = rules.trim().endsWith(';') ? rules.trim() : rules.trim() + ';';
82
- styleProperties.push(rulesWithSemicolon);
83
- }
84
- else {
85
- // Class not found - might be invalid Tailwind
86
- invalidClasses.push(originalClass);
87
- }
88
- }
89
- // Combine all style properties with space separator
90
- const validStyles = styleProperties.join(' ').trim();
91
- return { validStyles, invalidClasses };
92
- }
93
- /**
94
- * Generate media query CSS for responsive classes
95
- */
96
- export function generateMediaQueries(responsiveClasses, tailwindConverter, tailwindConfig) {
97
- if (responsiveClasses.length === 0) {
98
- return [];
99
- }
100
- const mediaQueries = [];
101
- // Default breakpoints (can be overridden by config)
102
- const breakpoints = {
103
- sm: '475px',
104
- md: '768px',
105
- lg: '1024px',
106
- xl: '1280px',
107
- '2xl': '1536px',
108
- ...tailwindConfig?.theme?.screens
109
- };
110
- // Group classes by breakpoint
111
- const classesByBreakpoint = new Map();
112
- for (const cls of responsiveClasses) {
113
- const match = cls.match(/^(sm|md|lg|xl|2xl):(.+)/);
114
- if (match) {
115
- const [, breakpoint] = match;
116
- if (!classesByBreakpoint.has(breakpoint)) {
117
- classesByBreakpoint.set(breakpoint, []);
118
- }
119
- classesByBreakpoint.get(breakpoint).push(cls);
120
- }
121
- }
122
- // Generate CSS for each breakpoint
123
- for (const [breakpoint, classes] of classesByBreakpoint) {
124
- const breakpointValue = breakpoints[breakpoint];
125
- if (!breakpointValue)
126
- continue;
127
- // Generate full CSS including media queries
128
- const fullCSS = tailwindConverter(classes.join(' '), {
129
- merge: false,
130
- ignoreMediaQueries: false
131
- });
132
- // Extract just the media query portion
133
- const mediaQueryRegex = new RegExp(`@media[^{]*\\{([^{}]|\\{[^{}]*\\})*\\}`, 'g');
134
- let match;
135
- while ((match = mediaQueryRegex.exec(fullCSS)) !== null) {
136
- const mediaQueryBlock = match[0];
137
- // Make all rules !important for email clients
138
- const withImportant = mediaQueryBlock.replace(/([a-z-]+)\s*:\s*([^;!}]+)/gi, '$1: $2 !important');
139
- // Parse out the query and content
140
- const queryMatch = withImportant.match(/@media\s*([^{]+)/);
141
- if (queryMatch) {
142
- const query = `@media ${queryMatch[1].trim()}`;
143
- mediaQueries.push({
144
- query,
145
- className: `responsive-${breakpoint}`,
146
- rules: withImportant
147
- });
148
- }
149
- }
150
- }
151
- return mediaQueries;
152
- }
153
- /**
154
- * Sanitize class names for use in CSS (replace special characters)
155
- */
156
- export function sanitizeClassName(className) {
157
- return className.replace(/[:#\-[\]/.%!]+/g, '_');
158
- }
@@ -1,125 +0,0 @@
1
- import type { TailwindConfig } from 'tw-to-css';
2
- /**
3
- * Options for the preprocessor
4
- */
5
- export interface PreprocessorOptions {
6
- /**
7
- * Custom Tailwind configuration
8
- */
9
- tailwindConfig?: TailwindConfig;
10
- /**
11
- * Path to folder containing email components
12
- * @default '/src/lib/emails'
13
- */
14
- pathToEmailFolder?: string;
15
- /**
16
- * Enable debug logging
17
- * @default false
18
- */
19
- debug?: boolean;
20
- }
21
- /**
22
- * Represents a class attribute found in the AST
23
- */
24
- export interface ClassAttribute {
25
- /**
26
- * Raw class string (e.g., "text-red-500 sm:bg-blue")
27
- */
28
- raw: string;
29
- /**
30
- * Start position in source code
31
- */
32
- start: number;
33
- /**
34
- * End position in source code
35
- */
36
- end: number;
37
- /**
38
- * Parent element/component name
39
- */
40
- elementName: string;
41
- /**
42
- * Whether this is a static string or dynamic expression
43
- */
44
- isStatic: boolean;
45
- }
46
- /**
47
- * Represents a style attribute found in the AST
48
- */
49
- export interface StyleAttribute {
50
- /**
51
- * Raw style string (e.g., "background-color: red;")
52
- */
53
- raw: string;
54
- /**
55
- * Start position in source code
56
- */
57
- start: number;
58
- /**
59
- * End position in source code
60
- */
61
- end: number;
62
- /**
63
- * Parent element/component name
64
- */
65
- elementName: string;
66
- }
67
- /**
68
- * Result of transforming Tailwind classes
69
- */
70
- export interface TransformResult {
71
- /**
72
- * CSS styles for inline styleString prop
73
- */
74
- inlineStyles: string;
75
- /**
76
- * Responsive classes to keep in class attribute
77
- */
78
- responsiveClasses: string[];
79
- /**
80
- * Classes that couldn't be converted (warnings)
81
- */
82
- invalidClasses: string[];
83
- }
84
- /**
85
- * Media query CSS to inject into head
86
- */
87
- export interface MediaQueryStyle {
88
- /**
89
- * Media query condition (e.g., "@media (max-width: 475px)")
90
- */
91
- query: string;
92
- /**
93
- * CSS class name
94
- */
95
- className: string;
96
- /**
97
- * CSS rules
98
- */
99
- rules: string;
100
- }
101
- /**
102
- * Information about a component's transformations
103
- */
104
- export interface ComponentTransform {
105
- /**
106
- * Original source code
107
- */
108
- originalCode: string;
109
- /**
110
- * Transformed source code
111
- */
112
- transformedCode: string;
113
- /**
114
- * Media queries to inject
115
- */
116
- mediaQueries: MediaQueryStyle[];
117
- /**
118
- * Whether <Head> component was found
119
- */
120
- hasHead: boolean;
121
- /**
122
- * Warnings encountered during transformation
123
- */
124
- warnings: string[];
125
- }
@@ -1 +0,0 @@
1
- export {};