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.
Files changed (40) hide show
  1. package/dist/components/Body.svelte +14 -5
  2. package/dist/components/Body.svelte.d.ts +6 -12
  3. package/dist/components/Button.svelte +11 -4
  4. package/dist/components/Button.svelte.d.ts +5 -15
  5. package/dist/components/Column.svelte +19 -0
  6. package/dist/components/Column.svelte.d.ts +10 -0
  7. package/dist/components/Container.svelte +10 -3
  8. package/dist/components/Container.svelte.d.ts +5 -11
  9. package/dist/components/Hr.svelte +14 -0
  10. package/dist/components/Hr.svelte.d.ts +4 -0
  11. package/dist/components/Html.svelte +3 -3
  12. package/dist/components/Html.svelte.d.ts +1 -1
  13. package/dist/components/Link.svelte +26 -0
  14. package/dist/components/Link.svelte.d.ts +9 -0
  15. package/dist/components/Row.svelte +30 -0
  16. package/dist/components/Row.svelte.d.ts +8 -0
  17. package/dist/components/Section.svelte +14 -5
  18. package/dist/components/Section.svelte.d.ts +5 -11
  19. package/dist/components/Text.svelte +13 -3
  20. package/dist/components/Text.svelte.d.ts +6 -12
  21. package/dist/components/index.d.ts +7 -3
  22. package/dist/components/index.js +7 -3
  23. package/dist/emails/apple-receipt.svelte +260 -0
  24. package/dist/emails/apple-receipt.svelte.d.ts +18 -0
  25. package/dist/emails/demo-email.svelte +31 -31
  26. package/dist/emails/test-email.svelte +7 -3
  27. package/dist/emails/vercel-invite-user.svelte +133 -0
  28. package/dist/emails/vercel-invite-user.svelte.d.ts +14 -0
  29. package/dist/index.d.ts +3 -2
  30. package/dist/index.js +2 -2
  31. package/dist/preprocessor/index.js +27 -15
  32. package/dist/preprocessor/parser.d.ts +5 -2
  33. package/dist/preprocessor/parser.js +87 -21
  34. package/dist/preprocessor/transformer.js +3 -3
  35. package/dist/preprocessor/types.d.ts +21 -0
  36. package/dist/preview/Preview.svelte +231 -0
  37. package/dist/preview/Preview.svelte.d.ts +7 -0
  38. package/dist/preview/index.d.ts +85 -0
  39. package/dist/preview/index.js +183 -0
  40. package/package.json +3 -1
@@ -1,7 +1,8 @@
1
1
  import MagicString from 'magic-string';
2
- import { parseClassAttributes } from './parser.js';
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 = false } = options;
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}:`, result.warnings);
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 classAttributes = parseClassAttributes(source);
80
- if (classAttributes.length === 0) {
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 = [...classAttributes].sort((a, b) => b.start - a.start);
94
- for (const classAttr of sortedAttributes) {
95
- if (!classAttr.isStatic) {
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 ${classAttr.elementName}. ` +
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(classAttr.raw, tailwindConverter);
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 ${classAttr.elementName}: ${transformed.invalidClasses.join(', ')}`);
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, classAttr, newAttributes);
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, '&quot;');
150
- parts.push(`styleString="${escapedStyles}"`);
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 parseClassAttributes(source: string): ClassAttribute[];
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 parseClassAttributes(source) {
7
- const classAttributes = [];
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, classAttributes, source);
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 classAttributes;
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, classAttributes, source) {
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 extracted = extractClassValue(classAttr, source);
45
- if (extracted) {
46
- classAttributes.push({
47
- raw: extracted.value,
48
- start: extracted.start,
49
- end: extracted.end,
50
- elementName,
51
- isStatic: extracted.isStatic
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, classAttributes, source);
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, classAttributes, source);
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, classAttributes, source);
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, classAttributes, source);
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
- let start = classAttr.value[0].start;
134
- let end = classAttr.value[classAttr.value.length - 1].end;
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 (error) {
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,7 @@
1
+ import type { PreviewData } from './index.js';
2
+ type $$ComponentProps = {
3
+ emailList: PreviewData;
4
+ };
5
+ declare const Preview: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Preview = ReturnType<typeof Preview>;
7
+ export default Preview;
@@ -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';