better-svelte-email 1.1.0-beta.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,7 @@ For older versions, you can use [`svelte-email-tailwind`](https://github.com/ste
61
61
  - Nested components
62
62
  - All svelte features such as each blocks (`{#each}`) and if blocks (`{#if}`), and more
63
63
  - Custom Tailwind configurations
64
+ - Custom CSS injection (for app theme integration)
64
65
 
65
66
  ## Author
66
67
 
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from './components/index.js';
2
- export { default as Renderer, toPlainText, type TailwindConfig, type RenderOptions } from './render/index.js';
2
+ export { default as Renderer, toPlainText, type TailwindConfig, type RendererOptions, type RenderOptions } from './render/index.js';
@@ -49,10 +49,12 @@ export declare const getEmailComponent: (emailPath: string, file: string) => Pro
49
49
  * import Renderer from 'better-svelte-email/render';
50
50
  *
51
51
  * const renderer = new Renderer({
52
- * theme: {
53
- * extend: {
54
- * colors: {
55
- * brand: '#FF3E00'
52
+ * tailwindConfig: {
53
+ * theme: {
54
+ * extend: {
55
+ * colors: {
56
+ * brand: '#FF3E00'
57
+ * }
56
58
  * }
57
59
  * }
58
60
  * }
@@ -108,17 +110,19 @@ export declare const SendEmailFunction: ({ from, to, subject, html }: {
108
110
  * import Renderer from 'better-svelte-email/render';
109
111
  *
110
112
  * const renderer = new Renderer({
111
- * theme: {
112
- * extend: {
113
- * colors: {
114
- * brand: '#FF3E00'
113
+ * tailwindConfig: {
114
+ * theme: {
115
+ * extend: {
116
+ * colors: {
117
+ * brand: '#FF3E00'
118
+ * }
115
119
  * }
116
120
  * }
117
121
  * }
118
122
  * });
119
123
  *
120
124
  * export const actions = {
121
- * ...createEmail(renderer),
125
+ * ...createEmail({ renderer }),
122
126
  * ...sendEmail({ resendApiKey: PRIVATE_RESEND_API_KEY, renderer })
123
127
  * };
124
128
  * ```
@@ -98,10 +98,12 @@ const getEmailSource = async (emailPath, file) => {
98
98
  * import Renderer from 'better-svelte-email/render';
99
99
  *
100
100
  * const renderer = new Renderer({
101
- * theme: {
102
- * extend: {
103
- * colors: {
104
- * brand: '#FF3E00'
101
+ * tailwindConfig: {
102
+ * theme: {
103
+ * extend: {
104
+ * colors: {
105
+ * brand: '#FF3E00'
106
+ * }
105
107
  * }
106
108
  * }
107
109
  * }
@@ -176,17 +178,19 @@ const defaultSendEmailFunction = async ({ from, to, subject, html }, resendApiKe
176
178
  * import Renderer from 'better-svelte-email/render';
177
179
  *
178
180
  * const renderer = new Renderer({
179
- * theme: {
180
- * extend: {
181
- * colors: {
182
- * brand: '#FF3E00'
181
+ * tailwindConfig: {
182
+ * theme: {
183
+ * extend: {
184
+ * colors: {
185
+ * brand: '#FF3E00'
186
+ * }
183
187
  * }
184
188
  * }
185
189
  * }
186
190
  * });
187
191
  *
188
192
  * export const actions = {
189
- * ...createEmail(renderer),
193
+ * ...createEmail({ renderer }),
190
194
  * ...sendEmail({ resendApiKey: PRIVATE_RESEND_API_KEY, renderer })
191
195
  * };
192
196
  * ```
@@ -2,6 +2,28 @@ import { type DefaultTreeAdapterTypes } from 'parse5';
2
2
  import type { Config } from 'tailwindcss';
3
3
  export type TailwindConfig = Omit<Config, 'content'>;
4
4
  export type { DefaultTreeAdapterTypes as AST };
5
+ /**
6
+ * Options for creating a Renderer instance
7
+ */
8
+ export type RendererOptions = {
9
+ /** Tailwind CSS configuration */
10
+ tailwindConfig?: TailwindConfig;
11
+ /**
12
+ * Custom CSS to inject into email rendering (e.g., app theme variables).
13
+ *
14
+ * This CSS is injected during Tailwind compilation, making variables and styles
15
+ * available for processing. Useful for maintaining consistent styling between
16
+ * your app and emails (e.g., shadcn-svelte theme variables).
17
+ *
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import appStyles from './app.css?raw';
22
+ * const renderer = new Renderer({ customCSS: appStyles });
23
+ * ```
24
+ */
25
+ customCSS?: string;
26
+ };
5
27
  /**
6
28
  * Options for rendering a Svelte component
7
29
  */
@@ -16,13 +38,19 @@ export type RenderOptions = {
16
38
  * @example
17
39
  * ```ts
18
40
  * import Renderer from 'better-svelte-email/renderer';
19
- * import EmailComponent from './email.svelte';
41
+ * import EmailComponent from '../emails/email.svelte';
42
+ * import layoutStyles from 'src/routes/layout.css?raw';
20
43
  *
21
44
  * const renderer = new Renderer({
22
- * theme: {
23
- * extend: {
24
- * colors: {
25
- * brand: '#FF3E00'
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
+ * }
26
54
  * }
27
55
  * }
28
56
  * }
@@ -35,7 +63,9 @@ export type RenderOptions = {
35
63
  */
36
64
  export default class Renderer {
37
65
  private tailwindConfig;
66
+ private customCSS?;
38
67
  constructor(tailwindConfig?: TailwindConfig);
68
+ constructor(options?: RendererOptions);
39
69
  /**
40
70
  * Renders a Svelte component to email-safe HTML with inlined Tailwind CSS.
41
71
  *
@@ -17,13 +17,19 @@ import { convert } from 'html-to-text';
17
17
  * @example
18
18
  * ```ts
19
19
  * import Renderer from 'better-svelte-email/renderer';
20
- * import EmailComponent from './email.svelte';
20
+ * import EmailComponent from '../emails/email.svelte';
21
+ * import layoutStyles from 'src/routes/layout.css?raw';
21
22
  *
22
23
  * const renderer = new Renderer({
23
- * theme: {
24
- * extend: {
25
- * colors: {
26
- * brand: '#FF3E00'
24
+ * // Inject custom CSS such as app theme variables
25
+ * customCSS: layoutStyles,
26
+ * // Or provide a tailwind v3 config to extend the default theme
27
+ * tailwindConfig: {
28
+ * theme: {
29
+ * extend: {
30
+ * colors: {
31
+ * brand: '#FF3E00'
32
+ * }
27
33
  * }
28
34
  * }
29
35
  * }
@@ -36,8 +42,22 @@ import { convert } from 'html-to-text';
36
42
  */
37
43
  export default class Renderer {
38
44
  tailwindConfig;
39
- constructor(tailwindConfig = {}) {
40
- this.tailwindConfig = tailwindConfig;
45
+ customCSS;
46
+ constructor(optionsOrConfig = {}) {
47
+ // Detect whether the argument is a bare TailwindConfig (old API)
48
+ // 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;
55
+ }
56
+ else {
57
+ const config = optionsOrConfig;
58
+ this.tailwindConfig = config || {};
59
+ this.customCSS = undefined;
60
+ }
41
61
  }
42
62
  /**
43
63
  * Renders a Svelte component to email-safe HTML with inlined Tailwind CSS.
@@ -64,7 +84,7 @@ export default class Renderer {
64
84
  let ast = parse(body);
65
85
  ast = removeAttributesFunctions(ast);
66
86
  let classesUsed = [];
67
- const tailwindSetup = await setupTailwind(this.tailwindConfig);
87
+ const tailwindSetup = await setupTailwind(this.tailwindConfig, this.customCSS);
68
88
  walk(ast, (node) => {
69
89
  if (isValidNode(node)) {
70
90
  const classAttr = node.attrs?.find((attr) => attr.name === 'class');
@@ -3,6 +3,5 @@ export interface VariableDefinition {
3
3
  declaration: Declaration;
4
4
  selector: string;
5
5
  variableName: string;
6
- definition: string;
7
6
  }
8
7
  export declare function resolveAllCssVariables(root: Root): void;
@@ -1,4 +1,5 @@
1
1
  import valueParser from 'postcss-value-parser';
2
+ const MAX_CSS_VARIABLE_RESOLUTION_ITERATIONS = 10;
2
3
  function getSelector(decl) {
3
4
  const parent = decl.parent;
4
5
  if (parent?.type === 'rule') {
@@ -57,79 +58,108 @@ function doSelectorsIntersect(first, second) {
57
58
  return false;
58
59
  }
59
60
  export function resolveAllCssVariables(root) {
60
- const variableDefinitions = new Set();
61
- const variableUses = [];
62
- // First pass: collect variable definitions and uses
63
- root.walkDecls((decl) => {
64
- // Skip @layer (properties) { ... } to avoid variable resolution conflicts
65
- if (isInPropertiesLayer(decl)) {
66
- return;
67
- }
68
- if (decl.prop.startsWith('--')) {
69
- variableDefinitions.add({
70
- declaration: decl,
71
- selector: getSelector(decl),
72
- variableName: decl.prop,
73
- definition: decl.value
74
- });
75
- }
76
- else if (decl.value.includes('var(')) {
77
- const parseVariableUses = (value) => {
78
- const parsed = valueParser(value);
79
- parsed.walk((node) => {
80
- if (node.type === 'function' && node.value === 'var') {
81
- const varNameNode = node.nodes[0];
82
- const varName = varNameNode ? valueParser.stringify(varNameNode).trim() : '';
83
- // Find fallback (after the comma)
84
- let fallback;
85
- const commaIndex = node.nodes.findIndex((n) => n.type === 'div' && n.value === ',');
86
- if (commaIndex !== -1) {
87
- fallback = valueParser.stringify(node.nodes.slice(commaIndex + 1)).trim();
88
- }
89
- const raw = valueParser.stringify(node);
90
- variableUses.push({
91
- declaration: decl,
92
- selector: getSelector(decl),
93
- inAtRule: isInAtRule(decl),
94
- atRuleSelector: getAtRuleSelector(decl),
95
- fallback,
96
- variableName: varName,
97
- raw
98
- });
99
- // If fallback contains var(), recursively parse those too
100
- if (fallback?.includes('var(')) {
101
- parseVariableUses(fallback);
102
- }
103
- }
61
+ let iteration = 0;
62
+ while (iteration < MAX_CSS_VARIABLE_RESOLUTION_ITERATIONS) {
63
+ const variableDefinitions = new Set();
64
+ const variableUses = [];
65
+ // First pass: collect variable definitions and uses
66
+ root.walkDecls((decl) => {
67
+ // Skip @layer (properties) { ... } to avoid variable resolution conflicts
68
+ if (isInPropertiesLayer(decl)) {
69
+ return;
70
+ }
71
+ if (decl.prop.startsWith('--')) {
72
+ variableDefinitions.add({
73
+ declaration: decl,
74
+ selector: getSelector(decl),
75
+ variableName: decl.prop
104
76
  });
105
- };
106
- parseVariableUses(decl.value);
107
- }
108
- });
109
- // Second pass: resolve variables
110
- for (const use of variableUses) {
111
- let hasReplaced = false;
112
- for (const definition of variableDefinitions) {
113
- if (use.variableName !== definition.variableName) {
114
- continue;
115
77
  }
116
- // Check if use is in an at-rule and definition is in a matching rule
117
- if (use.inAtRule &&
118
- use.atRuleSelector &&
119
- doSelectorsIntersect(use.atRuleSelector, definition.selector)) {
120
- use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.definition);
121
- hasReplaced = true;
122
- break;
78
+ if (decl.value.includes('var(')) {
79
+ const parseVariableUses = (value) => {
80
+ const parsed = valueParser(value);
81
+ parsed.walk((node) => {
82
+ if (node.type === 'function' && node.value === 'var') {
83
+ const varNameNode = node.nodes[0];
84
+ const varName = varNameNode ? valueParser.stringify(varNameNode).trim() : '';
85
+ // Find fallback (after the comma)
86
+ let fallback;
87
+ const commaIndex = node.nodes.findIndex((n) => n.type === 'div' && n.value === ',');
88
+ if (commaIndex !== -1) {
89
+ fallback = valueParser.stringify(node.nodes.slice(commaIndex + 1)).trim();
90
+ }
91
+ const raw = valueParser.stringify(node);
92
+ variableUses.push({
93
+ declaration: decl,
94
+ selector: getSelector(decl),
95
+ inAtRule: isInAtRule(decl),
96
+ atRuleSelector: getAtRuleSelector(decl),
97
+ fallback,
98
+ variableName: varName,
99
+ raw
100
+ });
101
+ // If fallback contains var(), recursively parse those too
102
+ if (fallback?.includes('var(')) {
103
+ parseVariableUses(fallback);
104
+ }
105
+ }
106
+ });
107
+ };
108
+ parseVariableUses(decl.value);
123
109
  }
124
- // Check if both are in rules with matching selectors
125
- if (!use.inAtRule && doSelectorsIntersect(use.selector, definition.selector)) {
126
- use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.definition);
127
- hasReplaced = true;
128
- break;
110
+ });
111
+ // Early exit: If no variable uses found, we're done
112
+ if (variableUses.length === 0) {
113
+ break;
114
+ }
115
+ // Second pass: resolve variables
116
+ let replacedInThisIteration = false;
117
+ for (const use of variableUses) {
118
+ let hasReplaced = false;
119
+ for (const definition of variableDefinitions) {
120
+ if (use.variableName !== definition.variableName) {
121
+ continue;
122
+ }
123
+ // Check if use is in an at-rule and definition is in a matching rule
124
+ if (use.inAtRule &&
125
+ use.atRuleSelector &&
126
+ doSelectorsIntersect(use.atRuleSelector, definition.selector)) {
127
+ use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.declaration.value);
128
+ hasReplaced = true;
129
+ replacedInThisIteration = true;
130
+ break;
131
+ }
132
+ // Check if use is in a top-level at-rule (no atRuleSelector) and definition is in :root or universal
133
+ if (use.inAtRule &&
134
+ !use.atRuleSelector &&
135
+ (definition.selector.includes(':root') || definition.selector === '*')) {
136
+ use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.declaration.value);
137
+ hasReplaced = true;
138
+ replacedInThisIteration = true;
139
+ break;
140
+ }
141
+ // Check if both are in rules with matching selectors
142
+ if (!use.inAtRule && doSelectorsIntersect(use.selector, definition.selector)) {
143
+ use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.declaration.value);
144
+ hasReplaced = true;
145
+ replacedInThisIteration = true;
146
+ break;
147
+ }
148
+ }
149
+ if (!hasReplaced && use.fallback) {
150
+ use.declaration.value = use.declaration.value.replaceAll(use.raw, use.fallback);
151
+ replacedInThisIteration = true;
129
152
  }
130
153
  }
131
- if (!hasReplaced && use.fallback) {
132
- use.declaration.value = use.declaration.value.replaceAll(use.raw, use.fallback);
154
+ // Early exit: If nothing was replaced, no point continuing
155
+ if (!replacedInThisIteration) {
156
+ break;
133
157
  }
158
+ iteration++;
159
+ }
160
+ // Warning for circular references
161
+ if (iteration === MAX_CSS_VARIABLE_RESOLUTION_ITERATIONS) {
162
+ console.warn(`[better-svelte-email] CSS variable resolution hit maximum iterations (${MAX_CSS_VARIABLE_RESOLUTION_ITERATIONS}). ` +
163
+ `This may indicate circular variable references.`);
134
164
  }
135
165
  }
@@ -0,0 +1 @@
1
+ export declare function sanitizeCustomCss(css: string): string;
@@ -0,0 +1,10 @@
1
+ import postcss from 'postcss';
2
+ export function sanitizeCustomCss(css) {
3
+ const root = postcss.parse(css);
4
+ root.walkAtRules((atRule) => {
5
+ if (atRule.name === 'import' || atRule.name === 'plugin') {
6
+ atRule.remove();
7
+ }
8
+ });
9
+ return root.toString();
10
+ }
@@ -1,7 +1,12 @@
1
1
  import { type Root } from 'postcss';
2
2
  import type { TailwindConfig } from '../../index.js';
3
3
  export type TailwindSetup = Awaited<ReturnType<typeof setupTailwind>>;
4
- export declare function setupTailwind(config: TailwindConfig): Promise<{
4
+ /**
5
+ * Set up Tailwind CSS compiler with optional custom CSS injection
6
+ * @param config - Tailwind configuration
7
+ * @param customCSS - Optional custom CSS string to inject (e.g., your theme CSS variables)
8
+ */
9
+ export declare function setupTailwind(config: TailwindConfig, customCSS?: string): Promise<{
5
10
  addUtilities: (candidates: string[]) => void;
6
11
  getStyleSheet: () => Root;
7
12
  }>;
@@ -4,11 +4,19 @@ import indexCss from './tailwind-stylesheets/index.js';
4
4
  import preflightCss from './tailwind-stylesheets/preflight.js';
5
5
  import themeCss from './tailwind-stylesheets/theme.js';
6
6
  import utilitiesCss from './tailwind-stylesheets/utilities.js';
7
- export async function setupTailwind(config) {
7
+ import { sanitizeCustomCss } from './sanitize-custom-css.js';
8
+ /**
9
+ * Set up Tailwind CSS compiler with optional custom CSS injection
10
+ * @param config - Tailwind configuration
11
+ * @param customCSS - Optional custom CSS string to inject (e.g., your theme CSS variables)
12
+ */
13
+ export async function setupTailwind(config, customCSS) {
14
+ // Inject customCSS after base imports for theme variable resolution during compilation
8
15
  const baseCss = `
9
16
  @layer theme, base, components, utilities;
10
17
  @import "tailwindcss/theme.css" layer(theme);
11
18
  @import "tailwindcss/utilities.css" layer(utilities);
19
+ ${customCSS ? sanitizeCustomCss(customCSS) : ''}
12
20
  @config;
13
21
  `;
14
22
  const compiler = await compile(baseCss, {
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.1.0-beta.0",
4
+ "version": "1.2.0",
5
5
  "author": "Konixy",
6
6
  "repository": {
7
7
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
39
39
  "@tailwindcss/vite": "^4.1.17",
40
40
  "@types/html-to-text": "^9.0.4",
41
- "@types/node": "^24.10.1",
41
+ "@types/node": "22",
42
42
  "@vitest/coverage-v8": "^4.0.14",
43
43
  "eslint": "^9.39.1",
44
44
  "eslint-config-prettier": "^10.1.8",
@@ -51,7 +51,7 @@
51
51
  "publint": "^0.3.15",
52
52
  "rehype-autolink-headings": "^7.1.0",
53
53
  "rehype-slug": "^6.0.0",
54
- "svelte": "5.44.0",
54
+ "svelte": "5.45.2",
55
55
  "svelte-check": "^4.3.4",
56
56
  "tailwindcss-motion": "^1.1.1",
57
57
  "typescript": "^5.9.3",
@@ -119,7 +119,7 @@
119
119
  "build": "bun run prepack && vite build",
120
120
  "preview": "vite preview",
121
121
  "package": "svelte-package",
122
- "package:watch": "nodemon -x \"bun run package\" -i dist -e ts,svelte",
122
+ "package:watch": "svelte-package --watch",
123
123
  "prepare": "svelte-kit sync || echo ''",
124
124
  "prepack": "svelte-kit sync && svelte-package && publint",
125
125
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",