Skip to content

Commit 1a40224

Browse files
authored
Merge pull request #1 from luckrnx09/luckrnx09-init
Feat: Add EmailMinifier
2 parents d1d8d62 + bb1916f commit 1a40224

19 files changed

+1170
-1
lines changed

.eslintrc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"parser": "@typescript-eslint/parser",
3+
"extends": [
4+
"eslint:recommended",
5+
"plugin:@typescript-eslint/recommended",
6+
"prettier"
7+
],
8+
"plugins": [
9+
"prettier"
10+
],
11+
"rules": {
12+
"prettier/prettier": "error",
13+
"@typescript-eslint/no-explicit-any": "warn"
14+
},
15+
"ignorePatterns": [
16+
"dist"
17+
]
18+
}

.github/workflows/ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: CI
2+
3+
on: [push, pull_request]
4+
5+
env:
6+
FORCE_COLOR: 2
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Clone repository
13+
uses: actions/checkout@v4
14+
15+
- name: Setup Node
16+
uses: actions/setup-node@v4
17+
with:
18+
node-version: "18.x"
19+
20+
- name: Install pnpm
21+
uses: pnpm/action-setup@v2
22+
with:
23+
version: 8
24+
run_install: false
25+
26+
- name: Install dependencies
27+
run: pnpm install --no-frozen-lockfile
28+
29+
- name: Run tests
30+
run: pnpm run test

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,6 @@ dist
128128
.yarn/build-state.yml
129129
.yarn/install-state.gz
130130
.pnp.*
131+
132+
.npmrc
133+
pnpm-lock.yaml

.prettierrc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"semi": true,
3+
"singleQuote": true,
4+
"printWidth": 80,
5+
"tabWidth": 2
6+
}

README.md

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,98 @@
1-
# email-minifier
1+
# EmailMinifier
2+
3+
[![npm version](https://img.shields.io/npm/v/email-minifier.svg)](https://www.npmjs.com/package/email-minifier)
4+
5+
6+
EmailMinifier is a well-tested email minifier based on TypeScript for browser and Node.js
7+
8+
As a quick start, you can [Try it online](https://luckrnx09.github.io/email-minifier/playground) - NOT READY, Welcome to submit a PR 🚀
9+
10+
## Why not HTMLMinifier
11+
12+
EmailMinifier is a great tool for compressing HTML. But email is different from HTML in many ways, compression of HTML is often not the best solution.
13+
- JavaScript code is not supported or required in emails.
14+
- The interactive behavior of the email is very limited, most HTML attributes are useless for the email but still load them when user open it.
15+
- Some email clients crop oversized emails (e.g. Gmail) and the style of the email is broken after cropping, which is extremely detrimental to marketing.
16+
- ...
17+
18+
## Installation
19+
20+
You can use the tool you like to install EmailMinifier:
21+
22+
npm
23+
```shell
24+
npm install email-minifier
25+
```
26+
27+
yarn
28+
```shell
29+
yarn add email-minifier
30+
```
31+
32+
pnpm
33+
```shell
34+
pnpm install email-minifier
35+
```
36+
37+
## Usage
38+
39+
For both browser and Node.js if you use ESM:
40+
41+
```javascript
42+
import { EmailMinifier } from 'email-minifier';
43+
(async () => {
44+
const emailBody = `<div class="hello"></div>`;
45+
const options = {};
46+
const result = await new EmailMinifier(emailBody).minify(options);
47+
console.log(result);
48+
})();
49+
```
50+
51+
52+
For Node.js only if you use CommonJS:
53+
54+
```javascript
55+
const { EmailMinifier } = require('email-minifier');
56+
(async () => {
57+
const emailBody = `<div class="hello"></div>`;
58+
const options = {};
59+
const result = await new EmailMinifier(emailBody).minify(options);
60+
console.log(result);
61+
})();
62+
```
63+
64+
The `minify()` method will returns a Promise with the shape as follow:
65+
66+
```javascript
67+
{
68+
original: '', // the original email body string
69+
minified: '', // minified email body string will be here, if no tasks ran, it'll be null
70+
tasks: [] // all ran tasks when minify email body
71+
}
72+
```
73+
74+
All available properties for `options` are as follows
75+
76+
| Option | Description | Default |
77+
|--------------------------------|-----------------|---------|
78+
| `minifyIds` | Minifiy id attributes used in style tags | `true` |
79+
| `minifyClasses` | Minifiy class attributes used in style tags | `true` |
80+
| `minifyDatasets` | Minifiy data-* attributes used in style tags | `true` |
81+
| `removeUnusedAttrs` | Remove custom attributes unused in style tags | `false` |
82+
| `minifyStyles` | Minifiy CSS content for all the style tags | `true` |
83+
84+
For `removeUnusedAttrs`, if you want to remove the specific unused attributes, you can provide an array with `RegExp` instances to match them.
85+
86+
For example:
87+
```javascript
88+
const options = {
89+
removeUnusedAttrs: [
90+
new RegExp('custom-test-id') // Remove `custom-test-id` attributes if they not used in style tags
91+
]
92+
}
93+
```
94+
95+
96+
97+
# License
98+
See [LICENSE](./LICENSE)

jest.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { JestConfigWithTsJest } from 'ts-jest';
2+
3+
const config: JestConfigWithTsJest = {
4+
verbose: true,
5+
transform: {
6+
'^.+\\.ts?$': [
7+
'ts-jest',
8+
{
9+
useESM: true,
10+
},
11+
],
12+
},
13+
extensionsToTreatAsEsm: ['.ts'],
14+
moduleNameMapper: {
15+
'^(\\.{1,2}/.*)\\.js$': '$1',
16+
},
17+
testMatch: ['<rootDir>/**/*.test.ts'],
18+
};
19+
20+
export default config;

lib/document.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const createDocument = async (html: string): Promise<Document> => {
2+
const isNode =
3+
typeof window === 'undefined' && typeof document === 'undefined';
4+
if (isNode) {
5+
const { JSDOM } = await import('jsdom');
6+
return new JSDOM(html).window.document;
7+
} else {
8+
return Promise.resolve(new DOMParser().parseFromString(html, 'text/html'));
9+
}
10+
};

lib/email-minifier.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Sequence } from './sequence';
2+
import { createDocument } from './document';
3+
import { TaskManager } from './task-manager';
4+
import { Context, ComposeOption } from './types';
5+
6+
const DEFAULT_COMPOSE_OPTIONS: ComposeOption = {
7+
minifyIds: true,
8+
minifyClasses: true,
9+
minifyDatasets: true,
10+
minifyStyles: true,
11+
removeUnusedAttrs: false,
12+
};
13+
/**
14+
* Minify an email body
15+
* @example
16+
* const minifier = new EmailMinifier('<html>...</html>');
17+
* const result = await minifier.minify();
18+
* console.log(result.minified);
19+
*/
20+
export class EmailMinifier {
21+
private emailBody: string;
22+
23+
constructor(emailBody: string) {
24+
this.emailBody = emailBody;
25+
}
26+
/**
27+
* Compose the minify tasks
28+
*/
29+
private compose = (
30+
context: Context,
31+
options?: ComposeOption,
32+
): TaskManager => {
33+
const taskManager = new TaskManager();
34+
const {
35+
minifyIds,
36+
minifyClasses,
37+
minifyDatasets,
38+
minifyStyles,
39+
removeUnusedAttrs,
40+
} = { ...DEFAULT_COMPOSE_OPTIONS, ...(options ?? {}) };
41+
42+
const { document } = context;
43+
44+
document.body.querySelectorAll('*').forEach((el) => {
45+
minifyIds && taskManager.add('minify-ids', el, context);
46+
minifyClasses && taskManager.add('minify-classes', el, context);
47+
removeUnusedAttrs &&
48+
taskManager.add('remove-unused-attrs', el, context, {
49+
matches: removeUnusedAttrs,
50+
});
51+
minifyDatasets && taskManager.add('minify-dataset-attrs', el, context);
52+
});
53+
document.head.querySelectorAll('style').forEach((el) => {
54+
taskManager.add('minify-styles', el, context, {
55+
minifyStyles,
56+
});
57+
});
58+
return taskManager;
59+
};
60+
61+
private async createContext(): Promise<Context> {
62+
const { emailBody } = this;
63+
const document = await createDocument(emailBody);
64+
return {
65+
originalEmailBody: emailBody,
66+
styles: Array.from(document.head.querySelectorAll('style'))
67+
.map((el) => el.textContent)
68+
.join('\n'),
69+
document,
70+
idSequence: new Sequence(),
71+
classSequence: new Sequence(),
72+
mapping: {
73+
ids: new Map<string, string>(),
74+
classes: new Map<string, string>(),
75+
dataSets: new Map<string, string>(),
76+
},
77+
};
78+
}
79+
80+
async minify(options?: ComposeOption) {
81+
const context = await this.createContext();
82+
const taskManager = this.compose(context, options);
83+
const { originalEmailBody, document } = context;
84+
const tasks = taskManager.runAll();
85+
86+
if (tasks.length === 0) {
87+
return {
88+
original: originalEmailBody,
89+
minified: null,
90+
tasks,
91+
};
92+
}
93+
94+
return {
95+
original: originalEmailBody,
96+
minified: document.documentElement.outerHTML,
97+
tasks,
98+
};
99+
}
100+
}

lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { EmailMinifier } from './email-minifier';

lib/sequence.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Generates a sequence of alphabetical characters.
3+
*/
4+
export class Sequence {
5+
private current: string[];
6+
7+
constructor() {
8+
this.current = ['a'];
9+
}
10+
11+
next() {
12+
const sequence = this.current.join('');
13+
let i = this.current.length - 1;
14+
15+
while (i >= 0) {
16+
if (this.current[i] !== 'z') {
17+
this.current[i] = this.getNextChar(this.current[i]);
18+
break;
19+
} else {
20+
this.current[i] = 'a';
21+
i--;
22+
}
23+
}
24+
25+
if (i < 0) {
26+
this.current.unshift('a');
27+
}
28+
29+
return sequence;
30+
}
31+
32+
private getNextChar(char: string): string {
33+
const charCode = char.charCodeAt(0);
34+
const nextCharCode = charCode + 1;
35+
return String.fromCharCode(nextCharCode);
36+
}
37+
}

0 commit comments

Comments
 (0)