@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 +2 -2
- package/engine/commands/_shared.mjs +80 -9
- package/engine/commands/image.mjs +9 -3
- package/engine/commands/pdf.mjs +4 -1
- package/engine/output/chrome-pdf.mjs +102 -0
- package/engine/output/static-server.mjs +48 -8
- package/engine/react/document-export.mjs +22 -0
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +1 -0
- package/src/openpress/app/OpenPressRuntime.tsx +59 -5
- package/src/openpress/reader/PublicReaderPage.tsx +163 -74
- package/src/openpress/reader/SlidePresentationPage.tsx +8 -3
- package/src/openpress/reader/usePanelState.ts +14 -5
- package/src/openpress/shared/index.ts +1 -0
- package/src/openpress/shared/staticSearch.ts +174 -0
- package/src/openpress/workbench/Workbench.tsx +19 -16
- package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +14 -3
- package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
- package/vite.config.ts +50 -8
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
|
|
182
|
+
const result = await printUrlToPdf({
|
|
125
183
|
root,
|
|
126
|
-
url:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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) :
|
|
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(
|
|
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
|
package/engine/commands/pdf.mjs
CHANGED
|
@@ -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
|
|
212
|
-
const
|
|
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:
|
|
217
|
-
command
|
|
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(
|
|
239
|
+
const body = await fs.readFile(pdfPath);
|
|
231
240
|
res.writeHead(200, {
|
|
232
241
|
"Content-Type": "application/pdf",
|
|
233
|
-
"Content-Disposition": `inline; 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",
|
|
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
|
@@ -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
|
-
|
|
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",
|