@markdstage/markdstage 0.1.1 → 2.3.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.
@@ -1,6 +1,6 @@
1
1
  // markdstage present — serve the deck on loopback and open it in a browser.
2
2
 
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { spawn } from "node:child_process";
@@ -19,73 +19,162 @@ async function createWatcher(session, server, { onStatus }) {
19
19
  const { createMarkdownWatcher } = await import(
20
20
  pathToFileURL(sharedPath("scripts", "markdown-watcher.mjs")).href
21
21
  );
22
- return createMarkdownWatcher({
22
+ const setWatchState = (status, error = "") => {
23
+ const changed = session.watchStatus !== status || session.watchError !== error;
24
+ session.watchStatus = status;
25
+ session.watchError = error;
26
+ if (changed) server.broadcast();
27
+ };
28
+ const watcher = createMarkdownWatcher({
23
29
  path: session.file,
24
30
  onChange: async () => {
25
31
  try {
32
+ if ((await readFile(session.file, "utf8")) === session.sourceMarkdown) {
33
+ setWatchState("watching");
34
+ return;
35
+ }
26
36
  // Keep the current slide, and keep the last valid deck when the file is
27
37
  // saved in a broken intermediate state.
28
38
  await session.load({ preserveIndex: true });
39
+ setWatchState("watching");
29
40
  server.broadcast();
30
41
  onStatus(`reloaded ${session.sourceName} (${session.slides.length} slides)`);
31
42
  } catch (error) {
43
+ setWatchState("error", error?.code || "source_reload_failed");
32
44
  onStatus(`reload failed, keeping the last valid deck: ${error?.message || error}`, true);
33
45
  }
34
46
  },
35
- onError: (error) => onStatus(`watch error: ${error?.message || error}`, true),
47
+ onError: (error) => {
48
+ setWatchState("error", "watch_failed");
49
+ onStatus(`watch error: ${error?.message || error}`, true);
50
+ },
36
51
  });
52
+ setWatchState("watching");
53
+ return watcher;
37
54
  }
38
55
 
39
56
  export async function presentCommand(options, io) {
40
- return withDeckServer(options, async (session, server) => {
41
- const watcher = options.watch ? await createWatcher(session, server, {
42
- onStatus: (message, isError) => io.status(message, isError),
43
- }) : null;
57
+ let audienceProcess = null;
58
+ let audienceProfileDir = "";
59
+ let audienceUrl = "";
44
60
 
45
- let browserProcess = null;
46
- let profileDir = "";
47
- if (options.open) {
48
- const browser = findChromiumBrowser();
49
- if (!browser) {
50
- throw new MarkdStageError(
51
- "presenter_browser_not_found",
52
- "Presenting requires Microsoft Edge, Google Chrome, or Chromium. Re-run with --no-open to serve the deck only.",
53
- );
54
- }
55
- profileDir = await mkdtemp(join(tmpdir(), "markdstage-presenter-window-"));
56
- browserProcess = spawn(
57
- browser,
58
- buildPresenterBrowserArgs({ profileDir, presenterUrl: server.url }),
59
- { windowsHide: false, stdio: "ignore" },
60
- );
61
- browserProcess.once("error", (error) => {
62
- io.status(`browser failed to start: ${error?.message || error}`, true);
63
- });
61
+ const closeAudience = async () => {
62
+ const process = audienceProcess;
63
+ audienceProcess = null;
64
+ if (isProcessRunning(process)) await terminateProcessTree(process);
65
+ if (audienceProfileDir) {
66
+ await rm(audienceProfileDir, { recursive: true, force: true }).catch(() => {});
67
+ audienceProfileDir = "";
64
68
  }
69
+ return { stopped: Boolean(process) };
70
+ };
65
71
 
66
- io.print(`MarkdStage is presenting ${session.sourceName || session.file}`);
67
- io.print(` slides: ${session.slides.length}`);
68
- io.print(` theme: ${session.theme}`);
69
- io.print(` workspace: ${resolve(session.workspaceRoot)}`);
70
- io.print(` url: ${server.url}`);
71
- if (options.watch) io.print(" watching: on (the deck reloads when the file is saved)");
72
- io.print("Press Ctrl+C to stop.");
72
+ const audience = options.presenterView
73
+ ? {
74
+ isRunning: () => isProcessRunning(audienceProcess),
75
+ open: async () => {
76
+ if (isProcessRunning(audienceProcess)) return { alreadyRunning: true };
77
+ await closeAudience();
78
+ const browser = findChromiumBrowser();
79
+ if (!browser) {
80
+ throw new MarkdStageError(
81
+ "presenter_browser_not_found",
82
+ "Opening the audience view requires Microsoft Edge, Google Chrome, or Chromium.",
83
+ );
84
+ }
85
+ audienceProfileDir = await mkdtemp(join(tmpdir(), "markdstage-audience-window-"));
86
+ audienceProcess = spawn(
87
+ browser,
88
+ buildPresenterBrowserArgs({
89
+ profileDir: audienceProfileDir,
90
+ presenterUrl: audienceUrl,
91
+ }),
92
+ { windowsHide: false, stdio: "ignore" },
93
+ );
94
+ await new Promise((ready, reject) => {
95
+ audienceProcess.once("spawn", ready);
96
+ audienceProcess.once("error", reject);
97
+ });
98
+ return { alreadyRunning: false };
99
+ },
100
+ close: closeAudience,
101
+ }
102
+ : null;
73
103
 
74
- await new Promise((done) => {
75
- const stop = () => {
76
- process.off("SIGINT", stop);
77
- process.off("SIGTERM", stop);
78
- done();
79
- };
80
- process.once("SIGINT", stop);
81
- process.once("SIGTERM", stop);
82
- if (browserProcess) browserProcess.once("close", stop);
83
- if (options.until) options.until.then(stop, stop);
84
- });
104
+ try {
105
+ return await withDeckServer({ ...options, presenter: audience }, async (session, server) => {
106
+ if (audience) {
107
+ const url = new URL(server.url);
108
+ url.searchParams.set("present", "1");
109
+ audienceUrl = url.href;
110
+ }
111
+ const watcher = options.watch
112
+ ? await createWatcher(session, server, {
113
+ onStatus: (message, isError) => io.status(message, isError),
114
+ })
115
+ : null;
85
116
 
86
- watcher?.close();
87
- if (isProcessRunning(browserProcess)) await terminateProcessTree(browserProcess);
88
- if (profileDir) await rm(profileDir, { recursive: true, force: true }).catch(() => {});
89
- return { ok: true, url: server.url, total: session.slides.length, theme: session.theme };
90
- });
117
+ let browserProcess = null;
118
+ let profileDir = "";
119
+ const browserUrl = new URL(server.url);
120
+ if (options.presenterView) browserUrl.searchParams.set("presenter", "1");
121
+ try {
122
+ if (options.open) {
123
+ const browser = findChromiumBrowser();
124
+ if (!browser) {
125
+ throw new MarkdStageError(
126
+ "presenter_browser_not_found",
127
+ "Presenting requires Microsoft Edge, Google Chrome, or Chromium. Re-run with --no-open to serve the deck only.",
128
+ );
129
+ }
130
+ profileDir = await mkdtemp(join(tmpdir(), "markdstage-presenter-window-"));
131
+ browserProcess = spawn(
132
+ browser,
133
+ buildPresenterBrowserArgs({ profileDir, presenterUrl: browserUrl.href }),
134
+ { windowsHide: false, stdio: "ignore" },
135
+ );
136
+ await new Promise((ready, reject) => {
137
+ browserProcess.once("spawn", ready);
138
+ browserProcess.once("error", reject);
139
+ });
140
+ }
141
+
142
+ io.print(`MarkdStage is presenting ${session.sourceName || session.file}`);
143
+ io.print(` slides: ${session.slides.length}`);
144
+ io.print(` theme: ${session.theme}`);
145
+ io.print(` workspace: ${resolve(session.workspaceRoot)}`);
146
+ io.print(` url: ${browserUrl.href}`);
147
+ if (options.watch) {
148
+ io.print(" watching: on (live reload and Architecture editing are enabled)");
149
+ }
150
+ io.print("Press Ctrl+C to stop.");
151
+
152
+ await new Promise((done) => {
153
+ const stop = () => {
154
+ process.off("SIGINT", stop);
155
+ process.off("SIGTERM", stop);
156
+ done();
157
+ };
158
+ process.once("SIGINT", stop);
159
+ process.once("SIGTERM", stop);
160
+ if (browserProcess) browserProcess.once("close", stop);
161
+ if (options.until) options.until.then(stop, stop);
162
+ });
163
+
164
+ return {
165
+ ok: true,
166
+ url: browserUrl.href,
167
+ total: session.slides.length,
168
+ theme: session.theme,
169
+ };
170
+ } finally {
171
+ watcher?.close();
172
+ await closeAudience();
173
+ if (isProcessRunning(browserProcess)) await terminateProcessTree(browserProcess);
174
+ if (profileDir) await rm(profileDir, { recursive: true, force: true }).catch(() => {});
175
+ }
176
+ });
177
+ } finally {
178
+ await closeAudience();
179
+ }
91
180
  }
package/src/deck.mjs CHANGED
@@ -23,6 +23,8 @@ export async function withDeckServer(options, run) {
23
23
  const server = await startPresentationServer(session, {
24
24
  token,
25
25
  onLog: options.log,
26
+ editable: options.watch === true,
27
+ presenter: options.presenter,
26
28
  });
27
29
  try {
28
30
  return await run(session, server);
package/src/runtime.mjs CHANGED
@@ -64,9 +64,15 @@ export const { MarkdStageError } = errors;
64
64
  export const { createDeckSession, readDeckSlides, resolveDeckFile, resolveDeckTheme } =
65
65
  deckSession;
66
66
  export const { createUrlToken, startPresentationServer } = presentationServer;
67
- export const { captureSlides, exportPdf, inspectLayout, MAX_CAPTURE_SLIDES } = output;
67
+ export const {
68
+ captureSlides,
69
+ exportPdf,
70
+ exportPptx,
71
+ inspectLayout,
72
+ MAX_CAPTURE_SLIDES,
73
+ } = output;
68
74
  export const { findChromiumBrowser, terminateProcessTree, isProcessRunning } = browser;
69
- export const { captureDirectoryName, pdfNameForSource } = outputPaths;
75
+ export const { captureDirectoryName, pdfNameForSource, pptxNameForSource } = outputPaths;
70
76
  export const {
71
77
  architectureValidationErrors,
72
78
  deckValidationFeedback,
package/src/skills.mjs CHANGED
@@ -27,8 +27,8 @@ const DESCRIPTION =
27
27
  "Turn Markdown into 16:9 slides with the MarkdStage CLI. Use when the user " +
28
28
  'asks to present, preview, validate, screenshot, or export a Markdown deck ("present slides.md", ' +
29
29
  '"turn this file into slides", "export the deck to PDF", "check whether my slides fit"). ' +
30
- "Provides deterministic commands for presenting in a browser, validating Architecture DSL and " +
31
- "themes, inspecting 1280x720 clipping, capturing PNGs, and exporting PDF.";
30
+ "Provides deterministic commands for presenting and visually editing Architecture DSL in a " +
31
+ "browser, validating themes, inspecting 1280x720 clipping, capturing PNGs, and exporting PDF.";
32
32
 
33
33
  function frontMatter(fields) {
34
34
  const lines = ["---"];
@@ -74,6 +74,12 @@ what the MarkdStage canvas and MarkdStage Desktop render.
74
74
  4. Capture clipped slides for review: \`markdstage capture slides.md\`.
75
75
  5. Present or export: \`markdstage present slides.md --watch\` / \`markdstage export slides.md\`.
76
76
 
77
+ Use \`present --watch\` for live authoring. It starts in viewing mode; the user can
78
+ activate the pencil control to move Architecture elements, then choose **Advanced
79
+ edit** for the detailed designer. Placement changes save immediately, while the
80
+ detailed designer saves only when the user selects **Save**. \`present\` without
81
+ \`--watch\` is read-only.
82
+
77
83
  Never hand-write HTML or CSS for a slide. Fix layout problems by shortening the
78
84
  content or by changing the layout in front matter.
79
85
 
@@ -81,7 +87,7 @@ content or by changing the layout in front matter.
81
87
 
82
88
  | Command | Purpose |
83
89
  | --- | --- |
84
- | \`markdstage present <file> [--watch]\` | Serve the deck on loopback and open it in a browser window. \`--watch\` reloads on save and keeps the current slide. |
90
+ | \`markdstage present <file> [--watch]\` | Serve the deck on loopback and open it in a browser window. \`--watch\` reloads on save, keeps the current slide, and enables Architecture placement and detailed editing. Without it, the source is read-only. |
85
91
  | \`markdstage validate <file> [--json]\` | Check deck structure, Architecture DSL blocks, and themes. |
86
92
  | \`markdstage inspect <file> [--json]\` | Report 1280x720 clipping diagnostics for the deck or one slide. |
87
93
  | \`markdstage capture <file> [--pages 2,4]\` | Write 1280x720 PNG files; without \`--pages\` only clipped slides are captured. |