better-svelte-email 1.2.1 → 1.3.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/Hr.svelte +3 -1
- package/dist/preview/EmailPreview.svelte +1 -1
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +3 -2
- package/dist/render/index.d.ts +11 -29
- package/dist/render/index.js +19 -11
- package/dist/render/utils/css/extract-global-rules.d.ts +19 -0
- package/dist/render/utils/css/extract-global-rules.js +104 -0
- package/dist/render/utils/css/get-matching-global-rules-for-element.d.ts +3 -0
- package/dist/render/utils/css/get-matching-global-rules-for-element.js +35 -0
- package/dist/render/utils/css/make-inline-styles-for.js +2 -2
- package/dist/render/utils/css/resolve-calc-expressions.d.ts +4 -1
- package/dist/render/utils/css/resolve-calc-expressions.js +89 -5
- package/dist/render/utils/css/sanitize-declarations.d.ts +5 -1
- package/dist/render/utils/css/sanitize-declarations.js +51 -1
- package/dist/render/utils/css/sanitize-stylesheet.d.ts +4 -1
- package/dist/render/utils/css/sanitize-stylesheet.js +3 -3
- package/dist/render/utils/css/split-selector-list.d.ts +6 -0
- package/dist/render/utils/css/split-selector-list.js +41 -0
- package/dist/render/utils/tailwindcss/add-inlined-styles-to-element.d.ts +2 -1
- package/dist/render/utils/tailwindcss/add-inlined-styles-to-element.js +66 -14
- package/dist/render/utils/tailwindcss/sanitize-custom-css.js +1 -1
- package/dist/render/utils/tailwindcss/setup-tailwind.js +2 -1
- package/package.json +1 -1
package/dist/preview/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/preview/index.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/render/index.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/render/index.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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}
|
|
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
|
|
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
|
-
*
|
|
71
|
-
*
|
|
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,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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
const newStyles = combineStyles(
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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) {
|