react-email
Version:
A live preview of your emails right in your browser.
430 lines (404 loc) • 13.4 kB
text/typescript
import {
type CssNode,
type Declaration,
type FunctionNode,
generate,
List,
parse,
type Raw,
type Value,
walk,
} from 'css-tree';
function rgbNode(
r: number,
g: number,
b: number,
alpha?: number,
): FunctionNode {
const children = new List<CssNode>();
children.appendData({
type: 'Number',
value: r.toFixed(0),
});
children.appendData({
type: 'Operator',
value: ',',
});
children.appendData({
type: 'Number',
value: g.toFixed(0),
});
children.appendData({
type: 'Operator',
value: ',',
});
children.appendData({
type: 'Number',
value: b.toFixed(0),
});
if (alpha !== 1 && alpha !== undefined) {
children.appendData({
type: 'Operator',
value: ',',
});
children.appendData({
type: 'Number',
value: alpha.toString(),
});
}
return {
type: 'Function',
name: 'rgb',
children,
};
}
const LAB_TO_LMS = {
l: [0.3963377773761749, 0.2158037573099136],
m: [-0.1055613458156586, -0.0638541728258133],
s: [-0.0894841775298119, -1.2914855480194092],
};
const LSM_TO_RGB = {
r: [4.0767416360759583, -3.3077115392580629, 0.2309699031821043],
g: [-1.2684379732850315, 2.6097573492876882, -0.341319376002657],
b: [-0.0041960761386756, -0.7034186179359362, 1.7076146940746117],
};
function lrgbToRgb(input: number) {
const absoluteNumber = Math.abs(input);
const sign = input < 0 ? -1 : 1;
if (absoluteNumber > 0.0031308) {
return sign * (absoluteNumber ** (1 / 2.4) * 1.055 - 0.055);
}
return input * 12.92;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function oklchToOklab(oklch: { l: number; c: number; h: number }) {
return {
l: oklch.l,
a: oklch.c * Math.cos((oklch.h / 180) * Math.PI),
b: oklch.c * Math.sin((oklch.h / 180) * Math.PI),
};
}
/** Convert oklab to RGB */
function oklchToRgb(oklch: { l: number; c: number; h: number }) {
const oklab = oklchToOklab(oklch);
const l =
(oklab.l + LAB_TO_LMS.l[0]! * oklab.a + LAB_TO_LMS.l[1]! * oklab.b) ** 3;
const m =
(oklab.l + LAB_TO_LMS.m[0]! * oklab.a + LAB_TO_LMS.m[1]! * oklab.b) ** 3;
const s =
(oklab.l + LAB_TO_LMS.s[0]! * oklab.a + LAB_TO_LMS.s[1]! * oklab.b) ** 3;
const r =
255 *
lrgbToRgb(
LSM_TO_RGB.r[0]! * l + LSM_TO_RGB.r[1]! * m + LSM_TO_RGB.r[2]! * s,
);
const g =
255 *
lrgbToRgb(
LSM_TO_RGB.g[0]! * l + LSM_TO_RGB.g[1]! * m + LSM_TO_RGB.g[2]! * s,
);
const b =
255 *
lrgbToRgb(
LSM_TO_RGB.b[0]! * l + LSM_TO_RGB.b[1]! * m + LSM_TO_RGB.b[2]! * s,
);
return {
r: clamp(r, 0, 255),
g: clamp(g, 0, 255),
b: clamp(b, 0, 255),
};
}
function separteShorthandDeclaration(
shorthandToReplace: Declaration,
[start, end]: [string, string],
): Declaration {
shorthandToReplace.property = start;
const values =
shorthandToReplace.value.type === 'Value'
? shorthandToReplace.value.children
.toArray()
.filter(
(child) =>
child.type === 'Dimension' ||
child.type === 'Number' ||
child.type === 'Percentage',
)
: [shorthandToReplace.value];
let endValue = shorthandToReplace.value;
if (values.length === 2) {
endValue = {
type: 'Value',
children: new List<CssNode>().fromArray([values[1]!]),
};
shorthandToReplace.value = {
type: 'Value',
children: new List<CssNode>().fromArray([values[0]!]),
};
}
return {
type: 'Declaration',
property: end,
value: endValue,
important: shorthandToReplace.important,
};
}
/**
* Meant to do all the things necessary, in a per-declaration basis, to have the best email client
* support possible.
*
* Here's the transformations it does so far:
* - convert all `rgb` with space-based syntax into a comma based one;
* - convert all `oklch` values into `rgb`;
* - convert all hex values into `rgb`;
* - convert `padding-inline` into `padding-left` and `padding-right`;
* - convert `padding-block` into `padding-top` and `padding-bottom`;
* - convert `margin-inline` into `margin-left` and `margin-right`;
* - convert `margin-block` into `margin-top` and `margin-bottom`.
*/
export function sanitizeDeclarations(nodeContainingDeclarations: CssNode) {
walk(nodeContainingDeclarations, {
visit: 'Declaration',
enter(declaration, item, list) {
if (declaration.value.type === 'Raw') {
declaration.value = parse(declaration.value.value, {
context: 'value',
}) as Value | Raw;
}
if (
/border-radius\s*:\s*calc\s*\(\s*infinity\s*\*\s*1px\s*\)/i.test(
generate(declaration),
)
) {
declaration.value = parse('9999px', { context: 'value' }) as Value;
}
walk(declaration, {
visit: 'Function',
enter(func, funcParentListItem) {
const children = func.children.toArray();
if (func.name === 'oklch') {
let l: number | undefined;
let c: number | undefined;
let h: number | undefined;
let a: number | undefined;
for (const child of children) {
if (child.type === 'Number') {
if (l === undefined) {
l = Number.parseFloat(child.value);
continue;
}
if (c === undefined) {
c = Number.parseFloat(child.value);
continue;
}
if (h === undefined) {
h = Number.parseFloat(child.value);
continue;
}
if (a === undefined) {
a = Number.parseFloat(child.value);
continue;
}
}
if (child.type === 'Dimension' && child.unit === 'deg') {
if (h === undefined) {
h = Number.parseFloat(child.value);
continue;
}
}
if (child.type === 'Percentage') {
if (l === undefined) {
l = Number.parseFloat(child.value) / 100;
continue;
}
if (a === undefined) {
a = Number.parseFloat(child.value) / 100;
}
}
}
if (l === undefined || c === undefined || h === undefined) {
throw new Error(
'Could not determine the parameters of an oklch() function.',
{
cause: declaration,
},
);
}
const rgb = oklchToRgb({
l,
c,
h,
});
funcParentListItem.data = rgbNode(rgb.r, rgb.g, rgb.b, a);
}
if (func.name === 'rgb') {
let r: number | undefined;
let g: number | undefined;
let b: number | undefined;
let a: number | undefined;
for (const child of children) {
if (child.type === 'Number') {
if (r === undefined) {
r = Number.parseFloat(child.value);
continue;
}
if (g === undefined) {
g = Number.parseFloat(child.value);
continue;
}
if (b === undefined) {
b = Number.parseFloat(child.value);
continue;
}
if (a === undefined) {
a = Number.parseFloat(child.value);
continue;
}
}
if (child.type === 'Percentage') {
if (r === undefined) {
r = (Number.parseFloat(child.value) * 255) / 100;
continue;
}
if (g === undefined) {
g = (Number.parseFloat(child.value) * 255) / 100;
continue;
}
if (b === undefined) {
b = (Number.parseFloat(child.value) * 255) / 100;
continue;
}
if (a === undefined) {
a = Number.parseFloat(child.value) / 100;
}
}
}
if (r === undefined || g === undefined || b === undefined) {
throw new Error(
'Could not determine the parameters of an rgb() function.',
{
cause: declaration,
},
);
}
if (a === undefined || a === 1) {
funcParentListItem.data = rgbNode(r, g, b);
} else {
funcParentListItem.data = rgbNode(r, g, b, a);
}
}
},
});
walk(declaration, {
visit: 'Hash',
enter(hash, hashParentListItem) {
const hex = hash.value.trim();
if (hex.length === 3) {
const r = Number.parseInt(hex.charAt(0) + hex.charAt(0), 16);
const g = Number.parseInt(hex.charAt(1) + hex.charAt(1), 16);
const b = Number.parseInt(hex.charAt(2) + hex.charAt(2), 16);
hashParentListItem.data = rgbNode(r, g, b);
return;
}
if (hex.length === 4) {
const r = Number.parseInt(hex.charAt(0) + hex.charAt(0), 16);
const g = Number.parseInt(hex.charAt(1) + hex.charAt(1), 16);
const b = Number.parseInt(hex.charAt(2) + hex.charAt(2), 16);
const a = Number.parseInt(hex.charAt(3) + hex.charAt(3), 16) / 255;
hashParentListItem.data = rgbNode(r, g, b, a);
return;
}
if (hex.length === 5) {
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.charAt(2) + hex.charAt(2), 16);
const b = Number.parseInt(hex.charAt(3) + hex.charAt(3), 16);
const a = Number.parseInt(hex.charAt(4) + hex.charAt(4), 16) / 255;
hashParentListItem.data = rgbNode(r, g, b, a);
return;
}
if (hex.length === 6) {
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
hashParentListItem.data = rgbNode(r, g, b);
return;
}
if (hex.length === 7) {
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
const a = Number.parseInt(hex.charAt(6) + hex.charAt(6), 16) / 255;
hashParentListItem.data = rgbNode(r, g, b, a);
return;
}
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
const a = Number.parseInt(hex.slice(6, 8), 16) / 255;
hashParentListItem.data = rgbNode(r, g, b, a);
},
});
walk(declaration, {
visit: 'Function',
enter(func, parentListItem) {
if (func.name === 'color-mix') {
const children = func.children.toArray();
// We're expecting the children here to be something like:
// Identifier (in)
// Identifier (oklab)
// Operator (,)
// FunctionNode (rgb(...))
// Node (opacity)
// Operator (,)
// Identifier (transparent)
const color: CssNode | undefined = children[3];
const opacity: CssNode | undefined = children[4];
if (
func.children.last?.type === 'Identifier' &&
func.children.last.name === 'transparent' &&
color?.type === 'Function' &&
color?.name === 'rgb' &&
opacity
) {
color.children.appendData({
type: 'Operator',
value: ',',
});
color.children.appendData(opacity);
parentListItem.data = color;
}
}
},
});
if (declaration.property === 'padding-inline') {
const paddingRight = separteShorthandDeclaration(declaration, [
'padding-left',
'padding-right',
]);
list.insertData(paddingRight, item);
}
if (declaration.property === 'padding-block') {
const paddingBottom = separteShorthandDeclaration(declaration, [
'padding-top',
'padding-bottom',
]);
list.insertData(paddingBottom, item);
}
if (declaration.property === 'margin-inline') {
const marginRight = separteShorthandDeclaration(declaration, [
'margin-left',
'margin-right',
]);
list.insertData(marginRight, item);
}
if (declaration.property === 'margin-block') {
const paddingBottom = separteShorthandDeclaration(declaration, [
'margin-top',
'margin-bottom',
]);
list.insertData(paddingBottom, item);
}
},
});
}