@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.
@@ -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
  }
@@ -139,9 +139,51 @@ export async function inspectRenderedOverflow({ root, config, host = "127.0.0.1"
139
139
  }
140
140
  }
141
141
 
142
- export async function waitForInspectionReady(client) {
143
- const deadline = Date.now() + 30000;
144
- while (Date.now() < deadline) {
142
+ export const INSPECTION_READY_DEFAULTS = Object.freeze({
143
+ totalTimeoutMs: 300_000,
144
+ idleTimeoutMs: 30_000,
145
+ pollIntervalMs: 100,
146
+ });
147
+
148
+ export function resolveInspectionReadyTiming(env = process.env) {
149
+ const total = Number(env.OPENPRESS_INSPECTION_TIMEOUT_MS);
150
+ const idle = Number(env.OPENPRESS_INSPECTION_IDLE_MS);
151
+ return {
152
+ totalTimeoutMs: Number.isFinite(total) && total > 0 ? total : INSPECTION_READY_DEFAULTS.totalTimeoutMs,
153
+ idleTimeoutMs: Number.isFinite(idle) && idle > 0 ? idle : INSPECTION_READY_DEFAULTS.idleTimeoutMs,
154
+ pollIntervalMs: INSPECTION_READY_DEFAULTS.pollIntervalMs,
155
+ };
156
+ }
157
+
158
+ function formatInspectionTimeoutMessage(reason, snapshot, timing, elapsedMs) {
159
+ const seconds = Math.round(elapsedMs / 1000);
160
+ const observed = `(observed ${snapshot.pageCount} page(s))`;
161
+ if (reason === "idle") {
162
+ return (
163
+ `Timed out waiting for OpenPress pagination before inspection. ` +
164
+ `No progress for ${seconds}s ${observed}. ` +
165
+ `Raise OPENPRESS_INSPECTION_IDLE_MS (currently ${timing.idleTimeoutMs}ms) to extend the idle window.`
166
+ );
167
+ }
168
+ return (
169
+ `Timed out waiting for OpenPress pagination before inspection. ` +
170
+ `Total ${seconds}s exceeded ${observed}. ` +
171
+ `Raise OPENPRESS_INSPECTION_TIMEOUT_MS (currently ${timing.totalTimeoutMs}ms) to extend the hard cap.`
172
+ );
173
+ }
174
+
175
+ export async function waitForInspectionReady(client, timing = resolveInspectionReadyTiming()) {
176
+ const startedAt = Date.now();
177
+ let lastSignature = "";
178
+ let lastProgressAt = startedAt;
179
+ let lastSnapshot = { pageCount: 0 };
180
+
181
+ while (true) {
182
+ const totalElapsed = Date.now() - startedAt;
183
+ if (totalElapsed > timing.totalTimeoutMs) {
184
+ throw new Error(formatInspectionTimeoutMessage("total", lastSnapshot, timing, totalElapsed));
185
+ }
186
+
145
187
  const result = await client.send("Runtime.evaluate", {
146
188
  returnByValue: true,
147
189
  awaitPromise: true,
@@ -149,9 +191,23 @@ export async function waitForInspectionReady(client) {
149
191
  });
150
192
  const value = result.result?.value;
151
193
  if (Array.isArray(value)) return value;
152
- await delay(100);
194
+
195
+ const pageCount = Number.isFinite(Number(value?.pageCount)) ? Number(value.pageCount) : lastSnapshot.pageCount;
196
+ lastSnapshot = { pageCount };
197
+
198
+ const signature = String(pageCount);
199
+ if (signature !== lastSignature) {
200
+ lastSignature = signature;
201
+ lastProgressAt = Date.now();
202
+ }
203
+
204
+ const idleElapsed = Date.now() - lastProgressAt;
205
+ if (idleElapsed > timing.idleTimeoutMs) {
206
+ throw new Error(formatInspectionTimeoutMessage("idle", lastSnapshot, timing, idleElapsed));
207
+ }
208
+
209
+ await delay(timing.pollIntervalMs);
153
210
  }
154
- throw new Error("Timed out waiting for OpenPress pagination before inspection.");
155
211
  }
156
212
 
157
213
  export function overflowIssuesFromMeasurements(measurements) {
@@ -241,7 +297,9 @@ function humanOverflowTarget(code) {
241
297
  function inspectionExpression() {
242
298
  return `Promise.resolve().then(async () => {
243
299
  const root = document.querySelector('[data-openpress-print-document="true"]');
244
- if (!root || root.querySelectorAll('.openpress-html-page').length === 0) return null;
300
+ if (!root) return { pending: true, pageCount: 0 };
301
+ const candidates = root.querySelectorAll('.openpress-html-page');
302
+ if (candidates.length === 0) return { pending: true, pageCount: 0 };
245
303
 
246
304
  await document.fonts?.ready;
247
305
  await Promise.all(Array.from(document.images).map(async (img) => {
@@ -290,7 +348,7 @@ function inspectionExpression() {
290
348
  };
291
349
 
292
350
  const wrappers = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
293
- if (wrappers.length === 0) return null;
351
+ if (wrappers.length === 0) return { pending: true, pageCount: candidates.length };
294
352
  return wrappers.map((wrapper, index) => {
295
353
  const page = wrapper.querySelector('.reader-page') || wrapper;
296
354
  const frame = page.querySelector('.page-frame') || page;
@@ -0,0 +1,87 @@
1
+ const TOKEN_PATTERN = /^\s*(-?\d*)\s*(?:-\s*(\d*))?\s*$/;
2
+
3
+ export function parsePageSelector(input) {
4
+ if (typeof input !== "string") {
5
+ throw new TypeError("Page selector must be a string");
6
+ }
7
+ const trimmed = input.trim();
8
+ if (trimmed === "") {
9
+ throw new Error("Page selector is empty; expected something like '3', '3,5-7', or '12-'.");
10
+ }
11
+
12
+ const segments = trimmed.split(",").map((part) => part.trim()).filter(Boolean);
13
+ if (segments.length === 0) {
14
+ throw new Error(`Page selector "${input}" has no usable segments.`);
15
+ }
16
+
17
+ return segments.map((segment) => parseSegment(segment, input));
18
+ }
19
+
20
+ function parseSegment(segment, original) {
21
+ if (!segment.includes("-")) {
22
+ const value = toPositiveInteger(segment, original);
23
+ return { kind: "single", value };
24
+ }
25
+
26
+ if (segment === "-") {
27
+ throw new Error(`Page selector "${original}" contains a bare "-"; ranges need at least one bound.`);
28
+ }
29
+
30
+ const dashIndex = segment.indexOf("-");
31
+ if (segment.indexOf("-", dashIndex + 1) !== -1) {
32
+ throw new Error(`Page selector segment "${segment}" has too many dashes.`);
33
+ }
34
+
35
+ const leftRaw = segment.slice(0, dashIndex);
36
+ const rightRaw = segment.slice(dashIndex + 1);
37
+ const from = leftRaw.trim() === "" ? null : toPositiveInteger(leftRaw, original);
38
+ const to = rightRaw.trim() === "" ? null : toPositiveInteger(rightRaw, original);
39
+
40
+ if (from != null && to != null && from > to) {
41
+ throw new Error(`Page selector range "${segment}" goes backwards (${from} > ${to}).`);
42
+ }
43
+
44
+ return { kind: "range", from, to };
45
+ }
46
+
47
+ function toPositiveInteger(raw, original) {
48
+ const trimmed = raw.trim();
49
+ if (!/^\d+$/.test(trimmed)) {
50
+ throw new Error(`Page selector "${original}" contains non-integer token "${raw}".`);
51
+ }
52
+ const value = Number(trimmed);
53
+ if (!Number.isInteger(value) || value < 1) {
54
+ throw new Error(`Page selector "${original}" contains out-of-range page number "${raw}"; pages start at 1.`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ export function resolvePageSelector(spec, totalPages) {
60
+ if (!Array.isArray(spec)) {
61
+ throw new TypeError("resolvePageSelector expects a parsed selector array");
62
+ }
63
+ if (!Number.isInteger(totalPages) || totalPages < 0) {
64
+ throw new TypeError("resolvePageSelector expects a non-negative integer totalPages");
65
+ }
66
+ if (totalPages === 0) return [];
67
+
68
+ const selected = new Set();
69
+ for (const segment of spec) {
70
+ if (segment.kind === "single") {
71
+ if (segment.value > totalPages) {
72
+ throw new Error(`Page ${segment.value} is out of range; document has ${totalPages} page(s).`);
73
+ }
74
+ selected.add(segment.value);
75
+ continue;
76
+ }
77
+ const from = segment.from ?? 1;
78
+ const to = segment.to ?? totalPages;
79
+ if (from > totalPages) {
80
+ throw new Error(`Range start ${from} is out of range; document has ${totalPages} page(s).`);
81
+ }
82
+ const upper = Math.min(to, totalPages);
83
+ for (let i = from; i <= upper; i += 1) selected.add(i);
84
+ }
85
+
86
+ return Array.from(selected).sort((a, b) => a - b);
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.1.3",
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",