@open-press/core 1.1.1 → 1.1.3

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.
@@ -17,7 +17,7 @@ export async function run({ root, options }) {
17
17
  }
18
18
  const host = options.host ?? "127.0.0.1";
19
19
  const port = options.port ?? "5173";
20
- const url = `http://${host}:${port}/?dev=1`;
20
+ const url = `http://${host}:${port}/workspace`;
21
21
  if (options.dryRun) {
22
22
  console.log(`OpenPress dev URL: ${url}`);
23
23
  if (!options.noBuild) {
@@ -14,6 +14,7 @@ import ts from "typescript";
14
14
  import { createServer as createViteServer } from "vite";
15
15
  import { loadConfig } from "../runtime/config.mjs";
16
16
  import { inspectPressTree } from "./press-tree-inspection.mjs";
17
+ import { textSourceTransformPlugin } from "./text-source-transform.mjs";
17
18
 
18
19
  const ENGINE_REACT_DIR = path.dirname(fileURLToPath(import.meta.url));
19
20
  const FRAMEWORK_ROOT = path.resolve(ENGINE_REACT_DIR, "..", "..");
@@ -95,7 +96,14 @@ export async function createReactSsrServer(workspaceRoot = ".") {
95
96
  cacheDir: path.join(resolvedWorkspaceRoot, ".openpress", "vite-ssr"),
96
97
  appType: "custom",
97
98
  logLevel: "silent",
98
- plugins: [reactRuntimePlugin(), react()],
99
+ plugins: [
100
+ textSourceTransformPlugin({
101
+ workspaceRoot: resolvedWorkspaceRoot,
102
+ documentRoot: path.join(resolvedWorkspaceRoot, "press"),
103
+ }),
104
+ reactRuntimePlugin(),
105
+ react(),
106
+ ],
99
107
  resolve: {
100
108
  alias: [
101
109
  // ORDER MATTERS: subpath aliases must precede the base alias so that
@@ -24,6 +24,7 @@ import { resolveAllSources } from "./sources/mdx-resolver.mjs";
24
24
  import { discoverSectionStyles } from "./style-discovery.mjs";
25
25
 
26
26
  const MAX_ITERATIONS = 20;
27
+ const PRESS_TYPES = new Set(["pages", "slides"]);
27
28
 
28
29
  export async function exportReactDocument(root = ".", { syncAssets = true } = {}) {
29
30
  const workspaceRoot = path.resolve(root);
@@ -115,6 +116,7 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
115
116
  presses: pressResults.map((r) => ({
116
117
  slug: r.slug,
117
118
  title: r.readerDocument.meta.title,
119
+ type: r.pressType,
118
120
  page: r.readerDocument.theme ?? null,
119
121
  pageCount: r.pageCount,
120
122
  documentUrl: r.documentUrl,
@@ -158,6 +160,7 @@ async function exportSinglePress({
158
160
  const slug = typeof press.metadata?.slug === "string" && press.metadata.slug.trim()
159
161
  ? press.metadata.slug.trim()
160
162
  : "";
163
+ const pressType = normalizePressType(press.metadata?.type);
161
164
 
162
165
  // Effective config for this press: workspace config with per-press
163
166
  // metadata overlaid. Press JSX page prop wins over the workspace page.
@@ -308,6 +311,7 @@ async function exportSinglePress({
308
311
  const readerDocument = {
309
312
  meta: {
310
313
  title: trimmedString(effectiveConfig.title) ?? "Untitled Document",
314
+ type: pressType,
311
315
  subtitle: trimmedString(effectiveConfig.subtitle) ?? "",
312
316
  organization: trimmedString(effectiveConfig.organization) ?? "",
313
317
  workspaceLabel: trimmedString(effectiveConfig.workspaceLabel) ?? "",
@@ -349,6 +353,7 @@ async function exportSinglePress({
349
353
 
350
354
  return {
351
355
  slug,
356
+ pressType,
352
357
  documentPath,
353
358
  documentUrl: slug ? `/openpress/${slug}/document.json` : "/openpress/document.json",
354
359
  readerDocument,
@@ -356,6 +361,14 @@ async function exportSinglePress({
356
361
  };
357
362
  }
358
363
 
364
+ function normalizePressType(value) {
365
+ if (value === undefined || value === null || value === "") return "pages";
366
+ if (PRESS_TYPES.has(value)) return value;
367
+ throw new Error(
368
+ `Unsupported Press type "${value}". Supported types: ${[...PRESS_TYPES].join(", ")}.`,
369
+ );
370
+ }
371
+
359
372
  // Apply per-Press JSX prop overrides onto the workspace-level config.
360
373
  // Returns a new config object — the original is untouched so other
361
374
  // presses in the same workspace get a clean base.
@@ -509,4 +522,3 @@ function collectSectionRoots(presses, documentRoot) {
509
522
  }
510
523
  return [...roots];
511
524
  }
512
-
@@ -31,6 +31,7 @@ import React from "react";
31
31
  * props: Record<string, unknown>, // Press JSX props (no children)
32
32
  * metadata: {
33
33
  * title?: string,
34
+ * type?: "pages" | "slides",
34
35
  * page?: unknown,
35
36
  * slug?: string,
36
37
  * theme?: string,
@@ -144,6 +145,7 @@ function collectPressElements(root, PRESS_MARKER) {
144
145
  function pickPressMetadata(pressProps) {
145
146
  const out = {};
146
147
  if (typeof pressProps.title === "string") out.title = pressProps.title;
148
+ if (typeof pressProps.type === "string") out.type = pressProps.type;
147
149
  if (pressProps.page !== undefined) out.page = pressProps.page;
148
150
  if (typeof pressProps.slug === "string") out.slug = pressProps.slug;
149
151
  if (typeof pressProps.theme === "string") out.theme = pressProps.theme;
@@ -0,0 +1,175 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+
5
+ const TEXT_SOURCE_FILE_RE = /\.[jt]sx$/;
6
+
7
+ export function textSourceTransformPlugin({ workspaceRoot, documentRoot }) {
8
+ const resolvedWorkspaceRoot = realpathIfExists(path.resolve(workspaceRoot));
9
+ const resolvedDocumentRoot = realpathIfExists(path.resolve(documentRoot));
10
+
11
+ return {
12
+ name: "openpress-text-source-transform",
13
+ enforce: "pre",
14
+ transform(code, id) {
15
+ const filePath = cleanViteId(id);
16
+ if (!TEXT_SOURCE_FILE_RE.test(filePath)) return null;
17
+ if (!isInsidePath(filePath, resolvedDocumentRoot)) return null;
18
+
19
+ const relativePath = path.relative(resolvedWorkspaceRoot, filePath).replaceAll(path.sep, "/");
20
+ if (!relativePath || relativePath.startsWith("..")) return null;
21
+
22
+ const nextCode = addLiteralTextSourceProps(code, {
23
+ filePath,
24
+ sourcePath: relativePath,
25
+ });
26
+ if (nextCode === code) return null;
27
+ return { code: nextCode, map: null };
28
+ },
29
+ };
30
+ }
31
+
32
+ export function addLiteralTextSourceProps(code, { filePath = "index.tsx", sourcePath = "press/index.tsx" } = {}) {
33
+ const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
34
+ const textRefs = collectOpenPressTextRefs(sourceFile);
35
+ if (textRefs.identifiers.size === 0 && textRefs.namespaces.size === 0) return code;
36
+
37
+ const insertions = [];
38
+
39
+ const visit = (node) => {
40
+ if (ts.isJsxElement(node) && isTextElementName(node.openingElement.tagName, textRefs)) {
41
+ const opening = node.openingElement;
42
+ if (!hasJsxAttribute(opening, "source")) {
43
+ const literal = literalTextChildRange(node, sourceFile, code);
44
+ if (literal) {
45
+ insertions.push({
46
+ offset: opening.end - 1,
47
+ text: ` source={${sourcePropExpression({
48
+ sourcePath,
49
+ objectId: stringLiteralAttribute(opening, "objectId"),
50
+ range: literal.range,
51
+ })}}`,
52
+ });
53
+ }
54
+ }
55
+ }
56
+ ts.forEachChild(node, visit);
57
+ };
58
+ visit(sourceFile);
59
+
60
+ if (insertions.length === 0) return code;
61
+ let out = code;
62
+ for (const insertion of insertions.sort((a, b) => b.offset - a.offset)) {
63
+ out = `${out.slice(0, insertion.offset)}${insertion.text}${out.slice(insertion.offset)}`;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function collectOpenPressTextRefs(sourceFile) {
69
+ const identifiers = new Set();
70
+ const namespaces = new Set();
71
+
72
+ for (const statement of sourceFile.statements) {
73
+ if (!ts.isImportDeclaration(statement)) continue;
74
+ if (!statement.importClause) continue;
75
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
76
+ if (statement.moduleSpecifier.text !== "@open-press/core") continue;
77
+
78
+ const bindings = statement.importClause.namedBindings;
79
+ if (!bindings) continue;
80
+ if (ts.isNamespaceImport(bindings)) {
81
+ namespaces.add(bindings.name.text);
82
+ continue;
83
+ }
84
+ if (!ts.isNamedImports(bindings)) continue;
85
+
86
+ for (const element of bindings.elements) {
87
+ const importedName = element.propertyName?.text ?? element.name.text;
88
+ if (importedName === "Text") identifiers.add(element.name.text);
89
+ }
90
+ }
91
+
92
+ return { identifiers, namespaces };
93
+ }
94
+
95
+ function literalTextChildRange(node, sourceFile, code) {
96
+ const textChildren = [];
97
+ for (const child of node.children) {
98
+ if (ts.isJsxText(child)) {
99
+ const raw = code.slice(child.pos, child.end);
100
+ if (raw.trim()) textChildren.push({ child, raw });
101
+ continue;
102
+ }
103
+ if (ts.isJsxExpression(child) && !child.expression && code.slice(child.pos, child.end).trim() === "{}") continue;
104
+ if (code.slice(child.pos, child.end).trim()) return null;
105
+ }
106
+ if (textChildren.length !== 1) return null;
107
+
108
+ const { child, raw } = textChildren[0];
109
+ const text = raw.trim();
110
+ const startInRaw = raw.indexOf(text);
111
+ const startOffset = child.pos + startInRaw;
112
+ const endOffset = startOffset + text.length;
113
+ const start = sourceFile.getLineAndCharacterOfPosition(startOffset);
114
+ const end = sourceFile.getLineAndCharacterOfPosition(endOffset);
115
+ return {
116
+ text,
117
+ range: {
118
+ line: start.line + 1,
119
+ column: start.character + 1,
120
+ endLine: end.line + 1,
121
+ endColumn: end.character + 1,
122
+ },
123
+ };
124
+ }
125
+
126
+ function sourcePropExpression({ sourcePath, objectId, range }) {
127
+ const props = [
128
+ `path: ${JSON.stringify(sourcePath)}`,
129
+ `kind: "tsx-text"`,
130
+ `source: { line: ${range.line}, column: ${range.column}, endLine: ${range.endLine}, endColumn: ${range.endColumn} }`,
131
+ ];
132
+ if (objectId) props.splice(2, 0, `objectId: ${JSON.stringify(objectId)}`);
133
+ return `{ ${props.join(", ")} }`;
134
+ }
135
+
136
+ function isTextElementName(name, refs) {
137
+ if (ts.isIdentifier(name)) return refs.identifiers.has(name.text);
138
+ if (!ts.isJsxMemberExpression(name)) return false;
139
+ if (name.name.text !== "Text") return false;
140
+ return ts.isIdentifier(name.expression) && refs.namespaces.has(name.expression.text);
141
+ }
142
+
143
+ function hasJsxAttribute(opening, name) {
144
+ return opening.attributes.properties.some((prop) =>
145
+ ts.isJsxAttribute(prop) && prop.name.text === name
146
+ );
147
+ }
148
+
149
+ function stringLiteralAttribute(opening, name) {
150
+ const attr = opening.attributes.properties.find((prop) =>
151
+ ts.isJsxAttribute(prop) && prop.name.text === name
152
+ );
153
+ if (!attr || !ts.isJsxAttribute(attr) || !attr.initializer) return undefined;
154
+ if (ts.isStringLiteral(attr.initializer)) return attr.initializer.text;
155
+ return undefined;
156
+ }
157
+
158
+ function cleanViteId(id) {
159
+ const withoutQuery = String(id ?? "").split("?")[0];
160
+ const fsPath = withoutQuery.startsWith("/@fs/") ? withoutQuery.slice("/@fs".length) : withoutQuery;
161
+ return realpathIfExists(path.resolve(fsPath));
162
+ }
163
+
164
+ function realpathIfExists(filePath) {
165
+ try {
166
+ return fs.realpathSync.native(filePath);
167
+ } catch {
168
+ return filePath;
169
+ }
170
+ }
171
+
172
+ function isInsidePath(filePath, parentPath) {
173
+ const relative = path.relative(parentPath, filePath);
174
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
175
+ }
@@ -93,8 +93,7 @@ export async function applySourceBlockTextEdit({
93
93
  }) {
94
94
  const requestedPath = stringValue(sourcePath);
95
95
  if (!requestedPath) throw new Error("Source edit requires a source path.");
96
- const files = await collectSourceTextFiles(config, { scope: "content" });
97
- const file = files.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
96
+ const file = await findEditableSourceTextFile(config, requestedPath);
98
97
  if (!file) throw new Error(`Editable source file not found: ${requestedPath}`);
99
98
 
100
99
  const result = sourceMode
@@ -120,8 +119,7 @@ export async function applySourceBlockTextEdit({
120
119
  export async function readSourceBlockText({ config, path: sourcePath, source }) {
121
120
  const requestedPath = stringValue(sourcePath);
122
121
  if (!requestedPath) throw new Error("Source read requires a source path.");
123
- const files = await collectSourceTextFiles(config, { scope: "content" });
124
- const file = files.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
122
+ const file = await findEditableSourceTextFile(config, requestedPath);
125
123
  if (!file) throw new Error(`Editable source file not found: ${requestedPath}`);
126
124
  return {
127
125
  path: file.relativePath,
@@ -205,6 +203,13 @@ export function applySourceBlockTextEditToText(documentText, {
205
203
  name,
206
204
  });
207
205
  }
206
+ if (kind === "object-text") {
207
+ return applySourceBlockObjectTextEditToText(documentText, {
208
+ source,
209
+ text,
210
+ blockId,
211
+ });
212
+ }
208
213
  if (kind === "element" && name === "pre") {
209
214
  return applySourceBlockCodeEditToText(documentText, {
210
215
  source,
@@ -265,6 +270,48 @@ export function applySourceBlockTextEditToText(documentText, {
265
270
  };
266
271
  }
267
272
 
273
+ function applySourceBlockObjectTextEditToText(documentText, {
274
+ source,
275
+ text,
276
+ blockId,
277
+ } = {}) {
278
+ const sourceRange = normalizeSourceRange(source);
279
+ const replacementText = normalizeEditedText(text);
280
+ const lines = splitTextLines(documentText);
281
+ const startIndex = sourceRange.line - 1;
282
+ const endIndex = sourceRange.endLine - 1;
283
+ if (!lines[startIndex]) throw new Error(`Source edit line ${sourceRange.line} is outside the source file.`);
284
+ if (!lines[endIndex]) throw new Error(`Source edit end line ${sourceRange.endLine} is outside the source file.`);
285
+
286
+ const selectedLines = lines.slice(startIndex, endIndex + 1);
287
+ const before = selectedLines.map((line) => line.line).join("\n");
288
+ const after = replaceSourceRangeText(before, sourceRange, replacementText);
289
+ const replacementLines = after.split("\n");
290
+ const ending = selectedLines[selectedLines.length - 1].ending;
291
+ const nextLines = [
292
+ ...lines.slice(0, startIndex),
293
+ ...replacementLines.map((line, index) => ({
294
+ line,
295
+ ending: index === replacementLines.length - 1 ? ending : "\n",
296
+ })),
297
+ ...lines.slice(endIndex + 1),
298
+ ];
299
+
300
+ return {
301
+ text: joinTextLines(nextLines),
302
+ edit: {
303
+ blockId,
304
+ line: sourceRange.line,
305
+ column: sourceRange.column,
306
+ endLine: sourceRange.endLine,
307
+ endColumn: sourceRange.endColumn,
308
+ before,
309
+ after,
310
+ text: replacementText,
311
+ },
312
+ };
313
+ }
314
+
268
315
  function applySourceBlockCodeEditToText(documentText, {
269
316
  source,
270
317
  text,
@@ -722,6 +769,15 @@ function replaceTableCellContent(cell, replacementText) {
722
769
  return `${leading}${replacementText}${trailing}`;
723
770
  }
724
771
 
772
+ function replaceSourceRangeText(sourceText, sourceRange, replacementText) {
773
+ const lines = sourceText.split("\n");
774
+ const firstLine = lines[0] ?? "";
775
+ const lastLine = lines[lines.length - 1] ?? "";
776
+ const prefix = firstLine.slice(0, Math.max(0, sourceRange.column - 1));
777
+ const suffix = lastLine.slice(Math.max(0, sourceRange.endColumn - 1));
778
+ return `${prefix}${replacementText}${suffix}`;
779
+ }
780
+
725
781
  function markdownTextPrefix(line, { kind, name }) {
726
782
  if (kind === "list-item") return line.match(/^(\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?)/)?.[1] ?? "- ";
727
783
  if (name && /^h[1-6]$/.test(name)) return line.match(/^(\s{0,3}#{1,6}\s+)/)?.[1] ?? `${"#".repeat(Number(name.slice(1)))} `;
@@ -791,8 +847,8 @@ function applySourceBlockComponentCaptionEditToText(documentText, {
791
847
  }
792
848
 
793
849
  function assertEditableComponentCaption({ name, blockId }) {
794
- if (name === "MediaFigure" || name === "ImageFigure") return;
795
- throw new Error(`Only MediaFigure and ImageFigure caption props can be edited inline${blockId ? `: ${blockId}` : ""}.`);
850
+ if (typeof name === "string" && /^[A-Z][A-Za-z0-9_$]*$/.test(name)) return;
851
+ throw new Error(`Component caption edits require a named React component${blockId ? `: ${blockId}` : ""}.`);
796
852
  }
797
853
 
798
854
  function replaceComponentCaptionProp(sourceText, replacementText) {
@@ -820,6 +876,15 @@ function sourceTextPathMatches(candidatePath, requestedPath) {
820
876
  return normalizeSourceTextPath(candidatePath) === normalizeSourceTextPath(requestedPath);
821
877
  }
822
878
 
879
+ async function findEditableSourceTextFile(config, requestedPath) {
880
+ const contentFiles = await collectSourceTextFiles(config, { scope: "content" });
881
+ const contentMatch = contentFiles.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
882
+ if (contentMatch) return contentMatch;
883
+
884
+ const allFiles = await collectSourceTextFiles(config, { scope: "all" });
885
+ return allFiles.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
886
+ }
887
+
823
888
  function normalizeSourceTextPath(value) {
824
889
  return String(value ?? "")
825
890
  .replaceAll("\\", "/")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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",
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
- import { OpenPressRuntime } from "./OpenPressRuntime";
2
+ import { OpenPressRuntime, type OpenPressRuntimeMode } from "./OpenPressRuntime";
3
3
  import { WorkspaceGalleryPage } from "./WorkspaceGalleryPage";
4
4
  import { isLocalWorkspaceHost } from "../shared";
5
5
  import type {
@@ -28,9 +28,15 @@ type LoadState =
28
28
  // or for the root entry of a multi-Press workspace. Otherwise the
29
29
  // active press's slug — used by refresh/back/forward to re-resolve.
30
30
  activeSlug: string;
31
+ runtimeMode: OpenPressRuntimeMode;
31
32
  }
32
33
  | { status: "error"; message: string };
33
34
 
35
+ interface WorkspaceRoute {
36
+ slug: string;
37
+ mode: OpenPressRuntimeMode;
38
+ }
39
+
34
40
  interface DeployConfig {
35
41
  pdf?: string;
36
42
  deployed_at?: string;
@@ -63,23 +69,30 @@ export function OpenPressApp() {
63
69
 
64
70
  // Single resolution function — same code path for "boot from URL",
65
71
  // "click gallery card", and "browser back button". Given a manifest
66
- // + slug, decides whether to render gallery or load a press.
67
- const resolveFromSlug = useCallback(async (
72
+ // + route, decides whether to render gallery or load a press.
73
+ const resolveFromRoute = useCallback(async (
68
74
  manifest: WorkspaceManifest | null,
69
- slug: string,
75
+ route: WorkspaceRoute,
70
76
  deploymentInfo: DeploymentInfo,
71
77
  ) => {
72
78
  // No manifest (legacy deploy): always load /openpress/document.json.
73
79
  if (!manifest || manifest.presses.length === 0) {
74
80
  const document = await loadReaderDocument("/openpress/document.json");
75
- setState({ status: "ready", document, deploymentInfo, manifest, activeSlug: "" });
81
+ setState({
82
+ status: "ready",
83
+ document,
84
+ deploymentInfo,
85
+ manifest,
86
+ activeSlug: "",
87
+ runtimeMode: resolveRuntimeMode(document, route.mode),
88
+ });
76
89
  return;
77
90
  }
78
91
 
79
92
  // Empty slug + multi-Press: show gallery. Empty slug + single-Press:
80
93
  // load the only press. Same expression handles both — array length
81
94
  // is the only thing that matters.
82
- const normalizedSlug = normalizeSlug(slug);
95
+ const normalizedSlug = normalizeSlug(route.slug);
83
96
  if (!normalizedSlug && manifestHasMultiplePresses(manifest)) {
84
97
  setState({ status: "gallery", manifest, deploymentInfo });
85
98
  return;
@@ -96,7 +109,14 @@ export function OpenPressApp() {
96
109
  return;
97
110
  }
98
111
  const document = await loadReaderDocument(press.documentUrl);
99
- setState({ status: "ready", document, deploymentInfo, manifest, activeSlug: press.slug });
112
+ setState({
113
+ status: "ready",
114
+ document,
115
+ deploymentInfo,
116
+ manifest,
117
+ activeSlug: press.slug,
118
+ runtimeMode: resolveRuntimeMode(document, route.mode),
119
+ });
100
120
  }, []);
101
121
 
102
122
  const refreshDocument = useCallback(async () => {
@@ -112,12 +132,12 @@ export function OpenPressApp() {
112
132
  });
113
133
  }, [state]);
114
134
 
115
- // Gallery click → pushState + load. Bypasses resolveFromSlug's
135
+ // Gallery click → pushState + load. Bypasses resolveFromRoute's
116
136
  // "empty slug + multi-Press → gallery" branch: an explicit click on
117
137
  // the unslugged root Press must enter it, not bounce back to gallery.
118
138
  const enterPress = useCallback(async (press: WorkspaceManifestPress) => {
119
139
  if (state.status !== "gallery") return;
120
- pushSlug(press.slug);
140
+ pushPressRoute(press.slug, "preview");
121
141
  setState({ status: "loading" });
122
142
  try {
123
143
  const document = await loadReaderDocument(press.documentUrl);
@@ -127,6 +147,7 @@ export function OpenPressApp() {
127
147
  deploymentInfo: state.deploymentInfo,
128
148
  manifest: state.manifest,
129
149
  activeSlug: press.slug,
150
+ runtimeMode: "preview",
130
151
  });
131
152
  } catch (error) {
132
153
  setState({
@@ -147,7 +168,7 @@ export function OpenPressApp() {
147
168
  loadDeploymentInfo(),
148
169
  ]);
149
170
  if (cancelled) return;
150
- await resolveFromSlug(manifest, currentSlugFromLocation(), deploymentInfo);
171
+ await resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
151
172
  } catch (error) {
152
173
  if (!cancelled) {
153
174
  setState({
@@ -162,7 +183,7 @@ export function OpenPressApp() {
162
183
  return () => {
163
184
  cancelled = true;
164
185
  };
165
- }, [resolveFromSlug]);
186
+ }, [resolveFromRoute]);
166
187
 
167
188
  // Back / forward button — re-resolve from the new URL.
168
189
  useEffect(() => {
@@ -176,11 +197,11 @@ export function OpenPressApp() {
176
197
  const deploymentInfo = state.status === "gallery" || state.status === "ready"
177
198
  ? state.deploymentInfo
178
199
  : offlineDeploymentInfo;
179
- void resolveFromSlug(manifest, currentSlugFromLocation(), deploymentInfo);
200
+ void resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
180
201
  }
181
202
  window.addEventListener("popstate", onPopState);
182
203
  return () => window.removeEventListener("popstate", onPopState);
183
- }, [state, resolveFromSlug]);
204
+ }, [state, resolveFromRoute]);
184
205
 
185
206
  if (state.status === "loading") return <LoadingScreen />;
186
207
 
@@ -197,7 +218,7 @@ export function OpenPressApp() {
197
218
  const backToWorkspace = state.manifest && manifestHasMultiplePresses(state.manifest)
198
219
  ? () => {
199
220
  if (state.status !== "ready" || !state.manifest) return;
200
- pushSlug("");
221
+ pushPressRoute("", "preview");
201
222
  setState({
202
223
  status: "gallery",
203
224
  manifest: state.manifest,
@@ -206,36 +227,97 @@ export function OpenPressApp() {
206
227
  }
207
228
  : undefined;
208
229
 
230
+ const presentationSlug = state.activeSlug || currentRouteFromLocation().slug;
231
+ const openPresentation = state.document.meta.type === "slides" && presentationSlug
232
+ ? (pageIndex: number) => {
233
+ openPressRoute(presentationSlug, "present", pageIndex, { fullscreen: true });
234
+ }
235
+ : undefined;
236
+
237
+ const exitPresentation = state.document.meta.type === "slides"
238
+ ? (pageIndex: number) => {
239
+ if (state.status !== "ready") return;
240
+ const slug = state.activeSlug || currentRouteFromLocation().slug;
241
+ if (slug) pushPressRoute(slug, "preview", pageIndex);
242
+ setState((latest) => latest.status === "ready"
243
+ ? { ...latest, runtimeMode: "preview" }
244
+ : latest);
245
+ }
246
+ : undefined;
247
+
209
248
  return (
210
249
  <OpenPressRuntime
211
250
  document={state.document}
251
+ runtimeMode={state.runtimeMode}
212
252
  deploymentInfo={state.deploymentInfo}
213
253
  onDocumentRefresh={refreshDocument}
254
+ onOpenPresentation={openPresentation}
255
+ onExitPresentation={exitPresentation}
214
256
  onBackToWorkspace={backToWorkspace}
215
257
  />
216
258
  );
217
259
  }
218
260
 
219
- function currentSlugFromLocation(): string {
220
- if (typeof window === "undefined") return "";
221
- return normalizeSlug(window.location.pathname);
261
+ function currentRouteFromLocation(): WorkspaceRoute {
262
+ if (typeof window === "undefined") return { slug: "", mode: "preview" };
263
+ return routeFromWorkspacePathname(window.location.pathname);
222
264
  }
223
265
 
224
266
  function normalizeSlug(raw: string): string {
225
267
  return raw.replace(/^\/+|\/+$/g, "");
226
268
  }
227
269
 
228
- function pushSlug(slug: string) {
270
+ function routeFromWorkspacePathname(pathname: string): WorkspaceRoute {
271
+ const normalized = normalizeSlug(pathname);
272
+ if (!normalized || normalized === "workspace") return { slug: "", mode: "preview" };
273
+
274
+ const segments = normalized.split("/").filter(Boolean);
275
+ if (segments.length === 2 && (segments[1] === "preview" || segments[1] === "present")) {
276
+ return { slug: segments[0] ?? "", mode: segments[1] };
277
+ }
278
+
279
+ // Legacy static/public route compatibility. New workspace navigation
280
+ // writes /workspace and /<press-slug>/preview.
281
+ return { slug: normalized, mode: "preview" };
282
+ }
283
+
284
+ function pushPressRoute(slug: string, mode: OpenPressRuntimeMode, pageIndex?: number) {
229
285
  if (typeof window === "undefined") return;
230
- // Preserve the current query string (e.g. ?dev=1 keeps the workbench
231
- // chrome alive across gallery navigation). Drop the hash it's a
232
- // page anchor that means nothing in a different document.
233
- const pathname = slug ? `/${normalizeSlug(slug)}` : "/";
234
- const target = `${pathname}${window.location.search}`;
235
- if (window.location.pathname === pathname) return;
286
+ const target = buildPressRoute(slug, mode, pageIndex);
287
+ if (`${window.location.pathname}${window.location.search}${window.location.hash}` === target) return;
236
288
  window.history.pushState({}, "", target);
237
289
  }
238
290
 
291
+ function openPressRoute(
292
+ slug: string,
293
+ mode: OpenPressRuntimeMode,
294
+ pageIndex?: number,
295
+ options: { fullscreen?: boolean } = {},
296
+ ) {
297
+ if (typeof window === "undefined") return;
298
+ window.open(buildPressRoute(slug, mode, pageIndex, options), "_blank", "noopener,noreferrer");
299
+ }
300
+
301
+ function buildPressRoute(
302
+ slug: string,
303
+ mode: OpenPressRuntimeMode,
304
+ pageIndex?: number,
305
+ options: { fullscreen?: boolean } = {},
306
+ ) {
307
+ const normalizedSlug = normalizeSlug(slug);
308
+ const pathname = normalizedSlug ? `/${normalizedSlug}/${mode}` : "/workspace";
309
+ const search = mode === "present" && options.fullscreen ? "?fullscreen=1" : "";
310
+ const pageHash = typeof pageIndex === "number"
311
+ ? `#page-${String(pageIndex + 1).padStart(2, "0")}`
312
+ : "";
313
+ return `${pathname}${search}${pageHash}`;
314
+ }
315
+
316
+ function resolveRuntimeMode(document: ReaderDocument, requestedMode: OpenPressRuntimeMode): OpenPressRuntimeMode {
317
+ if (requestedMode === "present" && document.meta.type === "slides") return "present";
318
+ return "preview";
319
+ }
320
+
239
321
  async function loadWorkspaceManifest(): Promise<WorkspaceManifest | null> {
240
322
  // Optional — older deployments don't ship workspace.json. The reader
241
323
  // falls back to /openpress/document.json directly when missing, which