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(); 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().fromArray([values[1]!]), }; shorthandToReplace.value = { type: 'Value', children: new List().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); } }, }); }