@open-press/core 1.1.4 → 1.2.1

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.
Files changed (51) hide show
  1. package/engine/cli.mjs +3 -3
  2. package/engine/commands/_shared.mjs +89 -13
  3. package/engine/commands/deploy.mjs +19 -4
  4. package/engine/commands/image.mjs +9 -3
  5. package/engine/commands/pdf.mjs +4 -1
  6. package/engine/output/chrome-pdf.mjs +102 -0
  7. package/engine/output/static-server.mjs +64 -17
  8. package/engine/react/document-export.mjs +22 -0
  9. package/package.json +1 -1
  10. package/src/openpress/app/OpenPressApp.tsx +5 -1
  11. package/src/openpress/app/OpenPressRuntime.tsx +85 -6
  12. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  13. package/src/openpress/reader/PublicReaderPage.tsx +163 -74
  14. package/src/openpress/reader/SlidePresentationPage.tsx +37 -15
  15. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  16. package/src/openpress/reader/index.ts +1 -0
  17. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  18. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  19. package/src/openpress/reader/usePanelState.ts +14 -5
  20. package/src/openpress/shared/index.ts +1 -0
  21. package/src/openpress/shared/staticSearch.ts +174 -0
  22. package/src/openpress/workbench/Workbench.tsx +61 -176
  23. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  24. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  25. package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
  26. package/src/openpress/workbench/actions/index.ts +1 -1
  27. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +21 -5
  28. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  29. package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
  30. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  31. package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
  32. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  33. package/src/styles/openpress/app-shell.css +0 -83
  34. package/src/styles/openpress/print-route.css +1 -3
  35. package/src/styles/openpress/project-preview-panel.css +5 -783
  36. package/src/styles/openpress/public-viewer.css +7 -249
  37. package/src/styles/openpress/reader-runtime.css +0 -274
  38. package/src/styles/openpress/slide-presenter.css +150 -0
  39. package/src/styles/openpress/slide-public-viewer.css +222 -0
  40. package/src/styles/openpress/workbench-dialog.css +267 -0
  41. package/src/styles/openpress/workbench-export.css +154 -0
  42. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  43. package/src/styles/openpress/workbench-panels.css +0 -88
  44. package/src/styles/openpress/workbench-search.css +257 -0
  45. package/src/styles/openpress/workbench-toolbar.css +422 -0
  46. package/src/styles/openpress/workbench.css +34 -1263
  47. package/src/styles/openpress/workspace-gallery.css +0 -5
  48. package/src/styles/openpress.css +7 -1
  49. package/vite.config.ts +66 -17
  50. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  51. package/src/styles/openpress/media-workspace.css +0 -230
package/engine/cli.mjs CHANGED
@@ -85,9 +85,9 @@ Commands:
85
85
  preview --renderer react [--host 127.0.0.1] [--port 5173] [--no-build] [--dry-run]
86
86
  dev --renderer react [--host 127.0.0.1] [--port 5173] [--no-build] [--dry-run]
87
87
  typecheck
88
- image [--output <outputDir>] [--pages <selector>] [--no-build] [--dry-run]
89
- pdf [--output <outputDir>/<pdf.filename>] [--no-build] [--dry-run]
90
- deploy --confirm [--dry-run]
88
+ image [--output <outputDir>] [--press <slug>] [--pages <selector>] [--no-build] [--dry-run]
89
+ pdf [--output <outputDir>/<pdf.filename>] [--press <slug>] [--no-build] [--dry-run]
90
+ deploy --confirm [--press <slug>] [--dry-run]
91
91
  doctor [--json] [--no-cache] # version + skill staleness check
92
92
  upgrade [--dry-run] [--no-deps] [--no-skills] [--json] # apply updates; agent-driven
93
93
  migrate [--dry-run] [--no-deps] [--no-skills] [--json] # alias for upgrade; reads migration notes
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { captureUrlPagesToPng, printUrlToPdf, stopChildProcess, waitForPrintReady } from "../output/chrome-pdf.mjs";
7
- import { loadConfig, publicPdfHref } from "../runtime/config.mjs";
7
+ import { loadConfig } from "../runtime/config.mjs";
8
8
  import { exportDocument } from "../document-export.mjs";
9
9
  import { optimizePdfMediaForStaticRoot } from "../output/pdf-media.mjs";
10
10
 
@@ -41,6 +41,7 @@ export function parseOptions(argv) {
41
41
  else if (value === "--source") options.source = argv[++i];
42
42
  else if (value === "--output") options.output = argv[++i];
43
43
  else if (value === "--pages") options.pages = argv[++i];
44
+ else if (value === "--press") options.press = argv[++i];
44
45
  else if (value.startsWith("--")) throw new Error(`Unknown option: ${value}`);
45
46
  else positional.push(value);
46
47
  }
@@ -103,6 +104,56 @@ export async function buildReactStatic({ root, noBuild = false, recurse, silent
103
104
  return result.status ?? 1;
104
105
  }
105
106
 
107
+ export async function resolvePressSelection({ outputDir, slug }) {
108
+ const manifestPath = path.join(outputDir, "openpress", "workspace.json");
109
+ let manifest;
110
+ try {
111
+ const body = await fs.readFile(manifestPath, "utf8");
112
+ manifest = JSON.parse(body);
113
+ } catch (error) {
114
+ if (error?.code === "ENOENT") {
115
+ throw new Error(
116
+ `Cannot resolve --press: workspace manifest not found at ${manifestPath}. ` +
117
+ `Run a render first (or drop --no-build) so the manifest is regenerated.`,
118
+ );
119
+ }
120
+ throw error;
121
+ }
122
+ const presses = Array.isArray(manifest?.presses) ? manifest.presses : [];
123
+ if (presses.length === 0) {
124
+ throw new Error(`Workspace manifest at ${manifestPath} declares no Press entries.`);
125
+ }
126
+ const knownSlugs = presses.map((press) => press.slug || "").filter(Boolean);
127
+ const normalized = typeof slug === "string" ? slug.trim().replace(/^\/+|\/+$/g, "") : "";
128
+ if (!normalized) {
129
+ return { slug: presses[0].slug ?? "", title: presses[0].title ?? "", knownSlugs };
130
+ }
131
+ const match = presses.find((press) => (press.slug ?? "").replace(/^\/+|\/+$/g, "") === normalized);
132
+ if (!match) {
133
+ const listed = knownSlugs.length > 0 ? knownSlugs.join(", ") : "(none — workspace has no slugged presses)";
134
+ throw new Error(`Unknown --press "${slug}". Known slugs: ${listed}.`);
135
+ }
136
+ return { slug: match.slug ?? "", title: match.title ?? "", knownSlugs };
137
+ }
138
+
139
+ function pressPrintUrl(host, port, slug) {
140
+ const normalized = (slug ?? "").replace(/^\/+|\/+$/g, "");
141
+ if (!normalized) return `http://${host}:${port}/?print=1`;
142
+ return `http://${host}:${port}/${normalized}?print=1`;
143
+ }
144
+
145
+ export function pressSuffixedFilename(baseFilename, slug) {
146
+ const normalized = (slug ?? "").replace(/^\/+|\/+$/g, "");
147
+ if (!normalized) return baseFilename;
148
+ const ext = path.extname(baseFilename);
149
+ const stem = ext ? baseFilename.slice(0, -ext.length) : baseFilename;
150
+ return `${stem}-${normalized}${ext}`;
151
+ }
152
+
153
+ export function publicPdfHrefForFilename(filename) {
154
+ return `/${filename}`;
155
+ }
156
+
106
157
  export async function buildReactPdf({
107
158
  root,
108
159
  config,
@@ -111,31 +162,44 @@ export async function buildReactPdf({
111
162
  port = "5185",
112
163
  noBuild = false,
113
164
  recurse,
165
+ pressSlug = null,
114
166
  }) {
115
167
  config ??= await loadConfig(root);
116
- outPath ??= config.paths.pdf;
117
168
  const renderCode = await buildReactStatic({ root, noBuild, recurse });
118
169
  if (renderCode !== 0) throw new Error(`React render failed with exit code ${renderCode}`);
119
170
  await optimizePdfMediaForStaticRoot(config.paths.outputDir);
171
+
172
+ const selection = pressSlug
173
+ ? await resolvePressSelection({ outputDir: config.paths.outputDir, slug: pressSlug })
174
+ : { slug: "", title: "", knownSlugs: [] };
175
+
176
+ if (!outPath) {
177
+ const filename = selection.slug
178
+ ? pressSuffixedFilename(config.pdf.filename, selection.slug)
179
+ : config.pdf.filename;
180
+ outPath = path.join(config.paths.outputDir, filename);
181
+ }
120
182
  await fs.mkdir(path.dirname(outPath), { recursive: true });
121
183
 
122
184
  const server = await startStaticServer(root, config, host, port);
123
185
  try {
124
- const pageCount = await printUrlToPdf({
186
+ const result = await printUrlToPdf({
125
187
  root,
126
- url: `http://${host}:${port}/?print=1`,
188
+ url: pressPrintUrl(host, port, selection.slug),
127
189
  outPath,
128
190
  waitForReady: waitForPrintReady,
129
191
  debuggingPortBase: 9300,
130
192
  debuggingPortRange: 600,
131
193
  profilePrefix: "chrome-pdf",
132
194
  });
133
- console.log(`${pageCount} OpenPress pages printed to PDF`);
195
+ const pageCount = result?.pageCount ?? result;
196
+ const pressLabel = selection.slug ? ` (press: ${selection.title || selection.slug})` : "";
197
+ console.log(`${pageCount} OpenPress pages printed to PDF${pressLabel}`);
134
198
  } finally {
135
199
  await stopChildProcess(server);
136
200
  }
137
201
 
138
- return { pdfPath: outPath };
202
+ return { pdfPath: outPath, pressSlug: selection.slug };
139
203
  }
140
204
 
141
205
  export async function buildReactImages({
@@ -147,18 +211,27 @@ export async function buildReactImages({
147
211
  noBuild = false,
148
212
  recurse,
149
213
  pageSelector = null,
214
+ pressSlug = null,
150
215
  }) {
151
216
  config ??= await loadConfig(root);
152
- outDir ??= path.join(config.paths.outputDir, "images");
153
217
  const renderCode = await buildReactStatic({ root, noBuild, recurse });
154
218
  if (renderCode !== 0) throw new Error(`React render failed with exit code ${renderCode}`);
219
+
220
+ const selection = pressSlug
221
+ ? await resolvePressSelection({ outputDir: config.paths.outputDir, slug: pressSlug })
222
+ : { slug: "", title: "", knownSlugs: [] };
223
+
224
+ if (!outDir) {
225
+ const folder = selection.slug ? `images-${selection.slug}` : "images";
226
+ outDir = path.join(config.paths.outputDir, folder);
227
+ }
155
228
  await fs.mkdir(outDir, { recursive: true });
156
229
 
157
230
  const server = await startStaticServer(root, config, host, port);
158
231
  try {
159
232
  const result = await captureUrlPagesToPng({
160
233
  root,
161
- url: `http://${host}:${port}/?print=1`,
234
+ url: pressPrintUrl(host, port, selection.slug),
162
235
  outDir,
163
236
  waitForReady: waitForPrintReady,
164
237
  debuggingPortBase: 9700,
@@ -166,15 +239,17 @@ export async function buildReactImages({
166
239
  profilePrefix: "chrome-image",
167
240
  pageSelector,
168
241
  });
169
- const label = pageSelector
242
+ const pressLabel = selection.slug ? ` (press: ${selection.title || selection.slug})` : "";
243
+ const countLabel = pageSelector
170
244
  ? `${result.files.length}/${result.pageCount} OpenPress pages exported to PNG`
171
245
  : `${result.files.length} OpenPress pages exported to PNG`;
172
- console.log(label);
246
+ console.log(`${countLabel}${pressLabel}`);
173
247
  return {
174
248
  outDir,
175
249
  files: result.files,
176
250
  pageCount: result.pageCount,
177
251
  selectedPageNumbers: result.selectedPageNumbers,
252
+ pressSlug: selection.slug,
178
253
  };
179
254
  } finally {
180
255
  await stopChildProcess(server);
@@ -223,18 +298,19 @@ export function startStaticServer(root, config, host, port) {
223
298
  });
224
299
  }
225
300
 
226
- export async function writePdfStageDeployConfig(root, source, config) {
301
+ export async function writePdfStageDeployConfig(root, source, config, { pdfFilename = config.pdf.filename } = {}) {
227
302
  const deployRoot = path.resolve(root, source);
228
303
  const openpressDir = path.join(deployRoot, "openpress");
304
+ const pdfHref = publicPdfHrefForFilename(pdfFilename);
229
305
  await fs.mkdir(openpressDir, { recursive: true });
230
306
  await fs.writeFile(
231
307
  path.join(openpressDir, "deploy.json"),
232
- `${JSON.stringify({ pdf: publicPdfHref(config), deployed_at: new Date().toISOString() }, null, 2)}\n`,
308
+ `${JSON.stringify({ pdf: pdfHref, deployed_at: new Date().toISOString() }, null, 2)}\n`,
233
309
  "utf8",
234
310
  );
235
311
  await fs.writeFile(
236
312
  path.join(deployRoot, "_headers"),
237
- `${publicPdfHref(config)}\n Content-Type: application/pdf\n Content-Disposition: inline; filename="${config.pdf.filename}"\n`,
313
+ `${pdfHref}\n Content-Type: application/pdf\n Content-Disposition: inline; filename="${pdfFilename}"\n`,
238
314
  "utf8",
239
315
  );
240
316
  }
@@ -1,6 +1,12 @@
1
1
  import path from "node:path";
2
2
  import { deploySync } from "../output/deploy-sync.mjs";
3
- import { buildReactPdf, formatOpenPressCommand, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
3
+ import {
4
+ buildReactPdf,
5
+ formatOpenPressCommand,
6
+ pressSuffixedFilename,
7
+ runCommand,
8
+ writePdfStageDeployConfig,
9
+ } from "./_shared.mjs";
4
10
 
5
11
  export async function run({ root, config, options, recurse }) {
6
12
  if (config.deploy.requiresConfirmation === true && !options.confirm) {
@@ -10,11 +16,15 @@ export async function run({ root, config, options, recurse }) {
10
16
  const source = config.deploy.source;
11
17
  const projectName = config.deploy.projectName;
12
18
  const commitDirty = config.deploy.commitDirty;
19
+ const pressSlug = normalizePressSlug(options.press);
20
+ const pdfFilename = pressSuffixedFilename(config.pdf.filename, pressSlug);
21
+ const pdfArgs = ["pdf", ".", "--output", `${source}/${pdfFilename}`];
22
+ if (pressSlug) pdfArgs.push("--press", pressSlug);
13
23
  if (options.dryRun) {
14
24
  console.log("OpenPress deploy dry run");
15
25
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
16
26
  console.log(`Step: deploy-sync (copy ${config.outputDir} → ${source})`);
17
- console.log(`Command: ${formatOpenPressCommand(["pdf", ".", "--output", `${source}/${config.pdf.filename}`])}`);
27
+ console.log(`Command: ${formatOpenPressCommand(pdfArgs)}`);
18
28
  console.log(`Step: write ${source}/openpress/deploy.json with deployment metadata`);
19
29
  console.log(`Command: npx wrangler pages deploy ${source}${projectName ? ` --project-name=${projectName}` : ""}${commitDirty ? " --commit-dirty=true" : ""}`);
20
30
  return 0;
@@ -22,10 +32,15 @@ export async function run({ root, config, options, recurse }) {
22
32
  const renderCode = await recurse("render", [root, "--renderer", "react"]);
23
33
  if (renderCode !== 0) return renderCode;
24
34
  await deploySync(root, config.outputDir, source);
25
- await buildReactPdf({ root, config, outPath: path.resolve(root, source, config.pdf.filename), noBuild: true, recurse });
26
- await writePdfStageDeployConfig(root, source, config);
35
+ await buildReactPdf({ root, config, outPath: path.resolve(root, source, pdfFilename), noBuild: true, recurse, pressSlug });
36
+ await writePdfStageDeployConfig(root, source, config, { pdfFilename });
27
37
  const wranglerArgs = ["wrangler", "pages", "deploy", source];
28
38
  if (projectName) wranglerArgs.push(`--project-name=${projectName}`);
29
39
  if (commitDirty) wranglerArgs.push("--commit-dirty=true");
30
40
  return runCommand("npx", wranglerArgs, root);
31
41
  }
42
+
43
+ function normalizePressSlug(value) {
44
+ if (typeof value !== "string") return "";
45
+ return value.trim().replace(/^\/+|\/+$/g, "");
46
+ }
@@ -3,20 +3,25 @@ import { STATIC_SERVER, buildReactImages, formatNodeScriptCommand, formatOpenPre
3
3
  import { parsePageSelector } from "../runtime/page-selector.mjs";
4
4
 
5
5
  export async function run({ root, config, options, recurse }) {
6
- const outputDir = options.output ? path.resolve(root, options.output) : path.join(config.paths.outputDir, "images");
6
+ const outputDir = options.output ? path.resolve(root, options.output) : undefined;
7
7
  const host = options.host ?? "127.0.0.1";
8
8
  const port = options.port ?? "5186";
9
9
 
10
10
  const pageSelector = options.pages ? parsePageSelector(options.pages) : null;
11
+ const pressSlug = options.press ?? null;
11
12
 
12
13
  if (options.dryRun) {
14
+ const pressPath = pressSlug ? `/${String(pressSlug).replace(/^\/+|\/+$/g, "")}` : "";
15
+ const previewDir = outputDir
16
+ ?? path.join(config.paths.outputDir, pressSlug ? `images-${String(pressSlug).replace(/^\/+|\/+$/g, "")}` : "images");
13
17
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
14
18
  console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
15
- console.log(`Chrome image export URL: http://${host}:${port}/?print=1`);
19
+ console.log(`Chrome image export URL: http://${host}:${port}${pressPath}/?print=1`);
20
+ if (pressSlug) console.log(`Press: ${pressSlug} (validated against workspace manifest at run time)`);
16
21
  if (pageSelector) {
17
22
  console.log(`Page selector: ${options.pages} (resolved at capture time against the rendered page count)`);
18
23
  }
19
- console.log(`Output: ${path.relative(root, path.join(outputDir, "page-001.png"))}`);
24
+ console.log(`Output: ${path.relative(root, path.join(previewDir, "page-001.png"))}`);
20
25
  return 0;
21
26
  }
22
27
 
@@ -29,6 +34,7 @@ export async function run({ root, config, options, recurse }) {
29
34
  noBuild: options.noBuild,
30
35
  recurse,
31
36
  pageSelector,
37
+ pressSlug,
32
38
  });
33
39
 
34
40
  const suffix = pageSelector
@@ -7,9 +7,11 @@ export async function run({ root, config, options, recurse }) {
7
7
  const relOutput = path.relative(root, outputPath ?? config.paths.pdf);
8
8
  const host = options.host ?? "127.0.0.1";
9
9
  const port = options.port ?? "5185";
10
+ const pressPath = options.press ? `/${String(options.press).replace(/^\/+|\/+$/g, "")}` : "";
10
11
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
11
12
  console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
12
- console.log(`Command: Chrome --print-to-pdf=${relOutput} http://${host}:${port}/?print=1`);
13
+ console.log(`Command: Chrome --print-to-pdf=${relOutput} http://${host}:${port}${pressPath}/?print=1`);
14
+ if (options.press) console.log(`Press: ${options.press} (validated against workspace manifest at run time)`);
13
15
  return 0;
14
16
  }
15
17
  const result = await buildReactPdf({
@@ -20,6 +22,7 @@ export async function run({ root, config, options, recurse }) {
20
22
  port: options.port,
21
23
  noBuild: options.noBuild,
22
24
  recurse,
25
+ pressSlug: options.press ?? null,
23
26
  });
24
27
  console.log(`OpenPress PDF: ${path.relative(root, result.pdfPath)}`);
25
28
  return 0;
@@ -101,6 +101,100 @@ export const DEFAULT_PRINT_VIEWPORT = Object.freeze({
101
101
  mobile: false,
102
102
  });
103
103
 
104
+ export function pageGeometryProbeExpression() {
105
+ return `(() => {
106
+ if (!document.body) return null;
107
+ // OpenPressRuntime sets --openpress-page-width / -height on the
108
+ // PrintDocument <main> via inline style. The workspace's global
109
+ // theme also defines a *default* --openpress-page-width on :root
110
+ // (e.g. 210mm for the A4 preset). If we let getComputedStyle fall
111
+ // back to :root we will silently return that default before the
112
+ // print document has even rendered — and the per-document override
113
+ // never gets a chance to apply. Require the actual print surface
114
+ // so the probe waits until React paints it instead of locking in
115
+ // the wrong size from the workspace stylesheet.
116
+ const target = document.querySelector('[data-openpress-print-document="true"]')
117
+ || document.querySelector('.openpress-html-page');
118
+ if (!target) return null;
119
+ const cs = getComputedStyle(target);
120
+ const widthStr = cs.getPropertyValue('--openpress-page-width').trim();
121
+ const heightStr = cs.getPropertyValue('--openpress-page-height').trim();
122
+ if (!widthStr || !heightStr) return null;
123
+ const helper = document.createElement('div');
124
+ helper.style.position = 'absolute';
125
+ helper.style.left = '-99999px';
126
+ helper.style.top = '-99999px';
127
+ helper.style.visibility = 'hidden';
128
+ helper.style.pointerEvents = 'none';
129
+ helper.style.width = widthStr;
130
+ helper.style.height = heightStr;
131
+ document.body.appendChild(helper);
132
+ const rect = helper.getBoundingClientRect();
133
+ helper.remove();
134
+ if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height) || rect.width <= 0 || rect.height <= 0) return null;
135
+ return { width: rect.width, height: rect.height };
136
+ })()`;
137
+ }
138
+
139
+ export async function measurePageGeometryPx(client, { timeoutMs = 5000, pollIntervalMs = 50 } = {}) {
140
+ const deadline = Date.now() + timeoutMs;
141
+ while (Date.now() < deadline) {
142
+ const result = await client.send("Runtime.evaluate", {
143
+ returnByValue: true,
144
+ expression: pageGeometryProbeExpression(),
145
+ });
146
+ const dims = result.result?.value;
147
+ if (dims && Number.isFinite(dims.width) && dims.width > 0) {
148
+ return { width: dims.width, height: dims.height };
149
+ }
150
+ await delay(pollIntervalMs);
151
+ }
152
+ return null;
153
+ }
154
+
155
+ export async function syncViewportToPageGeometry(client, viewport, options = {}) {
156
+ const dims = await measurePageGeometryPx(client, options);
157
+ if (!dims) return { viewport, pageDimensionsPx: null };
158
+ const targetWidth = Math.max(viewport.width, Math.ceil(dims.width));
159
+ const targetHeight = Math.max(viewport.height, Math.ceil(dims.height));
160
+ if (targetWidth === viewport.width && targetHeight === viewport.height) {
161
+ return { viewport, pageDimensionsPx: dims };
162
+ }
163
+ const next = { ...viewport, width: targetWidth, height: targetHeight };
164
+ await client.send("Emulation.setDeviceMetricsOverride", next);
165
+ return { viewport: next, pageDimensionsPx: dims };
166
+ }
167
+
168
+ // Chrome's Page.printToPDF takes paperWidth / paperHeight in inches and
169
+ // honors them when preferCSSPageSize falls through. Because our @page
170
+ // rule reads CSS custom properties scoped to <main> instead of :root,
171
+ // preferCSSPageSize cannot resolve the size in headless Chrome — we have
172
+ // to pass the inches explicitly. Convert from CSS px at the 1in = 96px
173
+ // rate the rest of the runtime already assumes.
174
+ //
175
+ // Wider-than-tall geometries (slide 16:9, landscape pages) also need
176
+ // `landscape: true`: with `landscape: false` Chrome silently rotates the
177
+ // MediaBox to portrait so the short side becomes the width, leaving the
178
+ // content laid out for the wide canvas but cropped against the short page.
179
+ export function pageDimensionsPxToPaperInches(dims) {
180
+ if (!dims || !Number.isFinite(dims.width) || dims.width <= 0) return null;
181
+ if (!Number.isFinite(dims.height) || dims.height <= 0) return null;
182
+ // Chrome's printToPDF semantics: paperWidth/paperHeight are taken as
183
+ // *portrait* page dimensions, and `landscape: true` then rotates the
184
+ // canvas 90°. So for a 1920×1080 slide we have to pass the short side
185
+ // as paperWidth (11.25"), the long side as paperHeight (20"), and
186
+ // landscape: true — Chrome will produce a 20"×11.25" landscape page
187
+ // whose content frame matches the original 1920×1080 layout.
188
+ const widthIn = dims.width / 96;
189
+ const heightIn = dims.height / 96;
190
+ const landscape = widthIn > heightIn;
191
+ return {
192
+ paperWidth: landscape ? heightIn : widthIn,
193
+ paperHeight: landscape ? widthIn : heightIn,
194
+ landscape,
195
+ };
196
+ }
197
+
104
198
  export async function printUrlToPdf({
105
199
  root,
106
200
  url,
@@ -139,6 +233,13 @@ export async function printUrlToPdf({
139
233
  try {
140
234
  await preparePdfPage(client, { viewport });
141
235
  await client.send("Page.navigate", { url });
236
+ // Widen the headless viewport when the document's page geometry is
237
+ // wider than the default A4 viewport, so layout uses the full page
238
+ // width before pagination. Paper size itself is driven by the
239
+ // @page rule in print-route.css, which now resolves
240
+ // --openpress-page-width / -height from :root (PrintDocument
241
+ // mirrors the per-document theme vars onto the document element).
242
+ await syncViewportToPageGeometry(client, viewport);
142
243
  const readyResult = await waitForReady(client);
143
244
  warnAboutOverflowingPages("PDF", readyResult);
144
245
  const result = await client.send("Page.printToPDF", {
@@ -205,6 +306,7 @@ export async function captureUrlPagesToPng({
205
306
  try {
206
307
  await preparePdfPage(client, { viewport });
207
308
  await client.send("Page.navigate", { url });
309
+ await syncViewportToPageGeometry(client, viewport);
208
310
  const readyResult = await waitForReady(client);
209
311
  warnAboutOverflowingPages("image", readyResult);
210
312
  const pageCount = readyResult?.pageCount ?? 0;
@@ -208,13 +208,18 @@ async function handleLocalPdfExportRequest(req, res) {
208
208
  return;
209
209
  }
210
210
 
211
- const result = await runLocalPdfExport();
212
- const exists = await fileExists(config.paths.pdf);
211
+ const body = await readJsonBody(req);
212
+ const slug = normalizePressSlug(body?.press);
213
+ const result = await runLocalPdfExport(slug);
214
+ const pdfPath = pressPdfPath(slug);
215
+ const exists = await fileExists(pdfPath);
216
+ const command = slug ? `open-press pdf . --press ${slug}` : "open-press pdf .";
217
+ const pdfUrl = `/__openpress/local-pdf-file?${slug ? `press=${encodeURIComponent(slug)}&` : ""}ts=${Date.now()}`;
213
218
  writeJson(res, result.code === 0 && exists ? 200 : 500, {
214
219
  ok: result.code === 0 && exists,
215
220
  code: result.code,
216
- pdf: `/__openpress/local-pdf-file?ts=${Date.now()}`,
217
- command: "open-press pdf .",
221
+ pdf: pdfUrl,
222
+ command,
218
223
  stdout: result.stdout,
219
224
  stderr: result.stderr,
220
225
  });
@@ -226,11 +231,15 @@ async function handleLocalPdfFileRequest(req, res) {
226
231
  return;
227
232
  }
228
233
 
234
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
235
+ const slug = normalizePressSlug(url.searchParams.get("press"));
236
+ const pdfPath = pressPdfPath(slug);
237
+ const filename = pressFilename(config.pdf.filename, slug);
229
238
  try {
230
- const body = await fs.readFile(config.paths.pdf);
239
+ const body = await fs.readFile(pdfPath);
231
240
  res.writeHead(200, {
232
241
  "Content-Type": "application/pdf",
233
- "Content-Disposition": `inline; filename="${config.pdf.filename}"`,
242
+ "Content-Disposition": `inline; filename="${filename}"`,
234
243
  "Cache-Control": "no-store",
235
244
  });
236
245
  res.end(body);
@@ -239,12 +248,46 @@ async function handleLocalPdfFileRequest(req, res) {
239
248
  }
240
249
  }
241
250
 
251
+ function normalizePressSlug(value) {
252
+ if (typeof value !== "string") return "";
253
+ return value.trim().replace(/^\/+|\/+$/g, "");
254
+ }
255
+
256
+ function pressFilename(baseFilename, slug) {
257
+ if (!slug) return baseFilename;
258
+ const ext = path.extname(baseFilename);
259
+ const stem = ext ? baseFilename.slice(0, -ext.length) : baseFilename;
260
+ return `${stem}-${slug}${ext}`;
261
+ }
262
+
263
+ function pressPdfPath(slug) {
264
+ return path.join(config.outputDir, pressFilename(config.pdf.filename, slug));
265
+ }
266
+
267
+ async function readJsonBody(req) {
268
+ try {
269
+ const chunks = [];
270
+ for await (const chunk of req) chunks.push(chunk);
271
+ if (chunks.length === 0) return null;
272
+ const text = Buffer.concat(chunks.map((chunk) => (typeof chunk === "string" ? Buffer.from(chunk) : chunk))).toString("utf8");
273
+ if (!text.trim()) return null;
274
+ return JSON.parse(text);
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
242
280
  async function handleDeployRequest(req, res) {
243
281
  if (req.method !== "POST") {
244
282
  writeJson(res, 405, { ok: false, message: "Deploy endpoint requires POST." });
245
283
  return;
246
284
  }
247
285
 
286
+ const body = await readJsonBody(req);
287
+ const slug = normalizePressSlug(body?.press);
288
+ const command = slug ? `open-press deploy . --confirm --press ${slug}` : "open-press deploy . --confirm";
289
+ const pdfFilename = pressFilename(config.pdf.filename, slug);
290
+
248
291
  if (!isDeployConfigured()) {
249
292
  writeJson(res, 400, {
250
293
  ok: false,
@@ -254,15 +297,15 @@ async function handleDeployRequest(req, res) {
254
297
  deploy_adapter: config.deploy.adapter,
255
298
  deploy_source: config.deploy.source,
256
299
  deploy_project_name: config.deploy.projectName,
257
- command: "open-press deploy . --confirm",
300
+ command,
258
301
  });
259
302
  return;
260
303
  }
261
304
 
262
- const result = await runDeploy();
305
+ const result = await runDeploy(slug);
263
306
  const deployedUrl = extractDeployUrl(result.stdout);
264
307
  if (result.code === 0 && deployedUrl) {
265
- await writeDeploymentPublicUrl(deployedUrl);
308
+ await writeDeploymentPublicUrl(deployedUrl, pdfFilename);
266
309
  }
267
310
  const deploymentInfo = await readDeploymentInfo();
268
311
  const publicUrl = deployedUrl ?? deploymentInfo.public_url;
@@ -270,10 +313,10 @@ async function handleDeployRequest(req, res) {
270
313
  ok: result.code === 0,
271
314
  code: result.code,
272
315
  deployed_at: deploymentInfo.deployed_at,
273
- pdf: deployedUrl ? `${deployedUrl}/${config.pdf.filename}` : deploymentInfo.pdf,
316
+ pdf: deployedUrl ? `${deployedUrl}/${pdfFilename}` : deploymentInfo.pdf,
274
317
  public_url: publicUrl,
275
318
  dirty: false,
276
- command: "open-press deploy . --confirm",
319
+ command,
277
320
  stdout: result.stdout,
278
321
  stderr: result.stderr,
279
322
  });
@@ -356,9 +399,11 @@ async function handleMediaFileRequest(req, res, url) {
356
399
  }
357
400
  }
358
401
 
359
- function runLocalPdfExport() {
402
+ function runLocalPdfExport(slug = "") {
403
+ const cliArgs = [CLI_ENTRY, "pdf", "."];
404
+ if (slug) cliArgs.push("--press", slug);
360
405
  return new Promise((resolve) => {
361
- const child = spawn("node", [CLI_ENTRY, "pdf", "."], {
406
+ const child = spawn("node", cliArgs, {
362
407
  cwd: workspace,
363
408
  shell: false,
364
409
  });
@@ -379,9 +424,11 @@ function runLocalPdfExport() {
379
424
  });
380
425
  }
381
426
 
382
- function runDeploy() {
427
+ function runDeploy(slug = "") {
428
+ const cliArgs = [CLI_ENTRY, "deploy", ".", "--confirm"];
429
+ if (slug) cliArgs.push("--press", slug);
383
430
  return new Promise((resolve) => {
384
- const child = spawn("node", [CLI_ENTRY, "deploy", ".", "--confirm"], {
431
+ const child = spawn("node", cliArgs, {
385
432
  cwd: workspace,
386
433
  shell: false,
387
434
  });
@@ -445,7 +492,7 @@ async function readDeploymentInfo() {
445
492
  }
446
493
  }
447
494
 
448
- async function writeDeploymentPublicUrl(publicUrl) {
495
+ async function writeDeploymentPublicUrl(publicUrl, pdfFilename = config.pdf.filename) {
449
496
  let deployConfig = {};
450
497
  try {
451
498
  deployConfig = JSON.parse(await fs.readFile(config.paths.deployMetadata, "utf8"));
@@ -455,7 +502,7 @@ async function writeDeploymentPublicUrl(publicUrl) {
455
502
  await fs.mkdir(path.dirname(config.paths.deployMetadata), { recursive: true });
456
503
  await fs.writeFile(
457
504
  config.paths.deployMetadata,
458
- `${JSON.stringify({ ...deployConfig, pdf: `${publicUrl}/${config.pdf.filename}`, public_url: publicUrl }, null, 2)}\n`,
505
+ `${JSON.stringify({ ...deployConfig, pdf: `${publicUrl}/${pdfFilename}`, public_url: publicUrl }, null, 2)}\n`,
459
506
  "utf8",
460
507
  );
461
508
  }
@@ -9,6 +9,7 @@ import { pathToFileURL } from "node:url";
9
9
  import React from "react";
10
10
  import { documentRelativePath, pageToBlock } from "../output/page-block.mjs";
11
11
  import { syncPublicAssets } from "../output/public-assets.mjs";
12
+ import { collectSourceTextFiles } from "../runtime/source-text-tools.mjs";
12
13
  import { pageGeometryToTheme } from "../runtime/page-geometry.mjs";
13
14
  import { normalizePageGeometry } from "../runtime/page-geometry.mjs";
14
15
  import { createCaptionNumberingState, numberCaptionsInHtml } from "./caption-numbering.mjs";
@@ -125,6 +126,27 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
125
126
  const workspacePath = path.join(entry.config.paths.publicDir, "workspace.json");
126
127
  await fs.writeFile(workspacePath, JSON.stringify(workspaceManifest, null, 2), "utf8");
127
128
 
129
+ // Static search corpus — raw text of every content source file in the
130
+ // workspace, shipped as JSON so the deployed reader can search without
131
+ // a backend. Lives next to workspace.json so the public route can
132
+ // GET /openpress/search-corpus.json once and grep in memory. Workspace-
133
+ // scoped (not per-press) because most workspaces have a single Press
134
+ // and corpus size for typical content is small (<1MB raw); per-press
135
+ // scoping can come later if multi-Press search noise becomes a problem.
136
+ const corpusFiles = await collectSourceTextFiles(entry.config, { scope: "content" });
137
+ const corpus = {
138
+ kind: "search-corpus",
139
+ version: 1,
140
+ files: corpusFiles.map((file) => ({
141
+ scope: file.scope,
142
+ file: file.name,
143
+ path: file.relativePath,
144
+ text: file.text,
145
+ })),
146
+ };
147
+ const corpusPath = path.join(entry.config.paths.publicDir, "search-corpus.json");
148
+ await fs.writeFile(corpusPath, JSON.stringify(corpus), "utf8");
149
+
128
150
  if (syncAssets) {
129
151
  await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
130
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.1.4",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "description": "open-press core — runtime primitives, CLI, and render pipeline for AI-first fixed-layout documents.",
6
6
  "license": "MIT",
@@ -230,7 +230,10 @@ export function OpenPressApp() {
230
230
  const presentationSlug = state.activeSlug || currentRouteFromLocation().slug;
231
231
  const openPresentation = state.document.meta.type === "slides" && presentationSlug
232
232
  ? (pageIndex: number) => {
233
- openPressRoute(presentationSlug, "present", pageIndex, { fullscreen: true });
233
+ const slug = normalizeSlug(presentationSlug);
234
+ const pathname = slug ? `/${slug}` : "/";
235
+ const hash = `#page-${String(pageIndex + 1).padStart(2, "0")}`;
236
+ window.open(`${pathname}?fullscreen=1${hash}`, "_blank", "noopener,noreferrer");
234
237
  }
235
238
  : undefined;
236
239
 
@@ -250,6 +253,7 @@ export function OpenPressApp() {
250
253
  document={state.document}
251
254
  runtimeMode={state.runtimeMode}
252
255
  deploymentInfo={state.deploymentInfo}
256
+ activeSlug={state.activeSlug}
253
257
  onDocumentRefresh={refreshDocument}
254
258
  onOpenPresentation={openPresentation}
255
259
  onExitPresentation={exitPresentation}