Skip to content

Commit f694145

Browse files
authored
Merge pull request #9 from customerio/html-email-formatter
2 parents 5fd2d06 + 917776e commit f694145

File tree

7 files changed

+433
-0
lines changed

7 files changed

+433
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Open source libraries and configurations used at [Customer.io](https://customer.
66

77
- [@ciolabs/config-eslint](./packages/config-eslint) - Shared ESLint configuration for Customer.io projects
88
- [@ciolabs/config-prettier](./packages/config-prettier) - Shared Prettier configuration for Customer.io projects
9+
- [@ciolabs/html-email-formatter](./packages/html-email-formatter) - Format and prettify HTML email content with conditional comment support
910
- [@ciolabs/html-find-conditional-comments](./packages/html-find-conditional-comments) - Finds all conditional comments in a string
1011
- [@ciolabs/html-preserve-comment-whitespace](./packages/html-preserve-comment-whitespace) - Preserves the presence or lack thereof of whitespace surrounding HTML comments
1112
- [@ciolabs/html-process-conditional-comments](./packages/html-process-conditional-comments) - Makes it easy to safely process HTML inside of conditional comments
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@ciolabs/html-email-formatter",
3+
"version": "0.0.0",
4+
"description": "Format and prettify HTML email content",
5+
"author": "Customer.io <win@customer.io>",
6+
"license": "MIT",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/customerio/ciolabs.git",
10+
"directory": "packages/html-email-formatter"
11+
},
12+
"homepage": "https://github.com/customerio/ciolabs/tree/main/packages/html-email-formatter#readme",
13+
"publishConfig": {
14+
"access": "public"
15+
},
16+
"main": "./dist/index.js",
17+
"module": "./dist/index.mjs",
18+
"types": "./dist/index.d.ts",
19+
"exports": {
20+
".": {
21+
"types": "./dist/index.d.ts",
22+
"import": "./dist/index.mjs",
23+
"require": "./dist/index.js"
24+
}
25+
},
26+
"files": [
27+
"dist"
28+
],
29+
"scripts": {
30+
"build": "tsup",
31+
"dev": "tsup --watch",
32+
"clean": "rm -rf dist",
33+
"test": "vitest run"
34+
},
35+
"dependencies": {
36+
"@ciolabs/html-find-conditional-comments": "workspace:*",
37+
"@ciolabs/html-preserve-comment-whitespace": "workspace:*",
38+
"@types/js-beautify": "^1.13.3",
39+
"@types/pretty": "^2.0.1",
40+
"js-beautify": "^1.15.1",
41+
"magic-string": "^0.30.0",
42+
"pretty": "^2.0.0"
43+
}
44+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { test, expect } from 'vitest';
2+
3+
import emailFormatter from './index';
4+
5+
test('email formatter should align the open and close tags', () => {
6+
const result = emailFormatter(`
7+
<div>
8+
<!--[if MAC]>
9+
<div>
10+
<span>
11+
<![endif]-->
12+
<!--[if MAC]>
13+
</span>
14+
</div>
15+
<![endif]-->
16+
</div>
17+
`);
18+
19+
expect(result).toBe(
20+
`<div>
21+
<!--[if MAC]>
22+
<div>
23+
<span>
24+
<![endif]-->
25+
<!--[if MAC]>
26+
</span>
27+
</div>
28+
<![endif]-->
29+
</div>`
30+
);
31+
});
32+
33+
test('email formatter should respect the whitespace', () => {
34+
const result = emailFormatter(`
35+
<div> <!--[if MAC]> hello <![endif]--> </div>
36+
`);
37+
38+
expect(result).toBe(`<div> <!--[if MAC]> hello <![endif]--> </div>`);
39+
});
40+
41+
test('should keep no whitespace if there was none', () => {
42+
const result = emailFormatter(`<div><!--[if MAC]>hello<![endif]--></div>`);
43+
44+
expect(result).toBe(`<div><!--[if MAC]>hello<![endif]--></div>`);
45+
});
46+
47+
test('email formatter should properly format from a single line', () => {
48+
const result = emailFormatter(`<!DOCTYPE html>
49+
<html style="">
50+
51+
<body class="">
52+
53+
<!--[if mso]><table cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="background-color: rgb(255, 255, 255);"><![endif]-->
54+
55+
<!--[if mso]></td></tr></table><![endif]-->
56+
57+
</body>
58+
</html>`);
59+
60+
expect(result).toBe(
61+
`<!DOCTYPE html>
62+
<html style="">
63+
64+
<body class="">
65+
66+
<!--[if mso]><table cellpadding="0" cellspacing="0" border="0" width="100%">
67+
<tr>
68+
<td style="background-color: rgb(255, 255, 255);"><![endif]-->
69+
70+
<!--[if mso]></td>
71+
</tr>
72+
</table><![endif]-->
73+
74+
</body>
75+
76+
</html>`
77+
);
78+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import findConditionalComments from '@ciolabs/html-find-conditional-comments';
2+
import { preserve, restore } from '@ciolabs/html-preserve-comment-whitespace';
3+
import jsBeautify from 'js-beautify';
4+
import MagicString from 'magic-string';
5+
import pretty from 'pretty';
6+
7+
export default function emailFormatter(
8+
html: string,
9+
options?: Parameters<typeof pretty>[1] & jsBeautify.HTMLBeautifyOptions
10+
): string {
11+
const id = Math.random();
12+
const opener = `<!--${id}`;
13+
const closer = `${id}-->`;
14+
15+
html = closeConditionalComments(html, { opener, closer });
16+
17+
const cache = preserve(html);
18+
html = pretty(html, options);
19+
html = restore(html, cache);
20+
html = openConditionalComments(html, { opener, closer });
21+
html = alignOpenAndCloseOfConditionalComments(html);
22+
23+
return html;
24+
}
25+
26+
/**
27+
* Close downlevel-hidden conditional comments
28+
*
29+
* Example:
30+
* <!--[if mso]>
31+
* <p>Content</p>
32+
* <![endif]-->
33+
*
34+
* Becomes:
35+
* <!--[if mso]>123456789-->
36+
* <p>Content</p>
37+
* <!--123456789<![endif]-->
38+
*
39+
* This opens the the MSO HTML to be formatted by the HTML formatter.
40+
*/
41+
function closeConditionalComments(html: string, { opener, closer }: { opener: string; closer: string }) {
42+
const comments = findConditionalComments(html);
43+
const s = new MagicString(html);
44+
45+
for (const comment of comments) {
46+
const contentStartIndex = comment.range[0] + comment.open.length;
47+
const contentEndIndex = comment.range[1] - comment.close.length;
48+
49+
s.appendLeft(contentStartIndex, closer);
50+
s.appendLeft(contentEndIndex, opener);
51+
}
52+
53+
return s.toString();
54+
}
55+
56+
/**
57+
* Open downlevel-hidden conditional comments
58+
*
59+
* Example:
60+
* <!--[if mso]>123456789-->
61+
* <p>Content</p>
62+
* <!--123456789<![endif]-->
63+
*
64+
* Becomes:
65+
* <!--[if mso]>
66+
* <p>Content</p>
67+
* <![endif]-->
68+
*
69+
* This reverses the effect of `closeConditionalComments`. After the HTML
70+
* formatter has formatted the MSO HTML, we restore the original state.
71+
*/
72+
function openConditionalComments(html: string, { opener, closer }: { opener: string; closer: string }) {
73+
return html.replaceAll(new RegExp(`(${opener}|${closer})`, 'g'), '');
74+
}
75+
76+
/**
77+
* Align open and close tags of conditional comments
78+
*/
79+
function alignOpenAndCloseOfConditionalComments(html: string) {
80+
const comments = findConditionalComments(html);
81+
const s = new MagicString(html);
82+
83+
for (const comment of comments) {
84+
const code = new Set(html.slice(comment.range[0], comment.range[1]));
85+
if (!code.has('\n') && !code.has('\r')) {
86+
continue;
87+
}
88+
89+
const { whitespace: openLeadingWhitespace } = parseWhitespaceAndTextOfLastLine(
90+
html.slice(0, Math.max(0, comment.range[0]))
91+
);
92+
const { whitespace: closeLeadingWhitespace, text: closeLeadingText } = parseWhitespaceAndTextOfLastLine(
93+
html.slice(comment.range[0], comment.range[1] - comment.close.length)
94+
);
95+
96+
if (openLeadingWhitespace.length === closeLeadingWhitespace.length) {
97+
continue;
98+
}
99+
100+
if (openLeadingWhitespace.length > closeLeadingWhitespace.length) {
101+
s.overwrite(comment.range[0] - openLeadingWhitespace.length, comment.range[0], closeLeadingWhitespace);
102+
} else {
103+
s.overwrite(
104+
comment.range[1] - comment.close.length - closeLeadingText.length - closeLeadingWhitespace.length,
105+
comment.range[1] - comment.close.length - closeLeadingText.length,
106+
openLeadingWhitespace
107+
);
108+
}
109+
}
110+
111+
return s.toString();
112+
}
113+
114+
function parseWhitespaceAndTextOfLastLine(string_: string): {
115+
whitespace: string;
116+
text: string;
117+
} {
118+
const lastLine = string_.split('\n').at(-1) ?? '';
119+
const match = /^[^\S\n\r]*/.exec(lastLine);
120+
const whitespace = match ? match[0] : '';
121+
const text = lastLine.slice(whitespace.length);
122+
123+
return { whitespace, text };
124+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./dist"
5+
},
6+
"include": ["src/**/*"],
7+
"exclude": ["dist", "node_modules"]
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'tsup';
2+
3+
import baseConfig from '../../tsup.config';
4+
5+
export default defineConfig({
6+
...baseConfig,
7+
entry: ['src/index.ts'],
8+
});

0 commit comments

Comments
 (0)