@markdstage/markdstage 3.3.0 → 3.8.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 +25 -13
- package/package.json +1 -1
- package/shared/README.md +47 -10
- package/shared/architecture-editor/editor.css +9 -5
- package/shared/architecture-editor/editor.js +440 -75
- package/shared/architecture-editor/index.html +2 -2
- package/shared/docs/custom-theme-authoring.md +61 -4
- package/shared/markdown-deck.mjs +9 -5
- package/shared/renderer/architecture-document.mjs +169 -10
- package/shared/renderer/index.html +24 -1
- package/shared/renderer/mermaid-scene.mjs +6725 -197
- package/shared/renderer/renderer.js +260 -55
- package/shared/renderer/scene-graph.mjs +83 -13
- package/shared/renderer/scene-pptx.mjs +154 -1
- package/shared/renderer/scene-svg.mjs +227 -11
- package/shared/renderer/slide-background.mjs +22 -0
- package/shared/renderer/slides.css +32 -6
- package/shared/renderer/theme.mjs +328 -12
- package/shared/runtime/browser.mjs +75 -5
- package/shared/runtime/deck-session.mjs +35 -9
- package/shared/runtime/output-paths.mjs +7 -0
- package/shared/runtime/output.mjs +4 -2
- package/shared/runtime/pptx-package.mjs +103 -22
- package/shared/runtime/presentation-server.mjs +410 -95
- package/shared/runtime/slide-backgrounds.mjs +44 -0
- package/shared/schema/theme-metadata-v1.schema.json +25 -0
- package/shared/schema/theme-v1.json +3 -3
- package/src/cli.mjs +101 -21
- package/src/commands/export.mjs +2 -0
- package/src/commands/present.mjs +133 -143
- package/src/deck.mjs +4 -0
- package/src/skills.mjs +12 -8
|
@@ -13,16 +13,24 @@
|
|
|
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,
|
|
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
|
+
import { loadSlideBackgrounds, resolveSlideBackgroundFile } from "./slide-backgrounds.mjs";
|
|
21
22
|
import { importedArchitectureBlockIndex } from "../scripts/markdown-blocks.mjs";
|
|
22
23
|
import { isMarkdownPath, listMarkdownFiles } from "../scripts/markdown-files.mjs";
|
|
24
|
+
import { createMarkdownWatcher } from "../scripts/markdown-watcher.mjs";
|
|
23
25
|
import { startArchitectureEditorServer } from "./architecture-editor-server.mjs";
|
|
24
26
|
import { saveArchitectureSource } from "./architecture-source.mjs";
|
|
25
|
-
import {
|
|
27
|
+
import { exportPdf, exportPptx } from "./output.mjs";
|
|
28
|
+
import {
|
|
29
|
+
isPathInside,
|
|
30
|
+
outputPathForSource,
|
|
31
|
+
pdfNameForSource,
|
|
32
|
+
pptxNameForSource,
|
|
33
|
+
} from "./output-paths.mjs";
|
|
26
34
|
import { safeJoin, sendChunkedVendorAsset, sendFile } from "./static-files.mjs";
|
|
27
35
|
|
|
28
36
|
const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -93,13 +101,139 @@ function broadcast(session) {
|
|
|
93
101
|
*/
|
|
94
102
|
export async function startPresentationServer(
|
|
95
103
|
session,
|
|
96
|
-
{
|
|
104
|
+
{
|
|
105
|
+
application = false,
|
|
106
|
+
editable = false,
|
|
107
|
+
exporters,
|
|
108
|
+
initialSourceMode = "snapshot",
|
|
109
|
+
onLog,
|
|
110
|
+
presenter,
|
|
111
|
+
token = createUrlToken(),
|
|
112
|
+
watcherFactory = createMarkdownWatcher,
|
|
113
|
+
} = {},
|
|
97
114
|
) {
|
|
98
115
|
const base = `/${token}`;
|
|
99
116
|
const architectureEditors = new Map();
|
|
100
|
-
const
|
|
117
|
+
const applicationMode = application === true;
|
|
118
|
+
const editingAvailable = applicationMode || editable === true;
|
|
119
|
+
const exportPdfImpl = exporters?.pdf ?? exportPdf;
|
|
120
|
+
const exportPptxImpl = exporters?.pptx ?? exportPptx;
|
|
121
|
+
let sourceMode = initialSourceMode === "live" ? "live" : "snapshot";
|
|
122
|
+
let sourceWatchStatus = "inactive";
|
|
123
|
+
let sourceWatchError = "";
|
|
124
|
+
let sourceWatcher = null;
|
|
125
|
+
let sourceWatcherGeneration = 0;
|
|
126
|
+
let sourceOperation = Promise.resolve();
|
|
101
127
|
let port = 0;
|
|
102
128
|
|
|
129
|
+
const runSourceOperation = (operation) => {
|
|
130
|
+
const result = sourceOperation.then(operation, operation);
|
|
131
|
+
sourceOperation = result.catch(() => {});
|
|
132
|
+
return result;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const setWatchState = (status, error = "") => {
|
|
136
|
+
const changed = sourceWatchStatus !== status || sourceWatchError !== error;
|
|
137
|
+
sourceWatchStatus = status;
|
|
138
|
+
sourceWatchError = error;
|
|
139
|
+
session.watchStatus = status;
|
|
140
|
+
session.watchError = error;
|
|
141
|
+
if (changed && port) broadcast(session);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const closeSourceWatcher = () => {
|
|
145
|
+
sourceWatcherGeneration += 1;
|
|
146
|
+
sourceWatcher?.close();
|
|
147
|
+
sourceWatcher = null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const reloadSourceNow = async (generation) => {
|
|
151
|
+
if (
|
|
152
|
+
generation !== sourceWatcherGeneration ||
|
|
153
|
+
sourceMode !== "live" ||
|
|
154
|
+
!session.file
|
|
155
|
+
) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const sourceFile = session.file;
|
|
159
|
+
try {
|
|
160
|
+
if ((await readFile(sourceFile, "utf8")) === session.sourceMarkdown) {
|
|
161
|
+
setWatchState("watching");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
generation !== sourceWatcherGeneration ||
|
|
166
|
+
sourceMode !== "live" ||
|
|
167
|
+
session.file !== sourceFile
|
|
168
|
+
) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await session.load({ preserveIndex: true });
|
|
172
|
+
setWatchState("watching");
|
|
173
|
+
broadcast(session);
|
|
174
|
+
onLog?.(`reloaded ${session.sourceName} (${session.slides.length} slides)`);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (
|
|
177
|
+
generation !== sourceWatcherGeneration ||
|
|
178
|
+
sourceMode !== "live" ||
|
|
179
|
+
session.file !== sourceFile
|
|
180
|
+
) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
setWatchState("error", error?.code || "source_reload_failed");
|
|
184
|
+
onLog?.(
|
|
185
|
+
`reload failed, keeping the last valid deck: ${error?.message || error}`,
|
|
186
|
+
"error",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const reloadSource = (generation) =>
|
|
192
|
+
runSourceOperation(() => reloadSourceNow(generation));
|
|
193
|
+
|
|
194
|
+
const bindSourceWatcher = () => {
|
|
195
|
+
closeSourceWatcher();
|
|
196
|
+
if (sourceMode !== "live" || !session.file) {
|
|
197
|
+
setWatchState("inactive");
|
|
198
|
+
return { ok: true };
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const generation = sourceWatcherGeneration;
|
|
202
|
+
sourceWatcher = watcherFactory({
|
|
203
|
+
path: session.file,
|
|
204
|
+
onChange: () => reloadSource(generation),
|
|
205
|
+
onError: (error) => {
|
|
206
|
+
setWatchState("error", "watch_failed");
|
|
207
|
+
onLog?.(`watch error: ${error?.message || error}`, "error");
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
setWatchState("watching");
|
|
211
|
+
return { ok: true, generation };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
setWatchState("error", "watch_failed");
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: "watch_failed",
|
|
217
|
+
message: error?.message || "The Markdown watcher could not be started.",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const setSourceMode = async (mode, { reload = true } = {}) => {
|
|
223
|
+
sourceMode = mode === "live" ? "live" : "snapshot";
|
|
224
|
+
const result = bindSourceWatcher();
|
|
225
|
+
if (sourceMode === "live" && result.ok && reload) {
|
|
226
|
+
await reloadSourceNow(result.generation);
|
|
227
|
+
}
|
|
228
|
+
return sourceMode === "live"
|
|
229
|
+
? {
|
|
230
|
+
ok: true,
|
|
231
|
+
status: sourceWatchStatus,
|
|
232
|
+
error: sourceWatchError,
|
|
233
|
+
}
|
|
234
|
+
: { ok: true };
|
|
235
|
+
};
|
|
236
|
+
|
|
103
237
|
const server = createServer(async (req, res) => {
|
|
104
238
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
105
239
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
@@ -158,21 +292,23 @@ export async function startPresentationServer(
|
|
|
158
292
|
customThemeCss: session.customThemeCss,
|
|
159
293
|
customThemeMeta: session.customThemeMeta,
|
|
160
294
|
mode: session.mode,
|
|
161
|
-
sourceBacked:
|
|
162
|
-
sourceModeAvailable:
|
|
163
|
-
sourceMode
|
|
164
|
-
sourceWatchStatus
|
|
165
|
-
sourceWatchError
|
|
295
|
+
sourceBacked: Boolean(session.file),
|
|
296
|
+
sourceModeAvailable: applicationMode && Boolean(session.file),
|
|
297
|
+
sourceMode,
|
|
298
|
+
sourceWatchStatus,
|
|
299
|
+
sourceWatchError,
|
|
166
300
|
presenterRunning: Boolean(presenter?.isRunning?.()),
|
|
167
301
|
presenterWindowAvailable: Boolean(presenter),
|
|
168
302
|
presenterViewAvailable: Boolean(presenter),
|
|
169
|
-
pdfExportAvailable:
|
|
170
|
-
pptxExportAvailable:
|
|
171
|
-
markdownImportAvailable:
|
|
172
|
-
architectureEditAvailable: editingAvailable,
|
|
173
|
-
architectureEdit:
|
|
174
|
-
|
|
175
|
-
|
|
303
|
+
pdfExportAvailable: applicationMode && Boolean(session.file),
|
|
304
|
+
pptxExportAvailable: applicationMode && Boolean(session.file),
|
|
305
|
+
markdownImportAvailable: applicationMode,
|
|
306
|
+
architectureEditAvailable: editingAvailable && Boolean(session.file),
|
|
307
|
+
architectureEdit:
|
|
308
|
+
editingAvailable && Boolean(session.file) && Boolean(session.architectureEdit),
|
|
309
|
+
architectureDetailedEdit: editingAvailable && Boolean(session.file),
|
|
310
|
+
architectureDetailedEditTarget:
|
|
311
|
+
editingAvailable && session.file ? "window" : "",
|
|
176
312
|
});
|
|
177
313
|
return;
|
|
178
314
|
}
|
|
@@ -267,6 +403,7 @@ export async function startPresentationServer(
|
|
|
267
403
|
themeLocked: job.themeLocked,
|
|
268
404
|
customThemeCss: job.customThemeCss,
|
|
269
405
|
customThemeMeta: job.customThemeMeta,
|
|
406
|
+
mermaidImageFallback: job.mermaidImageFallback === true,
|
|
270
407
|
});
|
|
271
408
|
return;
|
|
272
409
|
}
|
|
@@ -310,6 +447,10 @@ export async function startPresentationServer(
|
|
|
310
447
|
}
|
|
311
448
|
|
|
312
449
|
if (route === "/markdown-files") {
|
|
450
|
+
if (!applicationMode) {
|
|
451
|
+
json(res, 501, { ok: false, error: "not_supported" });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
313
454
|
if (req.method !== "GET") {
|
|
314
455
|
res.setHeader("Allow", "GET");
|
|
315
456
|
json(res, 405, { ok: false, error: "method_not_allowed" });
|
|
@@ -327,6 +468,10 @@ export async function startPresentationServer(
|
|
|
327
468
|
|
|
328
469
|
// Loading another workspace Markdown file from the browser 📂 button.
|
|
329
470
|
if (route === "/import") {
|
|
471
|
+
if (!applicationMode) {
|
|
472
|
+
json(res, 501, { ok: false, error: "not_supported" });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
330
475
|
if (req.method !== "POST") {
|
|
331
476
|
res.setHeader("Allow", "POST");
|
|
332
477
|
json(res, 405, { ok: false, error: "method_not_allowed" });
|
|
@@ -345,6 +490,12 @@ export async function startPresentationServer(
|
|
|
345
490
|
return;
|
|
346
491
|
}
|
|
347
492
|
const requested = typeof body.path === "string" ? body.path.trim() : "";
|
|
493
|
+
const requestedSourceMode =
|
|
494
|
+
body.sourceMode === undefined ? "snapshot" : body.sourceMode;
|
|
495
|
+
if (requestedSourceMode !== "snapshot" && requestedSourceMode !== "live") {
|
|
496
|
+
json(res, 400, { ok: false, error: "invalid_source_mode" });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
348
499
|
if (!requested) {
|
|
349
500
|
json(res, 400, { ok: false, error: "path (string) is required" });
|
|
350
501
|
return;
|
|
@@ -366,29 +517,72 @@ export async function startPresentationServer(
|
|
|
366
517
|
json(res, 404, { ok: false, error: "file_not_found" });
|
|
367
518
|
return;
|
|
368
519
|
}
|
|
520
|
+
let sourceResult;
|
|
369
521
|
try {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
522
|
+
sourceResult = await runSourceOperation(async () => {
|
|
523
|
+
await session.openFile(canonical);
|
|
524
|
+
return setSourceMode(requestedSourceMode);
|
|
525
|
+
});
|
|
374
526
|
} catch (error) {
|
|
375
527
|
json(res, 400, { ok: false, error: error?.code || "import_failed" });
|
|
376
528
|
return;
|
|
377
529
|
}
|
|
378
|
-
|
|
379
|
-
|
|
530
|
+
broadcast(session);
|
|
531
|
+
json(res, sourceResult.ok ? 200 : 409, {
|
|
532
|
+
...sourceResult,
|
|
380
533
|
version: session.version,
|
|
381
534
|
index: session.index,
|
|
382
535
|
total: session.slides.length,
|
|
383
536
|
theme: session.theme,
|
|
384
537
|
sourceName: session.sourceName,
|
|
385
|
-
sourceMode
|
|
386
|
-
sourceWatchStatus
|
|
538
|
+
sourceMode,
|
|
539
|
+
sourceWatchStatus,
|
|
540
|
+
});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (applicationMode && route === "/source-mode") {
|
|
545
|
+
if (req.method !== "POST") {
|
|
546
|
+
res.setHeader("Allow", "POST");
|
|
547
|
+
json(res, 405, { ok: false, error: "method_not_allowed" });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (!sameOrigin()) {
|
|
551
|
+
json(res, 403, { ok: false, error: "origin_not_allowed" });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
let body;
|
|
555
|
+
try {
|
|
556
|
+
body = await readJsonBody(req);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
res.setHeader("Connection", "close");
|
|
559
|
+
json(res, error?.message === "payload_too_large" ? 413 : 400, {
|
|
560
|
+
ok: false,
|
|
561
|
+
error: error?.message || "bad_request",
|
|
562
|
+
});
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (!session.file) {
|
|
566
|
+
json(res, 409, { ok: false, error: "no_deck" });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (body.mode !== "snapshot" && body.mode !== "live") {
|
|
570
|
+
json(res, 400, { ok: false, error: "invalid_source_mode" });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const previous = sourceMode;
|
|
574
|
+
const result = await runSourceOperation(() => setSourceMode(body.mode));
|
|
575
|
+
json(res, result.ok ? 200 : 409, {
|
|
576
|
+
...result,
|
|
577
|
+
changed: result.ok ? previous !== sourceMode : false,
|
|
578
|
+
sourceMode,
|
|
579
|
+
sourceWatchStatus,
|
|
580
|
+
sourceWatchError,
|
|
387
581
|
});
|
|
388
582
|
return;
|
|
389
583
|
}
|
|
390
584
|
|
|
391
|
-
if (editingAvailable && route === "/edit-mode") {
|
|
585
|
+
if (editingAvailable && session.file && route === "/edit-mode") {
|
|
392
586
|
if (req.method !== "POST") {
|
|
393
587
|
res.setHeader("Allow", "POST");
|
|
394
588
|
json(res, 405, { ok: false, error: "method_not_allowed" });
|
|
@@ -423,7 +617,7 @@ export async function startPresentationServer(
|
|
|
423
617
|
return;
|
|
424
618
|
}
|
|
425
619
|
|
|
426
|
-
if (editingAvailable && route === "/edit") {
|
|
620
|
+
if (editingAvailable && session.file && route === "/edit") {
|
|
427
621
|
if (req.method !== "POST") {
|
|
428
622
|
res.setHeader("Allow", "POST");
|
|
429
623
|
json(res, 405, { ok: false, error: "method_not_allowed" });
|
|
@@ -444,78 +638,102 @@ export async function startPresentationServer(
|
|
|
444
638
|
});
|
|
445
639
|
return;
|
|
446
640
|
}
|
|
447
|
-
if (!session.architectureEdit) {
|
|
448
|
-
json(res, 409, { ok: false, error: "edit_mode_disabled" });
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
641
|
if (typeof body.source !== "string" || !body.source.trim()) {
|
|
452
642
|
json(res, 400, { ok: false, error: "source (string) is required" });
|
|
453
643
|
return;
|
|
454
644
|
}
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
645
|
+
const response = await runSourceOperation(async () => {
|
|
646
|
+
if (!session.architectureEdit) {
|
|
647
|
+
return {
|
|
648
|
+
status: 409,
|
|
649
|
+
body: { ok: false, error: "edit_mode_disabled" },
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const index = Number.isInteger(body.index) ? body.index : session.index;
|
|
653
|
+
const block = Number.isInteger(body.block) ? body.block : 0;
|
|
654
|
+
const deckVersion = Number.isInteger(body.deckVersion)
|
|
655
|
+
? body.deckVersion
|
|
656
|
+
: session.deckVersion;
|
|
657
|
+
if (index < 0 || index >= session.slides.length) {
|
|
658
|
+
return {
|
|
659
|
+
status: 400,
|
|
660
|
+
body: { ok: false, error: "index_out_of_range" },
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (deckVersion !== session.deckVersion) {
|
|
664
|
+
return {
|
|
665
|
+
status: 409,
|
|
666
|
+
body: { ok: false, error: "deck_changed" },
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const globalBlock = importedArchitectureBlockIndex(
|
|
670
|
+
session.slides,
|
|
671
|
+
index,
|
|
672
|
+
block,
|
|
673
|
+
);
|
|
674
|
+
if (globalBlock === null) {
|
|
675
|
+
return {
|
|
676
|
+
status: 404,
|
|
677
|
+
body: { ok: false, error: "block_not_found" },
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
await loadSlideBackgrounds(session.workspaceRoot, session.sourceName, session.slides);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
return { status: 400, body: { ok: false, error: error.code, message: error.message } };
|
|
684
|
+
}
|
|
685
|
+
const result = await saveArchitectureSource({
|
|
686
|
+
workspaceRoot: session.workspaceRoot,
|
|
687
|
+
sourcePath: session.sourceName,
|
|
688
|
+
sourceFile: session.file,
|
|
689
|
+
blockIndex: globalBlock,
|
|
690
|
+
source: body.source,
|
|
691
|
+
expectedMarkdown: session.sourceMarkdown,
|
|
502
692
|
});
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
693
|
+
if (!result.ok) {
|
|
694
|
+
const status =
|
|
695
|
+
result.error === "source_changed"
|
|
696
|
+
? 409
|
|
697
|
+
: result.error === "block_not_found"
|
|
698
|
+
? 404
|
|
699
|
+
: result.error === "source_file_too_large"
|
|
700
|
+
? 413
|
|
701
|
+
: result.error === "source_write_failed"
|
|
702
|
+
? 500
|
|
703
|
+
: 422;
|
|
704
|
+
return { status, body: result };
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
await session.load({ preserveIndex: true });
|
|
708
|
+
} catch (error) {
|
|
709
|
+
return {
|
|
710
|
+
status: 500,
|
|
711
|
+
body: {
|
|
712
|
+
ok: false,
|
|
713
|
+
error: "source_reload_failed",
|
|
714
|
+
message: error?.message || "The saved deck could not be reloaded.",
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
status: 200,
|
|
720
|
+
body: {
|
|
721
|
+
ok: true,
|
|
722
|
+
version: session.version,
|
|
723
|
+
deckVersion: session.deckVersion,
|
|
724
|
+
index,
|
|
725
|
+
block,
|
|
726
|
+
markdown: session.markdown,
|
|
727
|
+
fileSaved: true,
|
|
728
|
+
},
|
|
729
|
+
};
|
|
514
730
|
});
|
|
731
|
+
if (response.status === 200) broadcast(session);
|
|
732
|
+
json(res, response.status, response.body);
|
|
515
733
|
return;
|
|
516
734
|
}
|
|
517
735
|
|
|
518
|
-
if (editingAvailable && route === "/architecture-editor/open") {
|
|
736
|
+
if (editingAvailable && session.file && route === "/architecture-editor/open") {
|
|
519
737
|
if (req.method !== "POST") {
|
|
520
738
|
res.setHeader("Allow", "POST");
|
|
521
739
|
json(res, 405, { ok: false, error: "method_not_allowed" });
|
|
@@ -559,11 +777,13 @@ export async function startPresentationServer(
|
|
|
559
777
|
theme: session.theme,
|
|
560
778
|
logger: onLog,
|
|
561
779
|
onMarkdownSaved: async ({ sourcePath }) => {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
780
|
+
await runSourceOperation(async () => {
|
|
781
|
+
if (resolve(session.workspaceRoot, sourcePath) !== resolve(session.file)) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
await session.load({ preserveIndex: true });
|
|
785
|
+
broadcast(session);
|
|
786
|
+
});
|
|
567
787
|
},
|
|
568
788
|
});
|
|
569
789
|
architectureEditors.set(key, entry);
|
|
@@ -608,6 +828,10 @@ export async function startPresentationServer(
|
|
|
608
828
|
json(res, 403, { ok: false, error: "origin_not_allowed" });
|
|
609
829
|
return;
|
|
610
830
|
}
|
|
831
|
+
if (!session.slides.length) {
|
|
832
|
+
json(res, 409, { ok: false, error: "no_deck" });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
611
835
|
try {
|
|
612
836
|
const result = req.method === "POST" ? await presenter.open() : await presenter.close();
|
|
613
837
|
json(res, 200, { ok: true, ...result });
|
|
@@ -621,11 +845,77 @@ export async function startPresentationServer(
|
|
|
621
845
|
return;
|
|
622
846
|
}
|
|
623
847
|
|
|
848
|
+
if (applicationMode && (route === "/export" || route === "/export-pptx")) {
|
|
849
|
+
if (req.method !== "POST") {
|
|
850
|
+
res.setHeader("Allow", "POST");
|
|
851
|
+
json(res, 405, { ok: false, error: "method_not_allowed" });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!sameOrigin()) {
|
|
855
|
+
json(res, 403, { ok: false, error: "origin_not_allowed" });
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (!session.slides.length) {
|
|
859
|
+
json(res, 409, { ok: false, error: "no_deck" });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const pptx = route === "/export-pptx";
|
|
863
|
+
let body = {};
|
|
864
|
+
if (pptx) {
|
|
865
|
+
try {
|
|
866
|
+
body = await readJsonBody(req);
|
|
867
|
+
if (!body || typeof body !== "object" || Array.isArray(body) ||
|
|
868
|
+
(body.mermaidImageFallback !== undefined && typeof body.mermaidImageFallback !== "boolean")) {
|
|
869
|
+
throw new Error("invalid_export_options");
|
|
870
|
+
}
|
|
871
|
+
} catch (error) {
|
|
872
|
+
json(res, error?.message === "payload_too_large" ? 413 : 400, {
|
|
873
|
+
ok: false,
|
|
874
|
+
error: error?.message || "bad_request",
|
|
875
|
+
});
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const result = pptx
|
|
881
|
+
? await exportPptxImpl(
|
|
882
|
+
session,
|
|
883
|
+
outputPathForSource(session.sourceName, pptxNameForSource(session.sourceName)),
|
|
884
|
+
session.theme,
|
|
885
|
+
undefined,
|
|
886
|
+
{ mermaidImageFallback: body.mermaidImageFallback === true },
|
|
887
|
+
)
|
|
888
|
+
: await exportPdfImpl(
|
|
889
|
+
session,
|
|
890
|
+
outputPathForSource(session.sourceName, pdfNameForSource(session.sourceName)),
|
|
891
|
+
session.theme,
|
|
892
|
+
);
|
|
893
|
+
json(res, 200, result);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
json(
|
|
896
|
+
res,
|
|
897
|
+
error?.code === "no_deck" || error?.code === "export_in_progress"
|
|
898
|
+
? 409
|
|
899
|
+
: 500,
|
|
900
|
+
{
|
|
901
|
+
ok: false,
|
|
902
|
+
error:
|
|
903
|
+
error?.code || (pptx ? "pptx_export_failed" : "pdf_export_failed"),
|
|
904
|
+
message:
|
|
905
|
+
error?.message ||
|
|
906
|
+
(pptx ? "PowerPoint export failed." : "PDF export failed."),
|
|
907
|
+
},
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
624
913
|
// Routes that are unavailable in this CLI server mode stay explicit so the
|
|
625
914
|
// browser reports an actionable message instead of a bare 404.
|
|
626
915
|
if (
|
|
627
916
|
route === "/present" ||
|
|
628
917
|
route === "/export" ||
|
|
918
|
+
route === "/export-pptx" ||
|
|
629
919
|
route === "/edit" ||
|
|
630
920
|
route === "/edit-mode" ||
|
|
631
921
|
route === "/source-mode" ||
|
|
@@ -694,6 +984,22 @@ export async function startPresentationServer(
|
|
|
694
984
|
return;
|
|
695
985
|
}
|
|
696
986
|
|
|
987
|
+
if (route.startsWith("/background-assets/")) {
|
|
988
|
+
try {
|
|
989
|
+
const file = await resolveSlideBackgroundFile(
|
|
990
|
+
session.workspaceRoot,
|
|
991
|
+
session.sourceName,
|
|
992
|
+
`/assets/${route.slice("/background-assets/".length)}`,
|
|
993
|
+
);
|
|
994
|
+
await sendFile(res, file, { cache: false });
|
|
995
|
+
} catch (error) {
|
|
996
|
+
res.statusCode = error?.code === "slide_background_too_large" ? 413
|
|
997
|
+
: error?.code === "invalid_slide_background" ? 403 : 404;
|
|
998
|
+
res.end(error.message);
|
|
999
|
+
}
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
697
1003
|
if (route.startsWith("/assets/")) {
|
|
698
1004
|
try {
|
|
699
1005
|
const abs = await resolveAssetFile(
|
|
@@ -730,14 +1036,23 @@ export async function startPresentationServer(
|
|
|
730
1036
|
port = typeof address === "object" && address ? address.port : 0;
|
|
731
1037
|
const url = `http://127.0.0.1:${port}${base}/`;
|
|
732
1038
|
session.url = url;
|
|
1039
|
+
const initialWatcher = bindSourceWatcher();
|
|
1040
|
+
if (sourceMode === "live" && initialWatcher.ok) {
|
|
1041
|
+
await runSourceOperation(() => reloadSourceNow(initialWatcher.generation));
|
|
1042
|
+
}
|
|
733
1043
|
|
|
734
1044
|
return {
|
|
735
1045
|
server,
|
|
736
1046
|
url,
|
|
737
1047
|
token,
|
|
738
1048
|
port,
|
|
1049
|
+
get sourceMode() {
|
|
1050
|
+
return sourceMode;
|
|
1051
|
+
},
|
|
739
1052
|
broadcast: () => broadcast(session),
|
|
740
1053
|
close: async () => {
|
|
1054
|
+
closeSourceWatcher();
|
|
1055
|
+
await sourceOperation.catch(() => {});
|
|
741
1056
|
await Promise.all(
|
|
742
1057
|
[...architectureEditors.values()].map(async (entry) =>
|
|
743
1058
|
(entry.editor ?? (await entry.promise.catch(() => null)))?.close().catch(() => {}),
|