better-svelte-email 1.0.0-beta.1 → 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 +24 -204
- package/dist/components/index.js +1 -2
- package/dist/index.d.ts +1 -4
- package/dist/index.js +1 -5
- package/dist/preview/index.d.ts +13 -3
- package/dist/preview/index.js +4 -3
- package/package.json +3 -11
- package/dist/preprocessor/head-injector.d.ts +0 -9
- package/dist/preprocessor/head-injector.js +0 -57
- package/dist/preprocessor/index.d.ts +0 -44
- package/dist/preprocessor/index.js +0 -227
- package/dist/preprocessor/parser.d.ts +0 -17
- package/dist/preprocessor/parser.js +0 -315
- package/dist/preprocessor/transformer.d.ts +0 -18
- package/dist/preprocessor/transformer.js +0 -158
- package/dist/preprocessor/types.d.ts +0 -125
- package/dist/preprocessor/types.js +0 -1
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
|
-
- **
|
|
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
|
|
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
|
-
-
|
|
238
|
-
-
|
|
239
|
-
-
|
|
240
|
-
-
|
|
241
|
-
-
|
|
242
|
-
-
|
|
243
|
-
-
|
|
244
|
-
-
|
|
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
|
package/dist/components/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// 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 {
|
|
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/dist/preview/index.d.ts
CHANGED
|
@@ -58,10 +58,12 @@ export declare const getEmailComponent: (emailPath: string, file: string) => Pro
|
|
|
58
58
|
* }
|
|
59
59
|
* });
|
|
60
60
|
*
|
|
61
|
-
* export const actions = createEmail(renderer);
|
|
61
|
+
* export const actions = createEmail({ renderer });
|
|
62
62
|
* ```
|
|
63
63
|
*/
|
|
64
|
-
export declare const createEmail: (
|
|
64
|
+
export declare const createEmail: (options?: {
|
|
65
|
+
renderer?: Renderer;
|
|
66
|
+
}) => {
|
|
65
67
|
'create-email': (event: RequestEvent) => Promise<{
|
|
66
68
|
status: number;
|
|
67
69
|
body: {
|
|
@@ -122,7 +124,15 @@ export declare const SendEmailFunction: ({ from, to, subject, html }: {
|
|
|
122
124
|
* ```
|
|
123
125
|
*/
|
|
124
126
|
export declare const sendEmail: ({ customSendEmailFunction, resendApiKey, renderer }?: {
|
|
125
|
-
customSendEmailFunction?:
|
|
127
|
+
customSendEmailFunction?: (email: {
|
|
128
|
+
from: string;
|
|
129
|
+
to: string;
|
|
130
|
+
subject: string;
|
|
131
|
+
html: string;
|
|
132
|
+
}) => Promise<{
|
|
133
|
+
success: boolean;
|
|
134
|
+
error?: any;
|
|
135
|
+
}>;
|
|
126
136
|
renderer?: Renderer;
|
|
127
137
|
resendApiKey?: string;
|
|
128
138
|
}) => {
|
package/dist/preview/index.js
CHANGED
|
@@ -107,10 +107,11 @@ const getEmailSource = async (emailPath, file) => {
|
|
|
107
107
|
* }
|
|
108
108
|
* });
|
|
109
109
|
*
|
|
110
|
-
* export const actions = createEmail(renderer);
|
|
110
|
+
* export const actions = createEmail({ renderer });
|
|
111
111
|
* ```
|
|
112
112
|
*/
|
|
113
|
-
export const createEmail = (
|
|
113
|
+
export const createEmail = (options = {}) => {
|
|
114
|
+
const { renderer = new Renderer() } = options;
|
|
114
115
|
return {
|
|
115
116
|
'create-email': async (event) => {
|
|
116
117
|
try {
|
|
@@ -214,7 +215,7 @@ export const sendEmail = ({ customSendEmailFunction, resendApiKey, renderer = ne
|
|
|
214
215
|
sent = await defaultSendEmailFunction(email, resendApiKey);
|
|
215
216
|
}
|
|
216
217
|
else if (customSendEmailFunction) {
|
|
217
|
-
sent = await customSendEmailFunction(email
|
|
218
|
+
sent = await customSendEmailFunction(email);
|
|
218
219
|
}
|
|
219
220
|
else if (!customSendEmailFunction && !resendApiKey) {
|
|
220
221
|
const error = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "better-svelte-email",
|
|
3
|
-
"version": "1.0.0
|
|
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/
|
|
67
|
-
"default": "./dist/
|
|
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, '"');
|
|
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 {};
|