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

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.
@@ -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;