@open-press/core 1.1.3 → 1.2.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.
@@ -51,8 +51,9 @@ export function HtmlWorkbench({
51
51
  document,
52
52
  pages,
53
53
  style,
54
- devMode,
54
+ workspaceMode,
55
55
  deploymentInfo,
56
+ pressSlug = null,
56
57
  onDocumentRefresh,
57
58
  onBackToWorkspace,
58
59
  onOpenPresentation,
@@ -61,8 +62,12 @@ export function HtmlWorkbench({
61
62
  document: ReaderDocument;
62
63
  pages: Array<HtmlPageBlock>;
63
64
  style: CSSProperties;
64
- devMode: boolean;
65
+ workspaceMode: boolean;
65
66
  deploymentInfo: DeploymentInfo;
67
+ // Active Press slug — threaded down to useDeploymentWorkbench so the
68
+ // local PDF export endpoint can pick the right Press in multi-Press
69
+ // workspaces. Null when the workspace is at the gallery root.
70
+ pressSlug?: string | null;
66
71
  onDocumentRefresh?: () => void | Promise<void>;
67
72
  onBackToWorkspace?: () => void;
68
73
  onOpenPresentation?: (pageIndex: number) => void;
@@ -83,7 +88,7 @@ export function HtmlWorkbench({
83
88
  sourceBlocksByPath,
84
89
  projectMentionItems,
85
90
  } = useDocumentWorkbenchModel(document, displayPages);
86
- const inspector = useInspector(document, { enabled: devMode });
91
+ const inspector = useInspector(document, { enabled: workspaceMode });
87
92
  const reader = useReaderRuntime({
88
93
  pageCount: Math.max(displayPages.length, 1),
89
94
  leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
@@ -96,7 +101,7 @@ export function HtmlWorkbench({
96
101
  pageCount: displayPages.length,
97
102
  layoutMode: pageLayoutMode,
98
103
  });
99
- const deployment = useDeploymentWorkbench({ deploymentInfo });
104
+ const deployment = useDeploymentWorkbench({ deploymentInfo, pressSlug });
100
105
  const [inlineEditStatus, setInlineEditStatus] = useState<InlineDocumentEditStatus>({ state: "idle" });
101
106
  const [sourceEditorTarget, setSourceEditorTarget] = useState<InlineDocumentSourceTarget | null>(null);
102
107
 
@@ -117,7 +122,7 @@ export function HtmlWorkbench({
117
122
  // text cursor active would (a) show the I-beam instead of the inspector
118
123
  // crosshair, (b) allow accidental text selection that paints the whole
119
124
  // page (notably covers) with the browser ::selection color.
120
- const inlineEditEnabled = devMode && !inspector.inspectorMode;
125
+ const inlineEditEnabled = workspaceMode && !inspector.inspectorMode;
121
126
  useInlineDocumentEditor({
122
127
  enabled: inlineEditEnabled,
123
128
  sourceContainerRef,
@@ -146,7 +151,7 @@ export function HtmlWorkbench({
146
151
  };
147
152
 
148
153
  const comments = useInspectorComments({
149
- devMode,
154
+ workspaceMode,
150
155
  inspector,
151
156
  sourceBlockMap,
152
157
  sourceBlocksByPath,
@@ -324,13 +329,13 @@ export function HtmlWorkbench({
324
329
  />
325
330
  </div>
326
331
  <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="工作台狀態與發布">
327
- {devMode ? (
332
+ {workspaceMode ? (
328
333
  <SearchControl
329
334
  sourceBlocksByPath={sourceBlocksByPath}
330
335
  onSelectPage={selectWorkspacePage}
331
336
  />
332
337
  ) : null}
333
- {devMode && editStatusMessage ? (
338
+ {workspaceMode && editStatusMessage ? (
334
339
  <span
335
340
  className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
336
341
  data-openpress-edit-status={inlineEditStatus.state}
@@ -341,7 +346,7 @@ export function HtmlWorkbench({
341
346
  <span>{editStatusMessage}</span>
342
347
  </span>
343
348
  ) : null}
344
- {devMode ? (
349
+ {workspaceMode ? (
345
350
  <button
346
351
  type="button"
347
352
  className="openpress-workbench-toolbar-action"
@@ -359,7 +364,7 @@ export function HtmlWorkbench({
359
364
  <span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
360
365
  </button>
361
366
  ) : null}
362
- {devMode && inspector.inspectorMode ? (
367
+ {workspaceMode && inspector.inspectorMode ? (
363
368
  <span
364
369
  className="openpress-dev-inspector-status"
365
370
  role="status"
@@ -391,7 +396,7 @@ export function HtmlWorkbench({
391
396
  deployment.pdfStatusMessage,
392
397
  deployment.pdfToolbarExpanded,
393
398
  deployment.status,
394
- devMode,
399
+ workspaceMode,
395
400
  editStatusMessage,
396
401
  inlineEditStatus.state,
397
402
  inspector.inspectorMode,
@@ -418,7 +423,6 @@ export function HtmlWorkbench({
418
423
  return (
419
424
  <WorkbenchShell
420
425
  style={style}
421
- devMode={devMode}
422
426
  viewMode={viewMode}
423
427
  pressType={pressType}
424
428
  presentationMode={false}
@@ -491,15 +495,14 @@ export function HtmlWorkbench({
491
495
  <PublicPage
492
496
  pages={displayPages}
493
497
  currentPageIndex={reader.currentPageIndex}
494
- devMode={devMode}
495
498
  sourceContainerRef={sourceContainerRef}
496
499
  registerPage={reader.registerPage}
497
- exposeSourceData={devMode}
500
+ exposeSourceData={workspaceMode}
498
501
  inspector={inspector}
499
502
  onInternalAnchorNavigate={selectWorkspaceAnchor}
500
503
  pageLayoutMode={pageLayoutMode}
501
504
  />
502
- {devMode ? (
505
+ {workspaceMode ? (
503
506
  <InlineInspectorLayer
504
507
  sourceContainerRef={sourceContainerRef}
505
508
  inspector={inspector}
@@ -508,7 +511,7 @@ export function HtmlWorkbench({
508
511
  geometryVersion={`${pageViewport.scaleMode}:${pageViewport.scale}:${pageLayoutMode}`}
509
512
  />
510
513
  ) : null}
511
- {devMode ? (
514
+ {workspaceMode ? (
512
515
  <InlineSourceEditorLayer
513
516
  target={sourceEditorTarget}
514
517
  onClose={() => setSourceEditorTarget(null)}
@@ -1,43 +1,15 @@
1
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react";
2
2
  import { FileText, Loader2, Search } from "lucide-react";
3
3
  import type { SourceBlock } from "../../document-model";
4
+ import type { SearchReport, SearchScope } from "../../shared";
4
5
  import { WorkbenchDialog } from "../dialog";
5
6
 
6
- type SearchScope = "content" | "all";
7
7
  type SearchStatus = "idle" | "loading" | "success" | "error";
8
8
  const SEARCH_SCOPE: SearchScope = "all";
9
9
  const LIVE_SEARCH_DEBOUNCE_MS = 280;
10
10
 
11
- type SearchFile = {
12
- scope: string;
13
- file: string;
14
- path: string;
15
- matchCount: number;
16
- };
17
-
18
- type SearchMatch = {
19
- id: string;
20
- scope: string;
21
- file: string;
22
- path: string;
23
- line: number;
24
- column: number;
25
- index: number;
26
- text: string;
27
- preview: string;
28
- };
29
-
30
- type SearchReport = {
31
- ok?: boolean;
32
- kind: "search";
33
- query: string;
34
- scope: SearchScope;
35
- caseSensitive: boolean;
36
- matchCount: number;
37
- files: Array<SearchFile>;
38
- matches: Array<SearchMatch>;
39
- message?: string;
40
- };
11
+ type SearchFile = SearchReport["files"][number];
12
+ type SearchMatch = SearchReport["matches"][number];
41
13
 
42
14
  type SearchJumpTarget = {
43
15
  blockId: string;
@@ -45,12 +17,37 @@ type SearchJumpTarget = {
45
17
  pageNumber: number;
46
18
  };
47
19
 
20
+ export interface SearchControlSearcherArgs {
21
+ query: string;
22
+ scope: SearchScope;
23
+ signal: AbortSignal;
24
+ }
25
+
26
+ export type SearchControlSearcher = (args: SearchControlSearcherArgs) => Promise<SearchReport>;
27
+
28
+ // Default searcher: hits the dev-only /__openpress/search endpoint.
29
+ // Public deploys override this via the `searcher` prop with a static
30
+ // in-browser searcher backed by /openpress/search-corpus.json.
31
+ async function liveSearcher({ query, scope, signal }: SearchControlSearcherArgs): Promise<SearchReport> {
32
+ const params = new URLSearchParams();
33
+ params.set("q", query);
34
+ params.set("scope", scope);
35
+ const response = await fetch(`/__openpress/search?${params.toString()}`, { cache: "no-store", signal });
36
+ const data = (await response.json().catch(() => null)) as (Partial<SearchReport> & { message?: string }) | null;
37
+ if (!response.ok || data?.ok === false || !isSearchReport(data)) {
38
+ throw new Error(data?.message ?? "搜尋失敗。");
39
+ }
40
+ return data;
41
+ }
42
+
48
43
  export function SearchControl({
49
44
  sourceBlocksByPath = {},
50
45
  onSelectPage,
46
+ searcher = liveSearcher,
51
47
  }: {
52
48
  sourceBlocksByPath?: Record<string, SourceBlock[]>;
53
49
  onSelectPage?: (pageIndex: number, options?: { behavior?: ScrollBehavior }) => void;
50
+ searcher?: SearchControlSearcher;
54
51
  }) {
55
52
  const titleId = useId();
56
53
  const [open, setOpen] = useState(false);
@@ -98,26 +95,18 @@ export function SearchControl({
98
95
  setError("");
99
96
 
100
97
  try {
101
- const params = new URLSearchParams();
102
- params.set("q", trimmedQuery);
103
- params.set("scope", SEARCH_SCOPE);
104
- const response = await fetch(`/__openpress/search?${params.toString()}`, {
105
- cache: "no-store",
106
- signal: controller.signal,
107
- });
108
- const data = await response.json().catch(() => null) as (Partial<SearchReport> & { message?: string }) | null;
98
+ const data = await searcher({ query: trimmedQuery, scope: SEARCH_SCOPE, signal: controller.signal });
109
99
  if (controller.signal.aborted || requestId !== searchRequestIdRef.current) return;
110
- if (!response.ok || data?.ok === false || !isSearchReport(data)) {
111
- throw new Error(data?.message ?? "搜尋失敗。");
112
- }
100
+ if (!isSearchReport(data)) throw new Error("搜尋失敗。");
113
101
  setReport(data);
114
102
  setStatus("success");
115
103
  } catch (searchError) {
116
104
  if (controller.signal.aborted || requestId !== searchRequestIdRef.current) return;
105
+ if (searchError instanceof DOMException && searchError.name === "AbortError") return;
117
106
  setError(searchError instanceof Error ? searchError.message : String(searchError));
118
107
  setStatus("error");
119
108
  }
120
- }, []);
109
+ }, [searcher]);
121
110
 
122
111
  useEffect(() => {
123
112
  if (!open) return undefined;
@@ -6,6 +6,12 @@ import { parseDeployError, workbenchPdfButtonText, workbenchPdfStatusMessage } f
6
6
 
7
7
  export interface UseDeploymentWorkbenchOptions {
8
8
  deploymentInfo: DeploymentInfo;
9
+ // Active Press slug — when present the local PDF export endpoint
10
+ // tells the CLI to export this Press (open-press pdf . --press <slug>)
11
+ // instead of defaulting to the first Press. Empty / null means the
12
+ // workspace has only one Press, or the workbench is at the gallery
13
+ // root, and the CLI default is correct.
14
+ pressSlug?: string | null;
9
15
  }
10
16
 
11
17
  export interface DeploymentWorkbench {
@@ -22,7 +28,7 @@ export interface DeploymentWorkbench {
22
28
  handleOpenWorkbenchPdf: () => void;
23
29
  }
24
30
 
25
- export function useDeploymentWorkbench({ deploymentInfo }: UseDeploymentWorkbenchOptions): DeploymentWorkbench {
31
+ export function useDeploymentWorkbench({ deploymentInfo, pressSlug = null }: UseDeploymentWorkbenchOptions): DeploymentWorkbench {
26
32
  const [status, setStatus] = useState<DeployStatus>("idle");
27
33
  const [pdfActionStatus, setPdfActionStatus] = useState<PdfActionStatus>("idle");
28
34
  const [currentDeploymentInfo, setCurrentDeploymentInfo] = useState(deploymentInfo);
@@ -96,7 +102,12 @@ export function useDeploymentWorkbench({ deploymentInfo }: UseDeploymentWorkbenc
96
102
  if (pdfActionStatus === "generating") return;
97
103
  setPdfActionStatus("generating");
98
104
  try {
99
- const response = await fetch("/__openpress/local-pdf-export", { method: "POST" });
105
+ const requestBody = pressSlug ? { press: pressSlug } : {};
106
+ const response = await fetch("/__openpress/local-pdf-export", {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify(requestBody),
110
+ });
100
111
  if (!response.ok) {
101
112
  const text = await response.text().catch(() => "");
102
113
  throw new Error(text || `Local PDF export failed with status ${response.status}`);
@@ -109,7 +120,7 @@ export function useDeploymentWorkbench({ deploymentInfo }: UseDeploymentWorkbenc
109
120
  console.error("OpenPress local PDF export failed", error);
110
121
  setPdfActionStatus("failed");
111
122
  }
112
- }, [pdfActionStatus]);
123
+ }, [pdfActionStatus, pressSlug]);
113
124
 
114
125
  const handleOpenWorkbenchPdf = useCallback(() => {
115
126
  if (localDeployEnabled) {
@@ -8,7 +8,7 @@ import type { InspectorState, PendingComment } from "./inspectorModel";
8
8
  import { getInlineSavedCommentForTarget, resolveInlineSavedComment } from "./inlineCommentModel";
9
9
 
10
10
  export interface UseInspectorCommentsOptions {
11
- devMode: boolean;
11
+ workspaceMode: boolean;
12
12
  inspector: InspectorState;
13
13
  sourceBlockMap: Record<string, SourceBlock>;
14
14
  sourceBlocksByPath: Record<string, SourceBlock[]>;
@@ -36,7 +36,7 @@ export interface InspectorComments {
36
36
  }
37
37
 
38
38
  export function useInspectorComments({
39
- devMode,
39
+ workspaceMode,
40
40
  inspector,
41
41
  sourceBlockMap,
42
42
  sourceBlocksByPath,
@@ -76,7 +76,7 @@ export function useInspectorComments({
76
76
  );
77
77
 
78
78
  const refreshPendingComments = useCallback(async () => {
79
- if (!devMode) return;
79
+ if (!workspaceMode) return;
80
80
  setCommentsStatus("loading");
81
81
  setCommentsError("");
82
82
  try {
@@ -87,7 +87,7 @@ export function useInspectorComments({
87
87
  setCommentsStatus("failed");
88
88
  setCommentsError(error instanceof Error ? error.message : String(error));
89
89
  }
90
- }, [devMode]);
90
+ }, [workspaceMode]);
91
91
 
92
92
  const clearPendingComment = useCallback(async (id: string) => {
93
93
  setCommentsStatus("clearing");
@@ -225,9 +225,9 @@ export function useInspectorComments({
225
225
 
226
226
  // Initial + dev-mode refresh of pending comments.
227
227
  useEffect(() => {
228
- if (!devMode) return;
228
+ if (!workspaceMode) return;
229
229
  void refreshPendingComments();
230
- }, [devMode, refreshPendingComments]);
230
+ }, [workspaceMode, refreshPendingComments]);
231
231
 
232
232
  return {
233
233
  pendingComments,
@@ -6,6 +6,7 @@ type WorkbenchShellContextValue = {
6
6
  rightPanelOpen: boolean;
7
7
  onToggleLeftPanel: () => void;
8
8
  onToggleRightPanel: () => void;
9
+ withRightPanel: boolean;
9
10
  };
10
11
 
11
12
  const WorkbenchShellContext = createContext<WorkbenchShellContextValue | null>(null);
@@ -18,7 +19,6 @@ function useWorkbenchShell() {
18
19
 
19
20
  function WorkbenchShellRoot({
20
21
  style,
21
- devMode,
22
22
  viewMode,
23
23
  pressType = "pages",
24
24
  presentationMode = false,
@@ -28,10 +28,11 @@ function WorkbenchShellRoot({
28
28
  rightPanelOpen,
29
29
  onToggleLeftPanel,
30
30
  onToggleRightPanel,
31
+ withRightPanel = true,
32
+ publicViewer = false,
31
33
  children,
32
34
  }: {
33
35
  style: CSSProperties;
34
- devMode: boolean;
35
36
  viewMode: string;
36
37
  pressType?: string;
37
38
  presentationMode?: boolean;
@@ -41,20 +42,42 @@ function WorkbenchShellRoot({
41
42
  rightPanelOpen: boolean;
42
43
  onToggleLeftPanel: () => void;
43
44
  onToggleRightPanel: () => void;
45
+ // When false the toolbar omits the right-panel toggle button and the
46
+ // shell grid runs without a right column. Used by the public viewer
47
+ // where the right panel currently has no content (comments + project
48
+ // entry are workbench-only).
49
+ withRightPanel?: boolean;
50
+ // Marks the outer <main> with `data-openpress-public-viewer` so CSS
51
+ // and external integrations can target the public reading surface.
52
+ publicViewer?: boolean;
44
53
  children: ReactNode;
45
54
  }) {
46
- const scrimOpen = leftPanelOpen || rightPanelOpen;
47
- const handleScrimClick = rightPanelOpen ? onToggleRightPanel : onToggleLeftPanel;
55
+ const effectiveRightOpen = withRightPanel ? rightPanelOpen : false;
56
+ const scrimOpen = leftPanelOpen || effectiveRightOpen;
57
+ const handleScrimClick = effectiveRightOpen ? onToggleRightPanel : onToggleLeftPanel;
48
58
  const shellClassName = [
49
59
  "reader-app openpress-reader-app openpress-public-viewer openpress-dev-public-viewer openpress-workbench-shell is-ready",
50
60
  leftPanelOpen ? "" : "is-closed-left",
51
- rightPanelOpen ? "" : "is-closed-right",
61
+ effectiveRightOpen ? "" : "is-closed-right",
62
+ withRightPanel ? "" : "openpress-workbench-shell--no-right-panel",
52
63
  presentationMode ? "is-presentation-mode" : "",
53
64
  ].filter(Boolean).join(" ");
54
65
 
55
66
  return (
56
- <WorkbenchShellContext.Provider value={{ leftPanelOpen, rightPanelOpen, onToggleLeftPanel, onToggleRightPanel }}>
57
- <main className="openpress-workbench" style={style} data-dev-mode={devMode ? "true" : "false"}>
67
+ <WorkbenchShellContext.Provider
68
+ value={{
69
+ leftPanelOpen,
70
+ rightPanelOpen: effectiveRightOpen,
71
+ onToggleLeftPanel,
72
+ onToggleRightPanel,
73
+ withRightPanel,
74
+ }}
75
+ >
76
+ <main
77
+ className="openpress-workbench"
78
+ style={style}
79
+ data-openpress-public-viewer={publicViewer ? "true" : undefined}
80
+ >
58
81
  <div
59
82
  className={shellClassName}
60
83
  data-openpress-react-runtime="true"
@@ -82,6 +105,7 @@ export function WorkbenchToolbar({ children }: { children: ReactNode }) {
82
105
  rightPanelOpen,
83
106
  onToggleLeftPanel,
84
107
  onToggleRightPanel,
108
+ withRightPanel,
85
109
  } = useWorkbenchShell();
86
110
  const LeftIcon = leftPanelOpen ? PanelLeftClose : PanelLeftOpen;
87
111
  const RightIcon = rightPanelOpen ? PanelRightClose : PanelRightOpen;
@@ -109,17 +133,19 @@ export function WorkbenchToolbar({ children }: { children: ReactNode }) {
109
133
  <div className="openpress-workbench-toolbar__content">
110
134
  {children}
111
135
  </div>
112
- <button
113
- type="button"
114
- className="openpress-workbench-toolbar-panel-toggle"
115
- data-openpress-toggle-right-panel
116
- data-openpress-panel-open={rightPanelOpen ? "true" : "false"}
117
- aria-label={rightLabel}
118
- title={rightLabel}
119
- onClick={onToggleRightPanel}
120
- >
121
- <RightIcon aria-hidden="true" />
122
- </button>
136
+ {withRightPanel ? (
137
+ <button
138
+ type="button"
139
+ className="openpress-workbench-toolbar-panel-toggle"
140
+ data-openpress-toggle-right-panel
141
+ data-openpress-panel-open={rightPanelOpen ? "true" : "false"}
142
+ aria-label={rightLabel}
143
+ title={rightLabel}
144
+ onClick={onToggleRightPanel}
145
+ >
146
+ <RightIcon aria-hidden="true" />
147
+ </button>
148
+ ) : null}
123
149
  </header>
124
150
  );
125
151
  }
package/vite.config.ts CHANGED
@@ -233,13 +233,18 @@ async function handleLocalPdfExportRequest(req: IncomingMessage, res: ServerResp
233
233
  return;
234
234
  }
235
235
 
236
- const result = await runLocalPdfExport();
237
- const exists = await fileExists(openpressConfig.paths.pdf);
236
+ const body = await readJsonRequestBody(req);
237
+ const slug = normalizePressSlug(body?.press);
238
+ const result = await runLocalPdfExport(slug);
239
+ const pdfPath = pressPdfAbsolutePath(slug);
240
+ const exists = await fileExists(pdfPath);
241
+ const cliArgs = slug ? ["pdf", ".", "--press", slug] : ["pdf", "."];
242
+ const pdfUrl = `/__openpress/local-pdf-file?${slug ? `press=${encodeURIComponent(slug)}&` : ""}ts=${Date.now()}`;
238
243
  writeJson(res, result.code === 0 && exists ? 200 : 500, {
239
244
  ok: result.code === 0 && exists,
240
245
  code: result.code,
241
- pdf: `/__openpress/local-pdf-file?ts=${Date.now()}`,
242
- command: openpressCliCommand(["pdf", "."]),
246
+ pdf: pdfUrl,
247
+ command: openpressCliCommand(cliArgs),
243
248
  stdout: result.stdout,
244
249
  stderr: result.stderr,
245
250
  });
@@ -251,11 +256,15 @@ async function handleLocalPdfFileRequest(req: IncomingMessage, res: ServerRespon
251
256
  return;
252
257
  }
253
258
 
259
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
260
+ const slug = normalizePressSlug(requestUrl.searchParams.get("press"));
261
+ const pdfPath = pressPdfAbsolutePath(slug);
262
+ const filename = pressFilename(openpressConfig.pdf.filename, slug);
254
263
  try {
255
- const body = await fs.readFile(openpressConfig.paths.pdf);
264
+ const body = await fs.readFile(pdfPath);
256
265
  res.writeHead(200, {
257
266
  "Content-Type": "application/pdf",
258
- "Content-Disposition": `inline; filename="${openpressConfig.pdf.filename}"`,
267
+ "Content-Disposition": `inline; filename="${filename}"`,
259
268
  "Cache-Control": "no-store",
260
269
  });
261
270
  res.end(body);
@@ -264,6 +273,37 @@ async function handleLocalPdfFileRequest(req: IncomingMessage, res: ServerRespon
264
273
  }
265
274
  }
266
275
 
276
+ function normalizePressSlug(value: unknown): string {
277
+ if (typeof value !== "string") return "";
278
+ return value.trim().replace(/^\/+|\/+$/g, "");
279
+ }
280
+
281
+ function pressFilename(baseFilename: string, slug: string): string {
282
+ if (!slug) return baseFilename;
283
+ const ext = path.extname(baseFilename);
284
+ const stem = ext ? baseFilename.slice(0, -ext.length) : baseFilename;
285
+ return `${stem}-${slug}${ext}`;
286
+ }
287
+
288
+ function pressPdfAbsolutePath(slug: string): string {
289
+ return path.join(openpressConfig.outputDir, pressFilename(openpressConfig.pdf.filename, slug));
290
+ }
291
+
292
+ async function readJsonRequestBody(req: IncomingMessage): Promise<{ press?: unknown } | null> {
293
+ try {
294
+ const chunks: Buffer[] = [];
295
+ for await (const chunk of req) {
296
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer));
297
+ }
298
+ if (chunks.length === 0) return null;
299
+ const text = Buffer.concat(chunks).toString("utf8");
300
+ if (!text.trim()) return null;
301
+ return JSON.parse(text);
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
267
307
  async function handleLocalStatusRequest(req: IncomingMessage, res: ServerResponse) {
268
308
  if (req.method !== "GET") {
269
309
  writeJson(res, 405, { ok: false, message: "Status endpoint requires GET." });
@@ -355,9 +395,11 @@ async function handleLocalDeployRequest(req: IncomingMessage, res: ServerRespons
355
395
  });
356
396
  }
357
397
 
358
- function runLocalPdfExport() {
398
+ function runLocalPdfExport(slug = "") {
399
+ const args = [openpressCliPath, "pdf", "."];
400
+ if (slug) args.push("--press", slug);
359
401
  return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
360
- const child = spawn("node", [openpressCliPath, "pdf", "."], {
402
+ const child = spawn("node", args, {
361
403
  cwd: workspaceRoot,
362
404
  shell: false,
363
405
  });