marp-dev-preview 0.0.5 → 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,6 +10,11 @@ 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
20
  .usage('Usage: $0 <markdown-file> [options]')
@@ -77,8 +82,8 @@ async function initializeMarp() {
77
82
  const themeFiles = await fs.readdir(themeDir);
78
83
  for (const file of themeFiles) {
79
84
  if (path.extname(file) === '.css') {
80
- const css = await fs.readFile(path.join(themeDir, file), 'utf8');
81
- marp.themeSet.add(css);
85
+ const css = await fs.readFile(path.join(themeDir, file), 'utf8');
86
+ marp.themeSet.add(css);
82
87
  }
83
88
  }
84
89
  }
@@ -88,12 +93,7 @@ async function initializeMarp() {
88
93
  async function renderMarp() {
89
94
  const md = await fs.readFile(markdownFile, 'utf8');
90
95
  const { html, css } = marp.render(md);
91
- return `
92
- <!DOCTYPE html>
93
- <html>
94
- <head>
95
- <style>
96
- ${css}
96
+ const customCss = `
97
97
  svg[data-marpit-svg] {
98
98
  margin-bottom:20px !important;
99
99
  border: 1px solid gray;
@@ -134,136 +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 && !found; 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
- return false;
170
- }
171
-
172
- return true;
173
- }
174
-
175
- ws.onmessage = (event) => {
176
- if (event.data === 'reload') {
177
- window.location.reload();
178
- return;
179
- }
180
- try {
181
- const data = JSON.parse(event.data);
182
- if (data.command === 'goto' && data.slide) {
183
- goToSlide(parseInt(data.slide, 10));
184
- } else if (data.command === 'find' && data.string) {
185
- findSlideByString(data.string);
186
- }
187
- } catch (e) {
188
- console.error('Failed to parse WebSocket message:', e);
189
- }
190
- };
191
-
192
- function updatePrompt(text, isError = false) {
193
- if (commandMode) {
194
- commandPrompt.style.display = 'block';
195
- commandPrompt.textContent = text;
196
- commandPrompt.style.color = isError ? 'red' : 'white';
197
- } else {
198
- commandPrompt.style.display = 'none';
199
- commandPrompt.style.color = 'white'; // Reset color when hidden
200
- }
201
- }
202
-
203
- document.addEventListener('keydown', (e) => {
204
- if (commandMode) {
205
- if (e.key === 'Enter') {
206
- const slideNumber = parseInt(command, 10);
207
- if (goToSlide(slideNumber)) {
208
- commandMode = false;
209
- command = '';
210
- updatePrompt(':' + command);
211
- } else {
212
- updatePrompt(\`Error: Slide not found.\`, true); // Pass message and error flag
213
- setTimeout(() => {
214
- commandMode = false;
215
- command = '';
216
- updatePrompt(':' + command); // Reset to normal prompt
217
- }, 2000);
218
- }
219
- } else if (e.key === 'Backspace') {
220
- command = command.slice(0, -1);
221
- updatePrompt(':' + command);
222
- } else if (e.key.length === 1 && !isNaN(parseInt(e.key,10))) {
223
- command += e.key;
224
- updatePrompt(':' + command);
225
- } else if (e.key === 'Escape') {
226
- commandMode = false;
227
- command = '';
228
- updatePrompt(':' + command);
229
- }
230
- return;
231
- }
137
+ `;
232
138
 
233
- if (e.key === 'g') {
234
- if (lastKey === 'g') {
235
- // gg
236
- if (slides.length > 0) {
237
- slides[0].scrollIntoView({ behavior: 'smooth' });
238
- }
239
- lastKey = '';
240
- } else {
241
- lastKey = 'g';
242
- setTimeout(() => { lastKey = '' }, 500); // reset after 500ms
243
- }
244
- } else if (e.key === 'G') {
245
- if (slides.length > 0) {
246
- slides[slides.length - 1].scrollIntoView({ behavior: 'smooth' });
247
- }
248
- lastKey = '';
249
- } else if (e.key === ':') {
250
- commandMode = true;
251
- command = '';
252
- lastKey = '';
253
- updatePrompt(':' + command);
254
- } else if (e.key === '?') {
255
- helpBox.style.display = helpBox.style.display === 'none' ? 'block' : 'none';
256
- lastKey = ''; // Reset lastKey to prevent unintended 'gg'
257
- } else {
258
- lastKey = '';
259
- }
260
- });
261
- });
262
- </script>
263
- <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">
264
149
  </head>
265
150
  <body>
266
- ${html}
151
+ <div id="marp-container">
152
+ ${html}
153
+ </div>
267
154
  <div id="help-box">
268
155
  <h3>Key Bindings</h3>
269
156
  <table>
@@ -286,23 +173,43 @@ const server = http.createServer(async (req, res) => {
286
173
  const html = await renderMarp();
287
174
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
288
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
+ });
289
196
  } else if (req.url === '/api/command' && req.method === 'POST') {
290
197
  let body = '';
291
198
  req.on('data', chunk => {
292
- body += chunk.toString();
199
+ body += chunk.toString();
293
200
  });
294
201
  req.on('end', () => {
295
- try {
296
- const command = JSON.parse(body);
297
- for (const ws of wss.clients) {
298
- ws.send(JSON.stringify(command));
299
- }
300
- res.writeHead(200, { 'Content-Type': 'application/json' });
301
- res.end(JSON.stringify({ status: 'ok', command }));
302
- } catch (e) {
303
- res.writeHead(400, { 'Content-Type': 'application/json' });
304
- res.end(JSON.stringify({ status: 'error', message: 'Invalid JSON' }));
305
- }
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
+ }
306
213
  });
307
214
  } else {
308
215
  const assetPath = path.join(markdownDir, req.url);
@@ -310,16 +217,16 @@ const server = http.createServer(async (req, res) => {
310
217
  const contentType = mimeTypes[ext] || 'application/octet-stream';
311
218
 
312
219
  try {
313
- const content = await fs.readFile(assetPath);
314
- res.writeHead(200, { 'Content-Type': contentType });
315
- res.end(content);
220
+ const content = await fs.readFile(assetPath);
221
+ res.writeHead(200, { 'Content-Type': contentType });
222
+ res.end(content);
316
223
  } catch (error) {
317
- if (error.code === 'ENOENT') {
318
- res.writeHead(404);
319
- res.end('Not Found');
320
- } else {
321
- throw error;
322
- }
224
+ if (error.code === 'ENOENT') {
225
+ res.writeHead(404);
226
+ res.end('Not Found');
227
+ } else {
228
+ throw error;
229
+ }
323
230
  }
324
231
  }
325
232
  } catch (error) {
@@ -329,11 +236,28 @@ const server = http.createServer(async (req, res) => {
329
236
  }
330
237
  });
331
238
 
332
- chokidar.watch(markdownFile).on('change', () => {
333
- console.log(`File ${markdownFile} changed, reloading...`);
334
- for (const ws of wss.clients) {
335
- ws.send('reload');
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;
336
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);
337
261
  });
338
262
 
339
263
  initializeMarp().then(() => {
@@ -344,6 +268,6 @@ initializeMarp().then(() => {
344
268
  }
345
269
  });
346
270
  }).catch(error => {
347
- console.error("Failed to initialize Marp:", error);
348
- process.exit(1);
271
+ console.error("Failed to initialize Marp:", error);
272
+ process.exit(1);
349
273
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "marp-dev-preview",
3
- "version": "0.0.5",
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
  }