marp-dev-preview 0.0.5 → 0.1.4

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 { fileURLToPath } from 'url';
14
+
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]')
@@ -28,6 +33,12 @@ const argv = yargs(hideBin(process.argv))
28
33
  type: 'number',
29
34
  default: 8080
30
35
  })
36
+ .option('verbose', {
37
+ alias: 'v',
38
+ describe: 'Enable verbose logging',
39
+ type: 'boolean',
40
+ default: false
41
+ })
31
42
  .config('config', 'Path to a JSON config file')
32
43
  .default('config', '.mp-config.json')
33
44
  .demandCommand(1, 'You must provide a markdown file.')
@@ -36,6 +47,41 @@ const argv = yargs(hideBin(process.argv))
36
47
  const markdownFile = argv._[0]
37
48
  const themeDir = argv.themeDir;
38
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
+ }
39
85
 
40
86
  if (!markdownFile) {
41
87
  console.error('Error: You must provide a path to a markdown file.');
@@ -77,8 +123,8 @@ async function initializeMarp() {
77
123
  const themeFiles = await fs.readdir(themeDir);
78
124
  for (const file of themeFiles) {
79
125
  if (path.extname(file) === '.css') {
80
- const css = await fs.readFile(path.join(themeDir, file), 'utf8');
81
- marp.themeSet.add(css);
126
+ const css = await fs.readFile(path.join(themeDir, file), 'utf8');
127
+ marp.themeSet.add(css);
82
128
  }
83
129
  }
84
130
  }
@@ -88,12 +134,7 @@ async function initializeMarp() {
88
134
  async function renderMarp() {
89
135
  const md = await fs.readFile(markdownFile, 'utf8');
90
136
  const { html, css } = marp.render(md);
91
- return `
92
- <!DOCTYPE html>
93
- <html>
94
- <head>
95
- <style>
96
- ${css}
137
+ const customCss = `
97
138
  svg[data-marpit-svg] {
98
139
  margin-bottom:20px !important;
99
140
  border: 1px solid gray;
@@ -134,136 +175,23 @@ async function renderMarp() {
134
175
  display: none;
135
176
  box-shadow: 0 4px 8px rgba(0,0,0,0.3);
136
177
  }
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
- }
178
+ `;
232
179
 
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">
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">
264
190
  </head>
265
191
  <body>
266
- ${html}
192
+ <div id="marp-container">
193
+ ${html}
194
+ </div>
267
195
  <div id="help-box">
268
196
  <h3>Key Bindings</h3>
269
197
  <table>
@@ -286,23 +214,43 @@ const server = http.createServer(async (req, res) => {
286
214
  const html = await renderMarp();
287
215
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
288
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
+ });
289
237
  } else if (req.url === '/api/command' && req.method === 'POST') {
290
238
  let body = '';
291
239
  req.on('data', chunk => {
292
- body += chunk.toString();
240
+ body += chunk.toString();
293
241
  });
294
242
  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
- }
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
+ }
306
254
  });
307
255
  } else {
308
256
  const assetPath = path.join(markdownDir, req.url);
@@ -310,16 +258,16 @@ const server = http.createServer(async (req, res) => {
310
258
  const contentType = mimeTypes[ext] || 'application/octet-stream';
311
259
 
312
260
  try {
313
- const content = await fs.readFile(assetPath);
314
- res.writeHead(200, { 'Content-Type': contentType });
315
- res.end(content);
261
+ const content = await fs.readFile(assetPath);
262
+ res.writeHead(200, { 'Content-Type': contentType });
263
+ res.end(content);
316
264
  } catch (error) {
317
- if (error.code === 'ENOENT') {
318
- res.writeHead(404);
319
- res.end('Not Found');
320
- } else {
321
- throw error;
322
- }
265
+ if (error.code === 'ENOENT') {
266
+ res.writeHead(404);
267
+ res.end('Not Found');
268
+ } else {
269
+ throw error;
270
+ }
323
271
  }
324
272
  }
325
273
  } catch (error) {
@@ -329,11 +277,28 @@ const server = http.createServer(async (req, res) => {
329
277
  }
330
278
  });
331
279
 
332
- chokidar.watch(markdownFile).on('change', () => {
333
- console.log(`File ${markdownFile} changed, reloading...`);
334
- for (const ws of wss.clients) {
335
- ws.send('reload');
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;
336
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);
337
302
  });
338
303
 
339
304
  initializeMarp().then(() => {
@@ -344,6 +309,6 @@ initializeMarp().then(() => {
344
309
  }
345
310
  });
346
311
  }).catch(error => {
347
- console.error("Failed to initialize Marp:", error);
348
- process.exit(1);
312
+ console.error("Failed to initialize Marp:", error);
313
+ process.exit(1);
349
314
  });
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.4",
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
  }