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 ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "presets": ["@babel/preset-env"]
3
+ }
package/README.md CHANGED
@@ -1,52 +1,62 @@
1
1
  # marp-dev-preview
2
2
 
3
- A CLI tool to preview Marp markdown files with live reloading and navigation features.
3
+ A lightweight CLI tool for previewing **Marp markdown slide decks** with live reloading, navigation, and development-focused features.
4
4
 
5
- The tool is mainly intended for slide deck authors who want to preview their slides in a web browser during development. In fact, while marp-cli provides a `-s` option to serve the slides, it is mainly intended for presenting the slides, not for development. This tool fills that gap by providing a live preview server presenting the slides similarly to how they are presented in the marp vs-code extension.
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
- ## Features
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
- * Live preview of Marp markdown files, with position syncing.
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
- The simplest way to start the previewer is via npx:
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 containing your themes> <your presentation>.md
30
+ npx marp-dev-preview --theme-dir <themes-dir> <presentation.md>
25
31
  ```
26
32
 
27
- ## Installation
33
+ ---
28
34
 
29
- To install the package globally (recommended for CLI tools):
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
- Alternatively, you can install it as a local dependency in your project:
43
+ Or as a local project dependency:
36
44
 
37
45
  ```bash
38
46
  npm install marp-dev-preview
39
47
  ```
40
48
 
41
- ## Usage
49
+ ---
50
+
51
+ ## ▶️ Usage
42
52
 
43
- To start the preview server, run the `mdp` command followed by your markdown file path:
53
+ Start the preview server with:
44
54
 
45
55
  ```bash
46
- mdp <path-to-your-markdown-file> [options]
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
- * `-t, --theme-dir <path>`: Specify a directory for custom Marp themes (e.g., CSS files).
58
- * `-p, --port <number>`: Specify the port for the preview server to listen on (default: `8080`).
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
- ## Keyboard Shortcuts
70
+ ---
61
71
 
62
- While viewing the presentation in your browser, in addition to the usual browser controls (page-up, page-down, home, end, etc.), you can use the following key bindings:
72
+ ## ⌨️ Keyboard Shortcuts
63
73
 
64
- * <kbd>gg</kbd>: Go to the first slide.
65
- * <kbd>G</kbd>: Go to the last slide.
66
- * <kbd>:&lt;number&gt;</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
- ## Integration with other tools
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
- The tool can respond to http requests to change slides and to scroll to a slide containing a given text. Any http client can be used to send such requests, e.g.:
85
+ ---
72
86
 
73
- ```bash
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
- ```bash
79
- curl -X POST -H "Content-Type: application/json" -d '{"command": "goto", "slide": 3}' http://localhost:8080/api/command
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
- This project is licensed under the MIT License - see the `LICENSE` file for details.
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'
@@ -0,0 +1,11 @@
1
+ export default {
2
+ transform: {
3
+ '^.+\.m?js'
4
+ : 'babel-jest',
5
+ },
6
+ testEnvironment: 'node',
7
+ moduleFileExtensions: ['js', 'mjs'],
8
+ transformIgnorePatterns: [
9
+ '/node_modules/(?!yargs|yargs-parser)/',
10
+ ],
11
+ };
@@ -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 = yargs(hideBin(process.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
- async function findPackageJson(startDir) {
53
- let dir = startDir;
54
- while (dir !== path.parse(dir).root) {
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 } = marp.render(md);
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>:&lt;number&gt</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 } = marp.render(markdown);
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.log(`File ${markdownFile} changed, updating...`);
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
- server.listen(port, () => {
306
- console.log(`Server listening on http://localhost:${port} for ${markdownFile}`);
307
- if (themeDir) {
308
- console.log(`Using custom themes from ${themeDir}`);
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.4",
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
+ }