@markdstage/markdstage 3.2.0 → 3.4.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.
@@ -13,16 +13,22 @@
13
13
  // overview, custom themes, Mermaid, Architecture DSL, local assets) is identical.
14
14
 
15
15
  import { createServer } from "node:http";
16
- import { realpath, stat } from "node:fs/promises";
17
- import { dirname, join, relative, resolve, sep } from "node:path";
16
+ import { readFile, realpath, stat } from "node:fs/promises";
17
+ import { dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { randomBytes } from "node:crypto";
20
20
  import { resolveAssetFile } from "../scripts/asset-paths.mjs";
21
21
  import { importedArchitectureBlockIndex } from "../scripts/markdown-blocks.mjs";
22
22
  import { isMarkdownPath, listMarkdownFiles } from "../scripts/markdown-files.mjs";
23
+ import { createMarkdownWatcher } from "../scripts/markdown-watcher.mjs";
23
24
  import { startArchitectureEditorServer } from "./architecture-editor-server.mjs";
24
25
  import { saveArchitectureSource } from "./architecture-source.mjs";
25
- import { isPathInside } from "./output-paths.mjs";
26
+ import { exportPdf, exportPptx } from "./output.mjs";
27
+ import {
28
+ isPathInside,
29
+ pdfNameForSource,
30
+ pptxNameForSource,
31
+ } from "./output-paths.mjs";
26
32
  import { safeJoin, sendChunkedVendorAsset, sendFile } from "./static-files.mjs";
27
33
 
28
34
  const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
@@ -93,13 +99,139 @@ function broadcast(session) {
93
99
  */
94
100
  export async function startPresentationServer(
95
101
  session,
96
- { editable = false, onLog, presenter, token = createUrlToken() } = {},
102
+ {
103
+ application = false,
104
+ editable = false,
105
+ exporters,
106
+ initialSourceMode = "snapshot",
107
+ onLog,
108
+ presenter,
109
+ token = createUrlToken(),
110
+ watcherFactory = createMarkdownWatcher,
111
+ } = {},
97
112
  ) {
98
113
  const base = `/${token}`;
99
114
  const architectureEditors = new Map();
100
- const editingAvailable = editable === true;
115
+ const applicationMode = application === true;
116
+ const editingAvailable = applicationMode || editable === true;
117
+ const exportPdfImpl = exporters?.pdf ?? exportPdf;
118
+ const exportPptxImpl = exporters?.pptx ?? exportPptx;
119
+ let sourceMode = initialSourceMode === "live" ? "live" : "snapshot";
120
+ let sourceWatchStatus = "inactive";
121
+ let sourceWatchError = "";
122
+ let sourceWatcher = null;
123
+ let sourceWatcherGeneration = 0;
124
+ let sourceOperation = Promise.resolve();
101
125
  let port = 0;
102
126
 
127
+ const runSourceOperation = (operation) => {
128
+ const result = sourceOperation.then(operation, operation);
129
+ sourceOperation = result.catch(() => {});
130
+ return result;
131
+ };
132
+
133
+ const setWatchState = (status, error = "") => {
134
+ const changed = sourceWatchStatus !== status || sourceWatchError !== error;
135
+ sourceWatchStatus = status;
136
+ sourceWatchError = error;
137
+ session.watchStatus = status;
138
+ session.watchError = error;
139
+ if (changed && port) broadcast(session);
140
+ };
141
+
142
+ const closeSourceWatcher = () => {
143
+ sourceWatcherGeneration += 1;
144
+ sourceWatcher?.close();
145
+ sourceWatcher = null;
146
+ };
147
+
148
+ const reloadSourceNow = async (generation) => {
149
+ if (
150
+ generation !== sourceWatcherGeneration ||
151
+ sourceMode !== "live" ||
152
+ !session.file
153
+ ) {
154
+ return;
155
+ }
156
+ const sourceFile = session.file;
157
+ try {
158
+ if ((await readFile(sourceFile, "utf8")) === session.sourceMarkdown) {
159
+ setWatchState("watching");
160
+ return;
161
+ }
162
+ if (
163
+ generation !== sourceWatcherGeneration ||
164
+ sourceMode !== "live" ||
165
+ session.file !== sourceFile
166
+ ) {
167
+ return;
168
+ }
169
+ await session.load({ preserveIndex: true });
170
+ setWatchState("watching");
171
+ broadcast(session);
172
+ onLog?.(`reloaded ${session.sourceName} (${session.slides.length} slides)`);
173
+ } catch (error) {
174
+ if (
175
+ generation !== sourceWatcherGeneration ||
176
+ sourceMode !== "live" ||
177
+ session.file !== sourceFile
178
+ ) {
179
+ return;
180
+ }
181
+ setWatchState("error", error?.code || "source_reload_failed");
182
+ onLog?.(
183
+ `reload failed, keeping the last valid deck: ${error?.message || error}`,
184
+ "error",
185
+ );
186
+ }
187
+ };
188
+
189
+ const reloadSource = (generation) =>
190
+ runSourceOperation(() => reloadSourceNow(generation));
191
+
192
+ const bindSourceWatcher = () => {
193
+ closeSourceWatcher();
194
+ if (sourceMode !== "live" || !session.file) {
195
+ setWatchState("inactive");
196
+ return { ok: true };
197
+ }
198
+ try {
199
+ const generation = sourceWatcherGeneration;
200
+ sourceWatcher = watcherFactory({
201
+ path: session.file,
202
+ onChange: () => reloadSource(generation),
203
+ onError: (error) => {
204
+ setWatchState("error", "watch_failed");
205
+ onLog?.(`watch error: ${error?.message || error}`, "error");
206
+ },
207
+ });
208
+ setWatchState("watching");
209
+ return { ok: true, generation };
210
+ } catch (error) {
211
+ setWatchState("error", "watch_failed");
212
+ return {
213
+ ok: false,
214
+ error: "watch_failed",
215
+ message: error?.message || "The Markdown watcher could not be started.",
216
+ };
217
+ }
218
+ };
219
+
220
+ const setSourceMode = async (mode, { reload = true } = {}) => {
221
+ sourceMode = mode === "live" ? "live" : "snapshot";
222
+ const result = bindSourceWatcher();
223
+ if (sourceMode === "live" && result.ok && reload) {
224
+ await reloadSourceNow(result.generation);
225
+ }
226
+ return sourceMode === "live"
227
+ ? {
228
+ ok: true,
229
+ status: sourceWatchStatus,
230
+ error: sourceWatchError,
231
+ }
232
+ : { ok: true };
233
+ };
234
+
103
235
  const server = createServer(async (req, res) => {
104
236
  res.setHeader("X-Content-Type-Options", "nosniff");
105
237
  res.setHeader("Referrer-Policy", "no-referrer");
@@ -158,21 +290,23 @@ export async function startPresentationServer(
158
290
  customThemeCss: session.customThemeCss,
159
291
  customThemeMeta: session.customThemeMeta,
160
292
  mode: session.mode,
161
- sourceBacked: editingAvailable,
162
- sourceModeAvailable: false,
163
- sourceMode: editingAvailable ? "live" : "snapshot",
164
- sourceWatchStatus: session.watchStatus || "inactive",
165
- sourceWatchError: session.watchError || "",
293
+ sourceBacked: Boolean(session.file),
294
+ sourceModeAvailable: applicationMode && Boolean(session.file),
295
+ sourceMode,
296
+ sourceWatchStatus,
297
+ sourceWatchError,
166
298
  presenterRunning: Boolean(presenter?.isRunning?.()),
167
299
  presenterWindowAvailable: Boolean(presenter),
168
300
  presenterViewAvailable: Boolean(presenter),
169
- pdfExportAvailable: false,
170
- pptxExportAvailable: false,
171
- markdownImportAvailable: false,
172
- architectureEditAvailable: editingAvailable,
173
- architectureEdit: editingAvailable && Boolean(session.architectureEdit),
174
- architectureDetailedEdit: editingAvailable,
175
- architectureDetailedEditTarget: editingAvailable ? "window" : "",
301
+ pdfExportAvailable: applicationMode && Boolean(session.file),
302
+ pptxExportAvailable: applicationMode && Boolean(session.file),
303
+ markdownImportAvailable: applicationMode,
304
+ architectureEditAvailable: editingAvailable && Boolean(session.file),
305
+ architectureEdit:
306
+ editingAvailable && Boolean(session.file) && Boolean(session.architectureEdit),
307
+ architectureDetailedEdit: editingAvailable && Boolean(session.file),
308
+ architectureDetailedEditTarget:
309
+ editingAvailable && session.file ? "window" : "",
176
310
  });
177
311
  return;
178
312
  }
@@ -310,6 +444,10 @@ export async function startPresentationServer(
310
444
  }
311
445
 
312
446
  if (route === "/markdown-files") {
447
+ if (!applicationMode) {
448
+ json(res, 501, { ok: false, error: "not_supported" });
449
+ return;
450
+ }
313
451
  if (req.method !== "GET") {
314
452
  res.setHeader("Allow", "GET");
315
453
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -327,6 +465,10 @@ export async function startPresentationServer(
327
465
 
328
466
  // Loading another workspace Markdown file from the browser 📂 button.
329
467
  if (route === "/import") {
468
+ if (!applicationMode) {
469
+ json(res, 501, { ok: false, error: "not_supported" });
470
+ return;
471
+ }
330
472
  if (req.method !== "POST") {
331
473
  res.setHeader("Allow", "POST");
332
474
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -345,6 +487,12 @@ export async function startPresentationServer(
345
487
  return;
346
488
  }
347
489
  const requested = typeof body.path === "string" ? body.path.trim() : "";
490
+ const requestedSourceMode =
491
+ body.sourceMode === undefined ? "snapshot" : body.sourceMode;
492
+ if (requestedSourceMode !== "snapshot" && requestedSourceMode !== "live") {
493
+ json(res, 400, { ok: false, error: "invalid_source_mode" });
494
+ return;
495
+ }
348
496
  if (!requested) {
349
497
  json(res, 400, { ok: false, error: "path (string) is required" });
350
498
  return;
@@ -366,29 +514,72 @@ export async function startPresentationServer(
366
514
  json(res, 404, { ok: false, error: "file_not_found" });
367
515
  return;
368
516
  }
517
+ let sourceResult;
369
518
  try {
370
- session.file = canonical;
371
- session.sourceName = relative(root, canonical).split(sep).join("/");
372
- await session.load();
373
- broadcast(session);
519
+ sourceResult = await runSourceOperation(async () => {
520
+ await session.openFile(canonical);
521
+ return setSourceMode(requestedSourceMode);
522
+ });
374
523
  } catch (error) {
375
524
  json(res, 400, { ok: false, error: error?.code || "import_failed" });
376
525
  return;
377
526
  }
378
- json(res, 200, {
379
- ok: true,
527
+ broadcast(session);
528
+ json(res, sourceResult.ok ? 200 : 409, {
529
+ ...sourceResult,
380
530
  version: session.version,
381
531
  index: session.index,
382
532
  total: session.slides.length,
383
533
  theme: session.theme,
384
534
  sourceName: session.sourceName,
385
- sourceMode: "snapshot",
386
- sourceWatchStatus: session.watchStatus || "inactive",
535
+ sourceMode,
536
+ sourceWatchStatus,
387
537
  });
388
538
  return;
389
539
  }
390
540
 
391
- if (editingAvailable && route === "/edit-mode") {
541
+ if (applicationMode && route === "/source-mode") {
542
+ if (req.method !== "POST") {
543
+ res.setHeader("Allow", "POST");
544
+ json(res, 405, { ok: false, error: "method_not_allowed" });
545
+ return;
546
+ }
547
+ if (!sameOrigin()) {
548
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
549
+ return;
550
+ }
551
+ let body;
552
+ try {
553
+ body = await readJsonBody(req);
554
+ } catch (error) {
555
+ res.setHeader("Connection", "close");
556
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
557
+ ok: false,
558
+ error: error?.message || "bad_request",
559
+ });
560
+ return;
561
+ }
562
+ if (!session.file) {
563
+ json(res, 409, { ok: false, error: "no_deck" });
564
+ return;
565
+ }
566
+ if (body.mode !== "snapshot" && body.mode !== "live") {
567
+ json(res, 400, { ok: false, error: "invalid_source_mode" });
568
+ return;
569
+ }
570
+ const previous = sourceMode;
571
+ const result = await runSourceOperation(() => setSourceMode(body.mode));
572
+ json(res, result.ok ? 200 : 409, {
573
+ ...result,
574
+ changed: result.ok ? previous !== sourceMode : false,
575
+ sourceMode,
576
+ sourceWatchStatus,
577
+ sourceWatchError,
578
+ });
579
+ return;
580
+ }
581
+
582
+ if (editingAvailable && session.file && route === "/edit-mode") {
392
583
  if (req.method !== "POST") {
393
584
  res.setHeader("Allow", "POST");
394
585
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -423,7 +614,7 @@ export async function startPresentationServer(
423
614
  return;
424
615
  }
425
616
 
426
- if (editingAvailable && route === "/edit") {
617
+ if (editingAvailable && session.file && route === "/edit") {
427
618
  if (req.method !== "POST") {
428
619
  res.setHeader("Allow", "POST");
429
620
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -444,78 +635,97 @@ export async function startPresentationServer(
444
635
  });
445
636
  return;
446
637
  }
447
- if (!session.architectureEdit) {
448
- json(res, 409, { ok: false, error: "edit_mode_disabled" });
449
- return;
450
- }
451
638
  if (typeof body.source !== "string" || !body.source.trim()) {
452
639
  json(res, 400, { ok: false, error: "source (string) is required" });
453
640
  return;
454
641
  }
455
- const index = Number.isInteger(body.index) ? body.index : session.index;
456
- const block = Number.isInteger(body.block) ? body.block : 0;
457
- const deckVersion = Number.isInteger(body.deckVersion)
458
- ? body.deckVersion
459
- : session.deckVersion;
460
- if (index < 0 || index >= session.slides.length) {
461
- json(res, 400, { ok: false, error: "index_out_of_range" });
462
- return;
463
- }
464
- if (deckVersion !== session.deckVersion) {
465
- json(res, 409, { ok: false, error: "deck_changed" });
466
- return;
467
- }
468
- const globalBlock = importedArchitectureBlockIndex(session.slides, index, block);
469
- if (globalBlock === null) {
470
- json(res, 404, { ok: false, error: "block_not_found" });
471
- return;
472
- }
473
- const result = await saveArchitectureSource({
474
- workspaceRoot: session.workspaceRoot,
475
- sourcePath: session.sourceName,
476
- sourceFile: session.file,
477
- blockIndex: globalBlock,
478
- source: body.source,
479
- expectedMarkdown: session.sourceMarkdown,
480
- });
481
- if (!result.ok) {
482
- const status =
483
- result.error === "source_changed"
484
- ? 409
485
- : result.error === "block_not_found"
486
- ? 404
487
- : result.error === "source_file_too_large"
488
- ? 413
489
- : result.error === "source_write_failed"
490
- ? 500
491
- : 422;
492
- json(res, status, result);
493
- return;
494
- }
495
- try {
496
- await session.load({ preserveIndex: true });
497
- } catch (error) {
498
- json(res, 500, {
499
- ok: false,
500
- error: "source_reload_failed",
501
- message: error?.message || "The saved deck could not be reloaded.",
642
+ const response = await runSourceOperation(async () => {
643
+ if (!session.architectureEdit) {
644
+ return {
645
+ status: 409,
646
+ body: { ok: false, error: "edit_mode_disabled" },
647
+ };
648
+ }
649
+ const index = Number.isInteger(body.index) ? body.index : session.index;
650
+ const block = Number.isInteger(body.block) ? body.block : 0;
651
+ const deckVersion = Number.isInteger(body.deckVersion)
652
+ ? body.deckVersion
653
+ : session.deckVersion;
654
+ if (index < 0 || index >= session.slides.length) {
655
+ return {
656
+ status: 400,
657
+ body: { ok: false, error: "index_out_of_range" },
658
+ };
659
+ }
660
+ if (deckVersion !== session.deckVersion) {
661
+ return {
662
+ status: 409,
663
+ body: { ok: false, error: "deck_changed" },
664
+ };
665
+ }
666
+ const globalBlock = importedArchitectureBlockIndex(
667
+ session.slides,
668
+ index,
669
+ block,
670
+ );
671
+ if (globalBlock === null) {
672
+ return {
673
+ status: 404,
674
+ body: { ok: false, error: "block_not_found" },
675
+ };
676
+ }
677
+ const result = await saveArchitectureSource({
678
+ workspaceRoot: session.workspaceRoot,
679
+ sourcePath: session.sourceName,
680
+ sourceFile: session.file,
681
+ blockIndex: globalBlock,
682
+ source: body.source,
683
+ expectedMarkdown: session.sourceMarkdown,
502
684
  });
503
- return;
504
- }
505
- broadcast(session);
506
- json(res, 200, {
507
- ok: true,
508
- version: session.version,
509
- deckVersion: session.deckVersion,
510
- index,
511
- block,
512
- markdown: session.markdown,
513
- fileSaved: true,
685
+ if (!result.ok) {
686
+ const status =
687
+ result.error === "source_changed"
688
+ ? 409
689
+ : result.error === "block_not_found"
690
+ ? 404
691
+ : result.error === "source_file_too_large"
692
+ ? 413
693
+ : result.error === "source_write_failed"
694
+ ? 500
695
+ : 422;
696
+ return { status, body: result };
697
+ }
698
+ try {
699
+ await session.load({ preserveIndex: true });
700
+ } catch (error) {
701
+ return {
702
+ status: 500,
703
+ body: {
704
+ ok: false,
705
+ error: "source_reload_failed",
706
+ message: error?.message || "The saved deck could not be reloaded.",
707
+ },
708
+ };
709
+ }
710
+ return {
711
+ status: 200,
712
+ body: {
713
+ ok: true,
714
+ version: session.version,
715
+ deckVersion: session.deckVersion,
716
+ index,
717
+ block,
718
+ markdown: session.markdown,
719
+ fileSaved: true,
720
+ },
721
+ };
514
722
  });
723
+ if (response.status === 200) broadcast(session);
724
+ json(res, response.status, response.body);
515
725
  return;
516
726
  }
517
727
 
518
- if (editingAvailable && route === "/architecture-editor/open") {
728
+ if (editingAvailable && session.file && route === "/architecture-editor/open") {
519
729
  if (req.method !== "POST") {
520
730
  res.setHeader("Allow", "POST");
521
731
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -559,11 +769,13 @@ export async function startPresentationServer(
559
769
  theme: session.theme,
560
770
  logger: onLog,
561
771
  onMarkdownSaved: async ({ sourcePath }) => {
562
- if (resolve(session.workspaceRoot, sourcePath) !== resolve(session.file)) {
563
- return;
564
- }
565
- await session.load({ preserveIndex: true });
566
- broadcast(session);
772
+ await runSourceOperation(async () => {
773
+ if (resolve(session.workspaceRoot, sourcePath) !== resolve(session.file)) {
774
+ return;
775
+ }
776
+ await session.load({ preserveIndex: true });
777
+ broadcast(session);
778
+ });
567
779
  },
568
780
  });
569
781
  architectureEditors.set(key, entry);
@@ -608,6 +820,10 @@ export async function startPresentationServer(
608
820
  json(res, 403, { ok: false, error: "origin_not_allowed" });
609
821
  return;
610
822
  }
823
+ if (!session.slides.length) {
824
+ json(res, 409, { ok: false, error: "no_deck" });
825
+ return;
826
+ }
611
827
  try {
612
828
  const result = req.method === "POST" ? await presenter.open() : await presenter.close();
613
829
  json(res, 200, { ok: true, ...result });
@@ -621,11 +837,59 @@ export async function startPresentationServer(
621
837
  return;
622
838
  }
623
839
 
840
+ if (applicationMode && (route === "/export" || route === "/export-pptx")) {
841
+ if (req.method !== "POST") {
842
+ res.setHeader("Allow", "POST");
843
+ json(res, 405, { ok: false, error: "method_not_allowed" });
844
+ return;
845
+ }
846
+ if (!sameOrigin()) {
847
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
848
+ return;
849
+ }
850
+ if (!session.slides.length) {
851
+ json(res, 409, { ok: false, error: "no_deck" });
852
+ return;
853
+ }
854
+ const pptx = route === "/export-pptx";
855
+ try {
856
+ const result = pptx
857
+ ? await exportPptxImpl(
858
+ session,
859
+ pptxNameForSource(session.sourceName),
860
+ session.theme,
861
+ )
862
+ : await exportPdfImpl(
863
+ session,
864
+ pdfNameForSource(session.sourceName),
865
+ session.theme,
866
+ );
867
+ json(res, 200, result);
868
+ } catch (error) {
869
+ json(
870
+ res,
871
+ error?.code === "no_deck" || error?.code === "export_in_progress"
872
+ ? 409
873
+ : 500,
874
+ {
875
+ ok: false,
876
+ error:
877
+ error?.code || (pptx ? "pptx_export_failed" : "pdf_export_failed"),
878
+ message:
879
+ error?.message ||
880
+ (pptx ? "PowerPoint export failed." : "PDF export failed."),
881
+ },
882
+ );
883
+ }
884
+ return;
885
+ }
886
+
624
887
  // Routes that are unavailable in this CLI server mode stay explicit so the
625
888
  // browser reports an actionable message instead of a bare 404.
626
889
  if (
627
890
  route === "/present" ||
628
891
  route === "/export" ||
892
+ route === "/export-pptx" ||
629
893
  route === "/edit" ||
630
894
  route === "/edit-mode" ||
631
895
  route === "/source-mode" ||
@@ -730,14 +994,23 @@ export async function startPresentationServer(
730
994
  port = typeof address === "object" && address ? address.port : 0;
731
995
  const url = `http://127.0.0.1:${port}${base}/`;
732
996
  session.url = url;
997
+ const initialWatcher = bindSourceWatcher();
998
+ if (sourceMode === "live" && initialWatcher.ok) {
999
+ await runSourceOperation(() => reloadSourceNow(initialWatcher.generation));
1000
+ }
733
1001
 
734
1002
  return {
735
1003
  server,
736
1004
  url,
737
1005
  token,
738
1006
  port,
1007
+ get sourceMode() {
1008
+ return sourceMode;
1009
+ },
739
1010
  broadcast: () => broadcast(session),
740
1011
  close: async () => {
1012
+ closeSourceWatcher();
1013
+ await sourceOperation.catch(() => {});
741
1014
  await Promise.all(
742
1015
  [...architectureEditors.values()].map(async (entry) =>
743
1016
  (entry.editor ?? (await entry.promise.catch(() => null)))?.close().catch(() => {}),