marp-dev-preview 0.1.4 → 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 +21 -177
- 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,79 +1,28 @@
|
|
|
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
6
|
import { fileURLToPath } from 'url';
|
|
14
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';
|
|
15
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
|
-
.option('verbose', {
|
|
37
|
-
alias: 'v',
|
|
38
|
-
describe: 'Enable verbose logging',
|
|
39
|
-
type: 'boolean',
|
|
40
|
-
default: false
|
|
41
|
-
})
|
|
42
|
-
.config('config', 'Path to a JSON config file')
|
|
43
|
-
.default('config', '.mp-config.json')
|
|
44
|
-
.demandCommand(1, 'You must provide a markdown file.')
|
|
45
|
-
.argv;
|
|
16
|
+
const argv = parseArgs();
|
|
46
17
|
|
|
47
18
|
const markdownFile = argv._[0]
|
|
48
19
|
const themeDir = argv.themeDir;
|
|
49
20
|
const port = argv.port;
|
|
50
21
|
const verbose = argv.verbose;
|
|
51
22
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const pkgPath = path.join(dir, 'package.json');
|
|
56
|
-
try {
|
|
57
|
-
await fs.access(pkgPath);
|
|
58
|
-
return pkgPath;
|
|
59
|
-
} catch {
|
|
60
|
-
dir = path.dirname(dir);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Version reporting block (replace your current version block)
|
|
67
|
-
if (argv.version || argv.v) {
|
|
68
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
69
|
-
const __dirname = dirname(__filename);
|
|
70
|
-
const pkgPath = await findPackageJson(__dirname);
|
|
71
|
-
if (pkgPath) {
|
|
72
|
-
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
|
|
73
|
-
console.log(`marp-dev-preview version ${pkg.version}`);
|
|
74
|
-
} else {
|
|
75
|
-
console.error('Could not find package.json for version info.');
|
|
76
|
-
}
|
|
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}`);
|
|
77
26
|
process.exit(0);
|
|
78
27
|
}
|
|
79
28
|
|
|
@@ -90,50 +39,11 @@ if (!markdownFile) {
|
|
|
90
39
|
|
|
91
40
|
const markdownDir = path.dirname(markdownFile);
|
|
92
41
|
|
|
93
|
-
const mimeTypes = {
|
|
94
|
-
'.html': 'text/html',
|
|
95
|
-
'.js': 'text/javascript',
|
|
96
|
-
'.css': 'text/css',
|
|
97
|
-
'.json': 'application/json',
|
|
98
|
-
'.png': 'image/png',
|
|
99
|
-
'.jpg': 'image/jpeg',
|
|
100
|
-
'.gif': 'image/gif',
|
|
101
|
-
'.svg': 'image/svg+xml',
|
|
102
|
-
'.wav': 'audio/wav',
|
|
103
|
-
'.mp4': 'video/mp4',
|
|
104
|
-
'.woff': 'application/font-woff',
|
|
105
|
-
'.ttf': 'application/font-ttf',
|
|
106
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
107
|
-
'.otf': 'application/font-otf',
|
|
108
|
-
'.wasm': 'application/wasm'
|
|
109
|
-
};
|
|
110
|
-
|
|
111
42
|
const wss = new WebSocketServer({ port: port + 1 });
|
|
112
43
|
|
|
113
|
-
let marp;
|
|
114
|
-
|
|
115
|
-
async function initializeMarp() {
|
|
116
|
-
const options = { html: true, linkify: true, };
|
|
117
|
-
marp = new Marp(options)
|
|
118
|
-
.use(markdownItFootnote)
|
|
119
|
-
.use(markdownItMark)
|
|
120
|
-
.use(markdownItContainer, 'note');
|
|
121
|
-
|
|
122
|
-
if (themeDir) {
|
|
123
|
-
const themeFiles = await fs.readdir(themeDir);
|
|
124
|
-
for (const file of themeFiles) {
|
|
125
|
-
if (path.extname(file) === '.css') {
|
|
126
|
-
const css = await fs.readFile(path.join(themeDir, file), 'utf8');
|
|
127
|
-
marp.themeSet.add(css);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
44
|
async function renderMarp() {
|
|
135
45
|
const md = await fs.readFile(markdownFile, 'utf8');
|
|
136
|
-
const { html, css } =
|
|
46
|
+
const { html, css } = renderMarpInternal(md);
|
|
137
47
|
const customCss = `
|
|
138
48
|
svg[data-marpit-svg] {
|
|
139
49
|
margin-bottom:20px !important;
|
|
@@ -194,11 +104,16 @@ async function renderMarp() {
|
|
|
194
104
|
</div>
|
|
195
105
|
<div id="help-box">
|
|
196
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>
|
|
197
108
|
<table>
|
|
198
109
|
<tr><th>Key</th><th>Action</th></tr>
|
|
199
110
|
<tr><td><kbd>gg</kbd> or <kbd>Home</kbd></td><td>Go to first slide</td></tr>
|
|
200
111
|
<tr><td><kbd>G</kbd> or <kbd>End</kbd></td><td>Go to last slide</td></tr>
|
|
201
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>
|
|
202
117
|
<tr><td><kbd>?</kbd></td><td>Show/hide help</td></tr>
|
|
203
118
|
</table>
|
|
204
119
|
</div>
|
|
@@ -208,78 +123,9 @@ async function renderMarp() {
|
|
|
208
123
|
`;
|
|
209
124
|
}
|
|
210
125
|
|
|
211
|
-
const server = http.createServer(async (req, res) => {
|
|
212
|
-
try {
|
|
213
|
-
if (req.url === '/') {
|
|
214
|
-
const html = await renderMarp();
|
|
215
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
216
|
-
res.end(html);
|
|
217
|
-
} else if (req.url === '/client.js') {
|
|
218
|
-
const clientJs = await fs.readFile(path.join(__dirname, 'client.js'), 'utf8');
|
|
219
|
-
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
|
220
|
-
res.end(clientJs);
|
|
221
|
-
} else if (req.url === '/api/reload' && req.method === 'POST') {
|
|
222
|
-
let body = '';
|
|
223
|
-
req.on('data', chunk => {
|
|
224
|
-
body += chunk.toString();
|
|
225
|
-
});
|
|
226
|
-
req.on('end', async () => {
|
|
227
|
-
console.debug("Reload request received");
|
|
228
|
-
const success = await reload(body);
|
|
229
|
-
if (success) {
|
|
230
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
231
|
-
res.end(JSON.stringify({ status: 'ok' }));
|
|
232
|
-
} else {
|
|
233
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
234
|
-
res.end(JSON.stringify({ status: 'error', message: 'Failed to render markdown' }));
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
} else if (req.url === '/api/command' && req.method === 'POST') {
|
|
238
|
-
let body = '';
|
|
239
|
-
req.on('data', chunk => {
|
|
240
|
-
body += chunk.toString();
|
|
241
|
-
});
|
|
242
|
-
req.on('end', () => {
|
|
243
|
-
try {
|
|
244
|
-
const command = JSON.parse(body);
|
|
245
|
-
for (const ws of wss.clients) {
|
|
246
|
-
ws.send(JSON.stringify(command));
|
|
247
|
-
}
|
|
248
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
249
|
-
res.end(JSON.stringify({ status: 'ok', command }));
|
|
250
|
-
} catch (e) {
|
|
251
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
252
|
-
res.end(JSON.stringify({ status: 'error', message: 'Invalid JSON' }));
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
} else {
|
|
256
|
-
const assetPath = path.join(markdownDir, req.url);
|
|
257
|
-
const ext = path.extname(assetPath);
|
|
258
|
-
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
const content = await fs.readFile(assetPath);
|
|
262
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
263
|
-
res.end(content);
|
|
264
|
-
} catch (error) {
|
|
265
|
-
if (error.code === 'ENOENT') {
|
|
266
|
-
res.writeHead(404);
|
|
267
|
-
res.end('Not Found');
|
|
268
|
-
} else {
|
|
269
|
-
throw error;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
} catch (error) {
|
|
274
|
-
console.error(error);
|
|
275
|
-
res.writeHead(500);
|
|
276
|
-
res.end('Internal Server Error');
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
|
|
280
126
|
async function reload(markdown) {
|
|
281
127
|
try {
|
|
282
|
-
const { html, css } =
|
|
128
|
+
const { html, css } = renderMarpInternal(markdown);
|
|
283
129
|
const message = JSON.stringify({
|
|
284
130
|
type: 'update',
|
|
285
131
|
html: html,
|
|
@@ -296,18 +142,16 @@ async function reload(markdown) {
|
|
|
296
142
|
}
|
|
297
143
|
|
|
298
144
|
chokidar.watch(markdownFile).on('change', async () => {
|
|
299
|
-
console.
|
|
145
|
+
console.debug(`File ${markdownFile} changed, updating...`);
|
|
300
146
|
const md = await fs.readFile(markdownFile, 'utf8');
|
|
301
147
|
await reload(md);
|
|
302
148
|
});
|
|
303
149
|
|
|
304
|
-
initializeMarp().then(() => {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
});
|
|
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
|
+
}
|
|
311
155
|
}).catch(error => {
|
|
312
156
|
console.error("Failed to initialize Marp:", error);
|
|
313
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
|
+
}
|