marp-dev-preview 0.1.0 → 0.1.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/.babelrc +3 -0
- package/README.md +78 -43
- package/args.mjs +32 -0
- package/args.test.mjs +90 -0
- package/client.js +18 -0
- package/jest.config.mjs +11 -0
- package/marp-dev-preview.mjs +32 -147
- package/marp-dev-preview.test.mjs +24 -0
- package/marp-utils.mjs +33 -0
- package/marp-utils.test.mjs +29 -0
- package/package.json +10 -2
- package/server.mjs +99 -0
package/.babelrc
ADDED
package/README.md
CHANGED
|
@@ -1,52 +1,62 @@
|
|
|
1
1
|
# marp-dev-preview
|
|
2
2
|
|
|
3
|
-
A CLI tool
|
|
3
|
+
A lightweight CLI tool for previewing **Marp markdown slide decks** with live reloading, navigation, and development-focused features.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Unlike the `marp-cli -s` option—which is primarily meant for presenting—this tool is designed specifically for **authoring and iterating** on slides. It provides a live preview server similar to the Marp VS Code extension, but in a terminal-friendly, editor-agnostic way.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Originally built as a dependency for the [marp-dev-preview.nvim](https://github.com/boborbt/marp-dev-preview.nvim) NeoVim plugin, it can also be used **standalone** or as a dependency in other projects.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
* API to reload the slides using incremental updates.
|
|
11
|
-
* Automatic browser reload on file changes.
|
|
12
|
-
* Custom theme support.
|
|
13
|
-
* Keyboard navigation for slides.
|
|
14
|
-
* Also installs and uses the following markdown-it plugins (it's easy to add more if needed):
|
|
15
|
-
* `markdown-it-container`
|
|
16
|
-
* `markdown-it-mark`
|
|
17
|
-
* `markdown-it-footnote`
|
|
18
|
-
|
|
19
|
-
## Usage via npx
|
|
9
|
+
---
|
|
20
10
|
|
|
21
|
-
|
|
11
|
+
## ✨ Features
|
|
12
|
+
|
|
13
|
+
- **Live preview** of Marp markdown files, with position syncing
|
|
14
|
+
- **Incremental updates API** for fast reloads
|
|
15
|
+
- **Automatic browser reload** on file changes
|
|
16
|
+
- **Custom theme support** (CSS-based themes)
|
|
17
|
+
- **Keyboard navigation** for slides
|
|
18
|
+
- Includes several **markdown-it plugins** (easy to extend):
|
|
19
|
+
- `markdown-it-container`
|
|
20
|
+
- `markdown-it-mark`
|
|
21
|
+
- `markdown-it-footnote`
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 🚦 Quick Start (via `npx`)
|
|
26
|
+
|
|
27
|
+
The simplest way to run the previewer:
|
|
22
28
|
|
|
23
29
|
```bash
|
|
24
|
-
npx marp-dev-preview --theme-dir <dir
|
|
30
|
+
npx marp-dev-preview --theme-dir <themes-dir> <presentation.md>
|
|
25
31
|
```
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
---
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
## 📦 Installation
|
|
36
|
+
|
|
37
|
+
Global install (recommended for CLI use):
|
|
30
38
|
|
|
31
39
|
```bash
|
|
32
40
|
npm install -g marp-dev-preview
|
|
33
41
|
```
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
Or as a local project dependency:
|
|
36
44
|
|
|
37
45
|
```bash
|
|
38
46
|
npm install marp-dev-preview
|
|
39
47
|
```
|
|
40
48
|
|
|
41
|
-
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## ▶️ Usage
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
Start the preview server with:
|
|
44
54
|
|
|
45
55
|
```bash
|
|
46
|
-
mdp <path-to-
|
|
56
|
+
mdp <path-to-markdown-file> [options]
|
|
47
57
|
```
|
|
48
58
|
|
|
49
|
-
**Example:**
|
|
59
|
+
**Example:**
|
|
50
60
|
|
|
51
61
|
```bash
|
|
52
62
|
mdp my-slides/presentation.md --port 3000 --theme-dir my-themes
|
|
@@ -54,34 +64,59 @@ mdp my-slides/presentation.md --port 3000 --theme-dir my-themes
|
|
|
54
64
|
|
|
55
65
|
### Options
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
- `-t, --theme-dir <path>` — Path to custom Marp themes (CSS files)
|
|
68
|
+
- `-p, --port <number>` — Port for the preview server (default: `8080`)
|
|
59
69
|
|
|
60
|
-
|
|
70
|
+
---
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
## ⌨️ Keyboard Shortcuts
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
* <kbd>G</kbd>: Go to the last slide.
|
|
66
|
-
* <kbd>:<number></kbd>: Go to the specified slide number.
|
|
67
|
-
* <kbd>?</kbd>: Toggle the help box displaying key bindings.
|
|
74
|
+
In addition to normal browser navigation keys (`Page Up`, `Page Down`, `Home`, `End`), the following bindings are available:
|
|
68
75
|
|
|
69
|
-
|
|
76
|
+
- **Ctrl+f** — Forward one page
|
|
77
|
+
- **Ctrl+b** — Backward one page
|
|
78
|
+
- **Ctrl+d** — Forward half a page
|
|
79
|
+
- **Ctrl+u** — Backward half a page
|
|
80
|
+
- **gg** — First slide
|
|
81
|
+
- **G** — Last slide
|
|
82
|
+
- **:number** — Jump to slide `{number}`
|
|
83
|
+
- **?** — Toggle help overlay
|
|
70
84
|
|
|
71
|
-
|
|
85
|
+
---
|
|
72
86
|
|
|
73
|
-
|
|
74
|
-
curl -X POST -H "Content-Type: application/json" -d '{"command": "find", "string": "my awesome text"}' http://localhost:8080/api/command
|
|
75
|
-
```
|
|
76
|
-
would scroll to the first slide containing "my awesome text".
|
|
87
|
+
## 🔗 Integration with Other Tools
|
|
77
88
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
The preview server exposes a simple **HTTP API** for controlling slides.
|
|
90
|
+
|
|
91
|
+
- Reload the document (the server will parse the received markdown content and incrementally update the presentation).
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
curl -X POST \
|
|
95
|
+
-H "Content-Type: text/markdown" \
|
|
96
|
+
-d "$(cat file-with-updated-content.md)" \
|
|
97
|
+
http://localhost:8080/api/reload
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- Go to the first slide containing `"my awesome text"`:
|
|
101
|
+
```bash
|
|
102
|
+
curl -X POST \
|
|
103
|
+
-H "Content-Type: application/json" \
|
|
104
|
+
-d '{"command": "find", "string": "my awesome text"}' \
|
|
105
|
+
http://localhost:8080/api/command
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- Jump directly to slide 3:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
curl -X POST
|
|
112
|
+
-H "Content-Type: application/json" \
|
|
113
|
+
-d '{"command": "goto", "slide": 3}' \
|
|
114
|
+
http://localhost:8080/api/command
|
|
115
|
+
```
|
|
81
116
|
|
|
82
|
-
would go to slide 3.
|
|
83
117
|
|
|
118
|
+
---
|
|
84
119
|
|
|
85
|
-
## License
|
|
120
|
+
## 📄 License
|
|
86
121
|
|
|
87
|
-
|
|
122
|
+
Licensed under the [MIT License](./LICENSE).
|
package/args.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import yargs from 'yargs';
|
|
2
|
+
import { hideBin } from 'yargs/helpers';
|
|
3
|
+
|
|
4
|
+
export function parseArgs() {
|
|
5
|
+
return yargs(hideBin(process.argv))
|
|
6
|
+
.usage('Usage: $0 <markdown-file> [options]')
|
|
7
|
+
.positional('markdown-file', {
|
|
8
|
+
describe: 'Path to the markdown file to preview',
|
|
9
|
+
type: 'string'
|
|
10
|
+
})
|
|
11
|
+
.option('theme-dir', {
|
|
12
|
+
alias: 't',
|
|
13
|
+
describe: 'Directory for custom themes',
|
|
14
|
+
type: 'string'
|
|
15
|
+
})
|
|
16
|
+
.option('port', {
|
|
17
|
+
alias: 'p',
|
|
18
|
+
describe: 'Port to listen on',
|
|
19
|
+
type: 'number',
|
|
20
|
+
default: 8080
|
|
21
|
+
})
|
|
22
|
+
.option('verbose', {
|
|
23
|
+
alias: 'v',
|
|
24
|
+
describe: 'Enable verbose logging',
|
|
25
|
+
type: 'boolean',
|
|
26
|
+
default: false
|
|
27
|
+
})
|
|
28
|
+
.config('config', 'Path to a JSON config file')
|
|
29
|
+
.default('config', '.mp-config.json')
|
|
30
|
+
.demandCommand(1, 'You must provide a markdown file.')
|
|
31
|
+
.argv;
|
|
32
|
+
}
|
package/args.test.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { parseArgs } from './args.mjs';
|
|
2
|
+
|
|
3
|
+
const mockArgv = {};
|
|
4
|
+
const defaultValues = {};
|
|
5
|
+
|
|
6
|
+
jest.mock('yargs', () => {
|
|
7
|
+
const yargsMock = jest.fn(() => yargsMock);
|
|
8
|
+
yargsMock.usage = jest.fn().mockReturnThis();
|
|
9
|
+
yargsMock.positional = jest.fn().mockReturnThis();
|
|
10
|
+
yargsMock.option = jest.fn().mockImplementation((name, options) => {
|
|
11
|
+
if (options.default !== undefined) {
|
|
12
|
+
defaultValues[name] = options.default;
|
|
13
|
+
}
|
|
14
|
+
return yargsMock;
|
|
15
|
+
});
|
|
16
|
+
yargsMock.config = jest.fn().mockReturnThis();
|
|
17
|
+
yargsMock.default = jest.fn().mockImplementation((name, value) => {
|
|
18
|
+
defaultValues[name] = value;
|
|
19
|
+
return yargsMock;
|
|
20
|
+
});
|
|
21
|
+
yargsMock.demandCommand = jest.fn().mockReturnThis();
|
|
22
|
+
Object.defineProperty(yargsMock, 'argv', {
|
|
23
|
+
get: () => ({
|
|
24
|
+
...defaultValues,
|
|
25
|
+
...mockArgv,
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
return yargsMock;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
jest.mock('yargs/helpers', () => ({
|
|
32
|
+
hideBin: jest.fn(() => ['node', 'marp-dev-preview.mjs']),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe('Args', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
// Reset mockArgv before each test
|
|
38
|
+
for (const key in mockArgv) {
|
|
39
|
+
delete mockArgv[key];
|
|
40
|
+
}
|
|
41
|
+
// Reset hideBin mock
|
|
42
|
+
require('yargs/helpers').hideBin.mockReturnValue(['node', 'marp-dev-preview.mjs']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return default port if not specified', () => {
|
|
46
|
+
mockArgv._ = ['test.md'];
|
|
47
|
+
const argv = parseArgs();
|
|
48
|
+
expect(argv.port).toBe(8080);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return specified port', () => {
|
|
52
|
+
mockArgv._ = ['test.md'];
|
|
53
|
+
mockArgv.port = 3000;
|
|
54
|
+
const argv = parseArgs();
|
|
55
|
+
expect(argv.port).toBe(3000);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return default verbose as false', () => {
|
|
59
|
+
mockArgv._ = ['test.md'];
|
|
60
|
+
const argv = parseArgs();
|
|
61
|
+
expect(argv.verbose).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return verbose as true if specified', () => {
|
|
65
|
+
mockArgv._ = ['test.md'];
|
|
66
|
+
mockArgv.verbose = true;
|
|
67
|
+
const argv = parseArgs();
|
|
68
|
+
expect(argv.verbose).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should capture markdown file positional argument', () => {
|
|
72
|
+
mockArgv._ = ['my-presentation.md'];
|
|
73
|
+
const argv = parseArgs();
|
|
74
|
+
expect(argv._[0]).toBe('my-presentation.md');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return theme-dir if specified', () => {
|
|
78
|
+
mockArgv._ = ['test.md'];
|
|
79
|
+
mockArgv.themeDir = '/path/to/themes';
|
|
80
|
+
const argv = parseArgs();
|
|
81
|
+
expect(argv.themeDir).toBe('/path/to/themes');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle config file path', () => {
|
|
85
|
+
mockArgv._ = ['test.md'];
|
|
86
|
+
mockArgv.config = './my-config.json';
|
|
87
|
+
const argv = parseArgs();
|
|
88
|
+
expect(argv.config).toBe('./my-config.json');
|
|
89
|
+
});
|
|
90
|
+
});
|
package/client.js
CHANGED
|
@@ -133,6 +133,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
133
133
|
command = '';
|
|
134
134
|
lastKey = '';
|
|
135
135
|
updatePrompt(':' + command);
|
|
136
|
+
} else if (e.key === 'j') {
|
|
137
|
+
window.scrollBy({ top: window.innerHeight * 0.1, behavior: 'smooth' });
|
|
138
|
+
lastKey = '';
|
|
139
|
+
} else if (e.key === 'k') {
|
|
140
|
+
window.scrollBy({ top: -window.innerHeight * 0.1, behavior: 'smooth' });
|
|
141
|
+
lastKey = '';
|
|
142
|
+
} else if (e.key === 'u' && e.ctrlKey) {
|
|
143
|
+
window.scrollBy({ top: -window.innerHeight * 0.5, behavior: 'smooth' });
|
|
144
|
+
lastKey = '';
|
|
145
|
+
} else if (e.key === 'd' && e.ctrlKey) {
|
|
146
|
+
window.scrollBy({ top: window.innerHeight * 0.5, behavior: 'smooth' });
|
|
147
|
+
lastKey = '';
|
|
148
|
+
} else if (e.key === 'f' && e.ctrlKey) {
|
|
149
|
+
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
|
|
150
|
+
lastKey = '';
|
|
151
|
+
} else if (e.key === 'b' && e.ctrlKey) {
|
|
152
|
+
window.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'smooth' });
|
|
153
|
+
lastKey = '';
|
|
136
154
|
} else if (e.key === '?') {
|
|
137
155
|
helpBox.style.display = helpBox.style.display === 'none' ? 'block' : 'none';
|
|
138
156
|
lastKey = ''; // Reset lastKey to prevent unintended 'gg'
|
package/jest.config.mjs
ADDED
package/marp-dev-preview.mjs
CHANGED
|
@@ -1,46 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Marp } from '@marp-team/marp-core';
|
|
3
2
|
import { promises as fs } from 'fs';
|
|
4
|
-
import http from 'http';
|
|
5
3
|
import path from 'path';
|
|
6
4
|
import chokidar from 'chokidar';
|
|
7
5
|
import { WebSocketServer } from 'ws';
|
|
8
|
-
import yargs from 'yargs';
|
|
9
|
-
import { hideBin } from 'yargs/helpers';
|
|
10
|
-
import markdownItFootnote from 'markdown-it-footnote';
|
|
11
|
-
import markdownItMark from 'markdown-it-mark';
|
|
12
|
-
import markdownItContainer from 'markdown-it-container';
|
|
13
|
-
import morphdom from 'morphdom';
|
|
14
6
|
import { fileURLToPath } from 'url';
|
|
15
7
|
|
|
8
|
+
/* Sub-modules */
|
|
9
|
+
import { createServer } from './server.mjs';
|
|
10
|
+
import { initializeMarp, renderMarp as renderMarpInternal } from './marp-utils.mjs';
|
|
11
|
+
import { parseArgs } from './args.mjs';
|
|
12
|
+
|
|
16
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
14
|
const __dirname = path.dirname(__filename);
|
|
18
15
|
|
|
19
|
-
const argv =
|
|
20
|
-
.usage('Usage: $0 <markdown-file> [options]')
|
|
21
|
-
.positional('markdown-file', {
|
|
22
|
-
describe: 'Path to the markdown file to preview',
|
|
23
|
-
type: 'string'
|
|
24
|
-
})
|
|
25
|
-
.option('theme-dir', {
|
|
26
|
-
alias: 't',
|
|
27
|
-
describe: 'Directory for custom themes',
|
|
28
|
-
type: 'string'
|
|
29
|
-
})
|
|
30
|
-
.option('port', {
|
|
31
|
-
alias: 'p',
|
|
32
|
-
describe: 'Port to listen on',
|
|
33
|
-
type: 'number',
|
|
34
|
-
default: 8080
|
|
35
|
-
})
|
|
36
|
-
.config('config', 'Path to a JSON config file')
|
|
37
|
-
.default('config', '.mp-config.json')
|
|
38
|
-
.demandCommand(1, 'You must provide a markdown file.')
|
|
39
|
-
.argv;
|
|
16
|
+
const argv = parseArgs();
|
|
40
17
|
|
|
41
18
|
const markdownFile = argv._[0]
|
|
42
19
|
const themeDir = argv.themeDir;
|
|
43
20
|
const port = argv.port;
|
|
21
|
+
const verbose = argv.verbose;
|
|
22
|
+
|
|
23
|
+
if (argv.version) {
|
|
24
|
+
const pkg = JSON.parse(await fs.readFile(path.join(__dirname, 'package.json'), 'utf8'));
|
|
25
|
+
console.log(`marp-dev-preview version ${pkg.version}`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (verbose) {
|
|
30
|
+
console.debug = console.log;
|
|
31
|
+
} else {
|
|
32
|
+
console.debug = () => { };
|
|
33
|
+
}
|
|
44
34
|
|
|
45
35
|
if (!markdownFile) {
|
|
46
36
|
console.error('Error: You must provide a path to a markdown file.');
|
|
@@ -49,50 +39,11 @@ if (!markdownFile) {
|
|
|
49
39
|
|
|
50
40
|
const markdownDir = path.dirname(markdownFile);
|
|
51
41
|
|
|
52
|
-
const mimeTypes = {
|
|
53
|
-
'.html': 'text/html',
|
|
54
|
-
'.js': 'text/javascript',
|
|
55
|
-
'.css': 'text/css',
|
|
56
|
-
'.json': 'application/json',
|
|
57
|
-
'.png': 'image/png',
|
|
58
|
-
'.jpg': 'image/jpeg',
|
|
59
|
-
'.gif': 'image/gif',
|
|
60
|
-
'.svg': 'image/svg+xml',
|
|
61
|
-
'.wav': 'audio/wav',
|
|
62
|
-
'.mp4': 'video/mp4',
|
|
63
|
-
'.woff': 'application/font-woff',
|
|
64
|
-
'.ttf': 'application/font-ttf',
|
|
65
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
66
|
-
'.otf': 'application/font-otf',
|
|
67
|
-
'.wasm': 'application/wasm'
|
|
68
|
-
};
|
|
69
|
-
|
|
70
42
|
const wss = new WebSocketServer({ port: port + 1 });
|
|
71
43
|
|
|
72
|
-
let marp;
|
|
73
|
-
|
|
74
|
-
async function initializeMarp() {
|
|
75
|
-
const options = { html: true, linkify: true, };
|
|
76
|
-
marp = new Marp(options)
|
|
77
|
-
.use(markdownItFootnote)
|
|
78
|
-
.use(markdownItMark)
|
|
79
|
-
.use(markdownItContainer, 'note');
|
|
80
|
-
|
|
81
|
-
if (themeDir) {
|
|
82
|
-
const themeFiles = await fs.readdir(themeDir);
|
|
83
|
-
for (const file of themeFiles) {
|
|
84
|
-
if (path.extname(file) === '.css') {
|
|
85
|
-
const css = await fs.readFile(path.join(themeDir, file), 'utf8');
|
|
86
|
-
marp.themeSet.add(css);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
44
|
async function renderMarp() {
|
|
94
45
|
const md = await fs.readFile(markdownFile, 'utf8');
|
|
95
|
-
const { html, css } =
|
|
46
|
+
const { html, css } = renderMarpInternal(md);
|
|
96
47
|
const customCss = `
|
|
97
48
|
svg[data-marpit-svg] {
|
|
98
49
|
margin-bottom:20px !important;
|
|
@@ -153,11 +104,16 @@ async function renderMarp() {
|
|
|
153
104
|
</div>
|
|
154
105
|
<div id="help-box">
|
|
155
106
|
<h3>Key Bindings</h3>
|
|
107
|
+
<p>In addition to standard key bindings (e.g., Space, Page Down, Arrow Down, Page Up, Arrow Up), the following key bindings are available:</p>
|
|
156
108
|
<table>
|
|
157
109
|
<tr><th>Key</th><th>Action</th></tr>
|
|
158
110
|
<tr><td><kbd>gg</kbd> or <kbd>Home</kbd></td><td>Go to first slide</td></tr>
|
|
159
111
|
<tr><td><kbd>G</kbd> or <kbd>End</kbd></td><td>Go to last slide</td></tr>
|
|
160
112
|
<tr><td><kbd>:<number></kbd></td><td>Go to the given slide number</td></tr>
|
|
113
|
+
<tr><td><kbd>^f</kbd></td><td>Forward one page</td></tr>
|
|
114
|
+
<tr><td><kbd>^b</kbd></td><td>Back one page</td></tr>
|
|
115
|
+
<tr><td><kbd>^d</kbd></td><td>Forward half a page</td></tr>
|
|
116
|
+
<tr><td><kbd>^u</kbd></td><td>Back half a page</td></tr>
|
|
161
117
|
<tr><td><kbd>?</kbd></td><td>Show/hide help</td></tr>
|
|
162
118
|
</table>
|
|
163
119
|
</div>
|
|
@@ -167,78 +123,9 @@ async function renderMarp() {
|
|
|
167
123
|
`;
|
|
168
124
|
}
|
|
169
125
|
|
|
170
|
-
const server = http.createServer(async (req, res) => {
|
|
171
|
-
try {
|
|
172
|
-
if (req.url === '/') {
|
|
173
|
-
const html = await renderMarp();
|
|
174
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
175
|
-
res.end(html);
|
|
176
|
-
} else if (req.url === '/client.js') {
|
|
177
|
-
const clientJs = await fs.readFile(path.join(__dirname, 'client.js'), 'utf8');
|
|
178
|
-
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
|
179
|
-
res.end(clientJs);
|
|
180
|
-
} else if (req.url === '/api/reload' && req.method === 'POST') {
|
|
181
|
-
let body = '';
|
|
182
|
-
req.on('data', chunk => {
|
|
183
|
-
body += chunk.toString();
|
|
184
|
-
});
|
|
185
|
-
req.on('end', async () => {
|
|
186
|
-
console.debug("Reload request received");
|
|
187
|
-
const success = await reload(body);
|
|
188
|
-
if (success) {
|
|
189
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
190
|
-
res.end(JSON.stringify({ status: 'ok' }));
|
|
191
|
-
} else {
|
|
192
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
193
|
-
res.end(JSON.stringify({ status: 'error', message: 'Failed to render markdown' }));
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
} else if (req.url === '/api/command' && req.method === 'POST') {
|
|
197
|
-
let body = '';
|
|
198
|
-
req.on('data', chunk => {
|
|
199
|
-
body += chunk.toString();
|
|
200
|
-
});
|
|
201
|
-
req.on('end', () => {
|
|
202
|
-
try {
|
|
203
|
-
const command = JSON.parse(body);
|
|
204
|
-
for (const ws of wss.clients) {
|
|
205
|
-
ws.send(JSON.stringify(command));
|
|
206
|
-
}
|
|
207
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
208
|
-
res.end(JSON.stringify({ status: 'ok', command }));
|
|
209
|
-
} catch (e) {
|
|
210
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
211
|
-
res.end(JSON.stringify({ status: 'error', message: 'Invalid JSON' }));
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
} else {
|
|
215
|
-
const assetPath = path.join(markdownDir, req.url);
|
|
216
|
-
const ext = path.extname(assetPath);
|
|
217
|
-
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
const content = await fs.readFile(assetPath);
|
|
221
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
222
|
-
res.end(content);
|
|
223
|
-
} catch (error) {
|
|
224
|
-
if (error.code === 'ENOENT') {
|
|
225
|
-
res.writeHead(404);
|
|
226
|
-
res.end('Not Found');
|
|
227
|
-
} else {
|
|
228
|
-
throw error;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
} catch (error) {
|
|
233
|
-
console.error(error);
|
|
234
|
-
res.writeHead(500);
|
|
235
|
-
res.end('Internal Server Error');
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
|
|
239
126
|
async function reload(markdown) {
|
|
240
127
|
try {
|
|
241
|
-
const { html, css } =
|
|
128
|
+
const { html, css } = renderMarpInternal(markdown);
|
|
242
129
|
const message = JSON.stringify({
|
|
243
130
|
type: 'update',
|
|
244
131
|
html: html,
|
|
@@ -255,18 +142,16 @@ async function reload(markdown) {
|
|
|
255
142
|
}
|
|
256
143
|
|
|
257
144
|
chokidar.watch(markdownFile).on('change', async () => {
|
|
258
|
-
console.
|
|
145
|
+
console.debug(`File ${markdownFile} changed, updating...`);
|
|
259
146
|
const md = await fs.readFile(markdownFile, 'utf8');
|
|
260
147
|
await reload(md);
|
|
261
148
|
});
|
|
262
149
|
|
|
263
|
-
initializeMarp().then(() => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
});
|
|
150
|
+
initializeMarp(themeDir).then(() => {
|
|
151
|
+
createServer(port, markdownFile, markdownDir, renderMarp, reload, wss, __dirname);
|
|
152
|
+
if (themeDir) {
|
|
153
|
+
console.log(`Using custom themes from ${themeDir}`);
|
|
154
|
+
}
|
|
270
155
|
}).catch(error => {
|
|
271
156
|
console.error("Failed to initialize Marp:", error);
|
|
272
157
|
process.exit(1);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createServer } from './server.mjs';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
|
|
4
|
+
jest.mock('http', () => ({
|
|
5
|
+
createServer: jest.fn(() => ({
|
|
6
|
+
listen: jest.fn(),
|
|
7
|
+
})),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe('Server', () => {
|
|
11
|
+
it('should create a server', () => {
|
|
12
|
+
const port = 8080;
|
|
13
|
+
const markdownFile = 'test.md';
|
|
14
|
+
const markdownDir = '.';
|
|
15
|
+
const renderMarp = jest.fn();
|
|
16
|
+
const reload = jest.fn();
|
|
17
|
+
const wss = { clients: [] };
|
|
18
|
+
const __dirname = '.';
|
|
19
|
+
|
|
20
|
+
createServer(port, markdownFile, markdownDir, renderMarp, reload, wss, __dirname);
|
|
21
|
+
|
|
22
|
+
expect(http.createServer).toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
});
|
package/marp-utils.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
|
|
2
|
+
import { Marp } from '@marp-team/marp-core';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import markdownItFootnote from 'markdown-it-footnote';
|
|
6
|
+
import markdownItMark from 'markdown-it-mark';
|
|
7
|
+
import markdownItContainer from 'markdown-it-container';
|
|
8
|
+
|
|
9
|
+
let marp;
|
|
10
|
+
|
|
11
|
+
export async function initializeMarp(themeDir) {
|
|
12
|
+
const options = { html: true, linkify: true, };
|
|
13
|
+
marp = new Marp(options)
|
|
14
|
+
.use(markdownItFootnote)
|
|
15
|
+
.use(markdownItMark)
|
|
16
|
+
.use(markdownItContainer, 'note');
|
|
17
|
+
|
|
18
|
+
if (themeDir) {
|
|
19
|
+
const themeFiles = await fs.readdir(themeDir);
|
|
20
|
+
for (const file of themeFiles) {
|
|
21
|
+
if (path.extname(file) === '.css') {
|
|
22
|
+
const css = await fs.readFile(path.join(themeDir, file), 'utf8');
|
|
23
|
+
marp.themeSet.add(css);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return marp;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderMarp(markdown) {
|
|
31
|
+
const { html, css } = marp.render(markdown);
|
|
32
|
+
return { html, css };
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { initializeMarp, renderMarp } from './marp-utils.mjs';
|
|
2
|
+
import { Marp } from '@marp-team/marp-core';
|
|
3
|
+
|
|
4
|
+
jest.mock('@marp-team/marp-core', () => ({
|
|
5
|
+
Marp: jest.fn(() => ({
|
|
6
|
+
use: jest.fn().mockReturnThis(),
|
|
7
|
+
render: jest.fn(() => ({ html: '', css: '' })),
|
|
8
|
+
themeSet: {
|
|
9
|
+
add: jest.fn(),
|
|
10
|
+
},
|
|
11
|
+
})),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('Marp Utils', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should initialize Marp', async () => {
|
|
20
|
+
await initializeMarp();
|
|
21
|
+
expect(Marp).toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should render markdown', async () => {
|
|
25
|
+
const marp = await initializeMarp();
|
|
26
|
+
renderMarp('# Hello');
|
|
27
|
+
expect(marp.render).toHaveBeenCalledWith('# Hello');
|
|
28
|
+
});
|
|
29
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "marp-dev-preview",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "A CLI tool to preview Marp markdown files.",
|
|
5
5
|
"main": "marp-dev-preview.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"mdp": "marp-dev-preview.mjs"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"start": "node marp-dev-preview.mjs"
|
|
11
|
+
"start": "node marp-dev-preview.mjs",
|
|
12
|
+
"test": "jest"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"marp",
|
|
@@ -32,5 +33,12 @@
|
|
|
32
33
|
"morphdom": "^2.7.7",
|
|
33
34
|
"ws": "^8.18.3",
|
|
34
35
|
"yargs": "^18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@babel/preset-env": "^7.28.3",
|
|
39
|
+
"babel-jest": "^30.1.2",
|
|
40
|
+
"jest": "^30.1.3",
|
|
41
|
+
"jest-esm-transformer": "^1.0.0",
|
|
42
|
+
"ts-jest": "^29.4.1"
|
|
35
43
|
}
|
|
36
44
|
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const mimeTypes = {
|
|
7
|
+
'.html': 'text/html',
|
|
8
|
+
'.js': 'text/javascript',
|
|
9
|
+
'.css': 'text/css',
|
|
10
|
+
'.json': 'application/json',
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.gif': 'image/gif',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.wav': 'audio/wav',
|
|
16
|
+
'.mp4': 'video/mp4',
|
|
17
|
+
'.woff': 'application/font-woff',
|
|
18
|
+
'.ttf': 'application/font-ttf',
|
|
19
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
20
|
+
'.otf': 'application/font-otf',
|
|
21
|
+
'.wasm': 'application/wasm'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createServer(port, markdownFile, markdownDir, renderMarp, reload, wss, __dirname) {
|
|
25
|
+
const server = http.createServer(async (req, res) => {
|
|
26
|
+
try {
|
|
27
|
+
if (req.url === '/') {
|
|
28
|
+
const html = await renderMarp();
|
|
29
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
30
|
+
res.end(html);
|
|
31
|
+
} else if (req.url === '/client.js') {
|
|
32
|
+
const clientJs = await fs.readFile(path.join(__dirname, 'client.js'), 'utf8');
|
|
33
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
|
34
|
+
res.end(clientJs);
|
|
35
|
+
} else if (req.url === '/api/reload' && req.method === 'POST') {
|
|
36
|
+
let body = '';
|
|
37
|
+
req.on('data', chunk => {
|
|
38
|
+
body += chunk.toString();
|
|
39
|
+
});
|
|
40
|
+
req.on('end', async () => {
|
|
41
|
+
console.debug("Reload request received");
|
|
42
|
+
const success = await reload(body);
|
|
43
|
+
if (success) {
|
|
44
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
45
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
46
|
+
} else {
|
|
47
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
48
|
+
res.end(JSON.stringify({ status: 'error', message: 'Failed to render markdown' }));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
} else if (req.url === '/api/command' && req.method === 'POST') {
|
|
52
|
+
let body = '';
|
|
53
|
+
req.on('data', chunk => {
|
|
54
|
+
body += chunk.toString();
|
|
55
|
+
});
|
|
56
|
+
req.on('end', () => {
|
|
57
|
+
try {
|
|
58
|
+
const command = JSON.parse(body);
|
|
59
|
+
for (const ws of wss.clients) {
|
|
60
|
+
ws.send(JSON.stringify(command));
|
|
61
|
+
}
|
|
62
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
63
|
+
res.end(JSON.stringify({ status: 'ok', command }));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
66
|
+
res.end(JSON.stringify({ status: 'error', message: 'Invalid JSON' }));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
const assetPath = path.join(markdownDir, req.url);
|
|
71
|
+
const ext = path.extname(assetPath);
|
|
72
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const content = await fs.readFile(assetPath);
|
|
76
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
77
|
+
res.end(content);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code === 'ENOENT') {
|
|
80
|
+
res.writeHead(404);
|
|
81
|
+
res.end('Not Found');
|
|
82
|
+
} else {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error(error);
|
|
89
|
+
res.writeHead(500);
|
|
90
|
+
res.end('Internal Server Error');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
server.listen(port, () => {
|
|
95
|
+
console.log(`Server listening on http://localhost:${port} for ${markdownFile}`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return server;
|
|
99
|
+
}
|