better-svelte-email 1.2.1 → 1.3.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.
@@ -7,7 +7,9 @@
7
7
  const defaultStyles = styleToString({
8
8
  width: '100%',
9
9
  border: 'none',
10
- borderTop: '1px solid #eaeaea'
10
+ borderTopWidth: '1px',
11
+ borderTopStyle: 'solid',
12
+ borderColor: '#eaeaea'
11
13
  });
12
14
  </script>
13
15
 
@@ -476,7 +476,7 @@
476
476
  title="Email Preview"
477
477
  srcdoc={iframeContent}
478
478
  class="preview-iframe"
479
- sandbox="allow-same-origin allow-scripts"
479
+ sandbox="allow-same-origin allow-scripts allow-popups"
480
480
  ></iframe>
481
481
  </div>
482
482
  {:else if viewMode === 'code'}
@@ -102,6 +102,7 @@ export declare const SendEmailFunction: ({ from, to, subject, html }: {
102
102
  * @param options.resendApiKey - Your Resend API key (keep this server-side only)
103
103
  * @param options.customSendEmailFunction - Optional custom function to send emails
104
104
  * @param options.renderer - Optional renderer to use for rendering the email component (use this if you want to use a custom tailwind config)
105
+ * @param options.from - Optional sender email address (defaults to 'better-svelte-email <onboarding@resend.dev>')
105
106
  *
106
107
  * @example
107
108
  * ```ts
@@ -127,7 +128,7 @@ export declare const SendEmailFunction: ({ from, to, subject, html }: {
127
128
  * };
128
129
  * ```
129
130
  */
130
- export declare const sendEmail: ({ customSendEmailFunction, resendApiKey, renderer }?: {
131
+ export declare const sendEmail: ({ customSendEmailFunction, resendApiKey, renderer, from }?: {
131
132
  customSendEmailFunction?: (email: {
132
133
  from: string;
133
134
  to: string;
@@ -137,8 +138,9 @@ export declare const sendEmail: ({ customSendEmailFunction, resendApiKey, render
137
138
  success: boolean;
138
139
  error?: any;
139
140
  }>;
140
- renderer?: Renderer;
141
141
  resendApiKey?: string;
142
+ renderer?: Renderer;
143
+ from?: string;
142
144
  }) => {
143
145
  'send-email': (event: RequestEvent) => Promise<{
144
146
  success: boolean;
@@ -170,6 +170,7 @@ const defaultSendEmailFunction = async ({ from, to, subject, html }, resendApiKe
170
170
  * @param options.resendApiKey - Your Resend API key (keep this server-side only)
171
171
  * @param options.customSendEmailFunction - Optional custom function to send emails
172
172
  * @param options.renderer - Optional renderer to use for rendering the email component (use this if you want to use a custom tailwind config)
173
+ * @param options.from - Optional sender email address (defaults to 'better-svelte-email <onboarding@resend.dev>')
173
174
  *
174
175
  * @example
175
176
  * ```ts
@@ -195,7 +196,7 @@ const defaultSendEmailFunction = async ({ from, to, subject, html }, resendApiKe
195
196
  * };
196
197
  * ```
197
198
  */
198
- export const sendEmail = ({ customSendEmailFunction, resendApiKey, renderer = new Renderer() } = {}) => {
199
+ export const sendEmail = ({ customSendEmailFunction, resendApiKey, renderer = new Renderer(), from = 'better-svelte-email <onboarding@resend.dev>' } = {}) => {
199
200
  return {
200
201
  'send-email': async (event) => {
201
202
  const data = await event.request.formData();
@@ -209,7 +210,7 @@ export const sendEmail = ({ customSendEmailFunction, resendApiKey, renderer = ne
209
210
  }
210
211
  const emailComponent = await getEmailComponent(emailPath, file);
211
212
  const email = {
212
- from: 'svelte-email-tailwind <onboarding@resend.dev>',
213
+ from,
213
214
  to: `${data.get('to')}`,
214
215
  subject: `${data.get('component')} ${data.get('note') ? '| ' + data.get('note') : ''}`,
215
216
  html: await renderer.render(emailComponent)
@@ -23,6 +23,16 @@ export type RendererOptions = {
23
23
  * ```
24
24
  */
25
25
  customCSS?: string;
26
+ /**
27
+ * Base font size in pixels for converting relative units (rem, em) to absolute pixels.
28
+ * Used when resolving calc() expressions with mixed units.
29
+ *
30
+ * Note: `em` is treated as `rem` (relative to this base) since parent element
31
+ * context is not available during email rendering.
32
+ *
33
+ * @default 16
34
+ */
35
+ baseFontSize?: number;
26
36
  };
27
37
  /**
28
38
  * Options for rendering a Svelte component
@@ -32,38 +42,10 @@ export type RenderOptions = {
32
42
  context?: Map<any, any>;
33
43
  idPrefix?: string;
34
44
  };
35
- /**
36
- * Email renderer that converts Svelte components to email-safe HTML with inlined Tailwind styles.
37
- *
38
- * @example
39
- * ```ts
40
- * import Renderer from 'better-svelte-email/renderer';
41
- * import EmailComponent from '../emails/email.svelte';
42
- * import layoutStyles from 'src/routes/layout.css?raw';
43
- *
44
- * const renderer = new Renderer({
45
- * // Inject custom CSS such as app theme variables
46
- * customCSS: layoutStyles,
47
- * // Or provide a tailwind v3 config to extend the default theme
48
- * tailwindConfig: {
49
- * theme: {
50
- * extend: {
51
- * colors: {
52
- * brand: '#FF3E00'
53
- * }
54
- * }
55
- * }
56
- * }
57
- * });
58
- *
59
- * const html = await renderer.render(EmailComponent, {
60
- * props: { name: 'John' }
61
- * });
62
- * ```
63
- */
64
45
  export default class Renderer {
65
46
  private tailwindConfig;
66
47
  private customCSS?;
48
+ private baseFontSize;
67
49
  constructor(tailwindConfig?: TailwindConfig);
68
50
  constructor(options?: RendererOptions);
69
51
  /**
@@ -5,6 +5,7 @@ import { walk } from './utils/html/walk.js';
5
5
  import { setupTailwind } from './utils/tailwindcss/setup-tailwind.js';
6
6
  import { sanitizeStyleSheet } from './utils/css/sanitize-stylesheet.js';
7
7
  import { extractRulesPerClass } from './utils/css/extract-rules-per-class.js';
8
+ import { extractGlobalRules } from './utils/css/extract-global-rules.js';
8
9
  import { getCustomProperties } from './utils/css/get-custom-properties.js';
9
10
  import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules.js';
10
11
  import { addInlinedStylesToElement } from './utils/tailwindcss/add-inlined-styles-to-element.js';
@@ -40,23 +41,27 @@ import { convert } from 'html-to-text';
40
41
  * });
41
42
  * ```
42
43
  */
44
+ function isRendererOptions(obj) {
45
+ return (typeof obj === 'object' &&
46
+ obj !== null &&
47
+ ('tailwindConfig' in obj || 'customCSS' in obj || 'baseFontSize' in obj));
48
+ }
43
49
  export default class Renderer {
44
50
  tailwindConfig;
45
51
  customCSS;
52
+ baseFontSize;
46
53
  constructor(optionsOrConfig = {}) {
47
54
  // Detect whether the argument is a bare TailwindConfig (old API)
48
55
  // or a RendererOptions object (new API).
49
- if (optionsOrConfig &&
50
- typeof optionsOrConfig === 'object' &&
51
- ('tailwindConfig' in optionsOrConfig || 'customCSS' in optionsOrConfig)) {
52
- const options = optionsOrConfig;
53
- this.tailwindConfig = options.tailwindConfig || {};
54
- this.customCSS = options.customCSS;
56
+ if (isRendererOptions(optionsOrConfig)) {
57
+ this.tailwindConfig = optionsOrConfig.tailwindConfig || {};
58
+ this.customCSS = optionsOrConfig.customCSS;
59
+ this.baseFontSize = optionsOrConfig.baseFontSize ?? 16;
55
60
  }
56
61
  else {
57
- const config = optionsOrConfig;
58
- this.tailwindConfig = config || {};
62
+ this.tailwindConfig = optionsOrConfig || {};
59
63
  this.customCSS = undefined;
64
+ this.baseFontSize = 16;
60
65
  }
61
66
  }
62
67
  /**
@@ -97,7 +102,9 @@ export default class Renderer {
97
102
  return node;
98
103
  });
99
104
  const styleSheet = tailwindSetup.getStyleSheet();
100
- sanitizeStyleSheet(styleSheet);
105
+ sanitizeStyleSheet(styleSheet, { baseFontSize: this.baseFontSize });
106
+ // Extract global rules (*, element selectors, :root) for application to all elements
107
+ const globalRules = extractGlobalRules(styleSheet);
101
108
  const { inlinable: inlinableRules, nonInlinable: nonInlinableRules } = extractRulesPerClass(styleSheet, classesUsed);
102
109
  const customProperties = getCustomProperties(styleSheet);
103
110
  // Create a new Root for non-inline styles
@@ -112,7 +119,7 @@ export default class Renderer {
112
119
  const unknownClasses = [];
113
120
  ast = walk(ast, (node) => {
114
121
  if (isValidNode(node)) {
115
- const elementWithInlinedStyles = addInlinedStylesToElement(node, inlinableRules, nonInlinableRules, customProperties, unknownClasses);
122
+ const elementWithInlinedStyles = addInlinedStylesToElement(node, inlinableRules, nonInlinableRules, customProperties, unknownClasses, globalRules);
116
123
  if (node.nodeName === 'head') {
117
124
  hasHead = true;
118
125
  }
@@ -126,7 +133,8 @@ export default class Renderer {
126
133
  }
127
134
  if (hasHead && hasNonInlineStylesToApply) {
128
135
  appliedNonInlineStyles = true;
129
- serialized = serialized.replace('<head>', '<head>' + '<style>' + nonInlineStyles.toString() + '</style>');
136
+ // Use regex to handle <head> with or without attributes (e.g., style from preflight)
137
+ serialized = serialized.replace(/<head([^>]*)>/, '<head$1>' + '<style>' + nonInlineStyles.toString() + '</style>');
130
138
  }
131
139
  if (hasNonInlineStylesToApply && !appliedNonInlineStyles) {
132
140
  throw new Error(`You are trying to use the following Tailwind classes that cannot be inlined: ${Array.from(nonInlinableRules.keys()).join(' ')}.
@@ -0,0 +1,19 @@
1
+ import type { Root, Rule } from 'postcss';
2
+ export interface GlobalRules {
3
+ /** Universal (*) rules - apply to all elements */
4
+ universal: Rule[];
5
+ /** Element selector rules - keyed by lowercase element name */
6
+ element: Map<string, Rule[]>;
7
+ /** :root rules - apply to html element only */
8
+ root: Rule[];
9
+ }
10
+ /**
11
+ * Extracts global CSS rules (non-class selectors) from a PostCSS Root.
12
+ * These include universal (*), element (div, p, etc.), and :root selectors.
13
+ *
14
+ * Handles comma-separated selector lists by splitting them and extracting
15
+ * only the inlinable parts. e.g., "*, ::before, ::after" extracts just "*".
16
+ *
17
+ * Only extracts inlinable rules (not in media queries, no pseudo-classes).
18
+ */
19
+ export declare function extractGlobalRules(root: Root): GlobalRules;
@@ -0,0 +1,104 @@
1
+ import { splitSelectorList } from './split-selector-list.js';
2
+ /**
3
+ * Checks if a selector string contains pseudo-classes or pseudo-elements.
4
+ * e.g., :hover, ::before, :nth-child()
5
+ */
6
+ function hasPseudoSelector(selector) {
7
+ return /::?[\w-]+(\([^)]*\))?/.test(selector);
8
+ }
9
+ /**
10
+ * Extracts global CSS rules (non-class selectors) from a PostCSS Root.
11
+ * These include universal (*), element (div, p, etc.), and :root selectors.
12
+ *
13
+ * Handles comma-separated selector lists by splitting them and extracting
14
+ * only the inlinable parts. e.g., "*, ::before, ::after" extracts just "*".
15
+ *
16
+ * Only extracts inlinable rules (not in media queries, no pseudo-classes).
17
+ */
18
+ export function extractGlobalRules(root) {
19
+ const result = {
20
+ universal: [],
21
+ element: new Map(),
22
+ root: []
23
+ };
24
+ root.walkRules((rule) => {
25
+ // Check media query once per rule (applies to all selectors)
26
+ const inMediaQuery = isRuleInMediaQuery(rule);
27
+ // Split comma-separated selector list
28
+ const selectors = splitSelectorList(rule.selector);
29
+ for (const rawSelector of selectors) {
30
+ const selector = rawSelector.trim();
31
+ if (!selector)
32
+ continue;
33
+ // :root selector is a special case - it's inlinable (just targets html element)
34
+ if (selector === ':root') {
35
+ if (!inMediaQuery) {
36
+ const cloned = rule.clone();
37
+ cloned.selector = selector;
38
+ result.root.push(cloned);
39
+ }
40
+ continue;
41
+ }
42
+ // Check pseudo-selectors on THIS specific selector
43
+ if (hasPseudoSelector(selector)) {
44
+ continue;
45
+ }
46
+ // Skip rules in media queries
47
+ if (inMediaQuery) {
48
+ continue;
49
+ }
50
+ // Skip if selector contains a class (handled by extractRulesPerClass)
51
+ if (selector.includes('.')) {
52
+ continue;
53
+ }
54
+ // Skip attribute selectors
55
+ if (selector.includes('[')) {
56
+ continue;
57
+ }
58
+ // Skip ID selectors
59
+ if (selector.includes('#')) {
60
+ continue;
61
+ }
62
+ // Skip complex selectors with combinators (except for universal *)
63
+ // e.g., "div > p", "div p", "div + p", "div ~ p"
64
+ if (/[>\s+~]/.test(selector) && selector !== '*') {
65
+ continue;
66
+ }
67
+ // Clone rule with single selector for storage
68
+ const cloned = rule.clone();
69
+ cloned.selector = selector;
70
+ // Universal selector
71
+ if (selector === '*') {
72
+ result.universal.push(cloned);
73
+ continue;
74
+ }
75
+ // Element selector (simple tag name only)
76
+ // Match valid HTML element names (letters, numbers, hyphens)
77
+ if (/^[a-z][a-z0-9-]*$/i.test(selector)) {
78
+ const elementName = selector.toLowerCase();
79
+ if (!result.element.has(elementName)) {
80
+ result.element.set(elementName, []);
81
+ }
82
+ result.element.get(elementName).push(cloned);
83
+ }
84
+ }
85
+ });
86
+ return result;
87
+ }
88
+ /**
89
+ * Checks if a rule is inside a media query or other non-inlinable at-rule.
90
+ */
91
+ function isRuleInMediaQuery(rule) {
92
+ const NON_INLINABLE_AT_RULES = new Set(['media', 'supports', 'container', 'document']);
93
+ let parent = rule.parent;
94
+ while (parent && parent.type !== 'root') {
95
+ if (parent.type === 'atrule') {
96
+ const atRule = parent;
97
+ if (NON_INLINABLE_AT_RULES.has(atRule.name)) {
98
+ return true;
99
+ }
100
+ }
101
+ parent = parent.parent;
102
+ }
103
+ return false;
104
+ }
@@ -0,0 +1,3 @@
1
+ import type { AST } from '../../index.js';
2
+ import { type Rule } from 'postcss';
3
+ export declare function getMatchingGlobalRulesForElement(rules: Rule[], element: AST.Element): Rule[];
@@ -0,0 +1,35 @@
1
+ import { Declaration } from 'postcss';
2
+ // Properties from * selector that should always be applied (layout-critical)
3
+ const ALWAYS_APPLY_PROPS = ['box-sizing', 'margin'];
4
+ // Properties from * selector that are conditionally applied based on element classes
5
+ const CONDITIONAL_PROPS_PATTERN = /(border|outline)-color/g;
6
+ export function getMatchingGlobalRulesForElement(rules, element) {
7
+ const matchingRules = [];
8
+ for (const rule of rules) {
9
+ const matchingDecls = [];
10
+ for (const node of rule.nodes) {
11
+ if (node.type !== 'decl')
12
+ continue;
13
+ const decl = node;
14
+ if (ALWAYS_APPLY_PROPS.includes(decl.prop)) {
15
+ matchingDecls.push(decl);
16
+ continue;
17
+ }
18
+ if (decl.prop.match(CONDITIONAL_PROPS_PATTERN)) {
19
+ const key = decl.prop.match(/(border|outline)-color/)?.[1];
20
+ if (!key)
21
+ continue;
22
+ const hasMatchingClass = element.attrs?.find((attr) => attr.name === 'class' && attr.value?.match(key));
23
+ if (hasMatchingClass) {
24
+ matchingDecls.push(decl);
25
+ }
26
+ }
27
+ }
28
+ if (matchingDecls.length > 0) {
29
+ const clonedRule = rule.clone();
30
+ clonedRule.nodes = matchingDecls;
31
+ matchingRules.push(clonedRule);
32
+ }
33
+ }
34
+ return matchingRules;
35
+ }
@@ -46,8 +46,8 @@ export function makeInlineStylesFor(inlinableRules, customProperties) {
46
46
  });
47
47
  value = valueParser.stringify(parsed.nodes);
48
48
  }
49
- const important = decl.important ? '!important' : '';
50
- styles += `${decl.prop}: ${value} ${important};`;
49
+ const important = decl.important ? ' !important' : '';
50
+ styles += `${decl.prop}:${value}${important};`;
51
51
  });
52
52
  }
53
53
  return styles;
@@ -1,2 +1,5 @@
1
1
  import type { Root } from 'postcss';
2
- export declare function resolveCalcExpressions(root: Root): void;
2
+ export interface CalcResolutionConfig {
3
+ baseFontSize?: number;
4
+ }
5
+ export declare function resolveCalcExpressions(root: Root, config?: CalcResolutionConfig): void;
@@ -15,6 +15,40 @@ function parseValue(str) {
15
15
  function formatValue(parsed) {
16
16
  return `${parsed.value}${parsed.unit}`;
17
17
  }
18
+ /**
19
+ * Converts a CSS length value to pixels.
20
+ * Returns null for units that cannot be converted (%, vw, vh, ch, ex, etc.)
21
+ */
22
+ function toPixels(value, unit, baseFontSize) {
23
+ switch (unit.toLowerCase()) {
24
+ case 'px':
25
+ case '':
26
+ return value;
27
+ case 'rem':
28
+ case 'em':
29
+ return value * baseFontSize;
30
+ case 'pt':
31
+ return value * (96 / 72);
32
+ case 'pc':
33
+ return value * 16;
34
+ case 'in':
35
+ return value * 96;
36
+ case 'cm':
37
+ return value * (96 / 2.54);
38
+ case 'mm':
39
+ return value * (96 / 25.4);
40
+ default:
41
+ // cannot convert %, vw, vh, dvh, svh, lvh, ch, ex, etc.
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Checks if a unit can be converted to pixels.
47
+ */
48
+ function isConvertibleUnit(unit) {
49
+ const convertible = ['px', 'rem', 'em', 'pt', 'pc', 'in', 'cm', 'mm', ''];
50
+ return convertible.includes(unit.toLowerCase());
51
+ }
18
52
  /**
19
53
  * Splits a calc expression string into tokens (values and operators)
20
54
  * Handles both space-separated and non-space-separated expressions
@@ -67,10 +101,15 @@ function tokenizeCalcExpression(expr) {
67
101
  return tokens;
68
102
  }
69
103
  /**
70
- * Intentionally only resolves `*` and `/` operations without dealing with parenthesis,
71
- * because this is the only thing required to run Tailwind v4
104
+ * Resolves calc expressions including `*`, `/`, `+`, and `-` operations.
105
+ * For `+` and `-` with mixed units, converts to pixels using the baseFontSize.
106
+ *
107
+ * Limitations:
108
+ * - Parenthesized sub-expressions like `calc((10px + 5px) * 2)` are not supported.
109
+ * - em units are treated as rem and converted to pixels using baseFontSize.
110
+ * - Non-convertible units (%, vw, vh, etc.) will leave the calc() unresolved.
72
111
  */
73
- function evaluateCalcExpression(expr) {
112
+ function evaluateCalcExpression(expr, baseFontSize) {
74
113
  const tokens = tokenizeCalcExpression(expr);
75
114
  if (tokens.length === 0)
76
115
  return null;
@@ -137,10 +176,55 @@ function evaluateCalcExpression(expr) {
137
176
  if (tokens.length === 1) {
138
177
  return tokens[0];
139
178
  }
179
+ // Process + and - operations (left to right)
180
+ i = 0;
181
+ while (i < tokens.length) {
182
+ const token = tokens[i];
183
+ if ((token === '+' || token === '-') && i > 0 && i < tokens.length - 1) {
184
+ const left = parseValue(tokens[i - 1]);
185
+ const right = parseValue(tokens[i + 1]);
186
+ if (left && right) {
187
+ // Same unit: directly compute
188
+ if (left.unit.toLowerCase() === right.unit.toLowerCase()) {
189
+ const resultValue = token === '+' ? left.value + right.value : left.value - right.value;
190
+ const result = formatValue({
191
+ value: resultValue,
192
+ unit: left.unit,
193
+ type: left.type
194
+ });
195
+ tokens.splice(i - 1, 3, result);
196
+ i = Math.max(0, i - 1);
197
+ continue;
198
+ }
199
+ // Mixed units: convert to pixels if both units are convertible
200
+ if (isConvertibleUnit(left.unit) && isConvertibleUnit(right.unit)) {
201
+ const leftPx = toPixels(left.value, left.unit, baseFontSize);
202
+ const rightPx = toPixels(right.value, right.unit, baseFontSize);
203
+ if (leftPx !== null && rightPx !== null) {
204
+ const resultPx = token === '+' ? leftPx + rightPx : leftPx - rightPx;
205
+ const result = formatValue({
206
+ value: resultPx,
207
+ unit: 'px',
208
+ type: 'dimension'
209
+ });
210
+ tokens.splice(i - 1, 3, result);
211
+ i = Math.max(0, i - 1);
212
+ continue;
213
+ }
214
+ }
215
+ // Non-convertible units (%, vw, vh, etc.): cannot resolve, skip
216
+ }
217
+ }
218
+ i++;
219
+ }
220
+ if (tokens.length === 1) {
221
+ return tokens[0];
222
+ }
140
223
  // If we still have multiple tokens, we couldn't fully evaluate
141
224
  return null;
142
225
  }
143
- export function resolveCalcExpressions(root) {
226
+ export function resolveCalcExpressions(root, config) {
227
+ const baseFontSize = config?.baseFontSize ?? 16;
144
228
  root.walkDecls((decl) => {
145
229
  if (!decl.value.includes('calc('))
146
230
  return;
@@ -149,7 +233,7 @@ export function resolveCalcExpressions(root) {
149
233
  if (node.type === 'function' && node.value === 'calc') {
150
234
  // Get the inner content of calc()
151
235
  const innerContent = valueParser.stringify(node.nodes);
152
- const result = evaluateCalcExpression(innerContent);
236
+ const result = evaluateCalcExpression(innerContent, baseFontSize);
153
237
  if (result) {
154
238
  // Replace the function with the result
155
239
  node.type = 'word';
@@ -1,9 +1,13 @@
1
1
  import type { Root } from 'postcss';
2
+ export interface SanitizeDeclarationsConfig {
3
+ baseFontSize?: number;
4
+ }
2
5
  /**
3
6
  * Meant to do all the things necessary, in a per-declaration basis, to have the best email client
4
7
  * support possible.
5
8
  *
6
9
  * Here's the transformations it does so far:
10
+ * - convert relative units (rem, em, pt, pc, in, cm, mm) to px for better email client support;
7
11
  * - convert all `rgb` with space-based syntax into a comma based one;
8
12
  * - convert all `oklch` values into `rgb`;
9
13
  * - convert all hex values into `rgb`;
@@ -12,4 +16,4 @@ import type { Root } from 'postcss';
12
16
  * - convert `margin-inline` into `margin-left` and `margin-right`;
13
17
  * - convert `margin-block` into `margin-top` and `margin-bottom`.
14
18
  */
15
- export declare function sanitizeDeclarations(root: Root): void;
19
+ export declare function sanitizeDeclarations(root: Root, config?: SanitizeDeclarationsConfig): void;
@@ -1,4 +1,29 @@
1
1
  import valueParser, {} from 'postcss-value-parser';
2
+ /**
3
+ * Converts a CSS length value to pixels.
4
+ * Returns null for units that cannot be converted (%, vw, vh, ch, ex, etc.)
5
+ */
6
+ function toPixels(value, unit, baseFontSize) {
7
+ switch (unit.toLowerCase()) {
8
+ case 'px':
9
+ return value;
10
+ case 'rem':
11
+ case 'em':
12
+ return value * baseFontSize;
13
+ case 'pt':
14
+ return value * (96 / 72);
15
+ case 'pc':
16
+ return value * 16;
17
+ case 'in':
18
+ return value * 96;
19
+ case 'cm':
20
+ return value * (96 / 2.54);
21
+ case 'mm':
22
+ return value * (96 / 25.4);
23
+ default:
24
+ return null;
25
+ }
26
+ }
2
27
  // Color conversion constants
3
28
  const LAB_TO_LMS = {
4
29
  l: [0.3963377773761749, 0.2158037573099136],
@@ -197,6 +222,7 @@ function transformColorMix(node) {
197
222
  * support possible.
198
223
  *
199
224
  * Here's the transformations it does so far:
225
+ * - convert relative units (rem, em, pt, pc, in, cm, mm) to px for better email client support;
200
226
  * - convert all `rgb` with space-based syntax into a comma based one;
201
227
  * - convert all `oklch` values into `rgb`;
202
228
  * - convert all hex values into `rgb`;
@@ -205,7 +231,8 @@ function transformColorMix(node) {
205
231
  * - convert `margin-inline` into `margin-left` and `margin-right`;
206
232
  * - convert `margin-block` into `margin-top` and `margin-bottom`.
207
233
  */
208
- export function sanitizeDeclarations(root) {
234
+ export function sanitizeDeclarations(root, config) {
235
+ const baseFontSize = config?.baseFontSize ?? 16;
209
236
  root.walkDecls((decl) => {
210
237
  // Handle infinity calc for border-radius
211
238
  if (decl.prop === 'border-radius' && /calc\s*\(\s*infinity\s*\*\s*1px\s*\)/i.test(decl.value)) {
@@ -236,6 +263,29 @@ export function sanitizeDeclarations(root) {
236
263
  decl.value = values[0];
237
264
  decl.cloneAfter({ prop: 'margin-bottom', value: values[1] || values[0] });
238
265
  }
266
+ // Convert relative units to px for better email client support
267
+ // Check if value contains any convertible units (excluding px which is already fine)
268
+ const hasConvertibleUnits = /\d+(rem|em|pt|pc|in|cm|mm)\b/i.test(decl.value);
269
+ if (hasConvertibleUnits) {
270
+ const parsed = valueParser(decl.value);
271
+ parsed.walk((node) => {
272
+ if (node.type === 'word') {
273
+ const match = node.value.match(/^(-?[\d.]+)(rem|em|pt|pc|in|cm|mm)$/i);
274
+ if (match) {
275
+ const numValue = parseFloat(match[1]);
276
+ const unit = match[2];
277
+ const pxValue = toPixels(numValue, unit, baseFontSize);
278
+ if (pxValue !== null) {
279
+ // Round to avoid floating point precision issues
280
+ // Use up to 3 decimal places for precision, then trim trailing zeros
281
+ const rounded = Math.round(pxValue * 1000) / 1000;
282
+ node.value = `${rounded}px`;
283
+ }
284
+ }
285
+ }
286
+ });
287
+ decl.value = valueParser.stringify(parsed.nodes);
288
+ }
239
289
  // Parse and transform color values
240
290
  if (decl.value.includes('oklch(') ||
241
291
  decl.value.includes('rgb(') ||
@@ -1,2 +1,5 @@
1
1
  import type { Root } from 'postcss';
2
- export declare function sanitizeStyleSheet(root: Root): void;
2
+ export interface SanitizeConfig {
3
+ baseFontSize?: number;
4
+ }
5
+ export declare function sanitizeStyleSheet(root: Root, config?: SanitizeConfig): void;
@@ -1,8 +1,8 @@
1
1
  import { resolveAllCssVariables } from './resolve-all-css-variables.js';
2
2
  import { resolveCalcExpressions } from './resolve-calc-expressions.js';
3
3
  import { sanitizeDeclarations } from './sanitize-declarations.js';
4
- export function sanitizeStyleSheet(root) {
4
+ export function sanitizeStyleSheet(root, config) {
5
5
  resolveAllCssVariables(root);
6
- resolveCalcExpressions(root);
7
- sanitizeDeclarations(root);
6
+ resolveCalcExpressions(root, config);
7
+ sanitizeDeclarations(root, config);
8
8
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Splits a CSS selector list by commas, respecting parentheses and brackets.
3
+ * e.g., "*, ::before, ::after" → ["*", "::before", "::after"]
4
+ * e.g., ":is(div, p), span" → [":is(div, p)", "span"]
5
+ */
6
+ export declare function splitSelectorList(selector: string): string[];
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Splits a CSS selector list by commas, respecting parentheses and brackets.
3
+ * e.g., "*, ::before, ::after" → ["*", "::before", "::after"]
4
+ * e.g., ":is(div, p), span" → [":is(div, p)", "span"]
5
+ */
6
+ export function splitSelectorList(selector) {
7
+ const result = [];
8
+ let current = '';
9
+ let parenDepth = 0;
10
+ let bracketDepth = 0;
11
+ let inString = null;
12
+ for (let i = 0; i < selector.length; i++) {
13
+ const char = selector[i];
14
+ // Handle string literals (for attribute selectors like [title="a,b"])
15
+ if ((char === '"' || char === "'") && !inString) {
16
+ inString = char;
17
+ }
18
+ else if (char === inString) {
19
+ inString = null;
20
+ }
21
+ if (!inString) {
22
+ if (char === '(')
23
+ parenDepth++;
24
+ if (char === ')')
25
+ parenDepth--;
26
+ if (char === '[')
27
+ bracketDepth++;
28
+ if (char === ']')
29
+ bracketDepth--;
30
+ // Split on comma only at top level
31
+ if (char === ',' && parenDepth === 0 && bracketDepth === 0) {
32
+ result.push(current.trim());
33
+ current = '';
34
+ continue;
35
+ }
36
+ }
37
+ current += char;
38
+ }
39
+ result.push(current.trim());
40
+ return result;
41
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Rule } from 'postcss';
2
2
  import type { CustomProperties } from '../css/get-custom-properties.js';
3
+ import type { GlobalRules } from '../css/extract-global-rules.js';
3
4
  import type { AST } from '../../index.js';
4
- export declare function addInlinedStylesToElement(element: AST.Element, inlinableRules: Map<string, Rule>, nonInlinableRules: Map<string, Rule>, customProperties: CustomProperties, unknownClasses: string[]): AST.Element;
5
+ export declare function addInlinedStylesToElement(element: AST.Element, inlinableRules: Map<string, Rule>, nonInlinableRules: Map<string, Rule>, customProperties: CustomProperties, unknownClasses: string[], globalRules?: GlobalRules): AST.Element;
@@ -1,8 +1,41 @@
1
1
  import { sanitizeClassName } from '../compatibility/sanitize-class-name.js';
2
2
  import { makeInlineStylesFor } from '../css/make-inline-styles-for.js';
3
3
  import { combineStyles } from '../../../utils/index.js';
4
- export function addInlinedStylesToElement(element, inlinableRules, nonInlinableRules, customProperties, unknownClasses) {
4
+ import { getMatchingGlobalRulesForElement } from '../css/get-matching-global-rules-for-element.js';
5
+ /**
6
+ * Gets global styles for an element based on its tag name.
7
+ * Applies rules in specificity order: universal (*) -> element -> :root (for html only)
8
+ */
9
+ function getGlobalStylesForElement(element, globalRules, customProperties) {
10
+ const rules = [];
11
+ const tagName = element.tagName.toLowerCase();
12
+ // 1. Universal rules on case-by-case basis
13
+ const matchingGlobalRules = getMatchingGlobalRulesForElement(globalRules.universal, element);
14
+ if (matchingGlobalRules.length > 0) {
15
+ rules.push(...matchingGlobalRules);
16
+ }
17
+ // 2. Element selector rules
18
+ const elementRules = globalRules.element.get(tagName);
19
+ if (elementRules) {
20
+ rules.push(...elementRules);
21
+ }
22
+ // 3. :root rules (only for html element)
23
+ if (tagName === 'html') {
24
+ rules.push(...globalRules.root);
25
+ }
26
+ if (rules.length === 0) {
27
+ return '';
28
+ }
29
+ return makeInlineStylesFor(rules, customProperties);
30
+ }
31
+ export function addInlinedStylesToElement(element, inlinableRules, nonInlinableRules, customProperties, unknownClasses, globalRules) {
32
+ // Get global styles first (lowest specificity)
33
+ const globalStyles = globalRules
34
+ ? getGlobalStylesForElement(element, globalRules, customProperties)
35
+ : '';
5
36
  const classAttr = element.attrs?.find((attr) => attr.name === 'class');
37
+ const styleAttr = element.attrs?.find((attr) => attr.name === 'style');
38
+ const existingStyles = styleAttr?.value ?? '';
6
39
  if (classAttr && classAttr.value) {
7
40
  const classes = classAttr.value.split(/\s+/).filter(Boolean);
8
41
  const residualClasses = [];
@@ -16,19 +49,21 @@ export function addInlinedStylesToElement(element, inlinableRules, nonInlinableR
16
49
  residualClasses.push(className);
17
50
  }
18
51
  }
19
- const styles = makeInlineStylesFor(rules, customProperties);
20
- const styleAttr = element.attrs?.find((attr) => attr.name === 'style');
21
- const newStyles = combineStyles(styleAttr?.value ?? '', styles);
22
- if (styleAttr) {
23
- element.attrs = element.attrs?.map((attr) => {
24
- if (attr.name === 'style') {
25
- return { ...attr, value: newStyles };
26
- }
27
- return attr;
28
- });
29
- }
30
- else {
31
- element.attrs = [...element.attrs, { name: 'style', value: newStyles }];
52
+ const classStyles = makeInlineStylesFor(rules, customProperties);
53
+ // Combine: global (lowest) -> existing inline -> class styles (highest)
54
+ const newStyles = combineStyles(globalStyles, existingStyles, classStyles);
55
+ if (newStyles) {
56
+ if (styleAttr) {
57
+ element.attrs = element.attrs?.map((attr) => {
58
+ if (attr.name === 'style') {
59
+ return { ...attr, value: newStyles };
60
+ }
61
+ return attr;
62
+ });
63
+ }
64
+ else {
65
+ element.attrs = [...element.attrs, { name: 'style', value: newStyles }];
66
+ }
32
67
  }
33
68
  if (residualClasses.length > 0) {
34
69
  element.attrs = element.attrs?.map((attr) => {
@@ -57,5 +92,22 @@ export function addInlinedStylesToElement(element, inlinableRules, nonInlinableR
57
92
  element.attrs = element.attrs?.filter((attr) => attr.name !== 'class');
58
93
  }
59
94
  }
95
+ else if (globalStyles) {
96
+ // Element has no classes but should still receive global styles
97
+ const newStyles = combineStyles(globalStyles, existingStyles);
98
+ if (newStyles) {
99
+ if (styleAttr) {
100
+ element.attrs = element.attrs?.map((attr) => {
101
+ if (attr.name === 'style') {
102
+ return { ...attr, value: newStyles };
103
+ }
104
+ return attr;
105
+ });
106
+ }
107
+ else {
108
+ element.attrs = [...(element.attrs || []), { name: 'style', value: newStyles }];
109
+ }
110
+ }
111
+ }
60
112
  return element;
61
113
  }
@@ -2,7 +2,7 @@ import postcss from 'postcss';
2
2
  export function sanitizeCustomCss(css) {
3
3
  const root = postcss.parse(css);
4
4
  root.walkAtRules((atRule) => {
5
- if (atRule.name === 'import' || atRule.name === 'plugin') {
5
+ if (atRule.name === 'import' || atRule.name === 'plugin' || atRule.name === 'source') {
6
6
  atRule.remove();
7
7
  }
8
8
  });
@@ -14,6 +14,7 @@ export async function setupTailwind(config, customCSS) {
14
14
  // Inject customCSS after base imports for theme variable resolution during compilation
15
15
  const baseCss = `
16
16
  @layer theme, base, components, utilities;
17
+ /* @import "tailwindcss/preflight.css" layer(base); */
17
18
  @import "tailwindcss/theme.css" layer(theme);
18
19
  @import "tailwindcss/utilities.css" layer(utilities);
19
20
  ${customCSS ? sanitizeCustomCss(customCSS) : ''}
@@ -28,7 +29,7 @@ ${customCSS ? sanitizeCustomCss(customCSS) : ''}
28
29
  module: config
29
30
  };
30
31
  }
31
- throw new Error(`NO-OP: should we implement support for ${resourceHint}?`);
32
+ throw new Error(`NO-OP: should we implement support for ${resourceHint}: ${id}?`);
32
33
  },
33
34
  polyfills: 0, // All
34
35
  async loadStylesheet(id, base) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "better-svelte-email",
3
3
  "description": "Svelte email renderer with Tailwind support",
4
- "version": "1.2.1",
4
+ "version": "1.3.1",
5
5
  "author": "Konixy",
6
6
  "repository": {
7
7
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "homepage": "https://better-svelte-email.konixy.fr",
11
11
  "peerDependencies": {
12
- "svelte": ">5.46.1",
12
+ "svelte": ">=5.14.3",
13
13
  "@sveltejs/kit": ">=2"
14
14
  },
15
15
  "peerDependenciesMeta": {
@@ -38,7 +38,7 @@
38
38
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
39
39
  "@tailwindcss/vite": "^4.1.18",
40
40
  "@types/html-to-text": "^9.0.4",
41
- "@types/node": "22.19.3",
41
+ "@types/node": "24",
42
42
  "@vitest/coverage-v8": "^4.0.16",
43
43
  "eslint": "^9.39.2",
44
44
  "eslint-config-prettier": "^10.1.8",