@markdstage/markdstage 0.1.3 → 2.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.
@@ -18,7 +18,10 @@ import { dirname, join, relative, resolve, sep } 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 { importedArchitectureBlockIndex } from "../scripts/markdown-blocks.mjs";
21
22
  import { isMarkdownPath, listMarkdownFiles } from "../scripts/markdown-files.mjs";
23
+ import { startArchitectureEditorServer } from "./architecture-editor-server.mjs";
24
+ import { saveArchitectureSource } from "./architecture-source.mjs";
22
25
  import { isPathInside } from "./output-paths.mjs";
23
26
  import { safeJoin, sendChunkedVendorAsset, sendFile } from "./static-files.mjs";
24
27
 
@@ -27,6 +30,7 @@ const EXT_DIR = resolve(RUNTIME_DIR, "..");
27
30
  const VENDOR_DIR = join(EXT_DIR, "vendor");
28
31
  const VENDOR_MANIFEST = join(VENDOR_DIR, "vendor-assets.lock.json");
29
32
  const MAX_BODY_BYTES = 4096;
33
+ const MAX_EDIT_BODY_BYTES = 256 * 1024;
30
34
 
31
35
  export function createUrlToken() {
32
36
  return randomBytes(24).toString("base64url");
@@ -87,8 +91,13 @@ function broadcast(session) {
87
91
  * Returns `{ url, token, port, close, broadcast }`. `session.url` is set to the
88
92
  * token-scoped base URL so the shared output runtime can render from it.
89
93
  */
90
- export async function startPresentationServer(session, { onLog, token = createUrlToken() } = {}) {
94
+ export async function startPresentationServer(
95
+ session,
96
+ { editable = false, onLog, presenter, token = createUrlToken() } = {},
97
+ ) {
91
98
  const base = `/${token}`;
99
+ const architectureEditors = new Map();
100
+ const editingAvailable = editable === true;
92
101
  let port = 0;
93
102
 
94
103
  const server = createServer(async (req, res) => {
@@ -149,13 +158,21 @@ export async function startPresentationServer(session, { onLog, token = createUr
149
158
  customThemeCss: session.customThemeCss,
150
159
  customThemeMeta: session.customThemeMeta,
151
160
  mode: session.mode,
152
- sourceBacked: false,
153
- sourceMode: "snapshot",
161
+ sourceBacked: editingAvailable,
162
+ sourceModeAvailable: false,
163
+ sourceMode: editingAvailable ? "live" : "snapshot",
154
164
  sourceWatchStatus: session.watchStatus || "inactive",
155
165
  sourceWatchError: session.watchError || "",
156
- presenterRunning: false,
157
- architectureEdit: false,
158
- architectureDetailedEdit: false,
166
+ presenterRunning: Boolean(presenter?.isRunning?.()),
167
+ presenterWindowAvailable: Boolean(presenter),
168
+ 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" : "",
159
176
  });
160
177
  return;
161
178
  }
@@ -371,8 +388,241 @@ export async function startPresentationServer(session, { onLog, token = createUr
371
388
  return;
372
389
  }
373
390
 
374
- // Canvas-only routes. The CLI keeps them explicit so the browser reports an
375
- // actionable message instead of a bare 404.
391
+ if (editingAvailable && route === "/edit-mode") {
392
+ if (req.method !== "POST") {
393
+ res.setHeader("Allow", "POST");
394
+ json(res, 405, { ok: false, error: "method_not_allowed" });
395
+ return;
396
+ }
397
+ if (!sameOrigin()) {
398
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
399
+ return;
400
+ }
401
+ let body;
402
+ try {
403
+ body = await readJsonBody(req);
404
+ } catch (error) {
405
+ res.setHeader("Connection", "close");
406
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
407
+ ok: false,
408
+ error: error?.message || "bad_request",
409
+ });
410
+ return;
411
+ }
412
+ if (typeof body.enabled !== "boolean") {
413
+ json(res, 400, { ok: false, error: "enabled (boolean) is required" });
414
+ return;
415
+ }
416
+ const changed = Boolean(session.architectureEdit) !== body.enabled;
417
+ session.architectureEdit = body.enabled;
418
+ json(res, 200, {
419
+ ok: true,
420
+ changed,
421
+ architectureEdit: session.architectureEdit,
422
+ });
423
+ return;
424
+ }
425
+
426
+ if (editingAvailable && route === "/edit") {
427
+ if (req.method !== "POST") {
428
+ res.setHeader("Allow", "POST");
429
+ json(res, 405, { ok: false, error: "method_not_allowed" });
430
+ return;
431
+ }
432
+ if (!sameOrigin()) {
433
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
434
+ return;
435
+ }
436
+ let body;
437
+ try {
438
+ body = await readJsonBody(req, MAX_EDIT_BODY_BYTES);
439
+ } catch (error) {
440
+ res.setHeader("Connection", "close");
441
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
442
+ ok: false,
443
+ error: error?.message || "bad_request",
444
+ });
445
+ return;
446
+ }
447
+ if (!session.architectureEdit) {
448
+ json(res, 409, { ok: false, error: "edit_mode_disabled" });
449
+ return;
450
+ }
451
+ if (typeof body.source !== "string" || !body.source.trim()) {
452
+ json(res, 400, { ok: false, error: "source (string) is required" });
453
+ return;
454
+ }
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.",
502
+ });
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,
514
+ });
515
+ return;
516
+ }
517
+
518
+ if (editingAvailable && route === "/architecture-editor/open") {
519
+ if (req.method !== "POST") {
520
+ res.setHeader("Allow", "POST");
521
+ json(res, 405, { ok: false, error: "method_not_allowed" });
522
+ return;
523
+ }
524
+ if (!sameOrigin()) {
525
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
526
+ return;
527
+ }
528
+ let body;
529
+ try {
530
+ body = await readJsonBody(req);
531
+ } catch (error) {
532
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
533
+ ok: false,
534
+ error: error?.message || "bad_request",
535
+ });
536
+ return;
537
+ }
538
+ const slideIndex = Number.isInteger(body.index) ? body.index : session.index;
539
+ const blockIndex = Number.isInteger(body.block) ? body.block : 0;
540
+ const globalBlock = importedArchitectureBlockIndex(
541
+ session.slides,
542
+ slideIndex,
543
+ blockIndex,
544
+ );
545
+ if (globalBlock === null) {
546
+ json(res, 404, { ok: false, error: "block_not_found" });
547
+ return;
548
+ }
549
+ const key = `${session.file}\0${globalBlock}`;
550
+ let entry = architectureEditors.get(key);
551
+ const refreshExisting = Boolean(entry?.editor);
552
+ if (!entry) {
553
+ entry = { editor: null };
554
+ entry.promise = startArchitectureEditorServer({
555
+ extensionDirectory: EXT_DIR,
556
+ workspaceRoot: session.workspaceRoot,
557
+ sourcePath: session.sourceName,
558
+ blockIndex: globalBlock,
559
+ theme: session.theme,
560
+ logger: onLog,
561
+ onMarkdownSaved: async ({ sourcePath }) => {
562
+ if (resolve(session.workspaceRoot, sourcePath) !== resolve(session.file)) {
563
+ return;
564
+ }
565
+ await session.load({ preserveIndex: true });
566
+ broadcast(session);
567
+ },
568
+ });
569
+ architectureEditors.set(key, entry);
570
+ }
571
+ try {
572
+ const editor = entry.editor ?? (await entry.promise);
573
+ entry.editor = editor;
574
+ if (refreshExisting && !editor.dirty) {
575
+ await editor.reload(
576
+ {
577
+ sourcePath: session.sourceName,
578
+ blockIndex: globalBlock,
579
+ theme: session.theme,
580
+ },
581
+ { discard: true },
582
+ );
583
+ } else {
584
+ editor.setTheme(session.theme);
585
+ }
586
+ json(res, 200, { ok: true, url: editor.url });
587
+ } catch (error) {
588
+ if (!entry.editor && architectureEditors.get(key) === entry) {
589
+ architectureEditors.delete(key);
590
+ }
591
+ json(res, error?.code === "block_not_found" ? 404 : 409, {
592
+ ok: false,
593
+ error: error?.code || "editor_open_failed",
594
+ message: error?.message || "Architecture Editor could not be opened.",
595
+ });
596
+ return;
597
+ }
598
+ return;
599
+ }
600
+
601
+ if (presenter && route === "/present") {
602
+ if (req.method !== "POST" && req.method !== "DELETE") {
603
+ res.setHeader("Allow", "POST, DELETE");
604
+ json(res, 405, { ok: false, error: "method_not_allowed" });
605
+ return;
606
+ }
607
+ if (!sameOrigin()) {
608
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
609
+ return;
610
+ }
611
+ try {
612
+ const result = req.method === "POST" ? await presenter.open() : await presenter.close();
613
+ json(res, 200, { ok: true, ...result });
614
+ } catch (error) {
615
+ json(res, 500, {
616
+ ok: false,
617
+ error: error?.code || "presenter_launch_failed",
618
+ message: error?.message || "The audience view could not be updated.",
619
+ });
620
+ }
621
+ return;
622
+ }
623
+
624
+ // Routes that are unavailable in this CLI server mode stay explicit so the
625
+ // browser reports an actionable message instead of a bare 404.
376
626
  if (
377
627
  route === "/present" ||
378
628
  route === "/export" ||
@@ -487,8 +737,14 @@ export async function startPresentationServer(session, { onLog, token = createUr
487
737
  token,
488
738
  port,
489
739
  broadcast: () => broadcast(session),
490
- close: () =>
491
- new Promise((done) => {
740
+ close: async () => {
741
+ await Promise.all(
742
+ [...architectureEditors.values()].map(async (entry) =>
743
+ (entry.editor ?? (await entry.promise.catch(() => null)))?.close().catch(() => {}),
744
+ ),
745
+ );
746
+ architectureEditors.clear();
747
+ await new Promise((done) => {
492
748
  for (const client of [...session.clients]) {
493
749
  try {
494
750
  client.end();
@@ -499,7 +755,7 @@ export async function startPresentationServer(session, { onLog, token = createUr
499
755
  session.clients.clear();
500
756
  server.close(() => done());
501
757
  server.closeAllConnections?.();
502
- }),
758
+ });
759
+ },
503
760
  };
504
761
  }
505
-
package/src/cli.mjs CHANGED
@@ -36,11 +36,12 @@ import {
36
36
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
37
37
 
38
38
  const COMMANDS = [
39
+ ["presentation", "Open presenter view and launch the audience view from it."],
39
40
  ["present", "Serve a deck on loopback and open it in a browser window."],
40
41
  ["validate", "Check deck structure, Architecture DSL blocks, and themes."],
41
42
  ["inspect", "Report 1280x720 clipping diagnostics for a deck."],
42
43
  ["capture", "Write 1280x720 PNG files for selected or clipped slides."],
43
- ["export", "Export the deck as a 16:9 PDF."],
44
+ ["export", "Export the deck as PDF or editable PowerPoint."],
44
45
  ["guide", "Print the canonical MarkdStage authoring guide."],
45
46
  ["skill", "Install or check the portable MarkdStage Agent Skills."],
46
47
  ["help", "Show help for MarkdStage or for one command."],
@@ -58,6 +59,7 @@ const GLOBAL_OPTIONS = {
58
59
  };
59
60
 
60
61
  const COMMAND_OPTIONS = {
62
+ presentation: { watch: { type: "boolean" }, "no-open": { type: "boolean" } },
61
63
  present: { watch: { type: "boolean" }, "no-open": { type: "boolean" } },
62
64
  validate: {},
63
65
  inspect: { slide: { type: "string" }, all: { type: "boolean" }, "fail-on-issues": { type: "boolean" } },
@@ -101,12 +103,26 @@ function usage(command) {
101
103
  return lines.join("\n");
102
104
  }
103
105
  const help = {
106
+ presentation: [
107
+ "Usage: markdstage presentation <file.md> [options]",
108
+ "",
109
+ "Opens presenter view with the current slide, next-slide preview, and speaker notes.",
110
+ "Use Start presentation in that view to open the synchronized audience window.",
111
+ "",
112
+ " --watch Reload on save.",
113
+ " --no-open Serve the presenter view without launching a browser.",
114
+ "",
115
+ "Presentation requires an installed Microsoft Edge, Google Chrome, or Chromium.",
116
+ ],
104
117
  present: [
105
118
  "Usage: markdstage present <file.md> [options]",
106
119
  "",
107
- " --watch Reload the deck when the Markdown file is saved.",
120
+ " --watch Reload on save and enable Architecture editing.",
108
121
  " --no-open Serve the deck without launching a browser.",
109
122
  "",
123
+ "Without --watch, presentation is read-only. Watch mode starts in normal viewing mode;",
124
+ "use the pencil control to edit Architecture diagrams and open the detailed designer.",
125
+ "",
110
126
  "Presenting requires an installed Microsoft Edge, Google Chrome, or Chromium.",
111
127
  ],
112
128
  validate: [
@@ -130,9 +146,10 @@ function usage(command) {
130
146
  "Without --pages only the slides reported as clipped are captured.",
131
147
  ],
132
148
  export: [
133
- "Usage: markdstage export <file.md> [--output slides.pdf]",
149
+ "Usage: markdstage export <file.md> [--output slides.pdf|slides.pptx]",
134
150
  "",
135
- "Produces the same 16:9 PDF as the MarkdStage canvas.",
151
+ "Produces the same 16:9 PDF or hybrid editable PowerPoint as the MarkdStage canvas.",
152
+ "The output extension selects the format; omitting --output keeps PDF as the default.",
136
153
  ],
137
154
  guide: [
138
155
  "Usage: markdstage guide [topic] [--json]",
@@ -259,6 +276,27 @@ export async function run(argv, io = {}) {
259
276
  if (values.json) json(report);
260
277
  return EXIT_OK;
261
278
  }
279
+ case "presentation": {
280
+ const file = requireFile(positionals, "presentation");
281
+ const report = await presentCommand(
282
+ {
283
+ ...deckOptions(file, values),
284
+ watch: values.watch,
285
+ open: !values["no-open"],
286
+ presenterView: true,
287
+ until: io.until,
288
+ },
289
+ {
290
+ print: values.json ? () => {} : (message) => out(message),
291
+ status: (message, isError) => {
292
+ if (isError) err(message);
293
+ else if (!values.json) out(message);
294
+ },
295
+ },
296
+ );
297
+ if (values.json) json(report);
298
+ return EXIT_OK;
299
+ }
262
300
  case "validate": {
263
301
  const file = requireFile(positionals, "validate");
264
302
  const report = await validateCommand(deckOptions(file, values));
@@ -1,18 +1,38 @@
1
- // markdstage export — produce the same 16:9 PDF as the Canvas Extension.
1
+ // markdstage export — produce the same PDF or editable PowerPoint as the Canvas Extension.
2
2
 
3
- import { exportPdf, pdfNameForSource } from "../runtime.mjs";
3
+ import { extname } from "node:path";
4
+
5
+ import {
6
+ exportPdf,
7
+ exportPptx,
8
+ pdfNameForSource,
9
+ pptxNameForSource,
10
+ } from "../runtime.mjs";
4
11
  import { withDeckServer } from "../deck.mjs";
5
12
 
6
- export async function exportCommand(options) {
7
- return withDeckServer(options, async (session) =>
8
- exportPdf(
9
- session,
10
- options.output || pdfNameForSource(session.sourceName),
11
- options.theme,
12
- ),
13
- );
13
+ export async function exportCommand(
14
+ options,
15
+ exporters = { pdf: exportPdf, pptx: exportPptx },
16
+ ) {
17
+ return withDeckServer(options, async (session) => {
18
+ const requested = options.output || pdfNameForSource(session.sourceName);
19
+ const extension = extname(requested).toLowerCase();
20
+ if (extension === ".pptx") {
21
+ return exporters.pptx(
22
+ session,
23
+ options.output || pptxNameForSource(session.sourceName),
24
+ options.theme,
25
+ );
26
+ }
27
+ return exporters.pdf(session, requested, options.theme);
28
+ });
14
29
  }
15
30
 
16
31
  export function formatExportReport(report) {
17
- return `Exported ${report.total} slide(s) to ${report.path} (${report.bytes} bytes, theme ${report.theme}).`;
32
+ const format = report.format === "pptx" ? "PowerPoint" : "PDF";
33
+ const fallback =
34
+ report.format === "pptx" && report.fallbackCount
35
+ ? `, ${report.fallbackCount} fallback item(s)`
36
+ : "";
37
+ return `Exported ${report.total} slide(s) to ${report.path} (${report.bytes} bytes, ${format}, theme ${report.theme}${fallback}).`;
18
38
  }