better-svelte-email 0.0.3 → 0.2.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/dist/components/Body.svelte +14 -5
- package/dist/components/Body.svelte.d.ts +6 -12
- package/dist/components/Button.svelte +11 -4
- package/dist/components/Button.svelte.d.ts +5 -15
- package/dist/components/Column.svelte +19 -0
- package/dist/components/Column.svelte.d.ts +10 -0
- package/dist/components/Container.svelte +10 -3
- package/dist/components/Container.svelte.d.ts +5 -11
- package/dist/components/Hr.svelte +14 -0
- package/dist/components/Hr.svelte.d.ts +4 -0
- package/dist/components/Html.svelte +3 -3
- package/dist/components/Html.svelte.d.ts +1 -1
- package/dist/components/Link.svelte +26 -0
- package/dist/components/Link.svelte.d.ts +9 -0
- package/dist/components/Row.svelte +30 -0
- package/dist/components/Row.svelte.d.ts +8 -0
- package/dist/components/Section.svelte +14 -5
- package/dist/components/Section.svelte.d.ts +5 -11
- package/dist/components/Text.svelte +13 -3
- package/dist/components/Text.svelte.d.ts +6 -12
- package/dist/components/index.d.ts +7 -3
- package/dist/components/index.js +7 -3
- package/dist/emails/apple-receipt.svelte +260 -0
- package/dist/emails/apple-receipt.svelte.d.ts +18 -0
- package/dist/emails/demo-email.svelte +31 -31
- package/dist/emails/test-email.svelte +7 -3
- package/dist/emails/vercel-invite-user.svelte +133 -0
- package/dist/emails/vercel-invite-user.svelte.d.ts +14 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/preprocessor/index.js +27 -15
- package/dist/preprocessor/parser.d.ts +5 -2
- package/dist/preprocessor/parser.js +87 -21
- package/dist/preprocessor/transformer.js +3 -3
- package/dist/preprocessor/types.d.ts +21 -0
- package/dist/preview/Preview.svelte +231 -0
- package/dist/preview/Preview.svelte.d.ts +7 -0
- package/dist/preview/index.d.ts +85 -0
- package/dist/preview/index.js +183 -0
- package/package.json +3 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import MagicString from 'magic-string';
|
|
2
|
-
import {
|
|
2
|
+
import { parseAttributes } from './parser.js';
|
|
3
3
|
import { createTailwindConverter, transformTailwindClasses, generateMediaQueries, sanitizeClassName } from './transformer.js';
|
|
4
4
|
import { injectMediaQueries } from './head-injector.js';
|
|
5
|
+
import path from 'path';
|
|
5
6
|
/**
|
|
6
7
|
* Svelte 5 preprocessor for transforming Tailwind classes in email components
|
|
7
8
|
*
|
|
@@ -24,7 +25,7 @@ import { injectMediaQueries } from './head-injector.js';
|
|
|
24
25
|
* Reference: https://svelte.dev/docs/svelte/svelte-compiler#preprocess
|
|
25
26
|
*/
|
|
26
27
|
export function betterSvelteEmailPreprocessor(options = {}) {
|
|
27
|
-
const { tailwindConfig, pathToEmailFolder = '/src/lib/emails', debug =
|
|
28
|
+
const { tailwindConfig, pathToEmailFolder = '/src/lib/emails', debug = true } = options;
|
|
28
29
|
// Initialize Tailwind converter once (performance optimization)
|
|
29
30
|
const tailwindConverter = createTailwindConverter(tailwindConfig);
|
|
30
31
|
// Return a Svelte 5 PreprocessorGroup
|
|
@@ -49,7 +50,7 @@ export function betterSvelteEmailPreprocessor(options = {}) {
|
|
|
49
50
|
// Log warnings if debug mode is enabled
|
|
50
51
|
if (result.warnings.length > 0) {
|
|
51
52
|
if (debug) {
|
|
52
|
-
console.warn(`[better-svelte-email] Warnings for ${filename}
|
|
53
|
+
console.warn(`[better-svelte-email] Warnings for ${path.relative(process.cwd(), filename)}:\n`, result.warnings.join('\n'));
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
// Return the transformed code
|
|
@@ -76,8 +77,8 @@ function processEmailComponent(source, _filename, tailwindConverter, tailwindCon
|
|
|
76
77
|
let transformedCode = source;
|
|
77
78
|
const allMediaQueries = [];
|
|
78
79
|
// Step 1: Parse and find all class attributes
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
80
|
+
const attributes = parseAttributes(source);
|
|
81
|
+
if (attributes.length === 0) {
|
|
81
82
|
// No classes to transform
|
|
82
83
|
return {
|
|
83
84
|
originalCode: source,
|
|
@@ -90,19 +91,19 @@ function processEmailComponent(source, _filename, tailwindConverter, tailwindCon
|
|
|
90
91
|
// Step 2: Transform each class attribute
|
|
91
92
|
const s = new MagicString(transformedCode);
|
|
92
93
|
// Process in reverse order to maintain correct positions
|
|
93
|
-
const sortedAttributes = [...
|
|
94
|
-
for (const
|
|
95
|
-
if (!
|
|
94
|
+
const sortedAttributes = [...attributes].sort((a, b) => b.class.start - a.class.start);
|
|
95
|
+
for (const attr of sortedAttributes) {
|
|
96
|
+
if (!attr.class.isStatic) {
|
|
96
97
|
// Skip dynamic classes for now
|
|
97
|
-
warnings.push(`Dynamic class expression detected in ${
|
|
98
|
+
warnings.push(`Dynamic class expression detected in ${attr.class.elementName}. ` +
|
|
98
99
|
`Only static classes can be transformed at build time.`);
|
|
99
100
|
continue;
|
|
100
101
|
}
|
|
101
102
|
// Transform the classes
|
|
102
|
-
const transformed = transformTailwindClasses(
|
|
103
|
+
const transformed = transformTailwindClasses(attr.class.raw, tailwindConverter);
|
|
103
104
|
// Collect warnings about invalid classes
|
|
104
105
|
if (transformed.invalidClasses.length > 0) {
|
|
105
|
-
warnings.push(`Invalid Tailwind classes in ${
|
|
106
|
+
warnings.push(`Invalid Tailwind classes in ${attr.class.elementName}: ${transformed.invalidClasses.join(', ')}`);
|
|
106
107
|
}
|
|
107
108
|
// Generate media queries for responsive classes
|
|
108
109
|
if (transformed.responsiveClasses.length > 0) {
|
|
@@ -110,9 +111,13 @@ function processEmailComponent(source, _filename, tailwindConverter, tailwindCon
|
|
|
110
111
|
allMediaQueries.push(...mediaQueries);
|
|
111
112
|
}
|
|
112
113
|
// Build the new attribute value
|
|
113
|
-
const newAttributes = buildNewAttributes(transformed.inlineStyles, transformed.responsiveClasses);
|
|
114
|
+
const newAttributes = buildNewAttributes(transformed.inlineStyles, transformed.responsiveClasses, attr.style?.raw);
|
|
115
|
+
// Remove the already existing style attribute if it exists
|
|
116
|
+
if (attr.style) {
|
|
117
|
+
removeStyleAttribute(s, attr.style);
|
|
118
|
+
}
|
|
114
119
|
// Replace the class attribute with new attributes
|
|
115
|
-
replaceClassAttribute(s,
|
|
120
|
+
replaceClassAttribute(s, attr.class, newAttributes);
|
|
116
121
|
}
|
|
117
122
|
transformedCode = s.toString();
|
|
118
123
|
// Step 3: Inject media queries into <Head>
|
|
@@ -136,7 +141,7 @@ function processEmailComponent(source, _filename, tailwindConverter, tailwindCon
|
|
|
136
141
|
/**
|
|
137
142
|
* Build new attribute string from transformation result
|
|
138
143
|
*/
|
|
139
|
-
function buildNewAttributes(inlineStyles, responsiveClasses) {
|
|
144
|
+
function buildNewAttributes(inlineStyles, responsiveClasses, existingStyles) {
|
|
140
145
|
const parts = [];
|
|
141
146
|
// Add responsive classes if any
|
|
142
147
|
if (responsiveClasses.length > 0) {
|
|
@@ -147,7 +152,8 @@ function buildNewAttributes(inlineStyles, responsiveClasses) {
|
|
|
147
152
|
if (inlineStyles) {
|
|
148
153
|
// Escape quotes in styles
|
|
149
154
|
const escapedStyles = inlineStyles.replace(/"/g, '"');
|
|
150
|
-
|
|
155
|
+
const withExisting = escapedStyles + (existingStyles ? existingStyles : '');
|
|
156
|
+
parts.push(`style="${withExisting}"`);
|
|
151
157
|
}
|
|
152
158
|
return parts.join(' ');
|
|
153
159
|
}
|
|
@@ -194,3 +200,9 @@ function replaceClassAttribute(s, classAttr, newAttributes) {
|
|
|
194
200
|
s.remove(removeStart, removeEnd);
|
|
195
201
|
}
|
|
196
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Remove style attribute with MagicString
|
|
205
|
+
*/
|
|
206
|
+
function removeStyleAttribute(s, styleAttr) {
|
|
207
|
+
s.remove(styleAttr.start, styleAttr.end);
|
|
208
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type { ClassAttribute } from './types.js';
|
|
1
|
+
import type { ClassAttribute, StyleAttribute } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Parse Svelte 5 source code and extract all class attributes
|
|
4
4
|
* Reference: https://svelte.dev/docs/svelte/svelte-compiler#parse
|
|
5
5
|
*/
|
|
6
|
-
export declare function
|
|
6
|
+
export declare function parseAttributes(source: string): {
|
|
7
|
+
class: ClassAttribute;
|
|
8
|
+
style?: StyleAttribute;
|
|
9
|
+
}[];
|
|
7
10
|
/**
|
|
8
11
|
* Find the <Head> component in Svelte 5 AST
|
|
9
12
|
* Returns the position where we should inject styles
|
|
@@ -3,8 +3,8 @@ import { parse } from 'svelte/compiler';
|
|
|
3
3
|
* Parse Svelte 5 source code and extract all class attributes
|
|
4
4
|
* Reference: https://svelte.dev/docs/svelte/svelte-compiler#parse
|
|
5
5
|
*/
|
|
6
|
-
export function
|
|
7
|
-
const
|
|
6
|
+
export function parseAttributes(source) {
|
|
7
|
+
const attributes = [];
|
|
8
8
|
try {
|
|
9
9
|
// Parse the Svelte file into an AST
|
|
10
10
|
// Svelte 5 parse returns a Root node with modern AST structure
|
|
@@ -12,7 +12,7 @@ export function parseClassAttributes(source) {
|
|
|
12
12
|
// Walk the html fragment (template portion) of the AST
|
|
13
13
|
if (ast.html && ast.html.children) {
|
|
14
14
|
for (const child of ast.html.children) {
|
|
15
|
-
walkNode(child,
|
|
15
|
+
walkNode(child, attributes, source);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -20,12 +20,12 @@ export function parseClassAttributes(source) {
|
|
|
20
20
|
console.error('Failed to parse Svelte file:', error);
|
|
21
21
|
throw error;
|
|
22
22
|
}
|
|
23
|
-
return
|
|
23
|
+
return attributes;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Recursively walk Svelte 5 AST nodes to find class attributes
|
|
27
27
|
*/
|
|
28
|
-
function walkNode(node,
|
|
28
|
+
function walkNode(node, attributes, source) {
|
|
29
29
|
if (!node)
|
|
30
30
|
return;
|
|
31
31
|
// Svelte 5 AST structure:
|
|
@@ -37,18 +37,33 @@ function walkNode(node, classAttributes, source) {
|
|
|
37
37
|
node.type === 'SlotElement' ||
|
|
38
38
|
node.type === 'Component') {
|
|
39
39
|
const elementName = node.name || 'unknown';
|
|
40
|
-
// Look for class attribute in Svelte 5 AST
|
|
40
|
+
// Look for class and style attribute in Svelte 5 AST
|
|
41
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');
|
|
42
43
|
if (classAttr && classAttr.value) {
|
|
43
44
|
// Extract class value
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
52
67
|
});
|
|
53
68
|
}
|
|
54
69
|
}
|
|
@@ -56,21 +71,21 @@ function walkNode(node, classAttributes, source) {
|
|
|
56
71
|
// Recursively process children
|
|
57
72
|
if (node.children) {
|
|
58
73
|
for (const child of node.children) {
|
|
59
|
-
walkNode(child,
|
|
74
|
+
walkNode(child, attributes, source);
|
|
60
75
|
}
|
|
61
76
|
}
|
|
62
77
|
// Handle conditional blocks (#if, #each, etc.)
|
|
63
78
|
if (node.consequent) {
|
|
64
79
|
if (node.consequent.children) {
|
|
65
80
|
for (const child of node.consequent.children) {
|
|
66
|
-
walkNode(child,
|
|
81
|
+
walkNode(child, attributes, source);
|
|
67
82
|
}
|
|
68
83
|
}
|
|
69
84
|
}
|
|
70
85
|
if (node.alternate) {
|
|
71
86
|
if (node.alternate.children) {
|
|
72
87
|
for (const child of node.alternate.children) {
|
|
73
|
-
walkNode(child,
|
|
88
|
+
walkNode(child, attributes, source);
|
|
74
89
|
}
|
|
75
90
|
}
|
|
76
91
|
}
|
|
@@ -78,7 +93,7 @@ function walkNode(node, classAttributes, source) {
|
|
|
78
93
|
if (node.body) {
|
|
79
94
|
if (node.body.children) {
|
|
80
95
|
for (const child of node.body.children) {
|
|
81
|
-
walkNode(child,
|
|
96
|
+
walkNode(child, attributes, source);
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
}
|
|
@@ -130,8 +145,8 @@ function extractClassValue(classAttr, source) {
|
|
|
130
145
|
// Mixed content (both Text and ExpressionTag)
|
|
131
146
|
// Extract only the static Text portions for partial transformation
|
|
132
147
|
let combinedValue = '';
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
const start = classAttr.value[0].start;
|
|
149
|
+
const end = classAttr.value[classAttr.value.length - 1].end;
|
|
135
150
|
let hasStaticContent = false;
|
|
136
151
|
for (const part of classAttr.value) {
|
|
137
152
|
if (part.type === 'Text' && part.data) {
|
|
@@ -150,6 +165,57 @@ function extractClassValue(classAttr, source) {
|
|
|
150
165
|
}
|
|
151
166
|
return null;
|
|
152
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
|
+
}
|
|
153
219
|
/**
|
|
154
220
|
* Find the <Head> component in Svelte 5 AST
|
|
155
221
|
* Returns the position where we should inject styles
|
|
@@ -167,7 +233,7 @@ export function findHeadComponent(source) {
|
|
|
167
233
|
}
|
|
168
234
|
return { found: false, insertPosition: null };
|
|
169
235
|
}
|
|
170
|
-
catch
|
|
236
|
+
catch {
|
|
171
237
|
return { found: false, insertPosition: null };
|
|
172
238
|
}
|
|
173
239
|
}
|
|
@@ -66,15 +66,15 @@ function extractStylesFromCSS(css, originalClasses) {
|
|
|
66
66
|
let match;
|
|
67
67
|
while ((match = classRegex.exec(cssWithoutMedia)) !== null) {
|
|
68
68
|
const className = match[1];
|
|
69
|
-
const rules = match[2].trim();
|
|
69
|
+
const rules = match[2].replace(/\\/g, '').trim();
|
|
70
70
|
// Normalize class name (tw-to-css might transform special chars)
|
|
71
|
-
const normalizedClass = className.replace(/[:#\-[\]/.%!_]+/g, '_');
|
|
71
|
+
const normalizedClass = className.replace(/\\/g, '').replace(/[:#\-[\]/.%!_]+/g, '_');
|
|
72
72
|
classMap.set(normalizedClass, rules);
|
|
73
73
|
}
|
|
74
74
|
// For each original class, try to find its CSS
|
|
75
75
|
for (const originalClass of originalClasses) {
|
|
76
76
|
// Normalize the original class name to match what tw-to-css produces
|
|
77
|
-
const normalized = originalClass.replace(/[:#\-[\]/.%!]+/g, '_');
|
|
77
|
+
const normalized = originalClass.replace(/[:#\-[\]/.%!_]+/g, '_');
|
|
78
78
|
if (classMap.has(normalized)) {
|
|
79
79
|
const rules = classMap.get(normalized);
|
|
80
80
|
// Ensure rules end with semicolon for proper concatenation
|
|
@@ -43,6 +43,27 @@ export interface ClassAttribute {
|
|
|
43
43
|
*/
|
|
44
44
|
isStatic: boolean;
|
|
45
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
|
+
}
|
|
46
67
|
/**
|
|
47
68
|
* Result of transforming Tailwind classes
|
|
48
69
|
*/
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { HighlightAuto } from 'svelte-highlight';
|
|
3
|
+
import oneDark from 'svelte-highlight/styles/onedark';
|
|
4
|
+
import type { PreviewData } from './index.js';
|
|
5
|
+
|
|
6
|
+
let { emailList }: { emailList: PreviewData } = $props();
|
|
7
|
+
|
|
8
|
+
let selectedEmail = $state<string | null>(null);
|
|
9
|
+
let renderedHtml = $state<string>('');
|
|
10
|
+
let iframeContent = $state<string>('');
|
|
11
|
+
let loading = $state(false);
|
|
12
|
+
let error = $state<string | null>(null);
|
|
13
|
+
|
|
14
|
+
const FONT_SANS_STYLE = `<style>
|
|
15
|
+
body {
|
|
16
|
+
font-family:
|
|
17
|
+
ui-sans-serif,
|
|
18
|
+
system-ui,
|
|
19
|
+
-apple-system,
|
|
20
|
+
BlinkMacSystemFont,
|
|
21
|
+
'Segoe UI',
|
|
22
|
+
Helvetica,
|
|
23
|
+
Arial,
|
|
24
|
+
'Noto Sans',
|
|
25
|
+
sans-serif;
|
|
26
|
+
margin: 0;
|
|
27
|
+
}
|
|
28
|
+
</style>`;
|
|
29
|
+
|
|
30
|
+
function withFontSans(html: string) {
|
|
31
|
+
if (!html) return '';
|
|
32
|
+
|
|
33
|
+
if (html.includes('<head')) {
|
|
34
|
+
return html.replace('<head>', `<head>${FONT_SANS_STYLE}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (html.includes('<html')) {
|
|
38
|
+
const htmlTagEnd = html.indexOf('>', html.indexOf('<html'));
|
|
39
|
+
if (htmlTagEnd !== -1) {
|
|
40
|
+
const before = html.slice(0, htmlTagEnd + 1);
|
|
41
|
+
const after = html.slice(htmlTagEnd + 1);
|
|
42
|
+
return `${before}<head>${FONT_SANS_STYLE}</head>${after}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (html.includes('<body')) {
|
|
47
|
+
const bodyTagEnd = html.indexOf('>', html.indexOf('<body'));
|
|
48
|
+
if (bodyTagEnd !== -1) {
|
|
49
|
+
const before = html.slice(0, bodyTagEnd + 1);
|
|
50
|
+
const after = html.slice(bodyTagEnd + 1);
|
|
51
|
+
return `${before}${FONT_SANS_STYLE}${after}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return `${FONT_SANS_STYLE}${html}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function previewEmail(fileName: string) {
|
|
59
|
+
selectedEmail = fileName;
|
|
60
|
+
loading = true;
|
|
61
|
+
error = null;
|
|
62
|
+
renderedHtml = '';
|
|
63
|
+
iframeContent = '';
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch('?/create-email', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
69
|
+
body: new URLSearchParams({
|
|
70
|
+
file: fileName,
|
|
71
|
+
path: emailList.path || '/src/lib/emails'
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = await response.json();
|
|
76
|
+
|
|
77
|
+
if (result.type === 'success' && result.data) {
|
|
78
|
+
let htmlOutput = '';
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(result.data);
|
|
81
|
+
if (Array.isArray(parsed) && typeof parsed[0] === 'string') {
|
|
82
|
+
htmlOutput = parsed[0];
|
|
83
|
+
} else if (typeof parsed === 'string') {
|
|
84
|
+
htmlOutput = parsed;
|
|
85
|
+
}
|
|
86
|
+
} catch (parseError) {
|
|
87
|
+
htmlOutput = typeof result.data === 'string' ? result.data : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!htmlOutput) {
|
|
91
|
+
throw new Error('Failed to parse rendered HTML response');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
renderedHtml = htmlOutput;
|
|
95
|
+
iframeContent = withFontSans(htmlOutput);
|
|
96
|
+
} else if (result.type === 'error') {
|
|
97
|
+
error = result.error?.message || 'Failed to render email';
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
error = e instanceof Error ? e.message : 'Failed to preview email';
|
|
101
|
+
} finally {
|
|
102
|
+
loading = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function copyHtml() {
|
|
107
|
+
if (renderedHtml) {
|
|
108
|
+
navigator.clipboard.writeText(renderedHtml);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<svelte:head>
|
|
114
|
+
{@html oneDark}
|
|
115
|
+
</svelte:head>
|
|
116
|
+
|
|
117
|
+
<div
|
|
118
|
+
class="grid h-screen grid-cols-[280px_1fr] bg-gray-50 font-sans max-md:grid-cols-1 max-md:grid-rows-[auto_1fr]"
|
|
119
|
+
>
|
|
120
|
+
<div
|
|
121
|
+
class="flex flex-col overflow-hidden border-r border-gray-200 bg-white max-md:max-h-[40vh] max-md:border-r-0 max-md:border-b"
|
|
122
|
+
>
|
|
123
|
+
<div class="flex items-center justify-between gap-2 border-b border-gray-200 p-6 pb-4">
|
|
124
|
+
<h2 class="m-0 text-lg font-semibold text-gray-900">Email Templates</h2>
|
|
125
|
+
{#if emailList.files}
|
|
126
|
+
<span
|
|
127
|
+
class="min-w-6 rounded-full bg-blue-500 px-2 py-0.5 text-center text-xs font-semibold text-white"
|
|
128
|
+
>
|
|
129
|
+
{emailList.files.length}
|
|
130
|
+
</span>
|
|
131
|
+
{/if}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{#if !emailList.files || emailList.files.length === 0}
|
|
135
|
+
<div class="px-4 py-8 text-center text-gray-500">
|
|
136
|
+
<p class="my-2 text-sm">No email templates found</p>
|
|
137
|
+
<p class="my-2 text-xs text-gray-400">
|
|
138
|
+
Create email components in <code
|
|
139
|
+
class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs"
|
|
140
|
+
>{emailList.path || '/src/lib/emails'}</code
|
|
141
|
+
>
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
{:else}
|
|
145
|
+
<ul class="m-0 flex-1 list-none overflow-y-auto p-2">
|
|
146
|
+
{#each emailList.files as file}
|
|
147
|
+
<li>
|
|
148
|
+
<button
|
|
149
|
+
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent p-3 text-left text-sm text-gray-700 transition-all duration-150 hover:bg-gray-100"
|
|
150
|
+
class:bg-blue-50={selectedEmail === file}
|
|
151
|
+
class:text-blue-900={selectedEmail === file}
|
|
152
|
+
class:font-medium={selectedEmail === file}
|
|
153
|
+
onclick={() => previewEmail(file)}
|
|
154
|
+
>
|
|
155
|
+
<span class="flex-shrink-0 text-xl">📧</span>
|
|
156
|
+
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{file}</span>
|
|
157
|
+
</button>
|
|
158
|
+
</li>
|
|
159
|
+
{/each}
|
|
160
|
+
</ul>
|
|
161
|
+
{/if}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="flex flex-col overflow-hidden bg-white">
|
|
165
|
+
{#if !selectedEmail}
|
|
166
|
+
<div class="flex flex-1 items-center justify-center bg-gray-50">
|
|
167
|
+
<div class="max-w-md p-8 text-center">
|
|
168
|
+
<div class="mb-4 text-6xl">✨</div>
|
|
169
|
+
<h3 class="mb-2 text-2xl font-semibold text-gray-900">Select an Email Template</h3>
|
|
170
|
+
<p class="text-gray-500">
|
|
171
|
+
Choose a template from the sidebar to preview its rendered HTML
|
|
172
|
+
</p>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
{:else if loading}
|
|
176
|
+
<div class="flex flex-1 items-center justify-center bg-gray-50">
|
|
177
|
+
<div class="max-w-md p-8 text-center">
|
|
178
|
+
<div
|
|
179
|
+
class="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500"
|
|
180
|
+
></div>
|
|
181
|
+
<p class="text-gray-500">Rendering email...</p>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
{:else if error}
|
|
185
|
+
<div class="flex flex-1 items-center justify-center bg-gray-50">
|
|
186
|
+
<div class="max-w-md p-8 text-center">
|
|
187
|
+
<div class="mb-4 text-5xl">⚠️</div>
|
|
188
|
+
<h3 class="mb-2 text-2xl font-semibold text-gray-900">Error Rendering Email</h3>
|
|
189
|
+
<p class="mb-0 text-gray-500">{error}</p>
|
|
190
|
+
<button
|
|
191
|
+
class="mt-4 cursor-pointer rounded-md border-0 bg-blue-500 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-600"
|
|
192
|
+
onclick={() => selectedEmail && previewEmail(selectedEmail)}
|
|
193
|
+
>
|
|
194
|
+
Try Again
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
{:else if renderedHtml}
|
|
199
|
+
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-4">
|
|
200
|
+
<h3 class="m-0 text-lg font-semibold text-gray-900">{selectedEmail}</h3>
|
|
201
|
+
<div class="flex gap-2">
|
|
202
|
+
<button
|
|
203
|
+
class="flex cursor-pointer items-center gap-1.5 rounded-md border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 transition-all duration-150 hover:border-gray-400 hover:bg-gray-50"
|
|
204
|
+
onclick={copyHtml}
|
|
205
|
+
title="Copy HTML"
|
|
206
|
+
>
|
|
207
|
+
<span class="text-base">📋</span> Copy HTML
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="flex-1 overflow-hidden bg-gray-50 p-4">
|
|
213
|
+
<iframe
|
|
214
|
+
title="Email Preview"
|
|
215
|
+
srcdoc={iframeContent}
|
|
216
|
+
class="h-full w-full rounded-lg border border-gray-200 bg-white"
|
|
217
|
+
sandbox="allow-same-origin allow-scripts"
|
|
218
|
+
></iframe>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<details class="overflow-auto border-t border-gray-200 bg-gray-50">
|
|
222
|
+
<summary
|
|
223
|
+
class="cursor-pointer px-6 py-3 font-medium text-gray-700 select-none hover:bg-gray-100"
|
|
224
|
+
>
|
|
225
|
+
View HTML Source
|
|
226
|
+
</summary>
|
|
227
|
+
<HighlightAuto class="h-full overflow-y-scroll text-xs" code={renderedHtml} />
|
|
228
|
+
</details>
|
|
229
|
+
{/if}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
/**
|
|
3
|
+
* Import all Svelte email components file paths.
|
|
4
|
+
* Create a list containing all Svelte email component file names.
|
|
5
|
+
* Return this list to the client.
|
|
6
|
+
*/
|
|
7
|
+
export type PreviewData = {
|
|
8
|
+
files: string[] | null;
|
|
9
|
+
path: string | null;
|
|
10
|
+
};
|
|
11
|
+
type EmailListProps = {
|
|
12
|
+
path?: string;
|
|
13
|
+
root?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Get a list of all email component files in the specified directory.
|
|
17
|
+
*
|
|
18
|
+
* @param options.path - Relative path from root to emails folder (default: '/src/lib/emails')
|
|
19
|
+
* @param options.root - Absolute path to project root (auto-detected if not provided)
|
|
20
|
+
* @returns PreviewData object with list of email files and the path
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // In a +page.server.ts file
|
|
25
|
+
* import { emailList } from 'better-svelte-email/preview';
|
|
26
|
+
*
|
|
27
|
+
* export function load() {
|
|
28
|
+
* const emails = emailList({
|
|
29
|
+
* root: process.cwd(),
|
|
30
|
+
* path: '/src/lib/emails'
|
|
31
|
+
* });
|
|
32
|
+
* return { emails };
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare const emailList: ({ path: emailPath, root }?: EmailListProps) => PreviewData;
|
|
37
|
+
/**
|
|
38
|
+
* SvelteKit form action to render an email component.
|
|
39
|
+
* Use this with the Preview component to render email templates on demand.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* // +page.server.ts
|
|
44
|
+
* import { createEmail } from 'better-svelte-email/preview';
|
|
45
|
+
*
|
|
46
|
+
* export const actions = createEmail;
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare const createEmail: {
|
|
50
|
+
'create-email': (event: RequestEvent) => Promise<string | {
|
|
51
|
+
status: number;
|
|
52
|
+
body: {
|
|
53
|
+
error: string;
|
|
54
|
+
};
|
|
55
|
+
error?: undefined;
|
|
56
|
+
} | {
|
|
57
|
+
status: number;
|
|
58
|
+
error: {
|
|
59
|
+
message: string;
|
|
60
|
+
};
|
|
61
|
+
body?: undefined;
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
export declare const SendEmailFunction: ({ from, to, subject, html }: {
|
|
65
|
+
from: string;
|
|
66
|
+
to: string;
|
|
67
|
+
subject: string;
|
|
68
|
+
html: string;
|
|
69
|
+
}, resendApiKey?: string) => Promise<{
|
|
70
|
+
success: boolean;
|
|
71
|
+
error?: any;
|
|
72
|
+
}>;
|
|
73
|
+
/**
|
|
74
|
+
* Sends the email using the submitted form data.
|
|
75
|
+
*/
|
|
76
|
+
export declare const sendEmail: ({ customSendEmailFunction, resendApiKey }: {
|
|
77
|
+
customSendEmailFunction?: typeof SendEmailFunction;
|
|
78
|
+
resendApiKey?: string;
|
|
79
|
+
}) => {
|
|
80
|
+
'send-email': (event: RequestEvent) => Promise<{
|
|
81
|
+
success: boolean;
|
|
82
|
+
error: any;
|
|
83
|
+
}>;
|
|
84
|
+
};
|
|
85
|
+
export { default as Preview } from './Preview.svelte';
|