使用 TypeScript 實作「markdown2html」套件

建立專案

建立專案。

1
2
3
4
5
npm create vite

✔ Project name: … markdown2html
✔ Select a framework: › Vanilla
✔ Select a variant: › TypeScript

建立 lib 資料夾,用來存放此套件相關的程式。

1
2
cd markdown2html
mkdir lib

修改 tsconfig.json 檔。

1
2
3
4
{
// ...
"include": ["src", "lib"]
}

安裝 TypeScript 相關套件。

1
npm i @types/node -D

安裝檢查工具

安裝 ESLint 相關套件。

1
npm i eslint @eslint/js typescript-eslint globals @types/eslint__js -D

建立 eslint.config.js 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import pluginJs from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';

export default [
{
files: [
'**/*.{js,mjs,cjs,ts}',
],
},
{
languageOptions: {
globals: globals.node,
},
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/ban-ts-comment': 'off',
'comma-dangle': ['error', 'always-multiline'],
'eol-last': ['error', 'always'],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'object-curly-spacing': ['error', 'always'],
indent: ['error', 2],
quotes: ['error', 'single'],
semi: ['error', 'always'],
},
},
];

修改 package.json 檔。

1
2
3
4
5
{
"scripts": {
"lint": "eslint ."
}
}

執行檢查。

1
npm run lint

實作

修改 tsconfig.json 檔,添加 ~ 路徑別名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"skipLibCheck": true,

// ...

"paths": {
"~/*": ["./lib/*"]
}
}
}

進到 lib 資料夾。

1
cd lib

實作功能

安裝依賴套件。

1
2
npm i --save-peer marked dompurify
npm i --save-dev jsdom @types/jsdom

lib/converter 資料夾,建立 Converter.ts 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import createDOMPurify, { DOMPurify, Config as DOMPurifyConfig } from 'dompurify';
import { Marked, MarkedExtension } from 'marked';

interface ConverterOptions {
marked?: Marked;
markedExtensions?: MarkedExtension[];
domPurify?: DOMPurify;
domPurifyConfig?: DOMPurifyConfig;
}

class Converter {
private markdown: string;

private marked: Marked;

private domPurify: DOMPurify;

constructor(markdown: string, options: ConverterOptions = {}) {
this.markdown = markdown;
this.marked = options.marked ?? new Marked();
this.setMarkedExtensions(options.markedExtensions);
this.domPurify = options.domPurify ?? createDOMPurify();
this.setDOMPurifyConfig(options.domPurifyConfig);
}

public setMarkedExtensions(extensions?: MarkedExtension[]): this {
if (extensions) this.marked.use(...extensions);
return this;
}

public setDOMPurifyConfig(config?: DOMPurifyConfig): this {
if (config) this.domPurify.setConfig(config);
return this;
}

/**
* Converts the provided Markdown content into HTML code.
*/
public static toHTML(markdown: string, options: ConverterOptions = {}): string {
return new Converter(markdown, options).toHTML();
}

/**
* Converts the provided Markdown content into HTML code.
*/
public toHTML(domPurifyConfig: DOMPurifyConfig = {}): string {
const html = this.marked
.parse(this.markdown)
.toString();

return this.domPurify.sanitize(html, domPurifyConfig);
}
}

export default Converter;

匯出模組

lib/converter 資料夾,建立 index.ts 檔。

1
2
3
import Converter from './Converter';

export default Converter;

lib 資料夾,建立 index.ts 檔。

1
2
3
4
5
import Converter from './converter';

export {
Converter,
};

單元測試

安裝 Vitest 相關套件。

1
npm i vitest -D

修改 package.json 檔。

1
2
3
4
5
6
{
"scripts": {
"test": "npm run test:unit -- --run",
"test:unit": "vitest"
}
}

lib/converter 資料夾,建立 Converter.test.ts 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import { describe, expect, test } from 'vitest';
import Converter from './Converter';

const { window } = new JSDOM();

describe('Converter', () => {
test('should convert and sanitize correctly', () => {
const markdown = `# Heading 1

<a href="https://example.com" target="_blank" onmouseover="alert('XSS Attack!')">Link</a>
`;

const converter = new Converter(markdown, {
domPurify: DOMPurify(window),
domPurifyConfig: {
ADD_ATTR: [
'target',
],
},
});

const actual = converter.toHTML();

const expected = `<h1>Heading 1</h1>
<p><a target="_blank" href="https://example.com">Link</a></p>
`;

expect(actual).toBe(expected);
});
});

執行測試。

1
npm run test

編譯

安裝 vite-plugin-dts 套件,用來產生定義檔。

1
npm i vite-plugin-dts -D

建立 vite.config.ts 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import path from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';

export default defineConfig({
plugins: [
dts({
include: [
'lib',
],
exclude: [
'**/*.test.ts',
],
}),
],
build: {
copyPublicDir: false,
lib: {
entry: path.resolve(__dirname, 'lib/index.ts'),
name: 'Markdown2HTML',
fileName: format => format === 'es' ? 'index.js' : `index.${format}.js`,
},
rollupOptions: {
external: [
'dompurify',
'marked',
],
output: {
globals: {
dompurify: 'DOMPurify',
marked: 'marked',
},
},
},
},
resolve: {
alias: {
'~': path.resolve(__dirname, 'lib'),
},
},
});

建立 tsconfig.build.json 檔。

1
2
3
4
{
"extends": "./tsconfig.json",
"include": ["lib"]
}

修改 package.json 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
"name": "@username/markdown2html-example",
"private": false,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "npm run test:unit -- --run",
"test:unit": "vitest",
"release": "npm run test && npm run build && npm publish --access public"
},
"peerDependencies": {
"dompurify": "^3.2.3",
"marked": "^15.0.6"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/eslint__js": "^8.42.3",
"@types/jsdom": "^21.1.7",
"eslint": "^9.18.0",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"typescript": "^5.0.2",
"typescript-eslint": "^8.21.0",
"vite": "^4.4.5",
"vite-plugin-dts": "^4.5.0",
"vitest": "^3.0.2"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.umd.js"
}
}
}

執行編譯。

1
npm run build

檢查 dist 資料夾。

1
2
3
4
5
6
7
8
9
tree dist

dist
├── converter
│ ├── Converter.d.ts
│ └── index.d.ts
├── index.d.ts
├── index.js
└── index.umd.js

使用

透過 ES 模組使用

修改 src/main.ts 檔,透過 ES 模組使用套件。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Converter } from '../dist';
import './style.css';

const output = Converter.toHTML('# Hello, World! \n<a href="/" target="_blank" onmouseover="alert(\'XSS Attack!\')">It works!</a>', {
domPurifyConfig: {
ADD_ATTR: [
'target',
'onmouseover', // uncomment this line to test the XSS attack
],
},
});

document.querySelector<HTMLDivElement>('#app')!.innerHTML = output;

啟動服務。

1
npm run dev

輸出如下:

1
Hello, World!

透過 UMD 模組使用

修改 index.html 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown2HTML</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script src="https://unpkg.com/marked/marked.min.js"></script>
<script src="https://unpkg.com/dompurify/dist/purify.min.js"></script>
<script src="dist/index.umd.js"></script>
<script>
const output = window.Markdown2HTML.Converter.toHTML('# Hello, World! \n<a href="/" target="_blank" onmouseover="alert(\'XSS Attack!\')">It works!</a>', {
domPurifyConfig: {
ADD_ATTR: [
'target',
// 'onmouseover', // uncomment this line to test the XSS attack
],
},
});

console.log(output);
</script>
</body>
</html>

啟動服務。

1
npm run dev

輸出如下:

1
<h1>Hello, World!</h1>

發布

登入 npm 套件管理平台。

1
npm login

測試發布,查看即將發布的檔案列表。

1
npm publish --dry-run

發布套件。

1
npm publish --access=public

程式碼