better-svelte-email
Version:
Svelte email renderer with Tailwind support
159 lines (158 loc) • 5.93 kB
JavaScript
import { tailwindToCSS } from 'tw-to-css';
/**
* Initialize Tailwind converter with config
*/
export function createTailwindConverter(config) {
const { twi } = tailwindToCSS({ config });
return twi;
}
/**
* Transform Tailwind classes to inline styles and responsive classes
*/
export function transformTailwindClasses(classString, tailwindConverter) {
// Split classes
const classes = classString.trim().split(/\s+/).filter(Boolean);
// Separate responsive from non-responsive classes
const responsiveClasses = [];
const nonResponsiveClasses = [];
for (const cls of classes) {
// Responsive classes have format: sm:, md:, lg:, xl:, 2xl:
if (/^(sm|md|lg|xl|2xl):/.test(cls)) {
responsiveClasses.push(cls);
}
else {
nonResponsiveClasses.push(cls);
}
}
// Convert non-responsive classes to CSS
let inlineStyles = '';
const invalidClasses = [];
if (nonResponsiveClasses.length > 0) {
const classesStr = nonResponsiveClasses.join(' ');
try {
// Generate CSS from Tailwind classes
const css = tailwindConverter(classesStr, {
merge: false,
ignoreMediaQueries: true
});
// Extract styles from CSS
const styles = extractStylesFromCSS(css, nonResponsiveClasses);
inlineStyles = styles.validStyles;
invalidClasses.push(...styles.invalidClasses);
}
catch (error) {
console.warn('Failed to convert Tailwind classes:', error);
}
}
return {
inlineStyles,
responsiveClasses,
invalidClasses
};
}
/**
* Extract CSS properties from generated CSS
* Handles the format: .classname { prop: value; }
*/
function extractStylesFromCSS(css, originalClasses) {
const invalidClasses = [];
const styleProperties = [];
// Remove media queries (we handle those separately)
const cssWithoutMedia = css.replace(/@media[^{]+\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
// Create a map of class name -> CSS rules
const classMap = new Map();
// Match .classname { rules }
const classRegex = /\.([^\s{]+)\s*\{([^}]+)\}/g;
let match;
while ((match = classRegex.exec(cssWithoutMedia)) !== null) {
const className = match[1];
const rules = match[2].replace(/\\/g, '').trim();
// Normalize class name (tw-to-css might transform special chars)
const normalizedClass = className.replace(/\\/g, '').replace(/[:#\-[\]/.%!_]+/g, '_');
classMap.set(normalizedClass, rules);
}
// For each original class, try to find its CSS
for (const originalClass of originalClasses) {
// Normalize the original class name to match what tw-to-css produces
const normalized = originalClass.replace(/[:#\-[\]/.%!_]+/g, '_');
if (classMap.has(normalized)) {
const rules = classMap.get(normalized);
// Ensure rules end with semicolon for proper concatenation
const rulesWithSemicolon = rules.trim().endsWith(';') ? rules.trim() : rules.trim() + ';';
styleProperties.push(rulesWithSemicolon);
}
else {
// Class not found - might be invalid Tailwind
invalidClasses.push(originalClass);
}
}
// Combine all style properties with space separator
const validStyles = styleProperties.join(' ').trim();
return { validStyles, invalidClasses };
}
/**
* Generate media query CSS for responsive classes
*/
export function generateMediaQueries(responsiveClasses, tailwindConverter, tailwindConfig) {
if (responsiveClasses.length === 0) {
return [];
}
const mediaQueries = [];
// Default breakpoints (can be overridden by config)
const breakpoints = {
sm: '475px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
...tailwindConfig?.theme?.screens
};
// Group classes by breakpoint
const classesByBreakpoint = new Map();
for (const cls of responsiveClasses) {
const match = cls.match(/^(sm|md|lg|xl|2xl):(.+)/);
if (match) {
const [, breakpoint] = match;
if (!classesByBreakpoint.has(breakpoint)) {
classesByBreakpoint.set(breakpoint, []);
}
classesByBreakpoint.get(breakpoint).push(cls);
}
}
// Generate CSS for each breakpoint
for (const [breakpoint, classes] of classesByBreakpoint) {
const breakpointValue = breakpoints[breakpoint];
if (!breakpointValue)
continue;
// Generate full CSS including media queries
const fullCSS = tailwindConverter(classes.join(' '), {
merge: false,
ignoreMediaQueries: false
});
// Extract just the media query portion
const mediaQueryRegex = new RegExp(`@media[^{]*\\{([^{}]|\\{[^{}]*\\})*\\}`, 'g');
let match;
while ((match = mediaQueryRegex.exec(fullCSS)) !== null) {
const mediaQueryBlock = match[0];
// Make all rules !important for email clients
const withImportant = mediaQueryBlock.replace(/([a-z-]+)\s*:\s*([^;!}]+)/gi, '$1: $2 !important');
// Parse out the query and content
const queryMatch = withImportant.match(/@media\s*([^{]+)/);
if (queryMatch) {
const query = `@media ${queryMatch[1].trim()}`;
mediaQueries.push({
query,
className: `responsive-${breakpoint}`,
rules: withImportant
});
}
}
}
return mediaQueries;
}
/**
* Sanitize class names for use in CSS (replace special characters)
*/
export function sanitizeClassName(className) {
return className.replace(/[:#\-[\]/.%!]+/g, '_');
}