@kobekeye/mdf 1.0.0 → 1.1.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 +25 -11
- package/index.js +56 -13
- package/package.json +2 -2
- package/renderer.js +50 -41
- package/themes/default.css +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# mdf
|
|
2
|
+
<img width="370" height="527" alt="mdfintro1" src="https://github.com/user-attachments/assets/30436af4-2f05-4678-ad80-64daa47a5f7c" />
|
|
3
|
+
<img width="372" height="527" alt="mdfintro2" src="https://github.com/user-attachments/assets/79bab5be-2ea6-430c-8075-42870caa41bc" />
|
|
2
4
|
|
|
3
|
-
Convert Markdown to beautiful PDFs — free, open-source, and zero-config.
|
|
4
5
|
|
|
5
|
-
## Features
|
|
6
6
|
|
|
7
|
+
Convert Markdown to beautiful PDFs — free, open-source, and zero-config.
|
|
8
|
+
Welcome to mdf! Please note that this project is currently in the MVP (Minimum Viable Product) stage. Any feedback, suggestions, or contributions are highly appreciated!
|
|
9
|
+
## Features
|
|
7
10
|
- **Syntax highlighting** — fenced code blocks with language detection (via highlight.js)
|
|
8
11
|
- **Math formulas** — inline and block LaTeX via KaTeX (`$...$` and `$$...$$`)
|
|
9
12
|
- **Table of contents** — insert `[TOC]` anywhere in your document
|
|
@@ -14,23 +17,34 @@ Convert Markdown to beautiful PDFs — free, open-source, and zero-config.
|
|
|
14
17
|
- **CJK support** — uses Inter + Noto Sans TC via Google Fonts (requires internet on first run)
|
|
15
18
|
|
|
16
19
|
## Installation
|
|
17
|
-
|
|
20
|
+
### Windows
|
|
18
21
|
```bash
|
|
19
|
-
npm install -g mdf
|
|
22
|
+
npm install -g @kobekeye/mdf
|
|
20
23
|
```
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
### Linux / macOS
|
|
25
|
+
It is highly recommended to use [nvm](https://github.com/nvm-sh/nvm) to manage permissions. Using sudo with global npm installs is not recommended.
|
|
26
|
+
#### 1. Install nvm
|
|
24
27
|
```bash
|
|
25
|
-
|
|
28
|
+
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
|
|
29
|
+
```
|
|
30
|
+
#### 2. Install nodejs
|
|
31
|
+
```bash
|
|
32
|
+
nvm install --lts
|
|
33
|
+
```
|
|
34
|
+
#### 3. Install mdf
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g @kobekeye/mdf
|
|
26
37
|
```
|
|
27
|
-
|
|
28
38
|
## Usage
|
|
29
|
-
|
|
30
39
|
```bash
|
|
31
40
|
mdf input.md # outputs input.pdf
|
|
32
41
|
mdf input.md output.pdf # custom output name
|
|
33
42
|
```
|
|
43
|
+
Or, if you want to try without installation,
|
|
44
|
+
```bash
|
|
45
|
+
npx @kobekeye/mdf input.md # outputs input.pdf
|
|
46
|
+
npx @kobekeye/mdf input.md output.pdf # custom output name
|
|
47
|
+
```
|
|
34
48
|
|
|
35
49
|
## Syntax Guide
|
|
36
50
|
|
|
@@ -71,7 +85,7 @@ Operation completed.
|
|
|
71
85
|
:::
|
|
72
86
|
```
|
|
73
87
|
|
|
74
|
-
### Manual Page Break
|
|
88
|
+
### Manual Page Break!
|
|
75
89
|
|
|
76
90
|
```markdown
|
|
77
91
|
==page==
|
package/index.js
CHANGED
|
@@ -4,26 +4,39 @@ const path = require('path');
|
|
|
4
4
|
const puppeteer = require('puppeteer');
|
|
5
5
|
const { renderToHtml } = require('./renderer');
|
|
6
6
|
|
|
7
|
+
let tempFiles = [];
|
|
8
|
+
|
|
9
|
+
// gracefully handle Ctrl+C: clean up temp files before exit
|
|
10
|
+
// must be registered before puppeteer.launch(), which installs its own SIGINT wrapper
|
|
11
|
+
process.on('SIGINT', () => {
|
|
12
|
+
for (const tempFile of tempFiles) {
|
|
13
|
+
try { fs.unlinkSync(tempFile); } catch (_) { }
|
|
14
|
+
}
|
|
15
|
+
process.exit(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
7
18
|
// parse command line arguments
|
|
8
19
|
const args = process.argv.slice(2);
|
|
9
20
|
|
|
10
21
|
if (args.length === 0) {
|
|
11
22
|
console.log(`
|
|
12
|
-
usage:
|
|
23
|
+
usage: mdf <input.md> [output.pdf]
|
|
13
24
|
|
|
14
25
|
input.md required, the Markdown file to convert
|
|
15
26
|
output.pdf optional, the output PDF file name
|
|
16
27
|
(if omitted, it will automatically use the same name, e.g. input.pdf)
|
|
17
28
|
|
|
18
29
|
examples:
|
|
19
|
-
|
|
20
|
-
|
|
30
|
+
mdf README.md
|
|
31
|
+
mdf doc.md custom-name.pdf
|
|
21
32
|
`);
|
|
22
33
|
process.exit(0);
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
const
|
|
26
|
-
const
|
|
36
|
+
const watchMode = args.includes('--watch') || args.includes('-w');
|
|
37
|
+
const filteredArgs = args.filter(a => a !== '--watch' && a !== '-w');
|
|
38
|
+
const inputFile = filteredArgs[0];
|
|
39
|
+
const outputFile = filteredArgs[1] || inputFile.replace(/\.md$/i, '.pdf');
|
|
27
40
|
|
|
28
41
|
// check if input file exists
|
|
29
42
|
if (!fs.existsSync(inputFile)) {
|
|
@@ -31,7 +44,7 @@ if (!fs.existsSync(inputFile)) {
|
|
|
31
44
|
process.exit(1);
|
|
32
45
|
}
|
|
33
46
|
|
|
34
|
-
async function convertToPdf() {
|
|
47
|
+
async function convertToPdf(browser) {
|
|
35
48
|
console.log(`converting: ${inputFile} → ${outputFile}`);
|
|
36
49
|
|
|
37
50
|
const fullHtml = renderToHtml(inputFile);
|
|
@@ -41,10 +54,9 @@ async function convertToPdf() {
|
|
|
41
54
|
const tempHtmlPath = path.join(inputDir, `.mdf-temp-${Date.now()}.html`);
|
|
42
55
|
fs.writeFileSync(tempHtmlPath, fullHtml, 'utf-8');
|
|
43
56
|
|
|
57
|
+
tempFiles.push(tempHtmlPath);
|
|
44
58
|
try {
|
|
45
|
-
const browser = await puppeteer.launch();
|
|
46
59
|
const page = await browser.newPage();
|
|
47
|
-
|
|
48
60
|
// use file:// URL to load, let relative path of images parse correctly
|
|
49
61
|
await page.goto(`file://${tempHtmlPath}`, { waitUntil: 'networkidle0' });
|
|
50
62
|
// wait for all fonts (including Google Fonts) to load completely, avoid CJK characters use synthetic bold
|
|
@@ -64,7 +76,7 @@ async function convertToPdf() {
|
|
|
64
76
|
</div>`,
|
|
65
77
|
});
|
|
66
78
|
|
|
67
|
-
await
|
|
79
|
+
await page.close();
|
|
68
80
|
console.log(`\x1b[32mDone! Output to: ${path.resolve(outputFile)}\x1b[0m`);
|
|
69
81
|
} finally {
|
|
70
82
|
// clean up temp HTML file
|
|
@@ -72,7 +84,38 @@ async function convertToPdf() {
|
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
async function main() {
|
|
88
|
+
const browser = await puppeteer.launch();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await convertToPdf(browser);
|
|
92
|
+
|
|
93
|
+
if (watchMode) {
|
|
94
|
+
console.log(`\x1b[36mWatching for changes: ${inputFile}...\x1b[0m`);
|
|
95
|
+
|
|
96
|
+
let debounceTimer;
|
|
97
|
+
fs.watch(inputFile, (eventType) => {
|
|
98
|
+
if (eventType === 'change') {
|
|
99
|
+
clearTimeout(debounceTimer);
|
|
100
|
+
debounceTimer = setTimeout(async () => {
|
|
101
|
+
console.log(`\x1b[33mFile changed, re-converting...\x1b[0m`);
|
|
102
|
+
try {
|
|
103
|
+
await convertToPdf(browser);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
|
|
106
|
+
}
|
|
107
|
+
}, 300);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
} else {
|
|
112
|
+
await browser.close();
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
await browser.close();
|
|
116
|
+
console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kobekeye/mdf",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Convert Markdown to beautiful PDFs — free, open-source, and zero-config.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"mdf": "
|
|
7
|
+
"mdf": "index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "echo \"Error: no test specified\" && exit 1"
|
package/renderer.js
CHANGED
|
@@ -7,67 +7,76 @@ const hljs = require('highlight.js');
|
|
|
7
7
|
const anchor = require('markdown-it-anchor');
|
|
8
8
|
const taskLists = require('markdown-it-task-lists');
|
|
9
9
|
const container = require('markdown-it-container');
|
|
10
|
+
// const md = new MarkdownIt({
|
|
11
|
+
// html: true,
|
|
12
|
+
// highlight: function (str, lang) {
|
|
13
|
+
// if (lang && hljs.getLanguage(lang)) {
|
|
14
|
+
// try {
|
|
15
|
+
// return '<pre class="hljs"><code>' +
|
|
16
|
+
// hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
|
|
17
|
+
// '</code></pre>';
|
|
18
|
+
// } catch (_) { }
|
|
19
|
+
// }
|
|
20
|
+
// return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
|
|
21
|
+
// }
|
|
22
|
+
// });
|
|
10
23
|
const md = new MarkdownIt({
|
|
11
24
|
html: true,
|
|
12
|
-
highlight:
|
|
25
|
+
highlight: (str, lang) => {
|
|
26
|
+
const wrap = (content) => `<pre class="hljs"><code>${content}</code></pre>`;
|
|
13
27
|
if (lang && hljs.getLanguage(lang)) {
|
|
14
28
|
try {
|
|
15
|
-
return
|
|
16
|
-
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
|
|
17
|
-
'</code></pre>';
|
|
29
|
+
return wrap(hljs.highlight(str, { language: lang, ignoreIllegals: true }).value);
|
|
18
30
|
} catch (_) { }
|
|
19
31
|
}
|
|
20
|
-
return
|
|
32
|
+
return wrap(md.utils.escapeHtml(str));
|
|
21
33
|
}
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
})
|
|
35
|
+
.use(texmath, {
|
|
36
|
+
engine: katex,
|
|
37
|
+
delimiters: 'dollars',
|
|
38
|
+
// maybe can add some katexoptions later on.
|
|
39
|
+
})
|
|
40
|
+
.use(anchor, {
|
|
41
|
+
permalink: false,
|
|
42
|
+
// e.g. hello world 你好 -> hello-world-%E4%BD%A0%E5%A5%BD(你好)
|
|
43
|
+
slugify: (s) => encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-')),
|
|
44
|
+
})
|
|
45
|
+
.use(taskLists, { enabled: true });
|
|
46
|
+
// --- Container blocks: :::info, :::warning, :::danger, :::success, :::spoiler ---
|
|
33
47
|
// Color aliases: :::blue = :::info, :::orange = :::warning, etc.
|
|
34
|
-
const
|
|
35
|
-
{ names: ['info', 'blue'], cssClass: 'info' },
|
|
36
|
-
{ names: ['warning', 'orange'], cssClass: 'warning' },
|
|
37
|
-
{ names: ['danger', 'red'], cssClass: 'danger' },
|
|
38
|
-
{ names: ['success', 'green'], cssClass: 'success' },
|
|
48
|
+
const CONTAINERS = [
|
|
49
|
+
{ names: ['info', 'blue'], type: 'callout', cssClass: 'info' },
|
|
50
|
+
{ names: ['warning', 'orange'], type: 'callout', cssClass: 'warning' },
|
|
51
|
+
{ names: ['danger', 'red'], type: 'callout', cssClass: 'danger' },
|
|
52
|
+
{ names: ['success', 'green'], type: 'callout', cssClass: 'success' },
|
|
53
|
+
{ names: ['spoiler'], type: 'spoiler' } // different type
|
|
39
54
|
];
|
|
40
|
-
|
|
55
|
+
|
|
56
|
+
for (const { names, type, cssClass } of CONTAINERS) {
|
|
41
57
|
for (const name of names) {
|
|
42
58
|
md.use(container, name, {
|
|
43
|
-
render(tokens, idx) {
|
|
59
|
+
render: function (tokens, idx) {
|
|
44
60
|
const token = tokens[idx];
|
|
45
61
|
if (token.nesting === 1) {
|
|
46
|
-
// opening tag – extract optional title after :::type
|
|
47
62
|
const titleMatch = token.info.trim().match(new RegExp(`^${name}\\s+(.*)$`));
|
|
48
63
|
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
|
|
65
|
+
// decide the HTML structure based on type
|
|
66
|
+
if (type === 'spoiler') {
|
|
67
|
+
const summary = title || 'Spoiler';
|
|
68
|
+
return `<details class="callout-spoiler">\n<summary>${md.utils.escapeHtml(summary)}</summary>\n`;
|
|
69
|
+
} else { // 'callout'
|
|
70
|
+
const titleHtml = title ? `<p class="callout-title">${md.utils.escapeHtml(title)}</p>\n` : '';
|
|
71
|
+
return `<div class="callout callout-${cssClass}">\n${titleHtml}`;
|
|
72
|
+
}
|
|
53
73
|
}
|
|
54
|
-
|
|
74
|
+
// ending tag
|
|
75
|
+
return type === 'spoiler' ? '</details>\n' : '</div>\n';
|
|
55
76
|
},
|
|
56
77
|
});
|
|
57
78
|
}
|
|
58
79
|
}
|
|
59
|
-
// --- Spoiler (collapsible) container: :::spoiler ---
|
|
60
|
-
md.use(container, 'spoiler', {
|
|
61
|
-
render(tokens, idx) {
|
|
62
|
-
const token = tokens[idx];
|
|
63
|
-
if (token.nesting === 1) {
|
|
64
|
-
const titleMatch = token.info.trim().match(/^spoiler\s+(.*)$/);
|
|
65
|
-
const summary = titleMatch ? titleMatch[1].trim() : 'Spoiler';
|
|
66
|
-
return `<details class="callout-spoiler">\n<summary>${md.utils.escapeHtml(summary)}</summary>\n`;
|
|
67
|
-
}
|
|
68
|
-
return '</details>\n';
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
80
|
// Unique HTML comment used as TOC marker (survives markdown-it rendering)
|
|
72
81
|
const TOC_MARKER = '<!--TOC_PLACEHOLDER-->';
|
|
73
82
|
// Regex to match [TOC] on its own line in raw Markdown
|