Last active
October 3, 2022 07:53
-
-
Save jakobo/51fc23e0081deafa220927e0ce0921b5 to your computer and use it in GitHub Desktop.
Tailwind CSS for Emails
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { readFile } from "fs/promises"; | |
import juice from "juice"; | |
import { rehype } from "rehype"; | |
import rehypeRewrite from "rehype-rewrite"; | |
import stringify from "rehype-stringify"; | |
/** Cache of compiled tailwind files */ | |
const twcCache = new Map<string, Promise<string>>(); | |
/** | |
* Originally based on The MailChimp Reset from Fabio Carneiro, MailChimp User Experience Design | |
* More info and templates on Github: https://github.com/mailchimp/Email-Blueprints | |
* http://www.mailchimp.com & http://www.fabio-carneiro.com | |
* These styles are non-inline; they impact UI added by email clients | |
* By line: | |
* (1) Force Outlook to provide a "view in browser" message | |
* (2) Force Hotmail to display emails at full width | |
* (3) Force Hotmail to display normal line spacing | |
* (4) Prevent WebKit and Windows mobile changing default text sizes | |
* (5) Remove spacing between tables in Outlook 2007 and up | |
* (6) Remove table borders on MSO 07+ http://www.campaignmonitor.com/blog/post/3392/1px-borders-padding-on-table-cells-in-outlook-07/ | |
* (7) Specify bicubic resampling for MSO on img objects | |
* (8) Media Query block - Pretty phone numbers in email: http://www.campaignmonitor.com/blog/post/3571/using-phone-numbers-in-html-email | |
* (9) Media Query block - same as above, but for tablet sized devices | |
*/ | |
const universalStyles = /*css*/ ` | |
#outlook a{ padding:0; } | |
.ReadMsgBody{ width:100%; } .ExternalClass{ width:100%; } | |
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } | |
body, table, td, p, a, li, blockquote{ -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; } | |
table, td{ mso-table-lspace:0pt; mso-table-rspace:0pt; } | |
table td {border-collapse: collapse;} | |
img{-ms-interpolation-mode: bicubic;} | |
@media only screen and (max-device-width: 480px) { | |
a[href^="tel"], | |
a[href^="sms"] { | |
text-decoration: default !important; | |
pointer-events: auto !important; | |
cursor: default !important; | |
} | |
} | |
@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) { | |
a[href^="tel"], | |
a[href^="sms"] { | |
text-decoration: default !important; | |
pointer-events: auto !important; | |
cursor: default !important; | |
} | |
} | |
`; | |
/** | |
* Inline reset styles | |
* These set common defaults for a consistent UI. Their comments will be automatically stripped by juice | |
*/ | |
const resetStyles = /*css*/ ` | |
/* default margin/padding, box sizing*/ | |
* { margin: 0; padding: 0; box-sizing: border-box; } | |
/* height 100% all the way down */ | |
table, tr, td {height: 100%;} | |
/* img behavior - bicubic ms resizing, no border, fix space gap on gmail/hotmail */ | |
img { outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; display: block; } | |
a img { border: none; } | |
/* mso 07, 10 table spacing fix http://www.campaignmonitor.com/blog/post/3694/removing-spacing-from-around-tables-in-outlook-2007-and-2010 */ | |
table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt;} | |
/* tel/sms links are unstyled by default */ | |
a[href^="tel"], a[href^="sms"] { text-decoration: none; pointer-events: none; cursor: default; } | |
`; | |
const variableDefRegex = /(--[a-zA-Z0-9-_]+)\s*:\s(.+?);/g; | |
const variableUsageRegex = /var\((\s*--[a-zA-Z0-9-_]+\s*)(?:\)|,\s*(.*)\))/; | |
/** Juices the styles, and then resolves CSS variables and additional HTML adjustements via rehype */ | |
const inlineStyles = (html: string, css?: string) => { | |
const juiced = juice(html, { extraCss: css }); | |
const hyped = rehype() | |
.use(rehypeRewrite, { | |
rewrite: (node) => { | |
if (node.type !== "element") { | |
return node; | |
} | |
// inline styles into the <head> | |
if (node.tagName === "head") { | |
node.children = [ | |
...node.children, | |
{ | |
type: "element", | |
tagName: "style", | |
children: [{ type: "text", value: universalStyles }], | |
}, | |
]; | |
} | |
const resolveVariables = (s: string): string => { | |
// pass 1: pull definitions | |
const defs = new Map<string, string>(); | |
let withoutDefs = s.replace( | |
variableDefRegex, | |
(_, def: string, value: string) => { | |
defs.set(def.trim(), value.trim()); | |
return ""; | |
} | |
); | |
// pass 2: replace variables | |
let maxCycles = 1000; | |
while (withoutDefs.match(variableUsageRegex)) { | |
maxCycles--; | |
if (maxCycles <= 0) { | |
throw new Error("Max Cycles for replacement exceeded"); | |
} | |
withoutDefs = withoutDefs.replace( | |
variableUsageRegex, | |
(_, def: string, fallback: string) => { | |
const d = def.trim(); | |
if (defs.has(d)) { | |
return defs.get(d) ?? ""; | |
} | |
return (fallback ?? "").trim(); | |
} | |
); | |
} | |
// return clean result | |
return withoutDefs; | |
}; | |
node.properties = { | |
...node.properties, | |
style: resolveVariables(`${node.properties?.style ?? ""}`), | |
}; | |
}, | |
}) | |
.use(stringify) | |
.processSync(juiced) | |
.toString(); | |
return hyped; | |
}; | |
interface MailwindOptions { | |
/** A path to your tailwind.css file, optimized for email */ | |
tailwindCss: string; | |
/** The base px value for 1rem, defaults to 16px */ | |
basePx?: number; | |
/** Set to `false` to disable extended resets */ | |
reset?: boolean; | |
} | |
const mailwindCss = async (email: string, options: MailwindOptions) => { | |
const basePx = options?.basePx ?? 16; | |
// cache promise for performance in serverless environments | |
if ( | |
!twcCache.has(options.tailwindCss) || | |
process.env.NODE_ENV === "development" | |
) { | |
// console.log("loading styles"); | |
const p = new Promise<string>((resolve, reject) => { | |
readFile(options.tailwindCss) | |
.then((buf) => { | |
const s = buf.toString(); | |
// rem to px | |
const pxed = s.replace( | |
/([\d.-]+rem)/gi, | |
(_, value) => `${parseFloat(value.replace(/rem$/, "")) * basePx}px` | |
); | |
resolve(pxed); | |
}) | |
.catch((reason) => reject(reason)); | |
}); | |
twcCache.set(options.tailwindCss, p); | |
} | |
const s = await twcCache.get(options.tailwindCss); | |
if (typeof s === "undefined") { | |
throw new Error(`Could not load tailwind css from ${options.tailwindCss}`); | |
} | |
return inlineStyles( | |
email, | |
options.reset === false ? s : [resetStyles, s].join("\n") | |
); | |
}; | |
export default mailwindCss; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @next/next/no-head-element */ | |
import React, { PropsWithChildren } from "react"; | |
import cx from "classnames"; | |
const fixedCss = /* css */ ` | |
@media all { | |
/* Resets */ | |
* { | |
padding: 0; | |
margin: 0; | |
} | |
img { | |
border: none; | |
-ms-interpolation-mode: bicubic; | |
max-width: 100%; | |
} | |
body { | |
-webkit-font-smoothing: antialiased; | |
-ms-text-size-adjust: 100%; | |
-webkit-text-size-adjust: 100%; | |
} | |
table { | |
border-collapse: separate; | |
mso-table-lspace: 0pt; | |
mso-table-rspace: 0pt; | |
width: 100%; | |
} | |
table td { | |
vertical-align: top; | |
} | |
/* Added by email clients */ | |
.ExternalClass { | |
width: 100%; | |
} | |
.ExternalClass, | |
.ExternalClass p, | |
.ExternalClass span, | |
.ExternalClass font, | |
.ExternalClass td, | |
.ExternalClass div { | |
line-height: 100%; | |
} | |
.apple-link a { | |
color: inherit !important; | |
font-family: inherit !important; | |
font-size: inherit !important; | |
font-weight: inherit !important; | |
line-height: inherit !important; | |
text-decoration: none !important; | |
} | |
#MessageViewBody a { | |
color: inherit; | |
text-decoration: none; | |
font-size: inherit; | |
font-family: inherit; | |
font-weight: inherit; | |
line-height: inherit; | |
} | |
.btn-primary table td:hover { | |
background-color: #34495e !important; | |
} | |
.btn-primary a:hover { | |
background-color: #34495e !important; | |
border-color: #34495e !important; | |
} | |
} | |
`; | |
const tableAttributes = { | |
role: "presentation", | |
cellPadding: 0, | |
cellSpacing: 0, | |
}; | |
interface EmailProps { | |
className?: string; | |
preview?: string; | |
subject?: string; | |
maxWidth?: number; | |
dir?: string; | |
css?: string; | |
} | |
export const Email: React.FC<PropsWithChildren<EmailProps>> = ({ | |
children, | |
className, | |
subject, | |
preview, | |
dir = "ltr", | |
maxWidth = 600, | |
css, | |
}) => { | |
return ( | |
<html dir={dir}> | |
<head> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<meta httpEquiv="Content-Type" content="text/html; charset=UTF-8" /> | |
<title>{subject ?? ""}</title> | |
<style dangerouslySetInnerHTML={{ __html: fixedCss }} /> | |
{css ? <style dangerouslySetInnerHTML={{ __html: fixedCss }} /> : null} | |
</head> | |
<body className={cx(className)}> | |
{preview ? ( | |
<span className="text-transparent hidden invisible overflow-hidden h-0 max-h-0 w-0 max-w-0 opacity-0"> | |
{preview} | |
</span> | |
) : null} | |
<table | |
{...tableAttributes} | |
{...({ border: "0" } as Record<string, string>)} | |
className={className} | |
> | |
<tr> | |
<td> </td> | |
<td | |
className="block w-full" | |
style={{ | |
maxWidth: `${maxWidth}px`, | |
marginLeft: "auto", | |
marginRight: "auto", | |
}} | |
> | |
<div | |
className="box-border block mx-auto my-0" | |
style={{ | |
maxWidth: `${maxWidth}px`, | |
}} | |
> | |
{children} | |
</div> | |
</td> | |
<td> </td> | |
</tr> | |
</table> | |
</body> | |
</html> | |
); | |
}; | |
interface BodyProps { | |
className?: string; | |
} | |
export const Body: React.FC<PropsWithChildren<BodyProps>> = ({ | |
children, | |
className, | |
}) => { | |
return ( | |
<table | |
{...tableAttributes} | |
{...({ border: "0" } as Record<string, string>)} | |
className={cx(className, "w-full")} | |
> | |
<tr> | |
<td className="box-border"> | |
<table | |
{...tableAttributes} | |
{...({ border: "0" } as Record<string, string>)} | |
> | |
<tr>{children}</tr> | |
</table> | |
</td> | |
</tr> | |
</table> | |
); | |
}; | |
interface ContentProps { | |
className?: string; | |
} | |
export const Content: React.FC<PropsWithChildren<ContentProps>> = ({ | |
children, | |
className, | |
}) => { | |
return <td className={className}>{children}</td>; | |
}; | |
interface RowProps { | |
className?: string; | |
} | |
export const Row: React.FC<PropsWithChildren<RowProps>> = ({ | |
children, | |
className, | |
}) => { | |
return ( | |
<table | |
{...tableAttributes} | |
{...({ border: "0" } as Record<string, string>)} | |
className={cx(className, "w-full")} | |
> | |
<tr>{children}</tr> | |
</table> | |
); | |
}; | |
interface ColumnProps { | |
className?: string; | |
} | |
export const Column: React.FC<PropsWithChildren<ColumnProps>> = ({ | |
children, | |
className, | |
}) => { | |
return <td className={className}>{children}</td>; | |
}; | |
interface FooterProps { | |
className?: string; | |
} | |
export const Footer: React.FC<PropsWithChildren<FooterProps>> = ({ | |
children, | |
className, | |
}) => { | |
return ( | |
<div className={cx(className, "clear-both w-full")}> | |
<table | |
{...tableAttributes} | |
{...({ border: "0" } as Record<string, string>)} | |
> | |
<tr> | |
<td>{children}</td> | |
</tr> | |
</table> | |
</div> | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment