Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Open source libraries and configurations used at [Customer.io](https://customer.

- [@ciolabs/config-eslint](./packages/config-eslint) - Shared ESLint configuration for Customer.io projects
- [@ciolabs/config-prettier](./packages/config-prettier) - Shared Prettier configuration for Customer.io projects
- [@ciolabs/html-email-formatter](./packages/html-email-formatter) - Format and prettify HTML email content with conditional comment support
- [@ciolabs/html-find-conditional-comments](./packages/html-find-conditional-comments) - Finds all conditional comments in a string
- [@ciolabs/html-preserve-comment-whitespace](./packages/html-preserve-comment-whitespace) - Preserves the presence or lack thereof of whitespace surrounding HTML comments
- [@ciolabs/html-process-conditional-comments](./packages/html-process-conditional-comments) - Makes it easy to safely process HTML inside of conditional comments
Expand Down
44 changes: 44 additions & 0 deletions packages/html-email-formatter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@ciolabs/html-email-formatter",
"version": "0.0.0",
"description": "Format and prettify HTML email content",
"author": "Customer.io <win@customer.io>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/customerio/ciolabs.git",
"directory": "packages/html-email-formatter"
},
"homepage": "https://github.com/customerio/ciolabs/tree/main/packages/html-email-formatter#readme",
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rm -rf dist",
"test": "vitest run"
},
"dependencies": {
"@ciolabs/html-find-conditional-comments": "workspace:*",
"@ciolabs/html-preserve-comment-whitespace": "workspace:*",
"@types/js-beautify": "^1.13.3",
"@types/pretty": "^2.0.1",
"js-beautify": "^1.15.1",
"magic-string": "^0.30.0",
"pretty": "^2.0.0"
}
}
78 changes: 78 additions & 0 deletions packages/html-email-formatter/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test, expect } from 'vitest';

import emailFormatter from './index';

test('email formatter should align the open and close tags', () => {
const result = emailFormatter(`
<div>
<!--[if MAC]>
<div>
<span>
<![endif]-->
<!--[if MAC]>
</span>
</div>
<![endif]-->
</div>
`);

expect(result).toBe(
`<div>
<!--[if MAC]>
<div>
<span>
<![endif]-->
<!--[if MAC]>
</span>
</div>
<![endif]-->
</div>`
);
});

test('email formatter should respect the whitespace', () => {
const result = emailFormatter(`
<div> <!--[if MAC]> hello <![endif]--> </div>
`);

expect(result).toBe(`<div> <!--[if MAC]> hello <![endif]--> </div>`);
});

test('should keep no whitespace if there was none', () => {
const result = emailFormatter(`<div><!--[if MAC]>hello<![endif]--></div>`);

expect(result).toBe(`<div><!--[if MAC]>hello<![endif]--></div>`);
});

test('email formatter should properly format from a single line', () => {
const result = emailFormatter(`<!DOCTYPE html>
<html style="">

<body class="">

<!--[if mso]><table cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="background-color: rgb(255, 255, 255);"><![endif]-->

<!--[if mso]></td></tr></table><![endif]-->

</body>
</html>`);

expect(result).toBe(
`<!DOCTYPE html>
<html style="">

<body class="">

<!--[if mso]><table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="background-color: rgb(255, 255, 255);"><![endif]-->

<!--[if mso]></td>
</tr>
</table><![endif]-->

</body>

</html>`
);
});
124 changes: 124 additions & 0 deletions packages/html-email-formatter/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import findConditionalComments from '@ciolabs/html-find-conditional-comments';
import { preserve, restore } from '@ciolabs/html-preserve-comment-whitespace';
import jsBeautify from 'js-beautify';
import MagicString from 'magic-string';
import pretty from 'pretty';

export default function emailFormatter(
html: string,
options?: Parameters<typeof pretty>[1] & jsBeautify.HTMLBeautifyOptions
): string {
const id = Math.random();
const opener = `<!--${id}`;
const closer = `${id}-->`;

html = closeConditionalComments(html, { opener, closer });

const cache = preserve(html);
html = pretty(html, options);
html = restore(html, cache);
html = openConditionalComments(html, { opener, closer });
html = alignOpenAndCloseOfConditionalComments(html);

return html;
}

/**
* Close downlevel-hidden conditional comments
*
* Example:
* <!--[if mso]>
* <p>Content</p>
* <![endif]-->
*
* Becomes:
* <!--[if mso]>123456789-->
* <p>Content</p>
* <!--123456789<![endif]-->
*
* This opens the the MSO HTML to be formatted by the HTML formatter.
*/
function closeConditionalComments(html: string, { opener, closer }: { opener: string; closer: string }) {
const comments = findConditionalComments(html);
const s = new MagicString(html);

for (const comment of comments) {
const contentStartIndex = comment.range[0] + comment.open.length;
const contentEndIndex = comment.range[1] - comment.close.length;

s.appendLeft(contentStartIndex, closer);
s.appendLeft(contentEndIndex, opener);
}

return s.toString();
}

/**
* Open downlevel-hidden conditional comments
*
* Example:
* <!--[if mso]>123456789-->
* <p>Content</p>
* <!--123456789<![endif]-->
*
* Becomes:
* <!--[if mso]>
* <p>Content</p>
* <![endif]-->
*
* This reverses the effect of `closeConditionalComments`. After the HTML
* formatter has formatted the MSO HTML, we restore the original state.
*/
function openConditionalComments(html: string, { opener, closer }: { opener: string; closer: string }) {
return html.replaceAll(new RegExp(`(${opener}|${closer})`, 'g'), '');
}

/**
* Align open and close tags of conditional comments
*/
function alignOpenAndCloseOfConditionalComments(html: string) {
const comments = findConditionalComments(html);
const s = new MagicString(html);

for (const comment of comments) {
const code = new Set(html.slice(comment.range[0], comment.range[1]));
if (!code.has('\n') && !code.has('\r')) {
continue;
}

const { whitespace: openLeadingWhitespace } = parseWhitespaceAndTextOfLastLine(
html.slice(0, Math.max(0, comment.range[0]))
);
const { whitespace: closeLeadingWhitespace, text: closeLeadingText } = parseWhitespaceAndTextOfLastLine(
html.slice(comment.range[0], comment.range[1] - comment.close.length)
);

if (openLeadingWhitespace.length === closeLeadingWhitespace.length) {
continue;
}

if (openLeadingWhitespace.length > closeLeadingWhitespace.length) {
s.overwrite(comment.range[0] - openLeadingWhitespace.length, comment.range[0], closeLeadingWhitespace);
} else {
s.overwrite(
comment.range[1] - comment.close.length - closeLeadingText.length - closeLeadingWhitespace.length,
comment.range[1] - comment.close.length - closeLeadingText.length,
openLeadingWhitespace
);
}
}

return s.toString();
}

function parseWhitespaceAndTextOfLastLine(string_: string): {
whitespace: string;
text: string;
} {
const lastLine = string_.split('\n').at(-1) ?? '';
const match = /^[^\S\n\r]*/.exec(lastLine);
const whitespace = match ? match[0] : '';
const text = lastLine.slice(whitespace.length);

return { whitespace, text };
}
8 changes: 8 additions & 0 deletions packages/html-email-formatter/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
8 changes: 8 additions & 0 deletions packages/html-email-formatter/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';

import baseConfig from '../../tsup.config';

export default defineConfig({
...baseConfig,
entry: ['src/index.ts'],
});
Loading