@octanejs/email 0.0.1

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.
@@ -0,0 +1,485 @@
1
+ import {
2
+ generate,
3
+ List,
4
+ parse,
5
+ walk,
6
+ type Atrule,
7
+ type CssNode,
8
+ type Declaration,
9
+ type Dimension,
10
+ type FunctionNode,
11
+ type NumberNode,
12
+ type Percentage,
13
+ type Rule,
14
+ } from 'css-tree';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { createRequire } from 'node:module';
17
+ import { dirname } from 'node:path';
18
+ import { compile } from 'tailwindcss';
19
+ import { collectTailwindBoundaries, type TailwindBoundaryOptions } from './context.ts';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const TAILWIND_STYLESHEETS = new Set(['tailwindcss/theme.css', 'tailwindcss/utilities.css']);
23
+
24
+ interface CompiledStyles {
25
+ inline: Map<string, string>;
26
+ nonInline: string;
27
+ }
28
+
29
+ export async function renderWithTailwind(render: () => string | Promise<string>): Promise<string> {
30
+ const { value: html, boundaries } = await collectTailwindBoundaries(render);
31
+ return transformTailwindHtml(html, boundaries);
32
+ }
33
+
34
+ export async function transformTailwindHtml(
35
+ html: string,
36
+ boundaries: ReadonlyMap<string, TailwindBoundaryOptions>,
37
+ ): Promise<string> {
38
+ let output = html;
39
+ for (const [id, options] of boundaries) {
40
+ const escapedId = escapeRegExp(id);
41
+ const boundaryPattern = new RegExp(
42
+ `<template[^>]*data-octane-email-tailwind-start=["']${escapedId}["'][^>]*><\\/template>([\\s\\S]*?)<template[^>]*data-octane-email-tailwind-end=["']${escapedId}["'][^>]*><\\/template>`,
43
+ );
44
+ const match = boundaryPattern.exec(output);
45
+ if (!match) continue;
46
+ const fragment = match[1];
47
+ if (fragment.includes('data-octane-email-tailwind-start')) {
48
+ throw new Error('Tailwind boundaries cannot be nested. Use a single Tailwind configuration.');
49
+ }
50
+ const classes = collectClasses(fragment);
51
+ const styles = await compileStyles(classes, options);
52
+ const transformed = inlineFragment(fragment, styles.inline);
53
+ output = output.replace(boundaryPattern, () => transformed);
54
+ if (styles.nonInline) output = injectHeadStyle(output, styles.nonInline);
55
+ }
56
+ return output;
57
+ }
58
+
59
+ async function compileStyles(
60
+ classes: string[],
61
+ options: TailwindBoundaryOptions,
62
+ ): Promise<CompiledStyles> {
63
+ if (classes.length === 0) return { inline: new Map(), nonInline: '' };
64
+ const css = `@layer theme, utilities;
65
+ @import "tailwindcss/theme.css";
66
+ @import "tailwindcss/utilities.css";
67
+ ${options.theme ?? ''}
68
+ ${options.utility ?? ''}
69
+ @config;`;
70
+ const compiler = await compile(css, {
71
+ polyfills: 0,
72
+ async loadStylesheet(id) {
73
+ if (!TAILWIND_STYLESHEETS.has(id)) {
74
+ throw new Error(`Unsupported Tailwind stylesheet: ${id}`);
75
+ }
76
+ const path = require.resolve(id);
77
+ return { path, base: dirname(path), content: await readFile(path, 'utf8') };
78
+ },
79
+ async loadModule(id, base, resourceHint) {
80
+ if (resourceHint === 'config') return { path: id, base, module: options.config ?? {} };
81
+ throw new Error(`Unsupported Tailwind module: ${id}`);
82
+ },
83
+ });
84
+ const ast = parse(compiler.build(classes), { context: 'stylesheet' });
85
+ const inline = new Map<string, string>();
86
+ const customProperties = new Map<string, string>();
87
+ const nonInlineNodes: Array<Rule | Atrule> = [];
88
+ walk(ast, {
89
+ visit: 'Rule',
90
+ enter(node, item, list) {
91
+ const rule = node as Rule;
92
+ const selector = generate(rule.prelude);
93
+ const parent = this.atrule;
94
+ if (!parent && (selector === ':root' || selector === ':root,:host')) {
95
+ walk(rule.block, {
96
+ visit: 'Declaration',
97
+ enter(declaration) {
98
+ const value = declaration as Declaration;
99
+ if (value.property.startsWith('--'))
100
+ customProperties.set(value.property, generate(value.value));
101
+ },
102
+ });
103
+ list?.remove(item);
104
+ return;
105
+ }
106
+ const className = selectorToClass(selector);
107
+ if (!className || parent || selector.includes(':')) return;
108
+ let declarations = '';
109
+ walk(rule.block, {
110
+ visit: 'Declaration',
111
+ enter(declaration) {
112
+ const value = declaration as Declaration;
113
+ if (!value.property.startsWith('--')) {
114
+ const important = value.important ? ' !important' : '';
115
+ const resolvedValue = resolveCssVariables(generate(value.value), customProperties);
116
+ declarations += `${value.property}:${sanitizeOklchColors(resolvedValue, 'value')}${important};`;
117
+ }
118
+ },
119
+ });
120
+ if (declarations) inline.set(className, declarations);
121
+ list?.remove(item);
122
+ },
123
+ });
124
+ // What remains is media/pseudo CSS plus Tailwind's supporting at-rules.
125
+ if (ast.type !== 'StyleSheet') throw new Error('Tailwind generated an invalid stylesheet.');
126
+ for (const node of ast.children) nonInlineNodes.push(node as Rule | Atrule);
127
+ const nonInline =
128
+ nonInlineNodes.length > 0
129
+ ? sanitizeOklchColors(resolveCssVariables(generate(ast), customProperties), 'stylesheet')
130
+ : '';
131
+ return { inline, nonInline };
132
+ }
133
+
134
+ function collectClasses(html: string): string[] {
135
+ const classes = new Set<string>();
136
+ mapStartTags(html, (tag) => {
137
+ const attribute = findQuotedAttribute(tag, 'class');
138
+ if (attribute) {
139
+ for (const name of attribute.value.trim().split(/\s+/)) if (name) classes.add(name);
140
+ }
141
+ return tag;
142
+ });
143
+ return [...classes];
144
+ }
145
+
146
+ function inlineFragment(html: string, styles: ReadonlyMap<string, string>): string {
147
+ return mapStartTags(html, (tag) => {
148
+ const classAttribute = findQuotedAttribute(tag, 'class');
149
+ if (!classAttribute) return tag;
150
+ const generated = classAttribute.value
151
+ .split(/\s+/)
152
+ .map((name) => styles.get(name) ?? '')
153
+ .join('');
154
+ if (!generated) return tag;
155
+
156
+ const styleAttribute = findQuotedAttribute(tag, 'style');
157
+ if (styleAttribute) {
158
+ const escapedGenerated = escapeAttributeFragment(generated, styleAttribute.quote);
159
+ return `${tag.slice(0, styleAttribute.valueStart)}${escapedGenerated}${styleAttribute.value}${tag.slice(styleAttribute.valueEnd)}`;
160
+ }
161
+
162
+ const escapedGenerated = escapeAttributeFragment(generated, '"');
163
+ const selfClosing = /\/\s*>$/.exec(tag);
164
+ const insertionPoint = selfClosing?.index ?? tag.lastIndexOf('>');
165
+ const prefix = tag.slice(0, insertionPoint);
166
+ const separator = /\s$/.test(prefix) ? '' : ' ';
167
+ return `${prefix}${separator}style="${escapedGenerated}"${tag.slice(insertionPoint)}`;
168
+ });
169
+ }
170
+
171
+ interface QuotedAttribute {
172
+ quote: '"' | "'";
173
+ value: string;
174
+ valueStart: number;
175
+ valueEnd: number;
176
+ }
177
+
178
+ function findQuotedAttribute(tag: string, expectedName: string): QuotedAttribute | undefined {
179
+ const tagName = /^<[a-z][^\s/>]*/i.exec(tag);
180
+ if (!tagName) return;
181
+ let cursor = tagName[0].length;
182
+ while (cursor < tag.length) {
183
+ while (/\s/.test(tag[cursor] ?? '')) cursor++;
184
+ if (tag[cursor] === '>' || (tag[cursor] === '/' && /^\/\s*>/.test(tag.slice(cursor)))) return;
185
+
186
+ const nameStart = cursor;
187
+ while (cursor < tag.length && !/[\s=/>]/.test(tag[cursor])) cursor++;
188
+ if (cursor === nameStart) {
189
+ cursor++;
190
+ continue;
191
+ }
192
+ const name = tag.slice(nameStart, cursor);
193
+ while (/\s/.test(tag[cursor] ?? '')) cursor++;
194
+ if (tag[cursor] !== '=') continue;
195
+ cursor++;
196
+ while (/\s/.test(tag[cursor] ?? '')) cursor++;
197
+ const quote = tag[cursor];
198
+ if (quote !== '"' && quote !== "'") {
199
+ while (cursor < tag.length && !/[\s>]/.test(tag[cursor])) cursor++;
200
+ continue;
201
+ }
202
+
203
+ const valueStart = ++cursor;
204
+ while (cursor < tag.length && tag[cursor] !== quote) cursor++;
205
+ if (cursor === tag.length) return;
206
+ const valueEnd = cursor;
207
+ cursor++;
208
+ if (name.toLowerCase() === expectedName) {
209
+ return {
210
+ quote,
211
+ value: tag.slice(valueStart, valueEnd),
212
+ valueStart,
213
+ valueEnd,
214
+ };
215
+ }
216
+ }
217
+ }
218
+
219
+ function mapStartTags(html: string, transform: (tag: string) => string): string {
220
+ let output = '';
221
+ let copiedThrough = 0;
222
+ let cursor = 0;
223
+ while (cursor < html.length) {
224
+ const tagStart = html.indexOf('<', cursor);
225
+ if (tagStart === -1) break;
226
+ if (!/[a-z]/i.test(html[tagStart + 1] ?? '')) {
227
+ cursor = tagStart + 1;
228
+ continue;
229
+ }
230
+
231
+ let tagEnd = tagStart + 2;
232
+ let quote: '"' | "'" | undefined;
233
+ for (; tagEnd < html.length; tagEnd++) {
234
+ const character = html[tagEnd];
235
+ if (quote) {
236
+ if (character === quote) quote = undefined;
237
+ } else if (character === '"' || character === "'") {
238
+ quote = character;
239
+ } else if (character === '>') {
240
+ break;
241
+ }
242
+ }
243
+ if (tagEnd === html.length) break;
244
+
245
+ output += html.slice(copiedThrough, tagStart);
246
+ output += transform(html.slice(tagStart, tagEnd + 1));
247
+ copiedThrough = tagEnd + 1;
248
+ cursor = copiedThrough;
249
+ }
250
+ return output + html.slice(copiedThrough);
251
+ }
252
+
253
+ function escapeAttributeFragment(value: string, quote: '"' | "'"): string {
254
+ return value.replaceAll('&', '&amp;').replaceAll(quote, quote === '"' ? '&quot;' : '&#39;');
255
+ }
256
+
257
+ function injectHeadStyle(html: string, css: string): string {
258
+ const style = `<style>${css}</style>`;
259
+ if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, () => `${style}</head>`);
260
+ throw new Error('Tailwind: <head> not found. Move <Head /> inside <Tailwind>.');
261
+ }
262
+
263
+ function selectorToClass(selector: string): string | undefined {
264
+ const match = /^\.((?:\\.|[\w-!])+)$/.exec(selector);
265
+ return match?.[1].replace(/\\(.)/g, '$1');
266
+ }
267
+
268
+ function escapeRegExp(value: string): string {
269
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
270
+ }
271
+
272
+ function resolveCssVariables(value: string, variables: ReadonlyMap<string, string>): string {
273
+ let resolved = value;
274
+ for (let pass = 0; pass < 8 && /var\(/i.test(resolved); pass++) {
275
+ const next = replaceCssVariables(resolved, variables);
276
+ if (next === resolved) break;
277
+ resolved = next;
278
+ }
279
+ return resolved;
280
+ }
281
+
282
+ function replaceCssVariables(value: string, variables: ReadonlyMap<string, string>): string {
283
+ let output = '';
284
+ let copiedThrough = 0;
285
+ let cursor = 0;
286
+
287
+ while (cursor < value.length) {
288
+ const character = value[cursor];
289
+ if (character === '"' || character === "'") {
290
+ cursor = skipCssString(value, cursor, character);
291
+ continue;
292
+ }
293
+ if (character === '/' && value[cursor + 1] === '*') {
294
+ cursor = skipCssComment(value, cursor);
295
+ continue;
296
+ }
297
+
298
+ const previous = value[cursor - 1];
299
+ if (
300
+ value.slice(cursor, cursor + 4).toLowerCase() !== 'var(' ||
301
+ (previous !== undefined && /[\w-]/.test(previous))
302
+ ) {
303
+ cursor++;
304
+ continue;
305
+ }
306
+
307
+ const closingParenthesis = findClosingParenthesis(value, cursor + 3);
308
+ if (closingParenthesis === -1) break;
309
+
310
+ const argumentsText = value.slice(cursor + 4, closingParenthesis);
311
+ const comma = findTopLevelComma(argumentsText);
312
+ const name = argumentsText.slice(0, comma === -1 ? undefined : comma).trim();
313
+ if (!/^--[\w-]+$/.test(name)) {
314
+ cursor = closingParenthesis + 1;
315
+ continue;
316
+ }
317
+
318
+ const fallback = comma === -1 ? undefined : argumentsText.slice(comma + 1).trim();
319
+ const replacement = variables.get(name) ?? fallback;
320
+ if (replacement === undefined) {
321
+ cursor = closingParenthesis + 1;
322
+ continue;
323
+ }
324
+
325
+ output += value.slice(copiedThrough, cursor) + replacement;
326
+ cursor = closingParenthesis + 1;
327
+ copiedThrough = cursor;
328
+ }
329
+
330
+ return output + value.slice(copiedThrough);
331
+ }
332
+
333
+ function findClosingParenthesis(value: string, openingParenthesis: number): number {
334
+ let depth = 1;
335
+ let cursor = openingParenthesis + 1;
336
+ while (cursor < value.length) {
337
+ const character = value[cursor];
338
+ if (character === '"' || character === "'") {
339
+ cursor = skipCssString(value, cursor, character);
340
+ continue;
341
+ }
342
+ if (character === '/' && value[cursor + 1] === '*') {
343
+ cursor = skipCssComment(value, cursor);
344
+ continue;
345
+ }
346
+ if (character === '(') depth++;
347
+ else if (character === ')' && --depth === 0) return cursor;
348
+ cursor++;
349
+ }
350
+ return -1;
351
+ }
352
+
353
+ function findTopLevelComma(value: string): number {
354
+ let depth = 0;
355
+ let cursor = 0;
356
+ while (cursor < value.length) {
357
+ const character = value[cursor];
358
+ if (character === '"' || character === "'") {
359
+ cursor = skipCssString(value, cursor, character);
360
+ continue;
361
+ }
362
+ if (character === '/' && value[cursor + 1] === '*') {
363
+ cursor = skipCssComment(value, cursor);
364
+ continue;
365
+ }
366
+ if (character === '(') depth++;
367
+ else if (character === ')') depth--;
368
+ else if (character === ',' && depth === 0) return cursor;
369
+ cursor++;
370
+ }
371
+ return -1;
372
+ }
373
+
374
+ function skipCssString(value: string, start: number, quote: '"' | "'"): number {
375
+ let cursor = start + 1;
376
+ while (cursor < value.length) {
377
+ if (value[cursor] === '\\') cursor += 2;
378
+ else if (value[cursor++] === quote) break;
379
+ }
380
+ return cursor;
381
+ }
382
+
383
+ function skipCssComment(value: string, start: number): number {
384
+ const end = value.indexOf('*/', start + 2);
385
+ return end === -1 ? value.length : end + 2;
386
+ }
387
+
388
+ const OKLAB_TO_LMS = {
389
+ l: [0.3963377773761749, 0.2158037573099136],
390
+ m: [-0.1055613458156586, -0.0638541728258133],
391
+ s: [-0.0894841775298119, -1.2914855480194092],
392
+ } as const;
393
+
394
+ const LMS_TO_RGB = {
395
+ r: [4.076741636075958, -3.307711539258063, 0.2309699031821043],
396
+ g: [-1.2684379732850315, 2.609757349287688, -0.341319376002657],
397
+ b: [-0.0041960761386756, -0.7034186179359362, 1.7076146940746117],
398
+ } as const;
399
+
400
+ function sanitizeOklchColors(value: string, context: 'stylesheet' | 'value'): string {
401
+ if (!/oklch\(/i.test(value)) return value;
402
+ const ast = parse(value, { context });
403
+ walk(ast, {
404
+ visit: 'Function',
405
+ enter(node, item) {
406
+ const color = node as FunctionNode;
407
+ if (color.name.toLowerCase() !== 'oklch') return;
408
+ item.data = oklchToRgbNode(color);
409
+ },
410
+ });
411
+ return generate(ast);
412
+ }
413
+
414
+ function oklchToRgbNode(color: FunctionNode): FunctionNode {
415
+ let lightness: number | undefined;
416
+ let chroma: number | undefined;
417
+ let hue: number | undefined;
418
+ let alpha: number | undefined;
419
+
420
+ for (const child of color.children) {
421
+ if (child.type === 'Number') {
422
+ const value = Number.parseFloat((child as NumberNode).value);
423
+ if (lightness === undefined) lightness = value;
424
+ else if (chroma === undefined) chroma = value;
425
+ else if (hue === undefined) hue = value;
426
+ else if (alpha === undefined) alpha = value;
427
+ } else if (child.type === 'Dimension') {
428
+ const value = child as Dimension;
429
+ if (hue === undefined && value.unit.toLowerCase() === 'deg') {
430
+ hue = Number.parseFloat(value.value);
431
+ }
432
+ } else if (child.type === 'Percentage') {
433
+ const value = Number.parseFloat((child as Percentage).value) / 100;
434
+ if (lightness === undefined) lightness = value;
435
+ else if (alpha === undefined) alpha = value;
436
+ }
437
+ }
438
+
439
+ if (lightness === undefined || chroma === undefined || hue === undefined) {
440
+ throw new Error(`Could not convert unsupported color ${generate(color)} to rgb().`);
441
+ }
442
+
443
+ const hueRadians = (hue / 180) * Math.PI;
444
+ const a = chroma * Math.cos(hueRadians);
445
+ const b = chroma * Math.sin(hueRadians);
446
+ const l = (lightness + OKLAB_TO_LMS.l[0] * a + OKLAB_TO_LMS.l[1] * b) ** 3;
447
+ const m = (lightness + OKLAB_TO_LMS.m[0] * a + OKLAB_TO_LMS.m[1] * b) ** 3;
448
+ const s = (lightness + OKLAB_TO_LMS.s[0] * a + OKLAB_TO_LMS.s[1] * b) ** 3;
449
+ const red = clampRgb(
450
+ 255 * linearRgbToRgb(LMS_TO_RGB.r[0] * l + LMS_TO_RGB.r[1] * m + LMS_TO_RGB.r[2] * s),
451
+ );
452
+ const green = clampRgb(
453
+ 255 * linearRgbToRgb(LMS_TO_RGB.g[0] * l + LMS_TO_RGB.g[1] * m + LMS_TO_RGB.g[2] * s),
454
+ );
455
+ const blue = clampRgb(
456
+ 255 * linearRgbToRgb(LMS_TO_RGB.b[0] * l + LMS_TO_RGB.b[1] * m + LMS_TO_RGB.b[2] * s),
457
+ );
458
+ const children: CssNode[] = [
459
+ rgbNumber(red),
460
+ rgbComma(),
461
+ rgbNumber(green),
462
+ rgbComma(),
463
+ rgbNumber(blue),
464
+ ];
465
+ if (alpha !== undefined && alpha !== 1) children.push(rgbComma(), rgbNumber(alpha, false));
466
+ return { type: 'Function', name: 'rgb', children: new List<CssNode>().fromArray(children) };
467
+ }
468
+
469
+ function linearRgbToRgb(value: number): number {
470
+ const absolute = Math.abs(value);
471
+ const sign = value < 0 ? -1 : 1;
472
+ return absolute > 0.0031308 ? sign * (absolute ** (1 / 2.4) * 1.055 - 0.055) : value * 12.92;
473
+ }
474
+
475
+ function clampRgb(value: number): number {
476
+ return Math.min(Math.max(value, 0), 255);
477
+ }
478
+
479
+ function rgbNumber(value: number, round = true): NumberNode {
480
+ return { type: 'Number', value: round ? value.toFixed(0) : value.toString() };
481
+ }
482
+
483
+ function rgbComma(): CssNode {
484
+ return { type: 'Operator', value: ',' };
485
+ }
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { OctaneNode } from 'octane';
2
+ import type { Octane } from 'octane/jsx-runtime';
3
+
4
+ export type EmailStyle = Exclude<
5
+ Octane.HTMLAttributes<HTMLElement>['style'],
6
+ string | undefined
7
+ > & {
8
+ msoPaddingAlt?: string | number;
9
+ msoTextRaise?: string | number;
10
+ };
11
+
12
+ type EmailHTMLAttributes<Element extends HTMLElement> = Element extends HTMLAnchorElement
13
+ ? Octane.AnchorHTMLAttributes<Element>
14
+ : Element extends HTMLImageElement
15
+ ? Octane.ImgHTMLAttributes<Element>
16
+ : Element extends HTMLTableCellElement
17
+ ? Octane.TdHTMLAttributes<Element>
18
+ : Element extends HTMLTableElement
19
+ ? Octane.TableHTMLAttributes<Element>
20
+ : Element extends HTMLHtmlElement
21
+ ? Octane.HtmlHTMLAttributes<Element>
22
+ : Octane.HTMLAttributes<Element>;
23
+
24
+ export type EmailElementProps<Element extends HTMLElement = HTMLElement> = Readonly<
25
+ Omit<EmailHTMLAttributes<Element>, 'children' | 'style' | 'ref'> & {
26
+ children?: OctaneNode;
27
+ style?: EmailStyle;
28
+ ref?: ((element: Element | null) => void) | { current: Element | null } | null;
29
+ }
30
+ >;
package/src/utils.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { EmailStyle } from './types.ts';
2
+
3
+ type Space = string | number | undefined;
4
+
5
+ export function splitPadding(style: EmailStyle = {}): [EmailStyle, EmailStyle] {
6
+ const cell: EmailStyle = {};
7
+ const outer: EmailStyle = {};
8
+ for (const key in style) {
9
+ if (!Object.hasOwn(style, key)) continue;
10
+ const target =
11
+ key === 'padding' ||
12
+ key === 'paddingTop' ||
13
+ key === 'paddingRight' ||
14
+ key === 'paddingBottom' ||
15
+ key === 'paddingLeft'
16
+ ? cell
17
+ : outer;
18
+ (target as Record<string, unknown>)[key] = (style as Record<string, unknown>)[key];
19
+ }
20
+ return [cell, outer];
21
+ }
22
+
23
+ function expandSpace(value: Space): [Space, Space, Space, Space] {
24
+ if (typeof value === 'number') return [value, value, value, value];
25
+ if (typeof value !== 'string') return [undefined, undefined, undefined, undefined];
26
+ const values = value.trim().split(/\s+/);
27
+ if (values.length === 1) return [values[0], values[0], values[0], values[0]];
28
+ if (values.length === 2) return [values[0], values[1], values[0], values[1]];
29
+ if (values.length === 3) return [values[0], values[1], values[2], values[1]];
30
+ return [values[0], values[1], values[2], values[3]];
31
+ }
32
+
33
+ export function computeMargins(style: EmailStyle): EmailStyle {
34
+ let result: EmailStyle = {
35
+ marginTop: undefined,
36
+ marginRight: undefined,
37
+ marginBottom: undefined,
38
+ marginLeft: undefined,
39
+ };
40
+ for (const [key, value] of Object.entries(style)) {
41
+ if (key === 'margin') {
42
+ const [top, right, bottom, left] = expandSpace(value as Space);
43
+ result = {
44
+ marginTop: top,
45
+ marginBottom: bottom,
46
+ marginLeft: left,
47
+ marginRight: right,
48
+ };
49
+ } else if (key === 'marginTop') {
50
+ result.marginTop = value as Space;
51
+ } else if (key === 'marginRight') {
52
+ result.marginRight = value as Space;
53
+ } else if (key === 'marginBottom') {
54
+ result.marginBottom = value as Space;
55
+ } else if (key === 'marginLeft') {
56
+ result.marginLeft = value as Space;
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
62
+ export function withMargins(props: Record<string, Space>): EmailStyle {
63
+ const result: EmailStyle = {};
64
+ const put = (value: Space, names: string[]) => {
65
+ if (value === undefined || Number.isNaN(Number.parseFloat(String(value)))) return;
66
+ for (const name of names) (result as Record<string, unknown>)[name] = `${value}px`;
67
+ };
68
+ put(props.m, ['margin']);
69
+ put(props.mx, ['marginLeft', 'marginRight']);
70
+ put(props.my, ['marginTop', 'marginBottom']);
71
+ put(props.mt, ['marginTop']);
72
+ put(props.mr, ['marginRight']);
73
+ put(props.mb, ['marginBottom']);
74
+ put(props.ml, ['marginLeft']);
75
+ return result;
76
+ }