@markdstage/markdstage 2.5.1 → 3.0.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.
@@ -250,12 +250,15 @@ body.mermaid-loading .mermaid{visibility:hidden;}
250
250
  .architecture-editor [data-architecture-movable="false"]{cursor:not-allowed;outline:none;}
251
251
  .architecture-editor [data-architecture-type]:focus-visible>rect,
252
252
  .architecture-editor [data-architecture-type]:focus-visible>ellipse,
253
+ .architecture-editor [data-architecture-type]:focus-visible>polygon,
253
254
  .architecture-editor .architecture-selected>rect,
254
- .architecture-editor .architecture-selected>ellipse{
255
+ .architecture-editor .architecture-selected>ellipse,
256
+ .architecture-editor .architecture-selected>polygon{
255
257
  stroke:var(--accent-strong);stroke-width:7;filter:drop-shadow(0 0 8px var(--accent-line));}
256
258
  /* Layout-managed elements cannot move. Show a dashed explanation only when selected. */
257
259
  .architecture-editor [data-architecture-movable="false"].architecture-selected>rect,
258
- .architecture-editor [data-architecture-movable="false"].architecture-selected>ellipse{
260
+ .architecture-editor [data-architecture-movable="false"].architecture-selected>ellipse,
261
+ .architecture-editor [data-architecture-movable="false"].architecture-selected>polygon{
259
262
  stroke-dasharray:10 8;}
260
263
  .architecture-error{display:flex;flex-direction:column;gap:.35em;margin:.5em 0;
261
264
  padding:.8em 1em;border:2px solid #d13438;border-radius:8px;
@@ -544,8 +547,22 @@ body.presenter-view-mode .layout-warning{display:none;}
544
547
  align-items:center;justify-content:center;border-radius:6px;
545
548
  font-size:.82em;font-weight:700;font-variant-numeric:tabular-nums;
546
549
  color:var(--muted);background:var(--code);border:1px solid var(--border);}
550
+ .overview-kind{flex:0 0 auto;padding:.18em .48em;border-radius:999px;
551
+ color:var(--accent-strong);background:var(--surface);border:1px solid var(--accent);
552
+ font-size:.66em;font-weight:750;letter-spacing:.06em;text-transform:uppercase;}
547
553
  .overview-label{flex:1 1 auto;font-size:.96em;line-height:1.35;
548
554
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
555
+ .overview-item-title{margin-bottom:.45em;}
556
+ .overview-item-title .overview-link{background:color-mix(in srgb,var(--accent-soft) 74%,transparent);
557
+ border:1px solid color-mix(in srgb,var(--accent) 48%,var(--border));}
558
+ .overview-item-title .overview-label{color:var(--fg);font-weight:700;}
559
+ .overview-item-section{margin-top:.65em;padding-top:.55em;border-top:1px solid var(--border);}
560
+ .overview-item-section .overview-link{padding-block:.65em;background:var(--accent-soft);
561
+ border-left:4px solid var(--accent);}
562
+ .overview-item-section .overview-num{color:var(--fg);border-color:var(--accent);}
563
+ .overview-item-section .overview-label{color:var(--fg);font-weight:700;}
564
+ .overview-item-section-child .overview-link{width:calc(100% - 1.2em);margin-left:1.2em;
565
+ border-left:1px solid color-mix(in srgb,var(--accent) 42%,var(--border));border-radius:0 8px 8px 0;}
549
566
  .overview-item.current .overview-link{background:var(--accent-soft);}
550
567
  .overview-item.current .overview-num{color:var(--fg);background:var(--accent);
551
568
  border-color:var(--accent);}
@@ -6,7 +6,7 @@
6
6
  // because the Extension is distributed as a folder ZIP.
7
7
 
8
8
  import { existsSync } from "node:fs";
9
- import { readFile, open, rm, stat } from "node:fs/promises";
9
+ import { readFile, open, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { execFileSync, spawn } from "node:child_process";
12
12
 
@@ -173,84 +173,6 @@ export function delay(milliseconds) {
173
173
  return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
174
174
  }
175
175
 
176
- /**
177
- * Run a headless browser once with `--print-to-pdf`.
178
- *
179
- * ⚠️ **`pageUrl` must include `?print=1&token=...` (#12).**
180
- *
181
- * `--print-to-pdf` completes only when the page becomes idle. In renderer `init()`,
182
- * only print mode returns early. Normal and presenter views keep an unclosed SSE
183
- * (`new EventSource("./events")`) and a two-second `setInterval` running.
184
- * Passing a URL without `?print=1` therefore means **the browser never exits**.
185
- *
186
- * Observed results, using Chrome arguments byte-for-byte identical to this function:
187
- *
188
- * | URL | Result |
189
- * | -------------------------- | --------------------------------------- |
190
- * | `/?print=1&token=<valid>` | exit 0 @ 2.4s (valid PDF) |
191
- * | `/?print=1&token=` (empty) | exit 0 @ 1.9s (blank; renderer reports failure) |
192
- * | `/` (normal view) | **HANG** (still running after 120 seconds) |
193
- * | `/?present=1` | **HANG** |
194
- * | `/nope-404` (no renderer) | exit 0 @ 3.0s |
195
- *
196
- * ⚠️ **`--virtual-time-budget` is effectively ignored by `--headless=new`.**
197
- * The `--virtual-time-budget=12000` argument below does not stop this hang.
198
- * Adding `--timeout=8000` is also ineffective, as verified empirically.
199
- * The argument is harmless and remains in place, but **do not treat it as a
200
- * wall-clock timeout**. Only Node's `PDF_RENDER_TIMEOUT_MS` and
201
- * `terminateProcessTree` enforce a limit, and failure may take up to 60 seconds.
202
- */
203
- export async function runHeadlessBrowser(browser, args, failureLabel) {
204
- await new Promise((resolvePromise, rejectPromise) => {
205
- const child = spawn(browser, args, {
206
- detached: process.platform !== "win32",
207
- windowsHide: true,
208
- stdio: ["ignore", "pipe", "pipe"],
209
- });
210
- let diagnostics = "";
211
- let settled = false;
212
- let timedOut = false;
213
- const appendDiagnostics = (chunk) => {
214
- diagnostics = `${diagnostics}${chunk.toString()}`.slice(-12_000);
215
- };
216
- child.stdout.on("data", appendDiagnostics);
217
- child.stderr.on("data", appendDiagnostics);
218
-
219
- const settle = (error) => {
220
- if (settled) return;
221
- settled = true;
222
- clearTimeout(timer);
223
- if (error) rejectPromise(error);
224
- else resolvePromise();
225
- };
226
- const timer = setTimeout(async () => {
227
- if (settled) return;
228
- timedOut = true;
229
- await terminateProcessTree(child);
230
- settle(new Error(`${failureLabel} timed out after ${PDF_RENDER_TIMEOUT_MS / 1000}s.`));
231
- }, PDF_RENDER_TIMEOUT_MS);
232
-
233
- child.once("error", (error) => {
234
- if (!timedOut) settle(error);
235
- });
236
- child.once("exit", (code, signal) => {
237
- if (timedOut) return;
238
- if (code === 0) {
239
- settle();
240
- return;
241
- }
242
- const detail = diagnostics.trim();
243
- settle(
244
- new Error(
245
- `${failureLabel} failed (${signal ? `signal ${signal}` : `exit ${code}`})${
246
- detail ? `: ${detail}` : "."
247
- }`,
248
- ),
249
- );
250
- });
251
- });
252
- }
253
-
254
176
  function withSandboxFallback(args) {
255
177
  if (
256
178
  process.platform !== "win32" &&
@@ -262,35 +184,38 @@ function withSandboxFallback(args) {
262
184
  return args;
263
185
  }
264
186
 
265
- export async function runPdfBrowser(browser, pageUrl, outputPath, profileDir) {
266
- // Enforce the contract at runtime. Otherwise it silently waits 60 seconds
267
- // before timing out, obscuring the cause; fail immediately with an explanation.
187
+ export async function runPdfBrowser(browser, pageUrl, outputPath, profileDir, job) {
268
188
  if (new URL(pageUrl).searchParams.get("print") !== "1") {
269
189
  throw new Error(
270
- `Refusing to run --print-to-pdf against a non-print URL (${pageUrl}): only ?print=1 stops the renderer's SSE and polling loops, so any other page hangs the browser forever.`,
190
+ `Refusing to render PDF from a non-print URL (${pageUrl}).`,
271
191
  );
272
192
  }
273
- const args = withSandboxFallback([
274
- "--headless=new",
275
- "--disable-gpu",
276
- "--disable-background-networking",
277
- "--disable-component-update",
278
- "--disable-default-apps",
279
- "--disable-extensions",
280
- "--force-color-profile=srgb",
281
- "--hide-scrollbars",
282
- "--no-first-run",
283
- "--no-pdf-header-footer",
284
- "--print-to-pdf-no-header",
285
- "--run-all-compositor-stages-before-draw",
286
- // Ineffective (#12): --headless=new ignores it. See the JSDoc above.
287
- // The actual safeguards are PDF_RENDER_TIMEOUT_MS + terminateProcessTree.
288
- "--virtual-time-budget=12000",
289
- `--user-data-dir=${profileDir}`,
290
- `--print-to-pdf=${outputPath}`,
291
- pageUrl,
292
- ]);
293
- await runHeadlessBrowser(browser, args, "Browser PDF rendering");
193
+ if (!job || typeof job !== "object") {
194
+ throw new Error("PDF rendering requires an output job.");
195
+ }
196
+
197
+ const { cdp, child } = await openCdpOutputPage(browser, pageUrl, profileDir, job);
198
+ try {
199
+ // The renderer reports ready only after Mermaid, images, fonts, and layout
200
+ // have settled. Give Chromium two compositor frames before printing.
201
+ await cdp.send("Runtime.evaluate", {
202
+ expression:
203
+ "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
204
+ awaitPromise: true,
205
+ });
206
+ const pdf = await cdp.send("Page.printToPDF", {
207
+ displayHeaderFooter: false,
208
+ printBackground: true,
209
+ preferCSSPageSize: true,
210
+ transferMode: "ReturnAsBase64",
211
+ });
212
+ if (typeof pdf.data !== "string" || pdf.data.length === 0) {
213
+ throw new Error("Chromium DevTools did not return PDF data.");
214
+ }
215
+ await writeFile(outputPath, Buffer.from(pdf.data, "base64"));
216
+ } finally {
217
+ await closeCdpOutputPage(cdp, child);
218
+ }
294
219
  }
295
220
 
296
221
  async function waitForDevToolsPort(profileDir, child, diagnostics) {
@@ -497,14 +497,14 @@ export async function exportPdf(inst, requestedPath, requestedTheme) {
497
497
  profileDir = await mkdtemp(join(tmpdir(), "markdstage-pdf-"));
498
498
  const outputBase = basename(outputPath, extname(outputPath)) || "markdstage";
499
499
  temporaryOutputPath = join(outputParent, `.${outputBase}.${token}.tmp.pdf`);
500
- inst.exportJobs.set(token, createOutputJob(snapshot, "pdf"));
500
+ const exportJob = createOutputJob(snapshot, "pdf");
501
+ inst.exportJobs.set(token, exportJob);
501
502
 
502
503
  const pageUrl = pageUrlFor(inst, { print: 1, token });
503
- await runPdfBrowser(browser, pageUrl, temporaryOutputPath, profileDir);
504
- const exportJob = inst.exportJobs.get(token);
505
- if (exportJob?.status !== "ready") {
504
+ await runPdfBrowser(browser, pageUrl, temporaryOutputPath, profileDir, exportJob);
505
+ if (exportJob.status !== "ready") {
506
506
  throw new Error(
507
- exportJob?.error || "The print renderer did not finish before the browser exited.",
507
+ exportJob.error || "The print renderer did not finish before PDF generation.",
508
508
  );
509
509
  }
510
510
  const bytes = await verifyPdf(temporaryOutputPath);
@@ -569,8 +569,16 @@ function nativeShapeXml(element, path, id, relationships) {
569
569
  rect: "rect",
570
570
  roundedRect: "roundRect",
571
571
  ellipse: "ellipse",
572
+ diamond: "diamond",
573
+ triangle: "triangle",
574
+ hexagon: "hexagon",
575
+ parallelogram: "parallelogram",
572
576
  }[element.shape];
573
- if (!preset) fail(`${path}.shape must be rect, roundedRect, or ellipse`);
577
+ if (!preset) {
578
+ fail(
579
+ `${path}.shape must be rect, roundedRect, ellipse, diamond, triangle, hexagon, or parallelogram`,
580
+ );
581
+ }
574
582
  const opacity = optionalUnitInterval(element.opacity, `${path}.opacity`);
575
583
  const properties = `${xfrmXml(bounds)}<a:prstGeom prst="${preset}"><a:avLst/></a:prstGeom>${colorXml(
576
584
  element.fill,
@@ -204,7 +204,17 @@
204
204
  "properties": {
205
205
  "type": { "const": "node" },
206
206
  "id": { "$ref": "#/$defs/identifier" },
207
- "shape": { "enum": ["rect", "rounded-rect", "ellipse"] },
207
+ "shape": {
208
+ "enum": [
209
+ "rect",
210
+ "rounded-rect",
211
+ "ellipse",
212
+ "diamond",
213
+ "triangle",
214
+ "hexagon",
215
+ "parallelogram"
216
+ ]
217
+ },
208
218
  "x": { "$ref": "#/$defs/coordinate" },
209
219
  "y": { "$ref": "#/$defs/coordinate" },
210
220
  "width": { "$ref": "#/$defs/extent" },
package/src/cli.mjs CHANGED
@@ -36,8 +36,8 @@ 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."],
40
- ["present", "Serve a deck on loopback and open it in a browser window."],
39
+ ["present", "Open presenter view and launch the audience view from it."],
40
+ ["preview", "Serve a deck on loopback and open it in a browser window."],
41
41
  ["validate", "Check deck structure, Architecture DSL blocks, and themes."],
42
42
  ["inspect", "Report 1280x720 clipping diagnostics for a deck."],
43
43
  ["capture", "Write 1280x720 PNG files for selected or clipped slides."],
@@ -59,8 +59,8 @@ const GLOBAL_OPTIONS = {
59
59
  };
60
60
 
61
61
  const COMMAND_OPTIONS = {
62
- presentation: { watch: { type: "boolean" }, "no-open": { type: "boolean" } },
63
62
  present: { watch: { type: "boolean" }, "no-open": { type: "boolean" } },
63
+ preview: { watch: { type: "boolean" }, "no-open": { type: "boolean" } },
64
64
  validate: {},
65
65
  inspect: { slide: { type: "string" }, all: { type: "boolean" }, "fail-on-issues": { type: "boolean" } },
66
66
  capture: { pages: { type: "string" }, output: { type: "string" } },
@@ -103,8 +103,8 @@ function usage(command) {
103
103
  return lines.join("\n");
104
104
  }
105
105
  const help = {
106
- presentation: [
107
- "Usage: markdstage presentation <file.md> [options]",
106
+ present: [
107
+ "Usage: markdstage present <file.md> [options]",
108
108
  "",
109
109
  "Opens presenter view with the current slide, next-slide preview, and speaker notes.",
110
110
  "Use Start presentation in that view to open the synchronized audience window.",
@@ -114,16 +114,16 @@ function usage(command) {
114
114
  "",
115
115
  "Presentation requires an installed Microsoft Edge, Google Chrome, or Chromium.",
116
116
  ],
117
- present: [
118
- "Usage: markdstage present <file.md> [options]",
117
+ preview: [
118
+ "Usage: markdstage preview <file.md> [options]",
119
119
  "",
120
120
  " --watch Reload on save and enable Architecture editing.",
121
121
  " --no-open Serve the deck without launching a browser.",
122
122
  "",
123
- "Without --watch, presentation is read-only. Watch mode starts in normal viewing mode;",
123
+ "Without --watch, preview is read-only. Watch mode starts in normal viewing mode;",
124
124
  "use the pencil control to edit Architecture diagrams and open the detailed designer.",
125
125
  "",
126
- "Presenting requires an installed Microsoft Edge, Google Chrome, or Chromium.",
126
+ "Preview requires an installed Microsoft Edge, Google Chrome, or Chromium.",
127
127
  ],
128
128
  validate: [
129
129
  "Usage: markdstage validate <file.md> [--json]",
@@ -262,8 +262,8 @@ export async function run(argv, io = {}) {
262
262
 
263
263
  try {
264
264
  switch (command) {
265
- case "present": {
266
- const file = requireFile(positionals, "present");
265
+ case "preview": {
266
+ const file = requireFile(positionals, "preview");
267
267
  const report = await presentCommand(
268
268
  { ...deckOptions(file, values), watch: values.watch, open: !values["no-open"], until: io.until },
269
269
  {
@@ -277,8 +277,8 @@ export async function run(argv, io = {}) {
277
277
  if (values.json) json(report);
278
278
  return EXIT_OK;
279
279
  }
280
- case "presentation": {
281
- const file = requireFile(positionals, "presentation");
280
+ case "present": {
281
+ const file = requireFile(positionals, "present");
282
282
  const report = await presentCommand(
283
283
  {
284
284
  ...deckOptions(file, values),
@@ -1,4 +1,4 @@
1
- // markdstage present serve the deck on loopback and open it in a browser.
1
+ // Shared server for the markdstage preview and present commands.
2
2
 
3
3
  import { mkdtemp, readFile, rm } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
@@ -139,7 +139,9 @@ export async function presentCommand(options, io) {
139
139
  });
140
140
  }
141
141
 
142
- io.print(`MarkdStage is presenting ${session.sourceName || session.file}`);
142
+ io.print(
143
+ `MarkdStage is ${options.presenterView ? "presenting" : "previewing"} ${session.sourceName || session.file}`,
144
+ );
143
145
  io.print(` slides: ${session.slides.length}`);
144
146
  io.print(` theme: ${session.theme}`);
145
147
  io.print(` workspace: ${resolve(session.workspaceRoot)}`);
package/src/skills.mjs CHANGED
@@ -78,7 +78,7 @@ what the MarkdStage canvas and MarkdStage Desktop render.
78
78
  \`references/slide-format.md\`).
79
79
  4. Validate structure, themes, and Architecture DSL before visual review:
80
80
  \`markdstage validate slides.md --json\`.
81
- 5. Use \`markdstage present slides.md --watch\` for live source-backed authoring.
81
+ 5. Use \`markdstage preview slides.md --watch\` for live source-backed authoring.
82
82
  It reloads on save without losing the current slide and keeps the last valid
83
83
  deck while a save is incomplete.
84
84
  6. Check fixed 16:9 output with \`markdstage inspect slides.md --json\`. Use
@@ -88,14 +88,14 @@ what the MarkdStage canvas and MarkdStage Desktop render.
88
88
  whose balance, spacing, or diagrams need visual judgment.
89
89
  8. Revise Markdown and repeat validation plus targeted inspection until the deck
90
90
  is valid, unclipped, concise, and visually balanced.
91
- 9. Deliver from the same source with \`markdstage presentation slides.md\`,
91
+ 9. Deliver from the same source with \`markdstage present slides.md\`,
92
92
  \`markdstage export slides.md --output slides.pdf\`, or
93
93
  \`markdstage export slides.md --output slides.pptx\`.
94
94
 
95
- The browser in \`present --watch\` starts in viewing mode. The user can activate
95
+ The browser in \`preview --watch\` starts in viewing mode. The user can activate
96
96
  the pencil control to move Architecture elements, then choose **Advanced edit**
97
97
  for the detailed designer. Placement changes save immediately, while the
98
- detailed designer saves only when the user selects **Save**. \`present\` without
98
+ detailed designer saves only when the user selects **Save**. \`preview\` without
99
99
  \`--watch\` is read-only.
100
100
 
101
101
  Never hand-write HTML or CSS for a slide. Fix layout problems by shortening the
@@ -106,8 +106,8 @@ and layout diagnostics over capturing every slide.
106
106
 
107
107
  | Command | Purpose |
108
108
  | --- | --- |
109
- | \`markdstage presentation <file> [--watch]\` | Open presenter view with the current slide, next-slide preview, speaker notes, and controls for a synchronized audience window. |
110
- | \`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. |
109
+ | \`markdstage present <file> [--watch]\` | Open presenter view with the current slide, next-slide preview, speaker notes, and controls for a synchronized audience window. |
110
+ | \`markdstage preview <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. |
111
111
  | \`markdstage validate <file> [--json]\` | Check deck structure, Architecture DSL blocks, and themes. |
112
112
  | \`markdstage inspect <file> [--json]\` | Report 1280x720 clipping diagnostics for the deck or one slide; use \`--fail-on-issues\` for quality gates. |
113
113
  | \`markdstage capture <file> [--pages 2,4]\` | Write 1280x720 PNG files; without \`--pages\` only clipped slides are captured. |