simple-customize-markdown-converter 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/dist/lexer.d.ts +4 -1
- package/dist/lexer.js +71 -3
- package/dist/parser.d.ts +1 -0
- package/dist/parser.js +45 -4
- package/dist/renderer.d.ts +4 -1
- package/dist/renderer.js +24 -2
- package/dist/types/node.d.ts +11 -0
- package/dist/types/renderOptions.d.ts +27 -6
- package/dist/types/token.d.ts +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Simple
|
|
1
|
+
# Simple Customize Markdown Converter
|
|
2
2
|
This simple library help you convert Markdown to HTML and customize it.
|
|
3
3
|
|
|
4
4
|
## Feature
|
|
5
|
-
|
|
5
|
+
This library currently supports the most common Markdown syntaxes:
|
|
6
6
|
- Headings (`#, ##, …`)
|
|
7
7
|
- Paragraphs
|
|
8
8
|
- Bold (`\*\*text\*\*`)
|
|
@@ -10,12 +10,13 @@ Currently, this lib only supports:
|
|
|
10
10
|
- Strikethrough (`\~\~text\~\~`)
|
|
11
11
|
- Inline code (`\`code\``)
|
|
12
12
|
- Code blocks (`\`\`\`lang ... \`\`\``)
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
- Horizontal
|
|
13
|
+
- Quotes (`> text`)
|
|
14
|
+
- Lists (`- Item 1,...`)
|
|
15
|
+
- Tasklists (`- [ ], - \[x\]`)
|
|
16
|
+
- Links (`\[link\]\(url\)`)
|
|
17
|
+
- Images (`\[alt\]\(url\)`)
|
|
18
|
+
- Horizontal lines (`---` or `***` or `___`)
|
|
19
|
+
- Tables
|
|
19
20
|
|
|
20
21
|
And customizable renderer for all elements
|
|
21
22
|
|
package/dist/lexer.d.ts
CHANGED
|
@@ -5,16 +5,19 @@ export default class Lexer {
|
|
|
5
5
|
listToken: Token[];
|
|
6
6
|
listLevelFlag: number;
|
|
7
7
|
constructor(input: string);
|
|
8
|
+
setInput(input: string): void;
|
|
8
9
|
/**
|
|
9
10
|
* Tokenize the markdown into a list of tokens.
|
|
11
|
+
* @param isEof - `True` when input is whole markdown, `False` if input is just a part of markdown.
|
|
10
12
|
* @returns List of tokens
|
|
11
13
|
*/
|
|
12
|
-
tokenize(): Token[];
|
|
14
|
+
tokenize(isEof?: boolean): Token[];
|
|
13
15
|
private peek;
|
|
14
16
|
private next;
|
|
15
17
|
private startsWith;
|
|
16
18
|
private isEndOfFile;
|
|
17
19
|
private getLastToken;
|
|
20
|
+
private handleTable;
|
|
18
21
|
private handleHeader;
|
|
19
22
|
private handleCodeBlock;
|
|
20
23
|
private handleTextBlock;
|
package/dist/lexer.js
CHANGED
|
@@ -8,11 +8,19 @@ class Lexer {
|
|
|
8
8
|
this.listLevelFlag = 0;
|
|
9
9
|
this.input = input;
|
|
10
10
|
}
|
|
11
|
+
//Reset input and other attribute
|
|
12
|
+
setInput(input) {
|
|
13
|
+
this.input = input;
|
|
14
|
+
this.pos = 0;
|
|
15
|
+
this.listLevelFlag = 0;
|
|
16
|
+
this.listToken = [];
|
|
17
|
+
}
|
|
11
18
|
/**
|
|
12
19
|
* Tokenize the markdown into a list of tokens.
|
|
20
|
+
* @param isEof - `True` when input is whole markdown, `False` if input is just a part of markdown.
|
|
13
21
|
* @returns List of tokens
|
|
14
22
|
*/
|
|
15
|
-
tokenize() {
|
|
23
|
+
tokenize(isEof = true) {
|
|
16
24
|
const TOKEN_HANDLER = [
|
|
17
25
|
//Handle escape character first
|
|
18
26
|
{
|
|
@@ -53,6 +61,8 @@ class Lexer {
|
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
},
|
|
64
|
+
//For table
|
|
65
|
+
{ match: (lex) => lex.isStartOfLine() && /^\s*\|.*\|\s*$/.test(lex.peekUntil("\n")), emit: (lex) => lex.handleTable() },
|
|
56
66
|
//For common syntax
|
|
57
67
|
{ match: (lex) => lex.peek() === "`", emit: (lex) => lex.handleInlineBlock() },
|
|
58
68
|
{ match: (lex) => lex.peek() === "#", emit: (lex) => lex.handleHeader() },
|
|
@@ -79,7 +89,8 @@ class Lexer {
|
|
|
79
89
|
while (this.listLevelFlag > 0) {
|
|
80
90
|
this.handleEndList();
|
|
81
91
|
}
|
|
82
|
-
|
|
92
|
+
if (isEof)
|
|
93
|
+
this.listToken.push({ type: "EOF" });
|
|
83
94
|
return this.listToken;
|
|
84
95
|
}
|
|
85
96
|
//Get current character with offset
|
|
@@ -101,6 +112,59 @@ class Lexer {
|
|
|
101
112
|
getLastToken() {
|
|
102
113
|
return this.listToken[this.listToken.length - 1];
|
|
103
114
|
}
|
|
115
|
+
handleTable() {
|
|
116
|
+
const tokenizeResult = [];
|
|
117
|
+
const handler = new Lexer("");
|
|
118
|
+
const header = this.readUntil("\n", true);
|
|
119
|
+
const headerDetails = header.trim().replace(/^ *\|/, "").replace(/\| *$/, "").split("|");
|
|
120
|
+
const align = this.readUntil("\n", true);
|
|
121
|
+
const alignDetails = align.trim().replace(/^ *\|/, "").replace(/\| *$/, "").split("|");
|
|
122
|
+
if (alignDetails.length !== headerDetails.length || !alignDetails.every(c => /^:?-{3,}:?$/.test(c))) {
|
|
123
|
+
this.listToken.push({ type: "Text", value: `${header}\n${align}\n` });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
//Handle alignment
|
|
128
|
+
const normalizeAlign = alignDetails.map(value => {
|
|
129
|
+
if (value.startsWith(":") && value.endsWith(":"))
|
|
130
|
+
return "center";
|
|
131
|
+
else if (value.endsWith(":"))
|
|
132
|
+
return "right";
|
|
133
|
+
else
|
|
134
|
+
return "left";
|
|
135
|
+
});
|
|
136
|
+
tokenizeResult.push({ type: "TableStart" });
|
|
137
|
+
//Handle header
|
|
138
|
+
tokenizeResult.push({ type: "RowStart", isHeader: true });
|
|
139
|
+
headerDetails.forEach((cell, index) => {
|
|
140
|
+
tokenizeResult.push({ type: "CellStart", align: normalizeAlign[index] ?? "left" });
|
|
141
|
+
handler.setInput(cell.trim());
|
|
142
|
+
tokenizeResult.push(...handler.tokenize(false));
|
|
143
|
+
tokenizeResult.push({ type: "CellEnd" });
|
|
144
|
+
});
|
|
145
|
+
tokenizeResult.push({ type: "RowEnd" });
|
|
146
|
+
//Handle body
|
|
147
|
+
while (!this.isEndOfFile()) {
|
|
148
|
+
const body = this.readUntil("\n", true);
|
|
149
|
+
if (!body)
|
|
150
|
+
break;
|
|
151
|
+
const line = body.trim();
|
|
152
|
+
if (!line.startsWith("|") || !line.endsWith("|"))
|
|
153
|
+
break; //End of table
|
|
154
|
+
const bodyDetail = body.trim().replace(/^ *\|/, "").replace(/\| *$/, "").split("|");
|
|
155
|
+
tokenizeResult.push({ type: "RowStart", isHeader: false });
|
|
156
|
+
bodyDetail.forEach((cell, index) => {
|
|
157
|
+
tokenizeResult.push({ type: "CellStart", align: normalizeAlign[index] ?? "left" });
|
|
158
|
+
handler.setInput(cell.trim());
|
|
159
|
+
tokenizeResult.push(...handler.tokenize(false));
|
|
160
|
+
tokenizeResult.push({ type: "CellEnd" });
|
|
161
|
+
});
|
|
162
|
+
tokenizeResult.push({ type: "RowEnd" });
|
|
163
|
+
}
|
|
164
|
+
tokenizeResult.push({ type: "TableEnd" });
|
|
165
|
+
this.listToken.push(...tokenizeResult);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
104
168
|
handleHeader() {
|
|
105
169
|
let level = 0;
|
|
106
170
|
while (this.peek() === "#") {
|
|
@@ -236,12 +300,16 @@ class Lexer {
|
|
|
236
300
|
this.listToken.push({ type: "HorizontalLine" });
|
|
237
301
|
}
|
|
238
302
|
//Utilities function
|
|
239
|
-
readUntil(char) {
|
|
303
|
+
readUntil(char, isConsumeChar = false) {
|
|
240
304
|
let result = "";
|
|
241
305
|
while (this.peek() !== char) {
|
|
242
306
|
result += this.peek();
|
|
243
307
|
this.next();
|
|
308
|
+
if (this.isEndOfFile())
|
|
309
|
+
break;
|
|
244
310
|
}
|
|
311
|
+
if (isConsumeChar)
|
|
312
|
+
this.next(char.length); //Make cursor skip the char
|
|
245
313
|
return result;
|
|
246
314
|
}
|
|
247
315
|
peekUntil(char) {
|
package/dist/parser.d.ts
CHANGED
package/dist/parser.js
CHANGED
|
@@ -57,6 +57,10 @@ class Parser {
|
|
|
57
57
|
listNode.push(this.parseList());
|
|
58
58
|
break;
|
|
59
59
|
}
|
|
60
|
+
case "TableStart": {
|
|
61
|
+
listNode.push(this.parseTable());
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
60
64
|
case "NewLine": {
|
|
61
65
|
this.next(); // skip
|
|
62
66
|
break;
|
|
@@ -164,10 +168,7 @@ class Parser {
|
|
|
164
168
|
if (["ListItem", "TaskItem", "ListEnd"].includes(tok.type)) {
|
|
165
169
|
break;
|
|
166
170
|
}
|
|
167
|
-
children.push(
|
|
168
|
-
type: "Paragraph",
|
|
169
|
-
children: this.parseInlineUntil("NewLine")
|
|
170
|
-
});
|
|
171
|
+
children.push(...this.parseInlineUntil("NewLine"));
|
|
171
172
|
}
|
|
172
173
|
return currentToken?.type === "TaskItem" ? {
|
|
173
174
|
type: "TaskItem",
|
|
@@ -203,6 +204,46 @@ class Parser {
|
|
|
203
204
|
else
|
|
204
205
|
return { type: "Image", src: "", alt: "" };
|
|
205
206
|
}
|
|
207
|
+
parseTable() {
|
|
208
|
+
this.next(); // skip TableStart token
|
|
209
|
+
const parseRow = () => {
|
|
210
|
+
const rowStartToken = this.peek();
|
|
211
|
+
if (rowStartToken?.type !== "RowStart")
|
|
212
|
+
return { isHeader: false, cells: [] };
|
|
213
|
+
this.next(); // skip RowStart token
|
|
214
|
+
const cells = [];
|
|
215
|
+
while (this.peek() && this.peek().type !== "RowEnd") {
|
|
216
|
+
cells.push(parseCell());
|
|
217
|
+
}
|
|
218
|
+
this.next(); // skip RowEnd token
|
|
219
|
+
return {
|
|
220
|
+
isHeader: rowStartToken.isHeader,
|
|
221
|
+
cells: cells
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
const parseCell = () => {
|
|
225
|
+
const cellStartToken = this.peek();
|
|
226
|
+
if (cellStartToken?.type !== "CellStart")
|
|
227
|
+
return { align: "left", children: [] };
|
|
228
|
+
this.next(); // skip CellStart token
|
|
229
|
+
const childrens = this.parseInlineUntil("CellEnd");
|
|
230
|
+
return {
|
|
231
|
+
align: cellStartToken.align,
|
|
232
|
+
children: childrens
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
const rows = [];
|
|
236
|
+
while (this.peek()?.type !== "TableEnd") {
|
|
237
|
+
rows.push(parseRow());
|
|
238
|
+
if (this.isEnd())
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
this.next();
|
|
242
|
+
return {
|
|
243
|
+
type: "Table",
|
|
244
|
+
rows: rows
|
|
245
|
+
};
|
|
246
|
+
}
|
|
206
247
|
parseHorizontalLine() {
|
|
207
248
|
const tok = this.peek();
|
|
208
249
|
this.next(); // skip marker
|
package/dist/renderer.d.ts
CHANGED
|
@@ -9,7 +9,10 @@ export default class Renderer {
|
|
|
9
9
|
* @param node - The abstract syntax tree (AST) from the Parser
|
|
10
10
|
* @returns The rendered HTML string.
|
|
11
11
|
*/
|
|
12
|
-
render(node: Node
|
|
12
|
+
render<K extends Node["type"]>(node: Extract<Node, {
|
|
13
|
+
type: K;
|
|
14
|
+
}>): string;
|
|
13
15
|
private handleRender;
|
|
16
|
+
private renderTable;
|
|
14
17
|
private escapeHtml;
|
|
15
18
|
}
|
package/dist/renderer.js
CHANGED
|
@@ -25,7 +25,8 @@ class Renderer {
|
|
|
25
25
|
//Container nodes
|
|
26
26
|
CodeBlock: (node) => `<pre><code class="lang-${node.lang}">${this.escapeHtml(node.content)}</code></pre>`,
|
|
27
27
|
Header: (node, children) => `<h${node.level}${node.level <= 2 ? ' style="border-bottom: 1px solid #d1d9e0b3"' : ''}>${children.join("")}</h${node.level}>`,
|
|
28
|
-
Quote: (_node, children) => `<blockquote>${children.join("")}</blockquote>`,
|
|
28
|
+
Quote: (_node, children) => `<blockquote style="margin:0; padding:0 1em; color:#59636e; border-left:.25em solid #d1d9e0;">${children.join("")}</blockquote>`,
|
|
29
|
+
//For list nodes
|
|
29
30
|
List: (node, children) => node.ordered ? `<ol>${children.join("")}</ol>` : `<ul>${children.join("")}</ul>`,
|
|
30
31
|
ListItem: (_node, children) => `<li>${children.join("")}</li>`,
|
|
31
32
|
TaskItem: (node, children) => `<li><input type="checkbox" disabled ${node.checked ? "checked" : ""}>${children.join("")}</li>`,
|
|
@@ -40,8 +41,29 @@ class Renderer {
|
|
|
40
41
|
//Leaf nodes
|
|
41
42
|
HorizontalLine: (_node) => `<hr>`,
|
|
42
43
|
Text: (node) => node.value,
|
|
44
|
+
//For table nodes
|
|
45
|
+
Table: (node, children) => this.renderTable(node, children),
|
|
43
46
|
};
|
|
44
|
-
return this.option.elements?.[type] ?? defaultRender[type];
|
|
47
|
+
return (this.option.elements?.[type] ?? defaultRender[type]);
|
|
48
|
+
}
|
|
49
|
+
renderTable(node, children) {
|
|
50
|
+
if (node.type === "Table") {
|
|
51
|
+
const header = node.rows.filter(row => row.isHeader);
|
|
52
|
+
const body = node.rows.filter(row => !row.isHeader);
|
|
53
|
+
const renderRows = (row) => {
|
|
54
|
+
const tag = row.isHeader ? "th" : "td";
|
|
55
|
+
const cells = row.cells.map(cell => {
|
|
56
|
+
const align = `style="text-align:${cell.align}"`;
|
|
57
|
+
return `<${tag} ${align}>${cell.children.map(c => this.render(c)).join("")}</${tag}>`;
|
|
58
|
+
}).join("");
|
|
59
|
+
return `<tr>${cells}</tr>`;
|
|
60
|
+
};
|
|
61
|
+
const tHead = header.length ? `<thead>${header.map(renderRows).join("")}</thead>` : "";
|
|
62
|
+
const tBody = body.length ? `<tbody>${body.map(renderRows).join("")}</tbody>` : "";
|
|
63
|
+
return `<table>${tHead}${tBody}</table>`;
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
return `<p>${children.join("\n")}</p>`;
|
|
45
67
|
}
|
|
46
68
|
escapeHtml(str) {
|
|
47
69
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
package/dist/types/node.d.ts
CHANGED
|
@@ -76,4 +76,15 @@ export type Node = {
|
|
|
76
76
|
} | {
|
|
77
77
|
type: "Text";
|
|
78
78
|
value: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: "Table";
|
|
81
|
+
rows: TableRow[];
|
|
82
|
+
};
|
|
83
|
+
export type TableRow = {
|
|
84
|
+
isHeader: boolean;
|
|
85
|
+
cells: TableCell[];
|
|
86
|
+
};
|
|
87
|
+
export type TableCell = {
|
|
88
|
+
align: "left" | "center" | "right";
|
|
89
|
+
children: Node[];
|
|
79
90
|
};
|
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import { Node } from "./node";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Function type for rendering an AST node to HTML.
|
|
4
4
|
*
|
|
5
|
-
* @
|
|
6
|
-
* - The
|
|
7
|
-
*
|
|
5
|
+
* @template T - A subtype of `Node` corresponding to the render node
|
|
6
|
+
* @param node - The AST node to render
|
|
7
|
+
* @param children - Rendered HTML strings of the node's children
|
|
8
|
+
* @returns A HTML string representation of the node
|
|
9
|
+
*/
|
|
10
|
+
type NodeRenderer<T extends Node = Node> = (node: T, children: string[]) => string;
|
|
11
|
+
/**
|
|
12
|
+
* A mapping of AST node types to custom render functions.
|
|
13
|
+
*
|
|
14
|
+
* - The key is a `Node["type"]` string literal (e.g. `"Header"`, `"Paragraph"`)
|
|
15
|
+
* - The value is a function `(node, children) => string`:
|
|
16
|
+
* - `node` is a `Node` with its attribute depending on its `type`.
|
|
17
|
+
* (e.g. `"Header"` nodes include `level`, `"CodeBlock"` nodes include `lang` and `content`, etc)
|
|
18
|
+
* - `children` is the array of rendered strings of its children.
|
|
19
|
+
*/
|
|
20
|
+
export type RenderElements = {
|
|
21
|
+
[K in Node["type"]]?: NodeRenderer<Extract<Node, {
|
|
22
|
+
type: K;
|
|
23
|
+
}>>;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Options to customize how AST nodes are renderes into HTML
|
|
27
|
+
*
|
|
28
|
+
* @property elements - Optional custom rendered for one or more node types
|
|
8
29
|
*
|
|
9
30
|
* @example
|
|
10
31
|
* ```ts
|
|
@@ -16,8 +37,8 @@ import { Node } from "./node";
|
|
|
16
37
|
* }
|
|
17
38
|
* ```
|
|
18
39
|
*
|
|
19
|
-
* @todo Update`node` type in value function from `any` to `Node`
|
|
20
40
|
*/
|
|
21
41
|
export type RenderOption = {
|
|
22
|
-
elements?:
|
|
42
|
+
elements?: RenderElements;
|
|
23
43
|
};
|
|
44
|
+
export {};
|
package/dist/types/token.d.ts
CHANGED
|
@@ -69,4 +69,18 @@ export type Token = {
|
|
|
69
69
|
value: string;
|
|
70
70
|
} | {
|
|
71
71
|
type: "EOF";
|
|
72
|
+
} | {
|
|
73
|
+
type: "TableStart";
|
|
74
|
+
} | {
|
|
75
|
+
type: "TableEnd";
|
|
76
|
+
} | {
|
|
77
|
+
type: "RowStart";
|
|
78
|
+
isHeader: boolean;
|
|
79
|
+
} | {
|
|
80
|
+
type: "RowEnd";
|
|
81
|
+
} | {
|
|
82
|
+
type: "CellStart";
|
|
83
|
+
align: "left" | "center" | "right";
|
|
84
|
+
} | {
|
|
85
|
+
type: "CellEnd";
|
|
72
86
|
};
|