@open-press/core 1.1.4 → 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.
package/engine/cli.mjs CHANGED
@@ -85,8 +85,8 @@ 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]
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
90
  deploy --confirm [--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
@@ -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,52 @@ 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
+ 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
+
106
153
  export async function buildReactPdf({
107
154
  root,
108
155
  config,
@@ -111,31 +158,44 @@ export async function buildReactPdf({
111
158
  port = "5185",
112
159
  noBuild = false,
113
160
  recurse,
161
+ pressSlug = null,
114
162
  }) {
115
163
  config ??= await loadConfig(root);
116
- outPath ??= config.paths.pdf;
117
164
  const renderCode = await buildReactStatic({ root, noBuild, recurse });
118
165
  if (renderCode !== 0) throw new Error(`React render failed with exit code ${renderCode}`);
119
166
  await optimizePdfMediaForStaticRoot(config.paths.outputDir);
167
+
168
+ const selection = pressSlug
169
+ ? await resolvePressSelection({ outputDir: config.paths.outputDir, slug: pressSlug })
170
+ : { slug: "", title: "", knownSlugs: [] };
171
+
172
+ if (!outPath) {
173
+ const filename = selection.slug
174
+ ? pressSuffixedFilename(config.pdf.filename, selection.slug)
175
+ : config.pdf.filename;
176
+ outPath = path.join(config.paths.outputDir, filename);
177
+ }
120
178
  await fs.mkdir(path.dirname(outPath), { recursive: true });
121
179
 
122
180
  const server = await startStaticServer(root, config, host, port);
123
181
  try {
124
- const pageCount = await printUrlToPdf({
182
+ const result = await printUrlToPdf({
125
183
  root,
126
- url: `http://${host}:${port}/?print=1`,
184
+ url: pressPrintUrl(host, port, selection.slug),
127
185
  outPath,
128
186
  waitForReady: waitForPrintReady,
129
187
  debuggingPortBase: 9300,
130
188
  debuggingPortRange: 600,
131
189
  profilePrefix: "chrome-pdf",
132
190
  });
133
- console.log(`${pageCount} OpenPress pages printed to PDF`);
191
+ const pageCount = result?.pageCount ?? result;
192
+ const pressLabel = selection.slug ? ` (press: ${selection.title || selection.slug})` : "";
193
+ console.log(`${pageCount} OpenPress pages printed to PDF${pressLabel}`);
134
194
  } finally {
135
195
  await stopChildProcess(server);
136
196
  }
137
197
 
138
- return { pdfPath: outPath };
198
+ return { pdfPath: outPath, pressSlug: selection.slug };
139
199
  }
140
200
 
141
201
  export async function buildReactImages({
@@ -147,18 +207,27 @@ export async function buildReactImages({
147
207
  noBuild = false,
148
208
  recurse,
149
209
  pageSelector = null,
210
+ pressSlug = null,
150
211
  }) {
151
212
  config ??= await loadConfig(root);
152
- outDir ??= path.join(config.paths.outputDir, "images");
153
213
  const renderCode = await buildReactStatic({ root, noBuild, recurse });
154
214
  if (renderCode !== 0) throw new Error(`React render failed with exit code ${renderCode}`);
215
+
216
+ const selection = pressSlug
217
+ ? await resolvePressSelection({ outputDir: config.paths.outputDir, slug: pressSlug })
218
+ : { slug: "", title: "", knownSlugs: [] };
219
+
220
+ if (!outDir) {
221
+ const folder = selection.slug ? `images-${selection.slug}` : "images";
222
+ outDir = path.join(config.paths.outputDir, folder);
223
+ }
155
224
  await fs.mkdir(outDir, { recursive: true });
156
225
 
157
226
  const server = await startStaticServer(root, config, host, port);
158
227
  try {
159
228
  const result = await captureUrlPagesToPng({
160
229
  root,
161
- url: `http://${host}:${port}/?print=1`,
230
+ url: pressPrintUrl(host, port, selection.slug),
162
231
  outDir,
163
232
  waitForReady: waitForPrintReady,
164
233
  debuggingPortBase: 9700,
@@ -166,15 +235,17 @@ export async function buildReactImages({
166
235
  profilePrefix: "chrome-image",
167
236
  pageSelector,
168
237
  });
169
- const label = pageSelector
238
+ const pressLabel = selection.slug ? ` (press: ${selection.title || selection.slug})` : "";
239
+ const countLabel = pageSelector
170
240
  ? `${result.files.length}/${result.pageCount} OpenPress pages exported to PNG`
171
241
  : `${result.files.length} OpenPress pages exported to PNG`;
172
- console.log(label);
242
+ console.log(`${countLabel}${pressLabel}`);
173
243
  return {
174
244
  outDir,
175
245
  files: result.files,
176
246
  pageCount: result.pageCount,
177
247
  selectedPageNumbers: result.selectedPageNumbers,
248
+ pressSlug: selection.slug,
178
249
  };
179
250
  } finally {
180
251
  await stopChildProcess(server);
@@ -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,6 +248,35 @@ 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." });
@@ -356,9 +394,11 @@ async function handleMediaFileRequest(req, res, url) {
356
394
  }
357
395
  }
358
396
 
359
- function runLocalPdfExport() {
397
+ function runLocalPdfExport(slug = "") {
398
+ const cliArgs = [CLI_ENTRY, "pdf", "."];
399
+ if (slug) cliArgs.push("--press", slug);
360
400
  return new Promise((resolve) => {
361
- const child = spawn("node", [CLI_ENTRY, "pdf", "."], {
401
+ const child = spawn("node", cliArgs, {
362
402
  cwd: workspace,
363
403
  shell: false,
364
404
  });
@@ -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.0",
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",
@@ -250,6 +250,7 @@ export function OpenPressApp() {
250
250
  document={state.document}
251
251
  runtimeMode={state.runtimeMode}
252
252
  deploymentInfo={state.deploymentInfo}
253
+ activeSlug={state.activeSlug}
253
254
  onDocumentRefresh={refreshDocument}
254
255
  onOpenPresentation={openPresentation}
255
256
  onExitPresentation={exitPresentation}
@@ -1,4 +1,4 @@
1
- import { useMemo, type CSSProperties } from "react";
1
+ import { useEffect, useMemo, useState, type CSSProperties } from "react";
2
2
  import { PrintDocument, PublicViewer, SlidePresentationPage } from "../reader";
3
3
  import { isPresentationModeLocation, isPrintModeLocation, isWorkspaceModeLocation } from "../shared";
4
4
  import { HtmlWorkbench } from "../workbench";
@@ -15,6 +15,12 @@ interface OpenPressRuntimeProps {
15
15
  document: ReaderDocument;
16
16
  runtimeMode?: OpenPressRuntimeMode;
17
17
  deploymentInfo?: DeploymentInfo;
18
+ // Active Press slug — supplied by OpenPressApp when the active document
19
+ // came from a multi-Press workspace. The workbench passes this through to
20
+ // useDeploymentWorkbench so the local PDF export endpoint can target the
21
+ // right Press instead of defaulting to the first one and producing a
22
+ // "0 pages observed" timeout.
23
+ activeSlug?: string;
18
24
  onDocumentRefresh?: () => void | Promise<void>;
19
25
  onOpenPresentation?: (pageIndex: number) => void;
20
26
  onExitPresentation?: (pageIndex: number) => void;
@@ -28,6 +34,7 @@ export function OpenPressRuntime({
28
34
  document,
29
35
  runtimeMode,
30
36
  deploymentInfo = { online: false },
37
+ activeSlug,
31
38
  onDocumentRefresh,
32
39
  onOpenPresentation,
33
40
  onExitPresentation,
@@ -35,19 +42,29 @@ export function OpenPressRuntime({
35
42
  }: OpenPressRuntimeProps) {
36
43
  const style = themeToCssVariables(document.theme);
37
44
  const htmlPages = document.blocks.filter((block): block is HtmlPageBlock => block.kind === "htmlPage");
45
+ // The mode flags below all read window.location synchronously. They
46
+ // would otherwise stay frozen at their mount-time values when
47
+ // OpenPressApp re-renders us in response to a client-side URL change
48
+ // (e.g. /<slug>/present -> /<slug>/preview after exiting the slide
49
+ // presenter), so the SlidePresentationPage exits to the wrong
50
+ // route-driven branch (PublicViewer instead of HtmlWorkbench) and the
51
+ // user sees the legacy public-viewer chrome until a hard reload.
52
+ // Bump a version on every pathname/search change so the memos
53
+ // re-evaluate exactly when the URL does.
54
+ const routeVersion = useLocationVersion();
38
55
  const activeRuntimeMode = useMemo<OpenPressRuntimeMode>(() => {
39
56
  if (runtimeMode) return runtimeMode;
40
57
  if (typeof window === "undefined") return "preview";
41
58
  return isPresentationModeLocation(window.location) ? "present" : "preview";
42
- }, [runtimeMode]);
59
+ }, [runtimeMode, routeVersion]);
43
60
  const workspaceMode = useMemo(() => {
44
61
  if (typeof window === "undefined") return false;
45
62
  return isWorkspaceModeLocation(window.location);
46
- }, []);
63
+ }, [routeVersion]);
47
64
  const printMode = useMemo(() => {
48
65
  if (typeof window === "undefined") return false;
49
66
  return isPrintModeLocation(window.location);
50
- }, []);
67
+ }, [routeVersion]);
51
68
 
52
69
  if (htmlPages.length > 0) {
53
70
  if (printMode) {
@@ -74,8 +91,9 @@ export function OpenPressRuntime({
74
91
  document={document}
75
92
  pages={htmlPages}
76
93
  style={style}
77
- devMode={workspaceMode}
94
+ workspaceMode={workspaceMode}
78
95
  deploymentInfo={deploymentInfo}
96
+ pressSlug={activeSlug ?? null}
79
97
  onDocumentRefresh={onDocumentRefresh}
80
98
  onOpenPresentation={onOpenPresentation}
81
99
  onBackToWorkspace={onBackToWorkspace}
@@ -110,6 +128,42 @@ function EmptyState({ style, workspaceMode }: { style: CSSProperties; workspaceM
110
128
  );
111
129
  }
112
130
 
131
+ // Bump a counter whenever client-side navigation changes pathname /
132
+ // search / hash, so location-derived memos in OpenPressRuntime
133
+ // re-evaluate. popstate fires on browser back/forward; we also patch
134
+ // pushState / replaceState because the SPA itself calls those when
135
+ // the user opens a Press, exits a slide presenter, or toggles between
136
+ // /<slug>/preview and /<slug>/present.
137
+ function useLocationVersion() {
138
+ const [version, setVersion] = useState(0);
139
+ useEffect(() => {
140
+ if (typeof window === "undefined") return undefined;
141
+ const bump = () => setVersion((value) => value + 1);
142
+ window.addEventListener("popstate", bump);
143
+ window.addEventListener("hashchange", bump);
144
+ const history = window.history;
145
+ const originalPushState = history.pushState.bind(history);
146
+ const originalReplaceState = history.replaceState.bind(history);
147
+ history.pushState = function patchedPushState(...args) {
148
+ const result = originalPushState(...args);
149
+ bump();
150
+ return result;
151
+ } as typeof history.pushState;
152
+ history.replaceState = function patchedReplaceState(...args) {
153
+ const result = originalReplaceState(...args);
154
+ bump();
155
+ return result;
156
+ } as typeof history.replaceState;
157
+ return () => {
158
+ window.removeEventListener("popstate", bump);
159
+ window.removeEventListener("hashchange", bump);
160
+ history.pushState = originalPushState;
161
+ history.replaceState = originalReplaceState;
162
+ };
163
+ }, []);
164
+ return version;
165
+ }
166
+
113
167
  function themeToCssVariables(theme?: Theme) {
114
168
  const style: CSSProperties & Record<`--${string}`, string> = {
115
169
  "--openpress-font-family": theme?.fontFamily ?? "'Noto Sans TC', 'PingFang TC', sans-serif",