@rokelamen/md2html 0.1.3 → 0.2.0
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 +40 -2
- package/bin/cli.cjs +277 -59
- package/dist/index.js +267 -56
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,12 +1,50 @@
|
|
|
1
1
|
# md2html
|
|
2
2
|
|
|
3
|
-
A simple markdown-html
|
|
3
|
+
A simple markdown-html converter written in Typescript.
|
|
4
|
+
|
|
5
|
+
The style relies on browser pure style, inspired by [`txti`](https://txti.es/).
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
4
8
|
|
|
5
9
|
> I create this project for learning TS and node dev, not for the purpose of building another better markdown parse engine.
|
|
6
10
|
|
|
11
|
+
Markdown syntax was first promoted with the release of `markdown.pl` by John Gruber. This leads to Markdown has no explicit definition, which means how markdown is parsed to HTML highly depends on the implementation of the tool. *And I choose a simplest way(line-by-line parsing)*
|
|
12
|
+
|
|
13
|
+
To stay as close as possible to the 'Standard Markdown', [CommonMark](https://commonmark.org/) is a great reference.
|
|
14
|
+
|
|
15
|
+
## Requirement
|
|
16
|
+
|
|
17
|
+
[Node](https://nodejs.org/) environment(>= v12.0.0) is necessary.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Using npm:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm i -g @rokelamen/md2html
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
md2html [options] [input]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
1. Parse from/to stdio
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
md2html "# Markdown content" > index.html
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. Parse from/to file
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
md2html -f input.md -o index.html
|
|
43
|
+
```
|
|
44
|
+
|
|
7
45
|
## Development Log
|
|
8
46
|
|
|
9
47
|
Why I choose to use [`rollup`](https://rollupjs.org/)?
|
|
10
48
|
|
|
11
|
-
A: Every time I `import` a module, I have to add extension to the module file and it is probably a `.js`
|
|
49
|
+
A: Every time I `import` a module, I have to add extension to the module file and it is probably a `.js` rather than `.ts`. It's wired that I must `import` a future JS file. So I decided to use a build tool(`rollup` of course) to pack all files together, which eliminates all `import` statements.
|
|
12
50
|
|
package/bin/cli.cjs
CHANGED
|
@@ -4336,7 +4336,158 @@ const {
|
|
|
4336
4336
|
Help,
|
|
4337
4337
|
} = commander;
|
|
4338
4338
|
|
|
4339
|
-
|
|
4339
|
+
/* For markdown line pattern pair */
|
|
4340
|
+
const headingReg = /^\s*(#{1,6})(?:\s+|$)(.*)$/;
|
|
4341
|
+
const delimiterReg = /^\s*((?:\*+\s*){3,}|(?:-+\s*){3,}|(?:_+\s*){3,})$/;
|
|
4342
|
+
const quoteReg = /^>\s*(.*)$/;
|
|
4343
|
+
const ulistReg = /^\s*([-+*])(?:\s+|$)(.*)$/;
|
|
4344
|
+
const olistReg = /^\s*(\d+)(.|\))(?:\s+|$)(.*)$/;
|
|
4345
|
+
const codeStartReg = /^```([^`]*)$/;
|
|
4346
|
+
const codeEndReg = /^```\s*$/;
|
|
4347
|
+
/* For text inline pattern pair */
|
|
4348
|
+
const inlineCodeReg = /(`+)([^`]+?)\1/g;
|
|
4349
|
+
const imgReg = /!\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
4350
|
+
const linkReg = /\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
4351
|
+
const boldItalicReg = /(\*\*\*|___)([^*_]+)\1/g;
|
|
4352
|
+
const boldReg = /(\*\*|__)([^*_]+)\1/g;
|
|
4353
|
+
const italicReg = /([*_])([^*_]+)\1/g;
|
|
4354
|
+
|
|
4355
|
+
/**
|
|
4356
|
+
* When a scope in Markdown is of `code` type,
|
|
4357
|
+
* the content inside this area must not be parsed as either Markdown or HTML.
|
|
4358
|
+
* It should be treated as pure text content.
|
|
4359
|
+
* Therefore, it can not carry any semantic representation in HTML.
|
|
4360
|
+
* This function is intended to remove all such representations.
|
|
4361
|
+
*/
|
|
4362
|
+
function escapeHtml(content) {
|
|
4363
|
+
return content
|
|
4364
|
+
.replace(/&/g, '&')
|
|
4365
|
+
.replace(/</g, '<')
|
|
4366
|
+
.replace(/>/g, '>')
|
|
4367
|
+
.replace(/"/g, '"')
|
|
4368
|
+
.replace(/'/g, ''');
|
|
4369
|
+
}
|
|
4370
|
+
const DEFAULT_STYLE = `
|
|
4371
|
+
body {
|
|
4372
|
+
margin: 0 auto;
|
|
4373
|
+
max-width: 650px;
|
|
4374
|
+
line-height: 1.6;
|
|
4375
|
+
font-size: 18px;
|
|
4376
|
+
color: #444;
|
|
4377
|
+
padding: 50px;
|
|
4378
|
+
}
|
|
4379
|
+
h1, h2, h3 {
|
|
4380
|
+
line-height: 1.2;
|
|
4381
|
+
}
|
|
4382
|
+
pre {
|
|
4383
|
+
background-color: #f8f8f8;
|
|
4384
|
+
padding: 18px;
|
|
4385
|
+
border-radius: 5px;
|
|
4386
|
+
}
|
|
4387
|
+
code {
|
|
4388
|
+
padding: 3px 5px;
|
|
4389
|
+
border-radius: 5px;
|
|
4390
|
+
background-color: #f4f4f4;
|
|
4391
|
+
font-size: 85%;
|
|
4392
|
+
}
|
|
4393
|
+
pre code {
|
|
4394
|
+
background-color: transparent;
|
|
4395
|
+
}
|
|
4396
|
+
blockquote {
|
|
4397
|
+
margin: 0;
|
|
4398
|
+
border-left: 5px solid #dfe2e5;
|
|
4399
|
+
padding: 0 18px;
|
|
4400
|
+
}
|
|
4401
|
+
blockquote > * {
|
|
4402
|
+
margin: 0;
|
|
4403
|
+
padding: 0;
|
|
4404
|
+
color: #888;
|
|
4405
|
+
}
|
|
4406
|
+
`;
|
|
4407
|
+
function wrapHtmlTemplate(content) {
|
|
4408
|
+
return `<!DOCTYPE html>
|
|
4409
|
+
<html>
|
|
4410
|
+
<head>
|
|
4411
|
+
<meta charset="UTF-8">
|
|
4412
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
4413
|
+
<title>Markdown preview</title>
|
|
4414
|
+
<style>
|
|
4415
|
+
${DEFAULT_STYLE.trim()}
|
|
4416
|
+
</style>
|
|
4417
|
+
</head>
|
|
4418
|
+
<body>
|
|
4419
|
+
${content.trim()}
|
|
4420
|
+
</body>
|
|
4421
|
+
</html>`;
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
/* traverse markdown content elements and wrap text with tags at proper positions. */
|
|
4425
|
+
function renderToHtml(mdElements) {
|
|
4426
|
+
let result = '';
|
|
4427
|
+
for (const element of mdElements) {
|
|
4428
|
+
const type = element.type;
|
|
4429
|
+
switch (type) {
|
|
4430
|
+
case 'text':
|
|
4431
|
+
result += `<p>${inlineParse(element.content)}</p>\n`;
|
|
4432
|
+
break;
|
|
4433
|
+
case 'heading':
|
|
4434
|
+
result += `<h${element.level}>${inlineParse(element.content)}</h${element.level}>\n`;
|
|
4435
|
+
break;
|
|
4436
|
+
case 'delimiter':
|
|
4437
|
+
result += `<hr>\n`;
|
|
4438
|
+
break;
|
|
4439
|
+
case 'quote':
|
|
4440
|
+
result += `<blockquote><p>${inlineParse(element.content)}</p></blockquote>\n`;
|
|
4441
|
+
break;
|
|
4442
|
+
case 'ulist':
|
|
4443
|
+
result += '<ul>\n' +
|
|
4444
|
+
element.items
|
|
4445
|
+
.map(item => `<li>${inlineParse(item)}</li>`)
|
|
4446
|
+
.join('\n') +
|
|
4447
|
+
'\n</ul>\n';
|
|
4448
|
+
break;
|
|
4449
|
+
case 'olist':
|
|
4450
|
+
result += `<ol start="${element.start}">\n` +
|
|
4451
|
+
element.items
|
|
4452
|
+
.map(item => `<li>${inlineParse(item)}</li>`)
|
|
4453
|
+
.join('\n') +
|
|
4454
|
+
'\n</ol>\n';
|
|
4455
|
+
break;
|
|
4456
|
+
case 'code':
|
|
4457
|
+
result += '<pre>\n' +
|
|
4458
|
+
element.items
|
|
4459
|
+
.map(item => `<code>${escapeHtml(item)}</code>`)
|
|
4460
|
+
.join('\n') +
|
|
4461
|
+
'\n</pre>\n';
|
|
4462
|
+
break;
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4465
|
+
return result;
|
|
4466
|
+
}
|
|
4467
|
+
function inlineParse(content) {
|
|
4468
|
+
const placeholders = [];
|
|
4469
|
+
let idx = 0;
|
|
4470
|
+
/* Make placeholders for code */
|
|
4471
|
+
const stash = (html) => {
|
|
4472
|
+
const key = `\u0000${idx}\u0000`;
|
|
4473
|
+
placeholders.push(html);
|
|
4474
|
+
idx++;
|
|
4475
|
+
return key;
|
|
4476
|
+
};
|
|
4477
|
+
// 1. code
|
|
4478
|
+
content = content
|
|
4479
|
+
.replace(inlineCodeReg, (_, __, code) => stash(code));
|
|
4480
|
+
// 2. link and emphasis
|
|
4481
|
+
content = content
|
|
4482
|
+
.replace(imgReg, '<img src="$2" alt="$1">')
|
|
4483
|
+
.replace(linkReg, '<a href="$2">$1</a>')
|
|
4484
|
+
.replace(boldItalicReg, '<strong><em>$2</em></strong>')
|
|
4485
|
+
.replace(boldReg, '<strong>$2</strong>')
|
|
4486
|
+
.replace(italicReg, '<em>$2</em>');
|
|
4487
|
+
// 3. restore codes
|
|
4488
|
+
content = content.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escapeHtml(placeholders[i])}</code>`);
|
|
4489
|
+
return content;
|
|
4490
|
+
}
|
|
4340
4491
|
|
|
4341
4492
|
/**
|
|
4342
4493
|
* Since AST-based parsing is too complex and not
|
|
@@ -4363,88 +4514,148 @@ const headerReg = /^\s*(#{1,6})(?:\s+|$)(.*)$/;
|
|
|
4363
4514
|
* is one paragraph as well.
|
|
4364
4515
|
*/
|
|
4365
4516
|
/* The main parse logic */
|
|
4366
|
-
function parse(markdown) {
|
|
4517
|
+
function parse(markdown, hasStyle) {
|
|
4367
4518
|
/* Split markdown content to many lines */
|
|
4368
4519
|
const crlfReg = /\r?\n/;
|
|
4369
4520
|
const lines = markdown.split(crlfReg);
|
|
4370
4521
|
// console.log(lines);
|
|
4371
|
-
const
|
|
4372
|
-
// console.log(
|
|
4373
|
-
const html =
|
|
4374
|
-
return html;
|
|
4522
|
+
const mdElements = parseToElements(lines);
|
|
4523
|
+
// console.log(mdElements);
|
|
4524
|
+
const html = renderToHtml(mdElements);
|
|
4525
|
+
return hasStyle ? wrapHtmlTemplate(html) : html;
|
|
4375
4526
|
}
|
|
4376
4527
|
/**
|
|
4377
|
-
* Traverse lines to turn to
|
|
4528
|
+
* Traverse lines to turn to markdown elements with different well-designed structures
|
|
4378
4529
|
*/
|
|
4379
|
-
function
|
|
4380
|
-
let
|
|
4381
|
-
|
|
4382
|
-
|
|
4530
|
+
function parseToElements(lines) {
|
|
4531
|
+
let lastFlowElement = null;
|
|
4532
|
+
const mdElements = [];
|
|
4533
|
+
/* Push last flow text element into the return value */
|
|
4534
|
+
const flush = () => {
|
|
4535
|
+
if (lastFlowElement) {
|
|
4536
|
+
mdElements.push(lastFlowElement);
|
|
4537
|
+
lastFlowElement = null;
|
|
4538
|
+
}
|
|
4539
|
+
};
|
|
4383
4540
|
for (const line of lines) {
|
|
4384
|
-
//
|
|
4385
|
-
if (
|
|
4386
|
-
if (
|
|
4387
|
-
|
|
4388
|
-
|
|
4541
|
+
// Code End
|
|
4542
|
+
if (lastFlowElement?.type === 'code') {
|
|
4543
|
+
if (codeEndReg.test(line)) {
|
|
4544
|
+
flush();
|
|
4545
|
+
}
|
|
4546
|
+
else {
|
|
4547
|
+
lastFlowElement.items.push(line);
|
|
4389
4548
|
}
|
|
4390
4549
|
continue;
|
|
4391
4550
|
}
|
|
4392
|
-
//
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4551
|
+
// Empty line
|
|
4552
|
+
if (!line.trim()) {
|
|
4553
|
+
flush();
|
|
4554
|
+
continue;
|
|
4555
|
+
}
|
|
4556
|
+
// Delimiter
|
|
4557
|
+
if (delimiterReg.test(line)) {
|
|
4558
|
+
flush();
|
|
4559
|
+
mdElements.push({ type: 'delimiter' });
|
|
4560
|
+
continue;
|
|
4561
|
+
}
|
|
4562
|
+
// Headings
|
|
4563
|
+
const headingM = line.match(headingReg);
|
|
4564
|
+
if (headingM) {
|
|
4565
|
+
flush();
|
|
4566
|
+
mdElements.push({
|
|
4567
|
+
type: 'heading',
|
|
4568
|
+
level: headingM[1].length,
|
|
4569
|
+
content: headingM[2].trim()
|
|
4399
4570
|
});
|
|
4400
4571
|
continue;
|
|
4401
4572
|
}
|
|
4573
|
+
// Quote
|
|
4574
|
+
const quoteM = line.match(quoteReg);
|
|
4575
|
+
if (quoteM) {
|
|
4576
|
+
/* Last line is quote as well */
|
|
4577
|
+
if (lastFlowElement?.type === 'quote') {
|
|
4578
|
+
lastFlowElement.content += ' ' + quoteM[1].trim();
|
|
4579
|
+
}
|
|
4580
|
+
else {
|
|
4581
|
+
flush();
|
|
4582
|
+
lastFlowElement = {
|
|
4583
|
+
type: 'quote',
|
|
4584
|
+
content: quoteM[1].trim()
|
|
4585
|
+
};
|
|
4586
|
+
}
|
|
4587
|
+
continue;
|
|
4588
|
+
}
|
|
4589
|
+
// Unordered List
|
|
4590
|
+
const ulistM = line.match(ulistReg);
|
|
4591
|
+
if (ulistM) {
|
|
4592
|
+
if (lastFlowElement?.type === 'ulist' && lastFlowElement.sign === ulistM[1]) {
|
|
4593
|
+
lastFlowElement.items.push(ulistM[2].trim());
|
|
4594
|
+
}
|
|
4595
|
+
else {
|
|
4596
|
+
flush();
|
|
4597
|
+
lastFlowElement = {
|
|
4598
|
+
type: 'ulist',
|
|
4599
|
+
sign: ulistM[1],
|
|
4600
|
+
items: [ulistM[2].trim()]
|
|
4601
|
+
};
|
|
4602
|
+
}
|
|
4603
|
+
continue;
|
|
4604
|
+
}
|
|
4605
|
+
// Ordered List
|
|
4606
|
+
const olistM = line.match(olistReg);
|
|
4607
|
+
if (olistM) {
|
|
4608
|
+
if (lastFlowElement?.type === 'olist' && lastFlowElement.delimiter === olistM[2]) {
|
|
4609
|
+
lastFlowElement.items.push(olistM[3].trim());
|
|
4610
|
+
}
|
|
4611
|
+
else {
|
|
4612
|
+
flush();
|
|
4613
|
+
lastFlowElement = {
|
|
4614
|
+
type: 'olist',
|
|
4615
|
+
start: parseInt(olistM[1]),
|
|
4616
|
+
delimiter: olistM[2],
|
|
4617
|
+
items: [olistM[3].trim()]
|
|
4618
|
+
};
|
|
4619
|
+
}
|
|
4620
|
+
continue;
|
|
4621
|
+
}
|
|
4622
|
+
// Code Start
|
|
4623
|
+
const codeStartM = line.match(codeStartReg);
|
|
4624
|
+
if (codeStartM) {
|
|
4625
|
+
flush();
|
|
4626
|
+
lastFlowElement = {
|
|
4627
|
+
type: 'code',
|
|
4628
|
+
lang: codeStartM[1],
|
|
4629
|
+
items: []
|
|
4630
|
+
};
|
|
4631
|
+
continue;
|
|
4632
|
+
}
|
|
4402
4633
|
// Fall back to plain text
|
|
4403
|
-
if (
|
|
4404
|
-
|
|
4405
|
-
|
|
4634
|
+
if (lastFlowElement &&
|
|
4635
|
+
['text', 'quote', 'ulist', 'olist'].includes(lastFlowElement.type)) {
|
|
4636
|
+
if (lastFlowElement.type === 'ulist' || lastFlowElement.type === 'olist') {
|
|
4637
|
+
lastFlowElement.items[lastFlowElement.items.length - 1] += ' ' + line.trim();
|
|
4638
|
+
}
|
|
4639
|
+
else {
|
|
4640
|
+
lastFlowElement.content += ' ' + line.trim();
|
|
4641
|
+
}
|
|
4406
4642
|
}
|
|
4407
4643
|
else {
|
|
4408
|
-
|
|
4644
|
+
flush();
|
|
4645
|
+
lastFlowElement = {
|
|
4409
4646
|
type: 'text',
|
|
4410
4647
|
content: line.trim()
|
|
4411
4648
|
};
|
|
4412
|
-
pushed = false;
|
|
4413
4649
|
}
|
|
4414
4650
|
}
|
|
4415
|
-
// Avoid the last
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
pushed = true;
|
|
4419
|
-
}
|
|
4420
|
-
return mdBlocks;
|
|
4421
|
-
}
|
|
4422
|
-
/* traverse markdown content blocks and wrap text with tags at proper positions. */
|
|
4423
|
-
function handleTags(mdBlocks) {
|
|
4424
|
-
let result = '';
|
|
4425
|
-
for (const block of mdBlocks) {
|
|
4426
|
-
const type = block.type;
|
|
4427
|
-
const content = tagSwtich(block);
|
|
4428
|
-
switch (type) {
|
|
4429
|
-
case "text":
|
|
4430
|
-
result += `<p>${content}</p>` +
|
|
4431
|
-
'\n';
|
|
4432
|
-
break;
|
|
4433
|
-
case "header":
|
|
4434
|
-
result += `<h${block.level}>${content}</h${block.level}>` +
|
|
4435
|
-
'\n';
|
|
4436
|
-
break;
|
|
4437
|
-
}
|
|
4438
|
-
}
|
|
4439
|
-
return result;
|
|
4440
|
-
}
|
|
4441
|
-
function tagSwtich(block) {
|
|
4442
|
-
return block.content;
|
|
4651
|
+
// Avoid the last element is omitted
|
|
4652
|
+
flush();
|
|
4653
|
+
return mdElements;
|
|
4443
4654
|
}
|
|
4444
4655
|
|
|
4445
4656
|
var name = "@rokelamen/md2html";
|
|
4446
|
-
var version = "0.
|
|
4447
|
-
var description = "A simple tool to convert
|
|
4657
|
+
var version = "0.2.0";
|
|
4658
|
+
var description = "A simple tool to convert Markdown content to HTML";
|
|
4448
4659
|
|
|
4449
4660
|
/* Command-line tool logic */
|
|
4450
4661
|
function command() {
|
|
@@ -4456,6 +4667,8 @@ function command() {
|
|
|
4456
4667
|
/* Config arguments info */
|
|
4457
4668
|
program
|
|
4458
4669
|
.option('-f, --file <path>', 'source file path')
|
|
4670
|
+
.option('-o, --output <path>', 'output file path')
|
|
4671
|
+
.option('-s, --style', 'output full html struct with style')
|
|
4459
4672
|
.argument('[input]', 'input content');
|
|
4460
4673
|
/* Parse the cli options */
|
|
4461
4674
|
program.parse(process.argv);
|
|
@@ -4481,6 +4694,11 @@ function command() {
|
|
|
4481
4694
|
}
|
|
4482
4695
|
})()
|
|
4483
4696
|
: input;
|
|
4484
|
-
|
|
4697
|
+
const html = parse(content, options.style);
|
|
4698
|
+
if (typeof options.output === 'string') {
|
|
4699
|
+
fs__namespace.writeFileSync(options.output, html, 'utf-8');
|
|
4700
|
+
return;
|
|
4701
|
+
}
|
|
4702
|
+
console.log(html);
|
|
4485
4703
|
}
|
|
4486
4704
|
command();
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,155 @@
|
|
|
1
|
-
|
|
1
|
+
/* For markdown line pattern pair */
|
|
2
|
+
const headingReg = /^\s*(#{1,6})(?:\s+|$)(.*)$/;
|
|
3
|
+
const delimiterReg = /^\s*((?:\*+\s*){3,}|(?:-+\s*){3,}|(?:_+\s*){3,})$/;
|
|
4
|
+
const quoteReg = /^>\s*(.*)$/;
|
|
5
|
+
const ulistReg = /^\s*([-+*])(?:\s+|$)(.*)$/;
|
|
6
|
+
const olistReg = /^\s*(\d+)(.|\))(?:\s+|$)(.*)$/;
|
|
7
|
+
const codeStartReg = /^```([^`]*)$/;
|
|
8
|
+
const codeEndReg = /^```\s*$/;
|
|
9
|
+
/* For text inline pattern pair */
|
|
10
|
+
const inlineCodeReg = /(`+)([^`]+?)\1/g;
|
|
11
|
+
const imgReg = /!\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
12
|
+
const linkReg = /\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
13
|
+
const boldItalicReg = /(\*\*\*|___)([^*_]+)\1/g;
|
|
14
|
+
const boldReg = /(\*\*|__)([^*_]+)\1/g;
|
|
15
|
+
const italicReg = /([*_])([^*_]+)\1/g;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* When a scope in Markdown is of `code` type,
|
|
19
|
+
* the content inside this area must not be parsed as either Markdown or HTML.
|
|
20
|
+
* It should be treated as pure text content.
|
|
21
|
+
* Therefore, it can not carry any semantic representation in HTML.
|
|
22
|
+
* This function is intended to remove all such representations.
|
|
23
|
+
*/
|
|
24
|
+
function escapeHtml(content) {
|
|
25
|
+
return content
|
|
26
|
+
.replace(/&/g, '&')
|
|
27
|
+
.replace(/</g, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"')
|
|
30
|
+
.replace(/'/g, ''');
|
|
31
|
+
}
|
|
32
|
+
const DEFAULT_STYLE = `
|
|
33
|
+
body {
|
|
34
|
+
margin: 0 auto;
|
|
35
|
+
max-width: 650px;
|
|
36
|
+
line-height: 1.6;
|
|
37
|
+
font-size: 18px;
|
|
38
|
+
color: #444;
|
|
39
|
+
padding: 50px;
|
|
40
|
+
}
|
|
41
|
+
h1, h2, h3 {
|
|
42
|
+
line-height: 1.2;
|
|
43
|
+
}
|
|
44
|
+
pre {
|
|
45
|
+
background-color: #f8f8f8;
|
|
46
|
+
padding: 18px;
|
|
47
|
+
border-radius: 5px;
|
|
48
|
+
}
|
|
49
|
+
code {
|
|
50
|
+
padding: 3px 5px;
|
|
51
|
+
border-radius: 5px;
|
|
52
|
+
background-color: #f4f4f4;
|
|
53
|
+
font-size: 85%;
|
|
54
|
+
}
|
|
55
|
+
pre code {
|
|
56
|
+
background-color: transparent;
|
|
57
|
+
}
|
|
58
|
+
blockquote {
|
|
59
|
+
margin: 0;
|
|
60
|
+
border-left: 5px solid #dfe2e5;
|
|
61
|
+
padding: 0 18px;
|
|
62
|
+
}
|
|
63
|
+
blockquote > * {
|
|
64
|
+
margin: 0;
|
|
65
|
+
padding: 0;
|
|
66
|
+
color: #888;
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
function wrapHtmlTemplate(content) {
|
|
70
|
+
return `<!DOCTYPE html>
|
|
71
|
+
<html>
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="UTF-8">
|
|
74
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
75
|
+
<title>Markdown preview</title>
|
|
76
|
+
<style>
|
|
77
|
+
${DEFAULT_STYLE.trim()}
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
${content.trim()}
|
|
82
|
+
</body>
|
|
83
|
+
</html>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* traverse markdown content elements and wrap text with tags at proper positions. */
|
|
87
|
+
function renderToHtml(mdElements) {
|
|
88
|
+
let result = '';
|
|
89
|
+
for (const element of mdElements) {
|
|
90
|
+
const type = element.type;
|
|
91
|
+
switch (type) {
|
|
92
|
+
case 'text':
|
|
93
|
+
result += `<p>${inlineParse(element.content)}</p>\n`;
|
|
94
|
+
break;
|
|
95
|
+
case 'heading':
|
|
96
|
+
result += `<h${element.level}>${inlineParse(element.content)}</h${element.level}>\n`;
|
|
97
|
+
break;
|
|
98
|
+
case 'delimiter':
|
|
99
|
+
result += `<hr>\n`;
|
|
100
|
+
break;
|
|
101
|
+
case 'quote':
|
|
102
|
+
result += `<blockquote><p>${inlineParse(element.content)}</p></blockquote>\n`;
|
|
103
|
+
break;
|
|
104
|
+
case 'ulist':
|
|
105
|
+
result += '<ul>\n' +
|
|
106
|
+
element.items
|
|
107
|
+
.map(item => `<li>${inlineParse(item)}</li>`)
|
|
108
|
+
.join('\n') +
|
|
109
|
+
'\n</ul>\n';
|
|
110
|
+
break;
|
|
111
|
+
case 'olist':
|
|
112
|
+
result += `<ol start="${element.start}">\n` +
|
|
113
|
+
element.items
|
|
114
|
+
.map(item => `<li>${inlineParse(item)}</li>`)
|
|
115
|
+
.join('\n') +
|
|
116
|
+
'\n</ol>\n';
|
|
117
|
+
break;
|
|
118
|
+
case 'code':
|
|
119
|
+
result += '<pre>\n' +
|
|
120
|
+
element.items
|
|
121
|
+
.map(item => `<code>${escapeHtml(item)}</code>`)
|
|
122
|
+
.join('\n') +
|
|
123
|
+
'\n</pre>\n';
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
function inlineParse(content) {
|
|
130
|
+
const placeholders = [];
|
|
131
|
+
let idx = 0;
|
|
132
|
+
/* Make placeholders for code */
|
|
133
|
+
const stash = (html) => {
|
|
134
|
+
const key = `\u0000${idx}\u0000`;
|
|
135
|
+
placeholders.push(html);
|
|
136
|
+
idx++;
|
|
137
|
+
return key;
|
|
138
|
+
};
|
|
139
|
+
// 1. code
|
|
140
|
+
content = content
|
|
141
|
+
.replace(inlineCodeReg, (_, __, code) => stash(code));
|
|
142
|
+
// 2. link and emphasis
|
|
143
|
+
content = content
|
|
144
|
+
.replace(imgReg, '<img src="$2" alt="$1">')
|
|
145
|
+
.replace(linkReg, '<a href="$2">$1</a>')
|
|
146
|
+
.replace(boldItalicReg, '<strong><em>$2</em></strong>')
|
|
147
|
+
.replace(boldReg, '<strong>$2</strong>')
|
|
148
|
+
.replace(italicReg, '<em>$2</em>');
|
|
149
|
+
// 3. restore codes
|
|
150
|
+
content = content.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escapeHtml(placeholders[i])}</code>`);
|
|
151
|
+
return content;
|
|
152
|
+
}
|
|
2
153
|
|
|
3
154
|
/**
|
|
4
155
|
* Since AST-based parsing is too complex and not
|
|
@@ -25,83 +176,143 @@ const headerReg = /^\s*(#{1,6})(?:\s+|$)(.*)$/;
|
|
|
25
176
|
* is one paragraph as well.
|
|
26
177
|
*/
|
|
27
178
|
/* The main parse logic */
|
|
28
|
-
function parse(markdown) {
|
|
179
|
+
function parse(markdown, hasStyle) {
|
|
29
180
|
/* Split markdown content to many lines */
|
|
30
181
|
const crlfReg = /\r?\n/;
|
|
31
182
|
const lines = markdown.split(crlfReg);
|
|
32
183
|
// console.log(lines);
|
|
33
|
-
const
|
|
34
|
-
// console.log(
|
|
35
|
-
const html =
|
|
36
|
-
return html;
|
|
184
|
+
const mdElements = parseToElements(lines);
|
|
185
|
+
// console.log(mdElements);
|
|
186
|
+
const html = renderToHtml(mdElements);
|
|
187
|
+
return hasStyle ? wrapHtmlTemplate(html) : html;
|
|
37
188
|
}
|
|
38
189
|
/**
|
|
39
|
-
* Traverse lines to turn to
|
|
190
|
+
* Traverse lines to turn to markdown elements with different well-designed structures
|
|
40
191
|
*/
|
|
41
|
-
function
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
|
|
192
|
+
function parseToElements(lines) {
|
|
193
|
+
let lastFlowElement = null;
|
|
194
|
+
const mdElements = [];
|
|
195
|
+
/* Push last flow text element into the return value */
|
|
196
|
+
const flush = () => {
|
|
197
|
+
if (lastFlowElement) {
|
|
198
|
+
mdElements.push(lastFlowElement);
|
|
199
|
+
lastFlowElement = null;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
45
202
|
for (const line of lines) {
|
|
46
|
-
//
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
203
|
+
// Code End
|
|
204
|
+
if (lastFlowElement?.type === 'code') {
|
|
205
|
+
if (codeEndReg.test(line)) {
|
|
206
|
+
flush();
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
lastFlowElement.items.push(line);
|
|
51
210
|
}
|
|
52
211
|
continue;
|
|
53
212
|
}
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
213
|
+
// Empty line
|
|
214
|
+
if (!line.trim()) {
|
|
215
|
+
flush();
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// Delimiter
|
|
219
|
+
if (delimiterReg.test(line)) {
|
|
220
|
+
flush();
|
|
221
|
+
mdElements.push({ type: 'delimiter' });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Headings
|
|
225
|
+
const headingM = line.match(headingReg);
|
|
226
|
+
if (headingM) {
|
|
227
|
+
flush();
|
|
228
|
+
mdElements.push({
|
|
229
|
+
type: 'heading',
|
|
230
|
+
level: headingM[1].length,
|
|
231
|
+
content: headingM[2].trim()
|
|
61
232
|
});
|
|
62
233
|
continue;
|
|
63
234
|
}
|
|
235
|
+
// Quote
|
|
236
|
+
const quoteM = line.match(quoteReg);
|
|
237
|
+
if (quoteM) {
|
|
238
|
+
/* Last line is quote as well */
|
|
239
|
+
if (lastFlowElement?.type === 'quote') {
|
|
240
|
+
lastFlowElement.content += ' ' + quoteM[1].trim();
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
flush();
|
|
244
|
+
lastFlowElement = {
|
|
245
|
+
type: 'quote',
|
|
246
|
+
content: quoteM[1].trim()
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// Unordered List
|
|
252
|
+
const ulistM = line.match(ulistReg);
|
|
253
|
+
if (ulistM) {
|
|
254
|
+
if (lastFlowElement?.type === 'ulist' && lastFlowElement.sign === ulistM[1]) {
|
|
255
|
+
lastFlowElement.items.push(ulistM[2].trim());
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
flush();
|
|
259
|
+
lastFlowElement = {
|
|
260
|
+
type: 'ulist',
|
|
261
|
+
sign: ulistM[1],
|
|
262
|
+
items: [ulistM[2].trim()]
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// Ordered List
|
|
268
|
+
const olistM = line.match(olistReg);
|
|
269
|
+
if (olistM) {
|
|
270
|
+
if (lastFlowElement?.type === 'olist' && lastFlowElement.delimiter === olistM[2]) {
|
|
271
|
+
lastFlowElement.items.push(olistM[3].trim());
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
flush();
|
|
275
|
+
lastFlowElement = {
|
|
276
|
+
type: 'olist',
|
|
277
|
+
start: parseInt(olistM[1]),
|
|
278
|
+
delimiter: olistM[2],
|
|
279
|
+
items: [olistM[3].trim()]
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
// Code Start
|
|
285
|
+
const codeStartM = line.match(codeStartReg);
|
|
286
|
+
if (codeStartM) {
|
|
287
|
+
flush();
|
|
288
|
+
lastFlowElement = {
|
|
289
|
+
type: 'code',
|
|
290
|
+
lang: codeStartM[1],
|
|
291
|
+
items: []
|
|
292
|
+
};
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
64
295
|
// Fall back to plain text
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
296
|
+
if (lastFlowElement &&
|
|
297
|
+
['text', 'quote', 'ulist', 'olist'].includes(lastFlowElement.type)) {
|
|
298
|
+
if (lastFlowElement.type === 'ulist' || lastFlowElement.type === 'olist') {
|
|
299
|
+
lastFlowElement.items[lastFlowElement.items.length - 1] += ' ' + line.trim();
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
lastFlowElement.content += ' ' + line.trim();
|
|
303
|
+
}
|
|
68
304
|
}
|
|
69
305
|
else {
|
|
70
|
-
|
|
306
|
+
flush();
|
|
307
|
+
lastFlowElement = {
|
|
71
308
|
type: 'text',
|
|
72
309
|
content: line.trim()
|
|
73
310
|
};
|
|
74
|
-
pushed = false;
|
|
75
311
|
}
|
|
76
312
|
}
|
|
77
|
-
// Avoid the last
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
pushed = true;
|
|
81
|
-
}
|
|
82
|
-
return mdBlocks;
|
|
83
|
-
}
|
|
84
|
-
/* traverse markdown content blocks and wrap text with tags at proper positions. */
|
|
85
|
-
function handleTags(mdBlocks) {
|
|
86
|
-
let result = '';
|
|
87
|
-
for (const block of mdBlocks) {
|
|
88
|
-
const type = block.type;
|
|
89
|
-
const content = tagSwtich(block);
|
|
90
|
-
switch (type) {
|
|
91
|
-
case "text":
|
|
92
|
-
result += `<p>${content}</p>` +
|
|
93
|
-
'\n';
|
|
94
|
-
break;
|
|
95
|
-
case "header":
|
|
96
|
-
result += `<h${block.level}>${content}</h${block.level}>` +
|
|
97
|
-
'\n';
|
|
98
|
-
break;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return result;
|
|
102
|
-
}
|
|
103
|
-
function tagSwtich(block) {
|
|
104
|
-
return block.content;
|
|
313
|
+
// Avoid the last element is omitted
|
|
314
|
+
flush();
|
|
315
|
+
return mdElements;
|
|
105
316
|
}
|
|
106
317
|
|
|
107
318
|
export { parse };
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokelamen/md2html",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "A simple tool to convert
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "A simple tool to convert Markdown content to HTML",
|
|
6
6
|
"author": "rokelamen <rogerskelamen@gmail.com>",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"main": "./dist/index.js",
|