better-svelte-email 0.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.
- package/README.md +422 -0
- package/dist/components/Body.svelte +9 -0
- package/dist/components/Body.svelte.d.ts +13 -0
- package/dist/components/Button.svelte +54 -0
- package/dist/components/Button.svelte.d.ts +21 -0
- package/dist/components/Container.svelte +28 -0
- package/dist/components/Container.svelte.d.ts +13 -0
- package/dist/components/Head.svelte +13 -0
- package/dist/components/Head.svelte.d.ts +6 -0
- package/dist/components/Html.svelte +19 -0
- package/dist/components/Html.svelte.d.ts +10 -0
- package/dist/components/Section.svelte +21 -0
- package/dist/components/Section.svelte.d.ts +13 -0
- package/dist/components/Text.svelte +17 -0
- package/dist/components/Text.svelte.d.ts +15 -0
- package/dist/components/__tests__/test-email.svelte +13 -0
- package/dist/components/__tests__/test-email.svelte.d.ts +26 -0
- package/dist/components/index.d.ts +7 -0
- package/dist/components/index.js +9 -0
- package/dist/emails/demo-email.svelte +108 -0
- package/dist/emails/demo-email.svelte.d.ts +13 -0
- package/dist/emails/test-email.svelte +15 -0
- package/dist/emails/test-email.svelte.d.ts +26 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +10 -0
- package/dist/preprocessor/head-injector.d.ts +9 -0
- package/dist/preprocessor/head-injector.js +57 -0
- package/dist/preprocessor/index.d.ts +25 -0
- package/dist/preprocessor/index.js +196 -0
- package/dist/preprocessor/parser.d.ts +14 -0
- package/dist/preprocessor/parser.js +249 -0
- package/dist/preprocessor/transformer.d.ts +18 -0
- package/dist/preprocessor/transformer.js +158 -0
- package/dist/preprocessor/types.d.ts +104 -0
- package/dist/preprocessor/types.js +1 -0
- package/dist/utils/index.d.ts +12 -0
- package/dist/utils/index.js +24 -0
- package/package.json +97 -0
|
@@ -0,0 +1,249 @@
|
|
|
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 parseClassAttributes(source) {
|
|
7
|
+
const classAttributes = [];
|
|
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, classAttributes, source);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
console.error('Failed to parse Svelte file:', error);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
return classAttributes;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Recursively walk Svelte 5 AST nodes to find class attributes
|
|
27
|
+
*/
|
|
28
|
+
function walkNode(node, classAttributes, 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 attribute in Svelte 5 AST
|
|
41
|
+
const classAttr = node.attributes?.find((attr) => attr.type === 'Attribute' && attr.name === 'class');
|
|
42
|
+
if (classAttr && classAttr.value) {
|
|
43
|
+
// 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
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Recursively process children
|
|
57
|
+
if (node.children) {
|
|
58
|
+
for (const child of node.children) {
|
|
59
|
+
walkNode(child, classAttributes, source);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Handle conditional blocks (#if, #each, etc.)
|
|
63
|
+
if (node.consequent) {
|
|
64
|
+
if (node.consequent.children) {
|
|
65
|
+
for (const child of node.consequent.children) {
|
|
66
|
+
walkNode(child, classAttributes, source);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (node.alternate) {
|
|
71
|
+
if (node.alternate.children) {
|
|
72
|
+
for (const child of node.alternate.children) {
|
|
73
|
+
walkNode(child, classAttributes, source);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Handle #each blocks
|
|
78
|
+
if (node.body) {
|
|
79
|
+
if (node.body.children) {
|
|
80
|
+
for (const child of node.body.children) {
|
|
81
|
+
walkNode(child, classAttributes, source);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Extract the actual class value from a Svelte 5 attribute node
|
|
88
|
+
*/
|
|
89
|
+
function extractClassValue(classAttr, source) {
|
|
90
|
+
// Svelte 5 attribute value formats:
|
|
91
|
+
// 1. Static string: class="text-red-500"
|
|
92
|
+
// → value: [{ type: 'Text', data: 'text-red-500' }]
|
|
93
|
+
//
|
|
94
|
+
// 2. Expression: class={someVar}
|
|
95
|
+
// → value: [{ type: 'ExpressionTag', expression: {...} }]
|
|
96
|
+
//
|
|
97
|
+
// 3. Mixed: class="static {dynamic} more"
|
|
98
|
+
// → value: [{ type: 'Text' }, { type: 'ExpressionTag' }, { type: 'Text' }]
|
|
99
|
+
if (!classAttr.value || classAttr.value.length === 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Check if entirely static (only Text nodes)
|
|
103
|
+
const hasOnlyText = classAttr.value.every((v) => v.type === 'Text');
|
|
104
|
+
if (hasOnlyText) {
|
|
105
|
+
// Fully static - we can safely transform this
|
|
106
|
+
const textContent = classAttr.value.map((v) => v.data || '').join('');
|
|
107
|
+
const start = classAttr.value[0].start;
|
|
108
|
+
const end = classAttr.value[classAttr.value.length - 1].end;
|
|
109
|
+
return {
|
|
110
|
+
value: textContent,
|
|
111
|
+
start,
|
|
112
|
+
end,
|
|
113
|
+
isStatic: true
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Check if entirely dynamic (only ExpressionTag or MustacheTag)
|
|
117
|
+
const hasOnlyExpression = classAttr.value.length === 1 &&
|
|
118
|
+
(classAttr.value[0].type === 'ExpressionTag' || classAttr.value[0].type === 'MustacheTag');
|
|
119
|
+
if (hasOnlyExpression) {
|
|
120
|
+
// Fully dynamic - cannot transform at build time
|
|
121
|
+
const exprNode = classAttr.value[0];
|
|
122
|
+
const expressionCode = source.substring(exprNode.start, exprNode.end);
|
|
123
|
+
return {
|
|
124
|
+
value: expressionCode,
|
|
125
|
+
start: exprNode.start,
|
|
126
|
+
end: exprNode.end,
|
|
127
|
+
isStatic: false
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Mixed content (both Text and ExpressionTag)
|
|
131
|
+
// Extract only the static Text portions for partial transformation
|
|
132
|
+
let combinedValue = '';
|
|
133
|
+
let start = classAttr.value[0].start;
|
|
134
|
+
let end = classAttr.value[classAttr.value.length - 1].end;
|
|
135
|
+
let hasStaticContent = false;
|
|
136
|
+
for (const part of classAttr.value) {
|
|
137
|
+
if (part.type === 'Text' && part.data) {
|
|
138
|
+
combinedValue += part.data + ' ';
|
|
139
|
+
hasStaticContent = true;
|
|
140
|
+
}
|
|
141
|
+
// Skip ExpressionTag nodes
|
|
142
|
+
}
|
|
143
|
+
if (hasStaticContent) {
|
|
144
|
+
return {
|
|
145
|
+
value: combinedValue.trim(),
|
|
146
|
+
start,
|
|
147
|
+
end,
|
|
148
|
+
isStatic: false // Mixed is not fully static
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Find the <Head> component in Svelte 5 AST
|
|
155
|
+
* Returns the position where we should inject styles
|
|
156
|
+
*/
|
|
157
|
+
export function findHeadComponent(source) {
|
|
158
|
+
try {
|
|
159
|
+
const ast = parse(source);
|
|
160
|
+
// Find Head component in the AST
|
|
161
|
+
if (ast.html && ast.html.children) {
|
|
162
|
+
for (const child of ast.html.children) {
|
|
163
|
+
const headInfo = findHeadInNode(child, source);
|
|
164
|
+
if (headInfo)
|
|
165
|
+
return headInfo;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { found: false, insertPosition: null };
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return { found: false, insertPosition: null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Recursively search for Head component in Svelte 5 AST
|
|
176
|
+
*/
|
|
177
|
+
function findHeadInNode(node, source) {
|
|
178
|
+
if (!node)
|
|
179
|
+
return null;
|
|
180
|
+
// Check if this is the Head component (InlineComponent type in Svelte 5)
|
|
181
|
+
if ((node.type === 'InlineComponent' || node.type === 'Component') && node.name === 'Head') {
|
|
182
|
+
// Svelte 5: Find the best insertion point for styles
|
|
183
|
+
// If Head has children, insert before first child
|
|
184
|
+
if (node.children && node.children.length > 0) {
|
|
185
|
+
return {
|
|
186
|
+
found: true,
|
|
187
|
+
insertPosition: node.children[0].start
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// No children - need to insert before closing tag
|
|
191
|
+
// Find where the opening tag ends
|
|
192
|
+
const headStart = node.start;
|
|
193
|
+
const headEnd = node.end;
|
|
194
|
+
const headContent = source.substring(headStart, headEnd);
|
|
195
|
+
// Self-closing: <Head />
|
|
196
|
+
if (headContent.includes('/>')) {
|
|
197
|
+
// Convert to non-self-closing by inserting before />
|
|
198
|
+
const selfClosingPos = source.indexOf('/>', headStart);
|
|
199
|
+
return {
|
|
200
|
+
found: true,
|
|
201
|
+
insertPosition: selfClosingPos
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// Regular closing tag: <Head></Head> or <Head>...</Head>
|
|
205
|
+
const closingTagPos = source.indexOf('</Head>', headStart);
|
|
206
|
+
if (closingTagPos !== -1) {
|
|
207
|
+
return {
|
|
208
|
+
found: true,
|
|
209
|
+
insertPosition: closingTagPos
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Fallback: insert right after opening tag
|
|
213
|
+
const openingTagEnd = source.indexOf('>', headStart);
|
|
214
|
+
if (openingTagEnd !== -1) {
|
|
215
|
+
return {
|
|
216
|
+
found: true,
|
|
217
|
+
insertPosition: openingTagEnd + 1
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Search recursively through the AST
|
|
222
|
+
if (node.children) {
|
|
223
|
+
for (const child of node.children) {
|
|
224
|
+
const found = findHeadInNode(child, source);
|
|
225
|
+
if (found)
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Check conditional branches
|
|
230
|
+
if (node.consequent) {
|
|
231
|
+
if (node.consequent.children) {
|
|
232
|
+
for (const child of node.consequent.children) {
|
|
233
|
+
const found = findHeadInNode(child, source);
|
|
234
|
+
if (found)
|
|
235
|
+
return found;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (node.alternate) {
|
|
240
|
+
if (node.alternate.children) {
|
|
241
|
+
for (const child of node.alternate.children) {
|
|
242
|
+
const found = findHeadInNode(child, source);
|
|
243
|
+
if (found)
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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;
|
|
@@ -0,0 +1,158 @@
|
|
|
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].trim();
|
|
70
|
+
// Normalize class name (tw-to-css might transform special chars)
|
|
71
|
+
const normalizedClass = className.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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
* Result of transforming Tailwind classes
|
|
48
|
+
*/
|
|
49
|
+
export interface TransformResult {
|
|
50
|
+
/**
|
|
51
|
+
* CSS styles for inline styleString prop
|
|
52
|
+
*/
|
|
53
|
+
inlineStyles: string;
|
|
54
|
+
/**
|
|
55
|
+
* Responsive classes to keep in class attribute
|
|
56
|
+
*/
|
|
57
|
+
responsiveClasses: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Classes that couldn't be converted (warnings)
|
|
60
|
+
*/
|
|
61
|
+
invalidClasses: string[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Media query CSS to inject into head
|
|
65
|
+
*/
|
|
66
|
+
export interface MediaQueryStyle {
|
|
67
|
+
/**
|
|
68
|
+
* Media query condition (e.g., "@media (max-width: 475px)")
|
|
69
|
+
*/
|
|
70
|
+
query: string;
|
|
71
|
+
/**
|
|
72
|
+
* CSS class name
|
|
73
|
+
*/
|
|
74
|
+
className: string;
|
|
75
|
+
/**
|
|
76
|
+
* CSS rules
|
|
77
|
+
*/
|
|
78
|
+
rules: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Information about a component's transformations
|
|
82
|
+
*/
|
|
83
|
+
export interface ComponentTransform {
|
|
84
|
+
/**
|
|
85
|
+
* Original source code
|
|
86
|
+
*/
|
|
87
|
+
originalCode: string;
|
|
88
|
+
/**
|
|
89
|
+
* Transformed source code
|
|
90
|
+
*/
|
|
91
|
+
transformedCode: string;
|
|
92
|
+
/**
|
|
93
|
+
* Media queries to inject
|
|
94
|
+
*/
|
|
95
|
+
mediaQueries: MediaQueryStyle[];
|
|
96
|
+
/**
|
|
97
|
+
* Whether <Head> component was found
|
|
98
|
+
*/
|
|
99
|
+
hasHead: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Warnings encountered during transformation
|
|
102
|
+
*/
|
|
103
|
+
warnings: string[];
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a style object to a CSS string
|
|
3
|
+
* @param style - Object containing CSS properties
|
|
4
|
+
* @returns CSS string with properties
|
|
5
|
+
*/
|
|
6
|
+
export declare function styleToString(style: Record<string, string | number | undefined>): string;
|
|
7
|
+
/**
|
|
8
|
+
* Convert pixels to points for email clients
|
|
9
|
+
* @param px - Pixel value as string
|
|
10
|
+
* @returns Point value as string
|
|
11
|
+
*/
|
|
12
|
+
export declare function pxToPt(px: string | number): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a style object to a CSS string
|
|
3
|
+
* @param style - Object containing CSS properties
|
|
4
|
+
* @returns CSS string with properties
|
|
5
|
+
*/
|
|
6
|
+
export function styleToString(style) {
|
|
7
|
+
return Object.entries(style)
|
|
8
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
9
|
+
.map(([key, value]) => {
|
|
10
|
+
// Convert camelCase to kebab-case
|
|
11
|
+
const cssKey = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
12
|
+
return `${cssKey}:${value}`;
|
|
13
|
+
})
|
|
14
|
+
.join(';');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert pixels to points for email clients
|
|
18
|
+
* @param px - Pixel value as string
|
|
19
|
+
* @returns Point value as string
|
|
20
|
+
*/
|
|
21
|
+
export function pxToPt(px) {
|
|
22
|
+
const value = typeof px === 'string' ? parseFloat(px) : px;
|
|
23
|
+
return `${Math.round(value * 0.75)}pt`;
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "better-svelte-email",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A Svelte 5 preprocessor that transforms Tailwind CSS classes in email components to inline styles with responsive media query support",
|
|
5
|
+
"author": "Anatole",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Konixy/better-svelte-email.git"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "vite dev",
|
|
13
|
+
"build": "vite build && npm run prepack",
|
|
14
|
+
"preview": "vite preview",
|
|
15
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
16
|
+
"prepack": "svelte-kit sync && svelte-package && publint",
|
|
17
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
18
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
19
|
+
"format": "prettier --write .",
|
|
20
|
+
"lint": "prettier --check . && eslint .",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"test:ui": "vitest --ui",
|
|
24
|
+
"test:coverage": "vitest run --coverage"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"!dist/**/*.test.*",
|
|
29
|
+
"!dist/**/*.spec.*"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": [
|
|
32
|
+
"**/*.css"
|
|
33
|
+
],
|
|
34
|
+
"svelte": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"type": "module",
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"svelte": "./dist/index.js",
|
|
41
|
+
"default": "./dist/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./preprocessor": {
|
|
44
|
+
"types": "./dist/preprocessor/index.d.ts",
|
|
45
|
+
"import": "./dist/preprocessor/index.js",
|
|
46
|
+
"default": "./dist/preprocessor/index.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"svelte": "^5.0.0"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"magic-string": "^0.30.19",
|
|
54
|
+
"resend": "^6.1.2",
|
|
55
|
+
"tw-to-css": "^0.0.12"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@eslint/compat": "^1.4.0",
|
|
59
|
+
"@eslint/js": "^9.37.0",
|
|
60
|
+
"@sveltejs/adapter-auto": "^6.1.1",
|
|
61
|
+
"@sveltejs/kit": "^2.43.8",
|
|
62
|
+
"@sveltejs/package": "^2.5.4",
|
|
63
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
64
|
+
"@tailwindcss/vite": "^4.1.14",
|
|
65
|
+
"@types/node": "^24",
|
|
66
|
+
"@vitest/browser": "^3.2.4",
|
|
67
|
+
"eslint": "^9.37.0",
|
|
68
|
+
"eslint-config-prettier": "^10.1.8",
|
|
69
|
+
"eslint-plugin-svelte": "^3.12.4",
|
|
70
|
+
"globals": "^16.4.0",
|
|
71
|
+
"playwright": "^1.55.1",
|
|
72
|
+
"prettier": "^3.6.2",
|
|
73
|
+
"prettier-plugin-svelte": "^3.4.0",
|
|
74
|
+
"prettier-plugin-tailwindcss": "^0.6.14",
|
|
75
|
+
"publint": "^0.3.13",
|
|
76
|
+
"svelte": "^5.39.8",
|
|
77
|
+
"svelte-check": "^4.3.2",
|
|
78
|
+
"tailwindcss": "^4.1.14",
|
|
79
|
+
"typescript": "^5.9.3",
|
|
80
|
+
"typescript-eslint": "^8.45.0",
|
|
81
|
+
"vite": "^7.1.9",
|
|
82
|
+
"vitest": "^3.2.4",
|
|
83
|
+
"vitest-browser-svelte": "^1.1.0"
|
|
84
|
+
},
|
|
85
|
+
"keywords": [
|
|
86
|
+
"svelte",
|
|
87
|
+
"svelte5",
|
|
88
|
+
"email",
|
|
89
|
+
"tailwind",
|
|
90
|
+
"tailwindcss",
|
|
91
|
+
"preprocessor",
|
|
92
|
+
"inline-styles",
|
|
93
|
+
"responsive-email",
|
|
94
|
+
"email-templates",
|
|
95
|
+
"svelte-preprocessor"
|
|
96
|
+
]
|
|
97
|
+
}
|