marp-dev-preview 0.1.4 → 0.1.6

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
+ ---
34
+
35
+ ## 📦 Installation
28
36
 
29
- To install the package globally (recommended for CLI tools):
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,87 @@ 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
+ ## 🔗 API Endpoints
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 and content.
90
+
91
+ ### `POST /api/reload`
92
+
93
+ Reloads the presentation with new markdown content. The server parses the received markdown, renders it into HTML, and broadcasts the changes to all connected clients. This is ideal for tools that need to push updates without requiring a full page refresh.
94
+
95
+ - **Request**:
96
+ - **Headers**: `Content-Type: text/markdown`
97
+ - **Body**: Raw markdown content.
98
+
99
+ - **Response**:
100
+ - `200 OK`: `{ "status": "ok" }` on success.
101
+ - `500 Internal Server Error`: `{ "status": "error", "message": "..." }` on failure.
102
+
103
+ - **Example**:
104
+ ```bash
105
+ curl -X POST \
106
+ -H "Content-Type: text/markdown" \
107
+ -d "$(cat path/to/your/slides.md)" \
108
+ http://localhost:8080/api/reload
109
+ ```
110
+
111
+ ### `POST /api/command`
112
+
113
+ Sends a command to the browser to control the presentation's navigation. This allows external tools to programmatically change the visible slide.
114
+
115
+ - **Request**:
116
+ - **Headers**: `Content-Type: application/json`
117
+ - **Body**: A JSON object describing the command.
118
+
119
+ - **Response**:
120
+ - `200 OK`: `{ "status": "ok", "command": { ... } }` on success.
121
+ - `400 Bad Request`: `{ "status": "error", "message": "Invalid JSON" }` if the body is malformed.
122
+
123
+ #### Supported Commands
124
+
125
+ 1. **`goto`**: Jumps to a specific slide number.
126
+ - **Payload**: `{ "command": "goto", "slide": <number> }`
127
+ - **Example**:
128
+ ```bash
129
+ curl -X POST \
130
+ -H "Content-Type: application/json" \
131
+ -d '{"command": "goto", "slide": 5}' \
132
+ http://localhost:8080/api/command
133
+ ```
134
+
135
+ 2. **`find`**: Searches for a string and jumps to the first slide containing it. The search is case-insensitive.
136
+ - **Payload**: `{ "command": "find", "string": "<search-term>" }`
137
+ - **Example**:
138
+ ```bash
139
+ curl -X POST \
140
+ -H "Content-Type: application/json" \
141
+ -d '{"command": "find", "string": "My Awesome Slide"}' \
142
+ http://localhost:8080/api/command
143
+ ```
81
144
 
82
- would go to slide 3.
83
145
 
146
+ ---
84
147
 
85
- ## License
148
+ ## 📄 License
86
149
 
87
- This project is licensed under the MIT License - see the `LICENSE` file for details.
150
+ Licensed under the [MIT License](./LICENSE).
@@ -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
+ };
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "marp-dev-preview",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A CLI tool to preview Marp markdown files.",
5
- "main": "marp-dev-preview.mjs",
5
+ "main": "src/marp-dev-preview.mjs",
6
6
  "type": "module",
7
7
  "bin": {
8
- "mdp": "marp-dev-preview.mjs"
8
+ "mdp": "src/marp-dev-preview.mjs"
9
9
  },
10
10
  "scripts": {
11
- "start": "node marp-dev-preview.mjs"
11
+ "start": "node src/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/src/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
+ }
@@ -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,158 @@
1
+ #!/usr/bin/env node
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import chokidar from 'chokidar';
5
+ import { WebSocketServer } from 'ws';
6
+ import { fileURLToPath } from 'url';
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
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ const argv = parseArgs();
17
+
18
+ const markdownFile = argv._[0]
19
+ const themeDir = argv.themeDir;
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
+ }
34
+
35
+ if (!markdownFile) {
36
+ console.error('Error: You must provide a path to a markdown file.');
37
+ process.exit(1);
38
+ }
39
+
40
+ const markdownDir = path.dirname(markdownFile);
41
+
42
+ const wss = new WebSocketServer({ port: port + 1 });
43
+
44
+ async function renderMarp() {
45
+ const md = await fs.readFile(markdownFile, 'utf8');
46
+ const { html, css } = renderMarpInternal(md);
47
+ const customCss = `
48
+ svg[data-marpit-svg] {
49
+ margin-bottom:20px !important;
50
+ border: 1px solid gray;
51
+ border-radius: 10px;
52
+ box-shadow: 2px 2px 6px rgba(0,0,0,0.3);
53
+ }
54
+ #help-box table td, #help-box table th {
55
+ padding: 0 15px 0 15px;
56
+ }
57
+ #help-box {
58
+ position: fixed;
59
+ top: 50%;
60
+ left: 50%;
61
+ transform: translate(-50%, -50%);
62
+ background-color: rgba(0, 0, 0, 0.8);
63
+ color: white;
64
+ padding: 20px;
65
+ border-radius: 10px;
66
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
67
+ z-index: 1001;
68
+ display: none; /* Initially hidden */
69
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
70
+ max-width: 400px;
71
+ text-align: left;
72
+ }
73
+ #command-prompt {
74
+ position: fixed;
75
+ bottom: 20px;
76
+ left: 50%;
77
+ transform: translateX(-50%);
78
+ background-color: #333;
79
+ color: white;
80
+ padding: 10px 20px;
81
+ border-radius: 8px;
82
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
83
+ font-size: 1.2em;
84
+ z-index: 1000;
85
+ display: none;
86
+ box-shadow: 0 4px 8px rgba(0,0,0,0.3);
87
+ }
88
+ `;
89
+
90
+ return `
91
+ <!DOCTYPE html>
92
+ <html>
93
+ <head>
94
+ <meta name="ws-port" content="${port + 1}">
95
+ <style id="marp-style">${css}</style>
96
+ <style id="custom-style">${customCss}</style>
97
+ <script src="https://unpkg.com/morphdom@2.7.0/dist/morphdom-umd.min.js"></script>
98
+ <script src="/client.js"></script>
99
+ <meta charset="UTF-8">
100
+ </head>
101
+ <body>
102
+ <div id="marp-container">
103
+ ${html}
104
+ </div>
105
+ <div id="help-box">
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>
108
+ <table>
109
+ <tr><th>Key</th><th>Action</th></tr>
110
+ <tr><td><kbd>gg</kbd> or <kbd>Home</kbd></td><td>Go to first slide</td></tr>
111
+ <tr><td><kbd>G</kbd> or <kbd>End</kbd></td><td>Go to last slide</td></tr>
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>
117
+ <tr><td><kbd>?</kbd></td><td>Show/hide help</td></tr>
118
+ </table>
119
+ </div>
120
+ <div id="command-prompt"></div>
121
+ </body>
122
+ </html>
123
+ `;
124
+ }
125
+
126
+ async function reload(markdown) {
127
+ try {
128
+ const { html, css } = renderMarpInternal(markdown);
129
+ const message = JSON.stringify({
130
+ type: 'update',
131
+ html: html,
132
+ css: css
133
+ });
134
+ for (const ws of wss.clients) {
135
+ ws.send(message);
136
+ }
137
+ return true;
138
+ } catch (error) {
139
+ console.error('Error rendering or sending update:', error);
140
+ return false;
141
+ }
142
+ }
143
+
144
+ chokidar.watch(markdownFile).on('change', async () => {
145
+ console.debug(`File ${markdownFile} changed, updating...`);
146
+ const md = await fs.readFile(markdownFile, 'utf8');
147
+ await reload(md);
148
+ });
149
+
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
+ }
155
+ }).catch(error => {
156
+ console.error("Failed to initialize Marp:", error);
157
+ process.exit(1);
158
+ });
@@ -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
+ }
package/src/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
+ }
@@ -0,0 +1,90 @@
1
+ import { parseArgs } from '../src/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
+ });
@@ -0,0 +1,24 @@
1
+ import { createServer } from '../src/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
+ });
@@ -0,0 +1,29 @@
1
+ import { initializeMarp, renderMarp } from '../src/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
+ });
@@ -1,314 +0,0 @@
1
- #!/usr/bin/env node
2
- import { Marp } from '@marp-team/marp-core';
3
- import { promises as fs } from 'fs';
4
- import http from 'http';
5
- import path from 'path';
6
- import chokidar from 'chokidar';
7
- 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 { fileURLToPath } from 'url';
14
-
15
-
16
- const __filename = fileURLToPath(import.meta.url);
17
- const __dirname = path.dirname(__filename);
18
-
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;
46
-
47
- const markdownFile = argv._[0]
48
- const themeDir = argv.themeDir;
49
- const port = argv.port;
50
- const verbose = argv.verbose;
51
-
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
- }
77
- process.exit(0);
78
- }
79
-
80
- if (verbose) {
81
- console.debug = console.log;
82
- } else {
83
- console.debug = () => { };
84
- }
85
-
86
- if (!markdownFile) {
87
- console.error('Error: You must provide a path to a markdown file.');
88
- process.exit(1);
89
- }
90
-
91
- const markdownDir = path.dirname(markdownFile);
92
-
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
- const wss = new WebSocketServer({ port: port + 1 });
112
-
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
- async function renderMarp() {
135
- const md = await fs.readFile(markdownFile, 'utf8');
136
- const { html, css } = marp.render(md);
137
- const customCss = `
138
- svg[data-marpit-svg] {
139
- margin-bottom:20px !important;
140
- border: 1px solid gray;
141
- border-radius: 10px;
142
- box-shadow: 2px 2px 6px rgba(0,0,0,0.3);
143
- }
144
- #help-box table td, #help-box table th {
145
- padding: 0 15px 0 15px;
146
- }
147
- #help-box {
148
- position: fixed;
149
- top: 50%;
150
- left: 50%;
151
- transform: translate(-50%, -50%);
152
- background-color: rgba(0, 0, 0, 0.8);
153
- color: white;
154
- padding: 20px;
155
- border-radius: 10px;
156
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
157
- z-index: 1001;
158
- display: none; /* Initially hidden */
159
- box-shadow: 0 4px 12px rgba(0,0,0,0.5);
160
- max-width: 400px;
161
- text-align: left;
162
- }
163
- #command-prompt {
164
- position: fixed;
165
- bottom: 20px;
166
- left: 50%;
167
- transform: translateX(-50%);
168
- background-color: #333;
169
- color: white;
170
- padding: 10px 20px;
171
- border-radius: 8px;
172
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
173
- font-size: 1.2em;
174
- z-index: 1000;
175
- display: none;
176
- box-shadow: 0 4px 8px rgba(0,0,0,0.3);
177
- }
178
- `;
179
-
180
- return `
181
- <!DOCTYPE html>
182
- <html>
183
- <head>
184
- <meta name="ws-port" content="${port + 1}">
185
- <style id="marp-style">${css}</style>
186
- <style id="custom-style">${customCss}</style>
187
- <script src="https://unpkg.com/morphdom@2.7.0/dist/morphdom-umd.min.js"></script>
188
- <script src="/client.js"></script>
189
- <meta charset="UTF-8">
190
- </head>
191
- <body>
192
- <div id="marp-container">
193
- ${html}
194
- </div>
195
- <div id="help-box">
196
- <h3>Key Bindings</h3>
197
- <table>
198
- <tr><th>Key</th><th>Action</th></tr>
199
- <tr><td><kbd>gg</kbd> or <kbd>Home</kbd></td><td>Go to first slide</td></tr>
200
- <tr><td><kbd>G</kbd> or <kbd>End</kbd></td><td>Go to last slide</td></tr>
201
- <tr><td><kbd>:&lt;number&gt</kbd></td><td>Go to the given slide number</td></tr>
202
- <tr><td><kbd>?</kbd></td><td>Show/hide help</td></tr>
203
- </table>
204
- </div>
205
- <div id="command-prompt"></div>
206
- </body>
207
- </html>
208
- `;
209
- }
210
-
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
- async function reload(markdown) {
281
- try {
282
- const { html, css } = marp.render(markdown);
283
- const message = JSON.stringify({
284
- type: 'update',
285
- html: html,
286
- css: css
287
- });
288
- for (const ws of wss.clients) {
289
- ws.send(message);
290
- }
291
- return true;
292
- } catch (error) {
293
- console.error('Error rendering or sending update:', error);
294
- return false;
295
- }
296
- }
297
-
298
- chokidar.watch(markdownFile).on('change', async () => {
299
- console.log(`File ${markdownFile} changed, updating...`);
300
- const md = await fs.readFile(markdownFile, 'utf8');
301
- await reload(md);
302
- });
303
-
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
- });
311
- }).catch(error => {
312
- console.error("Failed to initialize Marp:", error);
313
- process.exit(1);
314
- });