marp-dev-preview 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,8 @@ The tool is mainly intended for slide deck authors who want to preview their sli
6
6
 
7
7
  ## Features
8
8
 
9
- * Live preview of Marp markdown files.
9
+ * Live preview of Marp markdown files, with position syncing.
10
+ * API to reload the slides using incremental updates.
10
11
  * Automatic browser reload on file changes.
11
12
  * Custom theme support.
12
13
  * Keyboard navigation for slides.
package/client.js ADDED
@@ -0,0 +1,143 @@
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const wsPort = document.querySelector('meta[name="ws-port"]').content;
3
+ const ws = new WebSocket(`ws://localhost:${wsPort}`);
4
+
5
+ let slides = Array.from(document.querySelectorAll('section[id]'));
6
+ const commandPrompt = document.getElementById('command-prompt');
7
+ const helpBox = document.getElementById('help-box');
8
+
9
+ let lastKey = '';
10
+ let command = '';
11
+ let commandMode = false;
12
+
13
+ function goToSlide(slideNumber) {
14
+ if (isNaN(slideNumber)) {
15
+ console.error('Invalid slide number: ' + slideNumber);
16
+ return false;
17
+ }
18
+
19
+ if (slideNumber <= 0) {
20
+ console.error('Slide number must be greater than 0: ' + slideNumber);
21
+ return false;
22
+ }
23
+
24
+ if (slideNumber > slides.length) {
25
+ console.error('Slide number exceeds total slides: ' + slideNumber);
26
+ return false;
27
+ }
28
+
29
+ console.info('Navigating to slide: ' + slideNumber);
30
+
31
+ slides[slideNumber - 1].scrollIntoView({ behavior: 'smooth' });
32
+ return true;
33
+ }
34
+
35
+ function findSlideByString(string) {
36
+ const lowerString = string.toLowerCase();
37
+ let found = false;
38
+ for (let i = 0; i < slides.length && !found; i++) {
39
+ if (slides[i].textContent.toLowerCase().includes(lowerString)) {
40
+ slides[i].scrollIntoView({ behavior: 'smooth' });
41
+ found = true;
42
+ }
43
+ }
44
+ if (!found) {
45
+ console.error('No slide contains the string: ' + string);
46
+ return false;
47
+ }
48
+
49
+ return true;
50
+ }
51
+
52
+ ws.onmessage = (event) => {
53
+ try {
54
+ const data = JSON.parse(event.data);
55
+ if (data.type === 'update') {
56
+ const marpContainer = document.getElementById('marp-container');
57
+ if (marpContainer) {
58
+ morphdom(marpContainer, `<div id="marp-container">${data.html}</div>`);
59
+ }
60
+ if (document.getElementById('marp-style').innerHTML !== data.css) {
61
+ document.getElementById('marp-style').innerHTML = data.css;
62
+ }
63
+ slides = Array.from(document.querySelectorAll('section[id]'));
64
+ } else if (data.command === 'goto' && data.slide) {
65
+ goToSlide(parseInt(data.slide, 10));
66
+ } else if (data.command === 'find' && data.string) {
67
+ findSlideByString(data.string);
68
+ }
69
+ } catch (e) {
70
+ console.error('Failed to parse WebSocket message:', e);
71
+ }
72
+ };
73
+
74
+ function updatePrompt(text, isError = false) {
75
+ if (commandMode) {
76
+ commandPrompt.style.display = 'block';
77
+ commandPrompt.textContent = text;
78
+ commandPrompt.style.color = isError ? 'red' : 'white';
79
+ } else {
80
+ commandPrompt.style.display = 'none';
81
+ commandPrompt.style.color = 'white'; // Reset color when hidden
82
+ }
83
+ }
84
+
85
+ document.addEventListener('keydown', (e) => {
86
+ if (commandMode) {
87
+ if (e.key === 'Enter') {
88
+ const slideNumber = parseInt(command, 10);
89
+ if (goToSlide(slideNumber)) {
90
+ commandMode = false;
91
+ command = '';
92
+ updatePrompt(':' + command);
93
+ } else {
94
+ updatePrompt(`Error: Slide not found.`, true); // Pass message and error flag
95
+ setTimeout(() => {
96
+ commandMode = false;
97
+ command = '';
98
+ updatePrompt(':' + command); // Reset to normal prompt
99
+ }, 2000);
100
+ }
101
+ } else if (e.key === 'Backspace') {
102
+ command = command.slice(0, -1);
103
+ updatePrompt(':' + command);
104
+ } else if (e.key.length === 1 && !isNaN(parseInt(e.key, 10))) {
105
+ command += e.key;
106
+ updatePrompt(':' + command);
107
+ } else if (e.key === 'Escape') {
108
+ commandMode = false;
109
+ command = '';
110
+ updatePrompt(':' + command);
111
+ }
112
+ return;
113
+ }
114
+
115
+ if (e.key === 'g') {
116
+ if (lastKey === 'g') {
117
+ // gg
118
+ if (slides.length > 0) {
119
+ slides[0].scrollIntoView({ behavior: 'smooth' });
120
+ }
121
+ lastKey = '';
122
+ } else {
123
+ lastKey = 'g';
124
+ setTimeout(() => { lastKey = '' }, 500); // reset after 500ms
125
+ }
126
+ } else if (e.key === 'G') {
127
+ if (slides.length > 0) {
128
+ slides[slides.length - 1].scrollIntoView({ behavior: 'smooth' });
129
+ }
130
+ lastKey = '';
131
+ } else if (e.key === ':') {
132
+ commandMode = true;
133
+ command = '';
134
+ lastKey = '';
135
+ updatePrompt(':' + command);
136
+ } else if (e.key === '?') {
137
+ helpBox.style.display = helpBox.style.display === 'none' ? 'block' : 'none';
138
+ lastKey = ''; // Reset lastKey to prevent unintended 'gg'
139
+ } else {
140
+ lastKey = '';
141
+ }
142
+ });
143
+ });
@@ -10,56 +10,61 @@ import { hideBin } from 'yargs/helpers';
10
10
  import markdownItFootnote from 'markdown-it-footnote';
11
11
  import markdownItMark from 'markdown-it-mark';
12
12
  import markdownItContainer from 'markdown-it-container';
13
+ import morphdom from 'morphdom';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
13
18
 
14
19
  const argv = yargs(hideBin(process.argv))
15
- .usage('Usage: $0 <markdown-file> [options]')
16
- .positional('markdown-file', {
17
- describe: 'Path to the markdown file to preview',
18
- type: 'string'
19
- })
20
- .option('theme-dir', {
21
- alias: 't',
22
- describe: 'Directory for custom themes',
23
- type: 'string'
24
- })
25
- .option('port', {
26
- alias: 'p',
27
- describe: 'Port to listen on',
28
- type: 'number',
29
- default: 8080
30
- })
31
- .config('config', 'Path to a JSON config file')
32
- .default('config', '.mp-config.json')
33
- .demandCommand(1, 'You must provide a markdown file.')
34
- .argv;
20
+ .usage('Usage: $0 <markdown-file> [options]')
21
+ .positional('markdown-file', {
22
+ describe: 'Path to the markdown file to preview',
23
+ type: 'string'
24
+ })
25
+ .option('theme-dir', {
26
+ alias: 't',
27
+ describe: 'Directory for custom themes',
28
+ type: 'string'
29
+ })
30
+ .option('port', {
31
+ alias: 'p',
32
+ describe: 'Port to listen on',
33
+ type: 'number',
34
+ default: 8080
35
+ })
36
+ .config('config', 'Path to a JSON config file')
37
+ .default('config', '.mp-config.json')
38
+ .demandCommand(1, 'You must provide a markdown file.')
39
+ .argv;
35
40
 
36
41
  const markdownFile = argv._[0]
37
42
  const themeDir = argv.themeDir;
38
43
  const port = argv.port;
39
44
 
40
45
  if (!markdownFile) {
41
- console.error('Error: You must provide a path to a markdown file.');
42
- process.exit(1);
46
+ console.error('Error: You must provide a path to a markdown file.');
47
+ process.exit(1);
43
48
  }
44
49
 
45
50
  const markdownDir = path.dirname(markdownFile);
46
51
 
47
52
  const mimeTypes = {
48
- '.html': 'text/html',
49
- '.js': 'text/javascript',
50
- '.css': 'text/css',
51
- '.json': 'application/json',
52
- '.png': 'image/png',
53
- '.jpg': 'image/jpeg',
54
- '.gif': 'image/gif',
55
- '.svg': 'image/svg+xml',
56
- '.wav': 'audio/wav',
57
- '.mp4': 'video/mp4',
58
- '.woff': 'application/font-woff',
59
- '.ttf': 'application/font-ttf',
60
- '.eot': 'application/vnd.ms-fontobject',
61
- '.otf': 'application/font-otf',
62
- '.wasm': 'application/wasm'
53
+ '.html': 'text/html',
54
+ '.js': 'text/javascript',
55
+ '.css': 'text/css',
56
+ '.json': 'application/json',
57
+ '.png': 'image/png',
58
+ '.jpg': 'image/jpeg',
59
+ '.gif': 'image/gif',
60
+ '.svg': 'image/svg+xml',
61
+ '.wav': 'audio/wav',
62
+ '.mp4': 'video/mp4',
63
+ '.woff': 'application/font-woff',
64
+ '.ttf': 'application/font-ttf',
65
+ '.eot': 'application/vnd.ms-fontobject',
66
+ '.otf': 'application/font-otf',
67
+ '.wasm': 'application/wasm'
63
68
  };
64
69
 
65
70
  const wss = new WebSocketServer({ port: port + 1 });
@@ -67,33 +72,28 @@ const wss = new WebSocketServer({ port: port + 1 });
67
72
  let marp;
68
73
 
69
74
  async function initializeMarp() {
70
- const options = { html: true, linkify: true, };
71
- marp = new Marp(options)
72
- .use(markdownItFootnote)
73
- .use(markdownItMark)
74
- .use(markdownItContainer, 'note');
75
+ const options = { html: true, linkify: true, };
76
+ marp = new Marp(options)
77
+ .use(markdownItFootnote)
78
+ .use(markdownItMark)
79
+ .use(markdownItContainer, 'note');
75
80
 
76
- if (themeDir) {
77
- const themeFiles = await fs.readdir(themeDir);
78
- for (const file of themeFiles) {
79
- if (path.extname(file) === '.css') {
80
- const css = await fs.readFile(path.join(themeDir, file), 'utf8');
81
- marp.themeSet.add(css);
82
- }
83
- }
84
- }
81
+ if (themeDir) {
82
+ const themeFiles = await fs.readdir(themeDir);
83
+ for (const file of themeFiles) {
84
+ if (path.extname(file) === '.css') {
85
+ const css = await fs.readFile(path.join(themeDir, file), 'utf8');
86
+ marp.themeSet.add(css);
87
+ }
88
+ }
89
+ }
85
90
  }
86
91
 
87
92
 
88
93
  async function renderMarp() {
89
- const md = await fs.readFile(markdownFile, 'utf8');
90
- const { html, css } = marp.render(md);
91
- return `
92
- <!DOCTYPE html>
93
- <html>
94
- <head>
95
- <style>
96
- ${css}
94
+ const md = await fs.readFile(markdownFile, 'utf8');
95
+ const { html, css } = marp.render(md);
96
+ const customCss = `
97
97
  svg[data-marpit-svg] {
98
98
  margin-bottom:20px !important;
99
99
  border: 1px solid gray;
@@ -134,133 +134,23 @@ async function renderMarp() {
134
134
  display: none;
135
135
  box-shadow: 0 4px 8px rgba(0,0,0,0.3);
136
136
  }
137
- </style>
138
- <script>
139
- const ws = new WebSocket('ws://localhost:${port + 1}');
140
-
141
- document.addEventListener('DOMContentLoaded', () => {
142
- const slides = Array.from(document.querySelectorAll('section[id]'));
143
- const commandPrompt = document.getElementById('command-prompt');
144
- const helpBox = document.getElementById('help-box');
145
-
146
- let lastKey = '';
147
- let command = '';
148
- let commandMode = false;
149
-
150
- function goToSlide(slideNumber) {
151
- if (!isNaN(slideNumber) && slideNumber > 0 && slideNumber <= slides.length) {
152
- slides[slideNumber - 1].scrollIntoView({ behavior: 'smooth' });
153
- return true;
154
- }
155
- return false;
156
- }
157
-
158
- function findSlideByString(string) {
159
- const lowerString = string.toLowerCase();
160
- let found = false;
161
- for (let i = 0; i < slides.length; i++) {
162
- if (slides[i].textContent.toLowerCase().includes(lowerString)) {
163
- slides[i].scrollIntoView({ behavior: 'smooth' });
164
- found = true;
165
- }
166
- }
167
- if (!found) {
168
- console.error('No slide contains the string: ' + string);
169
- }
170
- }
171
-
172
- ws.onmessage = (event) => {
173
- if (event.data === 'reload') {
174
- window.location.reload();
175
- return;
176
- }
177
- try {
178
- const data = JSON.parse(event.data);
179
- if (data.command === 'goto' && data.slide) {
180
- goToSlide(parseInt(data.slide, 10));
181
- } else if (data.command === 'find' && data.string) {
182
- findSlideByString(data.string);
183
- }
184
- } catch (e) {
185
- console.error('Failed to parse WebSocket message:', e);
186
- }
187
- };
188
-
189
- function updatePrompt(text, isError = false) {
190
- if (commandMode) {
191
- commandPrompt.style.display = 'block';
192
- commandPrompt.textContent = text;
193
- commandPrompt.style.color = isError ? 'red' : 'white';
194
- } else {
195
- commandPrompt.style.display = 'none';
196
- commandPrompt.style.color = 'white'; // Reset color when hidden
197
- }
198
- }
199
-
200
- document.addEventListener('keydown', (e) => {
201
- if (commandMode) {
202
- if (e.key === 'Enter') {
203
- const slideNumber = parseInt(command, 10);
204
- if (goToSlide(slideNumber)) {
205
- commandMode = false;
206
- command = '';
207
- updatePrompt(':' + command);
208
- } else {
209
- updatePrompt(\`Error: Slide not found.\`, true); // Pass message and error flag
210
- setTimeout(() => {
211
- commandMode = false;
212
- command = '';
213
- updatePrompt(':' + command); // Reset to normal prompt
214
- }, 2000);
215
- }
216
- } else if (e.key === 'Backspace') {
217
- command = command.slice(0, -1);
218
- updatePrompt(':' + command);
219
- } else if (e.key.length === 1 && !isNaN(parseInt(e.key,10))) {
220
- command += e.key;
221
- updatePrompt(':' + command);
222
- } else if (e.key === 'Escape') {
223
- commandMode = false;
224
- command = '';
225
- updatePrompt(':' + command);
226
- }
227
- return;
228
- }
137
+ `;
229
138
 
230
- if (e.key === 'g') {
231
- if (lastKey === 'g') {
232
- // gg
233
- if (slides.length > 0) {
234
- slides[0].scrollIntoView({ behavior: 'smooth' });
235
- }
236
- lastKey = '';
237
- } else {
238
- lastKey = 'g';
239
- setTimeout(() => { lastKey = '' }, 500); // reset after 500ms
240
- }
241
- } else if (e.key === 'G') {
242
- if (slides.length > 0) {
243
- slides[slides.length - 1].scrollIntoView({ behavior: 'smooth' });
244
- }
245
- lastKey = '';
246
- } else if (e.key === ':') {
247
- commandMode = true;
248
- command = '';
249
- lastKey = '';
250
- updatePrompt(':' + command);
251
- } else if (e.key === '?') {
252
- helpBox.style.display = helpBox.style.display === 'none' ? 'block' : 'none';
253
- lastKey = ''; // Reset lastKey to prevent unintended 'gg'
254
- } else {
255
- lastKey = '';
256
- }
257
- });
258
- });
259
- </script>
260
- <meta charset="UTF-8">
139
+ return `
140
+ <!DOCTYPE html>
141
+ <html>
142
+ <head>
143
+ <meta name="ws-port" content="${port + 1}">
144
+ <style id="marp-style">${css}</style>
145
+ <style id="custom-style">${customCss}</style>
146
+ <script src="https://unpkg.com/morphdom@2.7.0/dist/morphdom-umd.min.js"></script>
147
+ <script src="/client.js"></script>
148
+ <meta charset="UTF-8">
261
149
  </head>
262
150
  <body>
263
- ${html}
151
+ <div id="marp-container">
152
+ ${html}
153
+ </div>
264
154
  <div id="help-box">
265
155
  <h3>Key Bindings</h3>
266
156
  <table>
@@ -278,69 +168,106 @@ async function renderMarp() {
278
168
  }
279
169
 
280
170
  const server = http.createServer(async (req, res) => {
281
- try {
282
- if (req.url === '/') {
283
- const html = await renderMarp();
284
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
285
- res.end(html);
286
- } else if (req.url === '/api/command' && req.method === 'POST') {
287
- let body = '';
288
- req.on('data', chunk => {
289
- body += chunk.toString();
290
- });
291
- req.on('end', () => {
292
- try {
293
- const command = JSON.parse(body);
294
- for (const ws of wss.clients) {
295
- ws.send(JSON.stringify(command));
296
- }
297
- res.writeHead(200, { 'Content-Type': 'application/json' });
298
- res.end(JSON.stringify({ status: 'ok', command }));
299
- } catch (e) {
300
- res.writeHead(400, { 'Content-Type': 'application/json' });
301
- res.end(JSON.stringify({ status: 'error', message: 'Invalid JSON' }));
302
- }
303
- });
304
- } else {
305
- const assetPath = path.join(markdownDir, req.url);
306
- const ext = path.extname(assetPath);
307
- const contentType = mimeTypes[ext] || 'application/octet-stream';
171
+ try {
172
+ if (req.url === '/') {
173
+ const html = await renderMarp();
174
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
175
+ res.end(html);
176
+ } else if (req.url === '/client.js') {
177
+ const clientJs = await fs.readFile(path.join(__dirname, 'client.js'), 'utf8');
178
+ res.writeHead(200, { 'Content-Type': 'text/javascript' });
179
+ res.end(clientJs);
180
+ } else if (req.url === '/api/reload' && req.method === 'POST') {
181
+ let body = '';
182
+ req.on('data', chunk => {
183
+ body += chunk.toString();
184
+ });
185
+ req.on('end', async () => {
186
+ console.debug("Reload request received");
187
+ const success = await reload(body);
188
+ if (success) {
189
+ res.writeHead(200, { 'Content-Type': 'application/json' });
190
+ res.end(JSON.stringify({ status: 'ok' }));
191
+ } else {
192
+ res.writeHead(500, { 'Content-Type': 'application/json' });
193
+ res.end(JSON.stringify({ status: 'error', message: 'Failed to render markdown' }));
194
+ }
195
+ });
196
+ } else if (req.url === '/api/command' && req.method === 'POST') {
197
+ let body = '';
198
+ req.on('data', chunk => {
199
+ body += chunk.toString();
200
+ });
201
+ req.on('end', () => {
202
+ try {
203
+ const command = JSON.parse(body);
204
+ for (const ws of wss.clients) {
205
+ ws.send(JSON.stringify(command));
206
+ }
207
+ res.writeHead(200, { 'Content-Type': 'application/json' });
208
+ res.end(JSON.stringify({ status: 'ok', command }));
209
+ } catch (e) {
210
+ res.writeHead(400, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify({ status: 'error', message: 'Invalid JSON' }));
212
+ }
213
+ });
214
+ } else {
215
+ const assetPath = path.join(markdownDir, req.url);
216
+ const ext = path.extname(assetPath);
217
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
308
218
 
309
- try {
310
- const content = await fs.readFile(assetPath);
311
- res.writeHead(200, { 'Content-Type': contentType });
312
- res.end(content);
313
- } catch (error) {
314
- if (error.code === 'ENOENT') {
315
- res.writeHead(404);
316
- res.end('Not Found');
317
- } else {
318
- throw error;
319
- }
320
- }
321
- }
322
- } catch (error) {
323
- console.error(error);
324
- res.writeHead(500);
325
- res.end('Internal Server Error');
326
- }
219
+ try {
220
+ const content = await fs.readFile(assetPath);
221
+ res.writeHead(200, { 'Content-Type': contentType });
222
+ res.end(content);
223
+ } catch (error) {
224
+ if (error.code === 'ENOENT') {
225
+ res.writeHead(404);
226
+ res.end('Not Found');
227
+ } else {
228
+ throw error;
229
+ }
230
+ }
231
+ }
232
+ } catch (error) {
233
+ console.error(error);
234
+ res.writeHead(500);
235
+ res.end('Internal Server Error');
236
+ }
327
237
  });
328
238
 
329
- chokidar.watch(markdownFile).on('change', () => {
330
- console.log(`File ${markdownFile} changed, reloading...`);
331
- for (const ws of wss.clients) {
332
- ws.send('reload');
333
- }
239
+ async function reload(markdown) {
240
+ try {
241
+ const { html, css } = marp.render(markdown);
242
+ const message = JSON.stringify({
243
+ type: 'update',
244
+ html: html,
245
+ css: css
246
+ });
247
+ for (const ws of wss.clients) {
248
+ ws.send(message);
249
+ }
250
+ return true;
251
+ } catch (error) {
252
+ console.error('Error rendering or sending update:', error);
253
+ return false;
254
+ }
255
+ }
256
+
257
+ chokidar.watch(markdownFile).on('change', async () => {
258
+ console.log(`File ${markdownFile} changed, updating...`);
259
+ const md = await fs.readFile(markdownFile, 'utf8');
260
+ await reload(md);
334
261
  });
335
262
 
336
263
  initializeMarp().then(() => {
337
- server.listen(port, () => {
338
- console.log(`Server listening on http://localhost:${port} for ${markdownFile}`);
339
- if (themeDir) {
340
- console.log(`Using custom themes from ${themeDir}`);
341
- }
342
- });
264
+ server.listen(port, () => {
265
+ console.log(`Server listening on http://localhost:${port} for ${markdownFile}`);
266
+ if (themeDir) {
267
+ console.log(`Using custom themes from ${themeDir}`);
268
+ }
269
+ });
343
270
  }).catch(error => {
344
- console.error("Failed to initialize Marp:", error);
345
- process.exit(1);
271
+ console.error("Failed to initialize Marp:", error);
272
+ process.exit(1);
346
273
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "marp-dev-preview",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "A CLI tool to preview Marp markdown files.",
5
5
  "main": "marp-dev-preview.mjs",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  "markdown-it-container": "^4.0.0",
30
30
  "markdown-it-footnote": "^4.0.0",
31
31
  "markdown-it-mark": "^4.0.0",
32
+ "morphdom": "^2.7.7",
32
33
  "ws": "^8.18.3",
33
34
  "yargs": "^18.0.0"
34
35
  }