使用 TypeScript 實作「json2markdown」套件

建立專案

建立專案。

1
2
3
4
5
npm create vite

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

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

1
2
cd json2markdown
mkdir lib

修改 tsconfig.json 檔。

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

安裝 TypeScript 相關套件。

1
npm i @types/node vite-plugin-dts -D

安裝檢查工具

安裝 ESLint 相關套件。

1
npm i eslint @eslint/js typescript-eslint globals @types/eslint__js @stylistic/eslint-plugin -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
31
32
33
34
35
36
37
38
39
40
41
42
import pluginJs from '@eslint/js';
import stylistic from '@stylistic/eslint-plugin';
import globals from 'globals';
import tseslint from 'typescript-eslint';

export default [
pluginJs.configs.recommended,
...tseslint.configs.recommended,
stylistic.configs.customize({
semi: true,
jsx: true,
braceStyle: '1tbs',
}),
{
files: [
'**/*.{js,mjs,cjs,ts}',
],
},
{
ignores: [
'dist/**/*',
],
},
{
languageOptions: {
globals: globals.node,
},
},
{
rules: {
'curly': ['error', 'multi-line'],
'dot-notation': 'error',
'no-console': ['warn', { allow: ['warn', 'error', 'debug'] }],
'no-lonely-if': 'error',
'no-useless-rename': 'error',
'object-shorthand': 'error',
'prefer-const': ['error', { destructuring: 'any', ignoreReadBeforeAssign: false }],
'require-await': 'error',
'sort-imports': ['error', { ignoreDeclarationSort: true }],
},
},
];

修改 package.json 檔。

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

執行檢查。

1
npm run lint

實作

安裝依賴套件。

1
npm i @memochou1993/stryle

修改 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

實作介面

lib 資料夾,建立 types 資料夾。

1
mkdir types

types 資料夾,建立 MarkdownSchema.ts 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface MarkdownSchema {
[key: string]: unknown;
br?: string;
h1?: string;
h2?: string;
h3?: string;
h4?: string;
h5?: string;
h6?: string;
indent?: number;
li?: unknown;
p?: string | boolean;
td?: string[];
tr?: string[];
}

export default MarkdownSchema;

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

1
2
3
4
5
import MarkdownSchema from './MarkdownSchema';

export type {
MarkdownSchema,
};

建立 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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import { toTitleCase } from '@memochou1993/stryle';
import { MarkdownSchema } from '~/types';

interface ConverterOptions {
initialHeadingLevel?: number;
disableTitleCase?: boolean;
}

class Converter {
private schema: MarkdownSchema[] = [];

private startLevel: number;

private disableTitleCase: boolean;

constructor(data: unknown, options: ConverterOptions = {}) {
this.startLevel = options.initialHeadingLevel ?? 1;
this.disableTitleCase = options.disableTitleCase ?? false;
this.convert(data);
}

public static toMarkdown(data: unknown, options: ConverterOptions = {}): string {
return new Converter(data, options).toMarkdown();
}

/**
* Converts the provided data into Markdown format.
*
* This method processes a data structure and converts it into
* Markdown, handling headings, list items, table rows, and paragraphs.
*/
public toMarkdown(): string {
const headings = Array.from({ length: 6 }, (_, i) => `h${i + 1}`);
return this.schema
.map((element) => {
const level = headings.findIndex(h => element[h] !== undefined);
if (level !== -1) {
return `${'#'.repeat(level + 1)} ${this.toTitleCase(String(element[headings[level]]))}\n\n`;
}
if (element.li !== undefined) {
return `${' '.repeat(Number(element.indent))}- ${element.li}\n`;
}
if (element.tr !== undefined) {
return `| ${element.tr.map(v => this.toTitleCase(v)).join(' | ')} |\n${element.tr.map(() => '| ---').join(' ')} |\n`;
}
if (element.td !== undefined) {
return `| ${element.td.join(' | ')} |\n`;
}
if (typeof element.p === 'boolean') {
return `${this.toTitleCase(String(element.p))}\n\n`;
}
if (element.br !== undefined) {
return '\n';
}
return `${element.p}\n\n`;
})
.join('');
}

private convert(data: unknown): void {
if (!data) return;
if (Array.isArray(data)) {
this.convertFromArray(data);
return;
}
if (typeof data === 'object') {
this.convertFromObject(data as Record<string, unknown>);
return;
}
this.convertFromPrimitive(data);
}

private convertFromArray(data: unknown[], indent: number = 0): MarkdownSchema[] {
for (const item of data) {
if (Array.isArray(item)) {
this.convertFromArray(item, indent + 1);
continue;
}
this.schema.push({
li: this.formatValue(item),
indent,
});
}
return this.schema;
}

private convertFromObject(data: Record<string, unknown>, level: number = 0): MarkdownSchema[] {
const heading = `h${Math.min(Math.max(this.startLevel, 1) + level, 6)}`;
for (let [key, value] of Object.entries(data)) {
key = key.trim();
if (typeof value === 'string') {
value = value.trim().replaceAll('\\n', '\n');
}
this.schema.push({
[heading]: key,
});
if (Array.isArray(value)) {
const [item] = value;
if (typeof item === 'object') {
this.schema.push({
tr: Object.keys(item),
});
value.forEach((item) => {
this.schema.push({
td: Object.values(item).map(v => this.formatValue(v)),
});
});
this.schema.push({
br: '',
});
continue;
}
this.convertFromArray(value);
this.schema.push({
br: '',
});
continue;
}
if (typeof value === 'object') {
this.convertFromObject(value as Record<string, unknown>, level + 1);
continue;
}
this.convertFromPrimitive(value);
}
return this.schema;
}

private convertFromPrimitive(value: unknown): MarkdownSchema[] {
this.schema.push({
p: this.formatValue(value),
});
return this.schema;
}

private formatValue(value: unknown): string {
if (Array.isArray(value)) {
return value.map(v => this.formatValue(v)).join(', ');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}

private toTitleCase(value: string): string {
if (this.disableTitleCase) return value;
return toTitleCase(value);
}
}

export default Converter;

單元測試

安裝 Vitest 相關套件。

1
npm i vitest -D

修改 package.json 檔。

1
2
3
4
5
{
"scripts": {
"test": "vitest"
}
}

建立 tests/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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import fs from 'fs';
import { describe, expect, test } from 'vitest';
import Converter from './Converter';

const OUTPUT_DIR = '.output';

describe('Converter', () => {
test('should convert from array', () => {
// @ts-expect-error Ignore error for testing private method
const actual = new Converter(undefined).convertFromArray([
1,
[
2,
[
3,
],
],
]);

const expected = [
{ li: '1', indent: 0 },
{ li: '2', indent: 1 },
{ li: '3', indent: 2 },
];

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

test('should convert from object', () => {
// @ts-expect-error Ignore error for testing private method
const actual = new Converter(undefined).convertFromObject({
foo: 'bar',
});

const expected = [
{ h1: 'foo' },
{ p: 'bar' },
];

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

test('should convert from primitive', () => {
// @ts-expect-error Ignore error for testing private method
const actual = new Converter(undefined).convertFromPrimitive('foo');

const expected = [
{ p: 'foo' },
];

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

test('should start from specified heading level', () => {
const data = {
heading_2: 'Hello, World!',
};

const actual = Converter.toMarkdown(data, {
initialHeadingLevel: 2,
});

const expected = `## Heading 2

Hello, World!

`;

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

test('should disable title case', () => {
const data = {
heading_1: 'Hello, World!',
};

const actual = Converter.toMarkdown(data, {
disableTitleCase: true,
});

const expected = `# heading_1

Hello, World!

`;

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

test('should convert correctly', () => {
const data = {
heading_1: 'Hello, World!',
nested: {
heading_2: 'Hello, World!',
nested: {
heading_3: 'Hello, World!',
nested: {
heading_4: 'Hello, World!',
nested: {
heading_5: 'Hello, World!',
nested: {
heading_6: 'Hello, World!',
nested: {
heading_7: 'Hello, World!',
},
},
},
},
},
},
table: [
{
id: 1,
name: 'Alice',
email: 'alice@example.com',
friends: ['Bob', 'Charlie'],
settings: {
theme: 'dark',
},
},
{
id: 2,
name: 'Bob',
email: 'bob@example.com',
friends: ['Charlie'],
settings: {
theme: 'light',
},
},
{
id: 3,
name: 'Charlie',
email: 'charlie@example.com',
friends: [],
},
],
array: [
1,
[
2,
[
3,
],
],
{
foo: 'bar',
},
],
markdown_code: '```\nconsole.log(\'Hello, World!\');\n```',
markdown_table: '| foo | bar | baz |\n| --- | --- | --- |\n| 1 | 2 | 3 |',
};

const converter = new Converter(data);

const actual = converter.toMarkdown();

const expected = `# Heading 1

Hello, World!

# Nested

## Heading 2

Hello, World!

## Nested

### Heading 3

Hello, World!

### Nested

#### Heading 4

Hello, World!

#### Nested

##### Heading 5

Hello, World!

##### Nested

###### Heading 6

Hello, World!

###### Nested

###### Heading 7

Hello, World!

# Table

| Id | Name | Email | Friends | Settings |
| --- | --- | --- | --- | --- |
| 1 | Alice | alice@example.com | Bob, Charlie | {"theme":"dark"} |
| 2 | Bob | bob@example.com | Charlie | {"theme":"light"} |
| 3 | Charlie | charlie@example.com | |

# Array

- 1
- 2
- 3
- {"foo":"bar"}

# Markdown Code

\`\`\`
console.log('Hello, World!');
\`\`\`

# Markdown Table

| foo | bar | baz |
| --- | --- | --- |
| 1 | 2 | 3 |

`;

if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR);
fs.writeFileSync(`${OUTPUT_DIR}/actual.md`, actual);
fs.writeFileSync(`${OUTPUT_DIR}/expected.md`, expected);

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

執行測試。

1
npm run test

編譯

建立 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
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: 'JSON2MD',
fileName: format => format === 'es' ? 'index.js' : `index.${format}.js`,
},
},
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
{
"name": "@memochou1993/json2markdown",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p ./tsconfig.build.json && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest"
},
"devDependencies": {
"@eslint/js": "^9.11.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.5.5",
"eslint": "^9.11.0",
"globals": "^15.9.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.6.0",
"vite": "^4.4.5",
"vite-plugin-dts": "^4.2.1",
"vitest": "^2.1.1"
},
"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
10
11
12
tree dist

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

使用

透過 ES 模組使用

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

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

const markdown = Converter.toMarkdown({
title: 'Hello, World!',
});

document.querySelector<HTMLDivElement>('#app')!.innerHTML = `<pre>${markdown}</pre>`;

啟動服務。

1
npm run dev

輸出如下:

1
2
3
# Title

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
<!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>Vite + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script src="dist/index.umd.js"></script>
<script>
const markdown = window.JSON2MD.Converter.toMarkdown({
title: 'Hello, World!',
});
console.log(JSON.stringify(markdown));
</script>
</body>
</html>

啟動服務。

1
npm run dev

輸出如下:

1
"# Title\n\nHello, World!\n\n"

發布

登入 npm 套件管理平台。

1
npm login

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

1
npm publish --dry-run

發布套件。

1
npm publish --access=public

程式碼