@open-press/core 0.8.0 → 1.0.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.
Files changed (64) hide show
  1. package/README.md +6 -3
  2. package/engine/cli.mjs +8 -8
  3. package/engine/commands/_shared.mjs +37 -15
  4. package/engine/commands/image.mjs +29 -0
  5. package/engine/commands/skills-sync.mjs +71 -0
  6. package/engine/commands/typecheck.mjs +63 -1
  7. package/engine/commands/upgrade.mjs +3 -3
  8. package/engine/document-export.mjs +1 -1
  9. package/engine/output/chrome-pdf.mjs +92 -0
  10. package/engine/output/static-server.mjs +48 -9
  11. package/engine/react/comment-marker.mjs +13 -13
  12. package/engine/react/document-entry.mjs +35 -28
  13. package/engine/react/document-export.mjs +309 -170
  14. package/engine/react/mdx-compile.mjs +30 -0
  15. package/engine/react/measurement-css.mjs +21 -0
  16. package/engine/react/object-entities.mjs +85 -0
  17. package/engine/react/pagination/allocator.mjs +48 -3
  18. package/engine/react/pagination.mjs +1 -1
  19. package/engine/react/pipeline/allocate.mjs +31 -65
  20. package/engine/react/pipeline/frame-measurement.mjs +4 -0
  21. package/engine/react/press-tree-inspection.mjs +172 -0
  22. package/engine/react/sources/mdx-resolver.mjs +1 -1
  23. package/engine/react/style-discovery.mjs +22 -4
  24. package/engine/runtime/config.d.mts +8 -0
  25. package/engine/runtime/config.mjs +57 -60
  26. package/engine/runtime/file-utils.mjs +9 -1
  27. package/engine/runtime/page-geometry.mjs +131 -0
  28. package/engine/runtime/source-text-tools.mjs +1 -1
  29. package/engine/runtime/source-workspace.mjs +12 -3
  30. package/engine/runtime/validation.mjs +19 -10
  31. package/package.json +3 -5
  32. package/src/openpress/app/OpenPressApp.tsx +173 -17
  33. package/src/openpress/app/OpenPressRuntime.tsx +10 -2
  34. package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  35. package/src/openpress/core/Frame.tsx +20 -7
  36. package/src/openpress/core/FrameContext.tsx +2 -0
  37. package/src/openpress/core/Press.tsx +25 -4
  38. package/src/openpress/core/Workspace.tsx +36 -0
  39. package/src/openpress/core/index.tsx +10 -3
  40. package/src/openpress/core/primitives.tsx +48 -1
  41. package/src/openpress/core/types.ts +86 -41
  42. package/src/openpress/core/useSource.ts +1 -1
  43. package/src/openpress/document-model/documentTypes.ts +9 -0
  44. package/src/openpress/document-model/index.ts +1 -0
  45. package/src/openpress/document-model/objectEntityModel.ts +4 -0
  46. package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  47. package/src/openpress/mdx/index.ts +15 -7
  48. package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  49. package/src/openpress/reader/index.ts +1 -0
  50. package/src/openpress/workbench/Workbench.tsx +120 -21
  51. package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  52. package/src/openpress/workbench/actions/SearchControl.tsx +3 -3
  53. package/src/openpress/workbench/actions/index.ts +1 -0
  54. package/src/openpress/workbench/inspector/useInspectorComments.ts +7 -1
  55. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +5 -1
  56. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +4 -2
  57. package/src/openpress/workbench/workbenchFormatters.ts +2 -2
  58. package/src/styles/openpress/reader-runtime.css +9 -0
  59. package/src/styles/openpress/workbench-panels.css +113 -0
  60. package/src/styles/openpress/workspace-gallery.css +300 -0
  61. package/src/styles/openpress.css +1 -0
  62. package/tsconfig.json +1 -1
  63. package/engine/commands/init.mjs +0 -24
  64. package/engine/init.mjs +0 -90
@@ -1,14 +1,33 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { OpenPressRuntime } from "./OpenPressRuntime";
3
+ import { WorkspaceGalleryPage } from "./WorkspaceGalleryPage";
3
4
  import { isLocalWorkspaceHost } from "../shared";
4
- import type { DeploymentInfo, ReaderDocument } from "../document-model";
5
+ import type {
6
+ DeploymentInfo,
7
+ ReaderDocument,
8
+ WorkspaceManifest,
9
+ WorkspaceManifestPress,
10
+ } from "../document-model";
11
+ import { findManifestPress, manifestHasMultiplePresses } from "../document-model";
5
12
 
6
13
  type LoadState =
7
14
  | { status: "loading" }
15
+ | {
16
+ // Gallery state — shown for multi-Press workspaces at the root URL.
17
+ // Single-Press workspaces never reach this state.
18
+ status: "gallery";
19
+ manifest: WorkspaceManifest;
20
+ deploymentInfo: DeploymentInfo;
21
+ }
8
22
  | {
9
23
  status: "ready";
10
24
  document: ReaderDocument;
11
25
  deploymentInfo: DeploymentInfo;
26
+ manifest: WorkspaceManifest | null;
27
+ // Empty string for single-Press workspaces (no slug routing needed)
28
+ // or for the root entry of a multi-Press workspace. Otherwise the
29
+ // active press's slug — used by refresh/back/forward to re-resolve.
30
+ activeSlug: string;
12
31
  }
13
32
  | { status: "error"; message: string };
14
33
 
@@ -42,26 +61,93 @@ function LoadingScreen() {
42
61
  export function OpenPressApp() {
43
62
  const [state, setState] = useState<LoadState>({ status: "loading" });
44
63
 
64
+ // Single resolution function — same code path for "boot from URL",
65
+ // "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 (
68
+ manifest: WorkspaceManifest | null,
69
+ slug: string,
70
+ deploymentInfo: DeploymentInfo,
71
+ ) => {
72
+ // No manifest (legacy deploy): always load /openpress/document.json.
73
+ if (!manifest || manifest.presses.length === 0) {
74
+ const document = await loadReaderDocument("/openpress/document.json");
75
+ setState({ status: "ready", document, deploymentInfo, manifest, activeSlug: "" });
76
+ return;
77
+ }
78
+
79
+ // Empty slug + multi-Press: show gallery. Empty slug + single-Press:
80
+ // load the only press. Same expression handles both — array length
81
+ // is the only thing that matters.
82
+ const normalizedSlug = normalizeSlug(slug);
83
+ if (!normalizedSlug && manifestHasMultiplePresses(manifest)) {
84
+ setState({ status: "gallery", manifest, deploymentInfo });
85
+ return;
86
+ }
87
+
88
+ const press = normalizedSlug
89
+ ? findManifestPress(manifest, normalizedSlug)
90
+ : manifest.presses[0];
91
+ if (!press) {
92
+ setState({
93
+ status: "error",
94
+ message: `Unknown document slug "/${normalizedSlug}". Known: ${manifest.presses.map((p) => `/${p.slug}`).join(", ")}.`,
95
+ });
96
+ return;
97
+ }
98
+ const document = await loadReaderDocument(press.documentUrl);
99
+ setState({ status: "ready", document, deploymentInfo, manifest, activeSlug: press.slug });
100
+ }, []);
101
+
45
102
  const refreshDocument = useCallback(async () => {
46
- const document = await loadReaderDocument();
47
- setState((current) => {
48
- if (current.status !== "ready") return current;
49
- return { ...current, document };
103
+ if (state.status !== "ready") return;
104
+ const press = state.manifest
105
+ ? findManifestPress(state.manifest, state.activeSlug)
106
+ : null;
107
+ const url = press?.documentUrl ?? "/openpress/document.json";
108
+ const document = await loadReaderDocument(url);
109
+ setState((latest) => {
110
+ if (latest.status !== "ready") return latest;
111
+ return { ...latest, document };
50
112
  });
51
- }, []);
113
+ }, [state]);
52
114
 
115
+ // Gallery click → pushState + load. Bypasses resolveFromSlug's
116
+ // "empty slug + multi-Press → gallery" branch: an explicit click on
117
+ // the unslugged root Press must enter it, not bounce back to gallery.
118
+ const enterPress = useCallback(async (press: WorkspaceManifestPress) => {
119
+ if (state.status !== "gallery") return;
120
+ pushSlug(press.slug);
121
+ setState({ status: "loading" });
122
+ try {
123
+ const document = await loadReaderDocument(press.documentUrl);
124
+ setState({
125
+ status: "ready",
126
+ document,
127
+ deploymentInfo: state.deploymentInfo,
128
+ manifest: state.manifest,
129
+ activeSlug: press.slug,
130
+ });
131
+ } catch (error) {
132
+ setState({
133
+ status: "error",
134
+ message: error instanceof Error ? error.message : "Unable to load OpenPress document.",
135
+ });
136
+ }
137
+ }, [state]);
138
+
139
+ // Bootstrap: read URL → load manifest + deploy info → resolve.
53
140
  useEffect(() => {
54
141
  let cancelled = false;
55
142
 
56
- async function loadDocument() {
143
+ async function bootstrap() {
57
144
  try {
58
- const [document, deploymentInfo] = await Promise.all([
59
- loadReaderDocument(),
145
+ const [manifest, deploymentInfo] = await Promise.all([
146
+ loadWorkspaceManifest(),
60
147
  loadDeploymentInfo(),
61
148
  ]);
62
- if (!cancelled) {
63
- setState({ status: "ready", document, deploymentInfo });
64
- }
149
+ if (cancelled) return;
150
+ await resolveFromSlug(manifest, currentSlugFromLocation(), deploymentInfo);
65
151
  } catch (error) {
66
152
  if (!cancelled) {
67
153
  setState({
@@ -72,11 +158,29 @@ export function OpenPressApp() {
72
158
  }
73
159
  }
74
160
 
75
- void loadDocument();
161
+ void bootstrap();
76
162
  return () => {
77
163
  cancelled = true;
78
164
  };
79
- }, []);
165
+ }, [resolveFromSlug]);
166
+
167
+ // Back / forward button — re-resolve from the new URL.
168
+ useEffect(() => {
169
+ function onPopState() {
170
+ if (state.status === "loading") return;
171
+ const manifest = state.status === "gallery"
172
+ ? state.manifest
173
+ : state.status === "ready"
174
+ ? state.manifest
175
+ : null;
176
+ const deploymentInfo = state.status === "gallery" || state.status === "ready"
177
+ ? state.deploymentInfo
178
+ : offlineDeploymentInfo;
179
+ void resolveFromSlug(manifest, currentSlugFromLocation(), deploymentInfo);
180
+ }
181
+ window.addEventListener("popstate", onPopState);
182
+ return () => window.removeEventListener("popstate", onPopState);
183
+ }, [state, resolveFromSlug]);
80
184
 
81
185
  if (state.status === "loading") return <LoadingScreen />;
82
186
 
@@ -84,19 +188,71 @@ export function OpenPressApp() {
84
188
  return <div className="openpress-load-state openpress-load-state--error">{state.message}</div>;
85
189
  }
86
190
 
191
+ if (state.status === "gallery") {
192
+ return <WorkspaceGalleryPage manifest={state.manifest} onSelectPress={enterPress} />;
193
+ }
194
+
195
+ // Only multi-Press workspaces have a gallery to go back to. Single-Press
196
+ // workspaces don't render the button (no destination exists).
197
+ const backToWorkspace = state.manifest && manifestHasMultiplePresses(state.manifest)
198
+ ? () => {
199
+ if (state.status !== "ready" || !state.manifest) return;
200
+ pushSlug("");
201
+ setState({
202
+ status: "gallery",
203
+ manifest: state.manifest,
204
+ deploymentInfo: state.deploymentInfo,
205
+ });
206
+ }
207
+ : undefined;
208
+
87
209
  return (
88
210
  <OpenPressRuntime
89
211
  document={state.document}
90
212
  deploymentInfo={state.deploymentInfo}
91
213
  onDocumentRefresh={refreshDocument}
214
+ onBackToWorkspace={backToWorkspace}
92
215
  />
93
216
  );
94
217
  }
95
218
 
96
- async function loadReaderDocument(): Promise<ReaderDocument> {
97
- const response = await fetch("/openpress/document.json", { cache: "no-store" });
219
+ function currentSlugFromLocation(): string {
220
+ if (typeof window === "undefined") return "";
221
+ return normalizeSlug(window.location.pathname);
222
+ }
223
+
224
+ function normalizeSlug(raw: string): string {
225
+ return raw.replace(/^\/+|\/+$/g, "");
226
+ }
227
+
228
+ function pushSlug(slug: string) {
229
+ 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;
236
+ window.history.pushState({}, "", target);
237
+ }
238
+
239
+ async function loadWorkspaceManifest(): Promise<WorkspaceManifest | null> {
240
+ // Optional — older deployments don't ship workspace.json. The reader
241
+ // falls back to /openpress/document.json directly when missing, which
242
+ // matches pre-v1.0 behavior.
243
+ try {
244
+ const response = await fetch("/openpress/workspace.json", { cache: "no-store" });
245
+ if (!response.ok) return null;
246
+ return (await response.json()) as WorkspaceManifest;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ async function loadReaderDocument(url: string): Promise<ReaderDocument> {
253
+ const response = await fetch(url, { cache: "no-store" });
98
254
  if (!response.ok) {
99
- throw new Error(`Unable to load /openpress/document.json (${response.status})`);
255
+ throw new Error(`Unable to load ${url} (${response.status})`);
100
256
  }
101
257
  return (await response.json()) as ReaderDocument;
102
258
  }
@@ -13,12 +13,17 @@ interface OpenPressRuntimeProps {
13
13
  document: ReaderDocument;
14
14
  deploymentInfo?: DeploymentInfo;
15
15
  onDocumentRefresh?: () => void | Promise<void>;
16
+ // Optional — supplied by OpenPressApp when this Press was entered from
17
+ // a multi-Press gallery. Renders a "工作台" home button in the toolbar
18
+ // that returns to the gallery without a full page reload.
19
+ onBackToWorkspace?: () => void;
16
20
  }
17
21
 
18
22
  export function OpenPressRuntime({
19
23
  document,
20
24
  deploymentInfo = { online: false },
21
25
  onDocumentRefresh,
26
+ onBackToWorkspace,
22
27
  }: OpenPressRuntimeProps) {
23
28
  const style = themeToCssVariables(document.theme);
24
29
  const htmlPages = document.blocks.filter((block): block is HtmlPageBlock => block.kind === "htmlPage");
@@ -48,6 +53,7 @@ export function OpenPressRuntime({
48
53
  devMode={workspaceMode}
49
54
  deploymentInfo={deploymentInfo}
50
55
  onDocumentRefresh={onDocumentRefresh}
56
+ onBackToWorkspace={onBackToWorkspace}
51
57
  />
52
58
  );
53
59
  }
@@ -62,11 +68,11 @@ function EmptyState({ style, workspaceMode }: { style: CSSProperties; workspaceM
62
68
  <p className="openpress-empty-state__eyebrow">OpenPress</p>
63
69
  <h1 className="openpress-empty-state__title">This document has no content yet.</h1>
64
70
  <p className="openpress-empty-state__body">
65
- Add React MDX chapter files under <code>document/chapters/**/content/</code>, then re-export.
71
+ Add React MDX chapter files under <code>press/chapters/**/content/</code>, then re-build.
66
72
  </p>
67
73
  {workspaceMode ? (
68
74
  <ol className="openpress-empty-state__steps">
69
- <li><code>npm run openpress:export</code> &nbsp;— refreshes <code>public/openpress/document.json</code></li>
75
+ <li><code>npm run build</code> &nbsp;— validates and refreshes <code>public/openpress/document.json</code></li>
70
76
  <li>Reload this page</li>
71
77
  </ol>
72
78
  ) : (
@@ -88,6 +94,8 @@ function themeToCssVariables(theme?: Theme) {
88
94
 
89
95
  if (theme?.pageWidth) style["--openpress-page-width"] = theme.pageWidth;
90
96
  if (theme?.pageHeight) style["--openpress-page-height"] = theme.pageHeight;
97
+ if (theme?.pageAspectRatio) style["--openpress-page-aspect-ratio"] = theme.pageAspectRatio;
98
+ if (theme?.pageHeightRatio) style["--openpress-page-height-ratio"] = theme.pageHeightRatio;
91
99
  if (theme?.pagePadding) style["--openpress-page-padding"] = theme.pagePadding;
92
100
 
93
101
  return style;
@@ -0,0 +1,219 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
2
+ import type { HtmlPageBlock, ReaderDocument, WorkspaceManifest, WorkspaceManifestPress } from "../document-model";
3
+
4
+ interface Props {
5
+ manifest: WorkspaceManifest;
6
+ // Called when the reader navigates into a specific Press. The host
7
+ // is responsible for routing (history.pushState, hash, etc.); the
8
+ // gallery just emits the chosen slug.
9
+ onSelectPress: (press: WorkspaceManifestPress) => void;
10
+ }
11
+
12
+ // Reader landing page for multi-Press workspaces. Shows a Figma-style
13
+ // uniform-grid card per Press; each card lazily loads that Press's
14
+ // document.json and renders the first page as a thumbnail preview.
15
+ // Single-Press workspaces skip the gallery entirely.
16
+ export function WorkspaceGalleryPage({ manifest, onSelectPress }: Props) {
17
+ const heading = manifest.name ?? "Workspace";
18
+ const pressCount = String(manifest.presses.length).padStart(2, "0");
19
+
20
+ return (
21
+ <main className="openpress-workspace-gallery" aria-labelledby="workspace-gallery-heading">
22
+ <header className="openpress-workspace-gallery__header">
23
+ <div className="openpress-workspace-gallery__headline">
24
+ <p className="openpress-workspace-gallery__eyebrow">Workspace</p>
25
+ <h1 id="workspace-gallery-heading">{heading}</h1>
26
+ </div>
27
+ <p className="openpress-workspace-gallery__count">
28
+ <span>{pressCount}</span>
29
+ <small>{manifest.presses.length === 1 ? "document" : "documents"}</small>
30
+ </p>
31
+ </header>
32
+
33
+ <ul className="openpress-workspace-gallery__grid" role="list">
34
+ {manifest.presses.map((press) => (
35
+ <li key={press.slug || "root"} className="openpress-workspace-gallery__item">
36
+ <PressCard press={press} onSelect={() => onSelectPress(press)} />
37
+ </li>
38
+ ))}
39
+ </ul>
40
+ </main>
41
+ );
42
+ }
43
+
44
+ // Card is a div+role=button (not <button>) so it can contain the
45
+ // rendered page HTML — buttons may only hold phrasing content, and
46
+ // page HTML is block-level.
47
+ function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelect: () => void }) {
48
+ const handleKey = (event: KeyboardEvent<HTMLDivElement>) => {
49
+ if (event.key === "Enter" || event.key === " ") {
50
+ event.preventDefault();
51
+ onSelect();
52
+ }
53
+ };
54
+
55
+ return (
56
+ <div
57
+ role="button"
58
+ tabIndex={0}
59
+ className="openpress-workspace-gallery__card"
60
+ onClick={onSelect}
61
+ onKeyDown={handleKey}
62
+ aria-label={`Open ${press.title}`}
63
+ >
64
+ <PressThumbnail press={press} />
65
+ <div className="openpress-workspace-gallery__body">
66
+ <div className="openpress-workspace-gallery__title">{press.title}</div>
67
+ <div className="openpress-workspace-gallery__meta">
68
+ {press.slug ? <span className="openpress-workspace-gallery__slug">{press.slug}</span> : null}
69
+ {press.page?.pageLabel ? (
70
+ <span className="openpress-workspace-gallery__geom">{press.page.pageLabel}</span>
71
+ ) : null}
72
+ </div>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ function PressThumbnail({ press }: { press: WorkspaceManifestPress }) {
79
+ const [state, setState] = useState<ThumbnailState>({ status: "loading" });
80
+
81
+ // Lazy-load each Press's document.json so the gallery doesn't block
82
+ // on a network waterfall when there are many Press. Errors degrade
83
+ // to the geometry-only placeholder used by the loading state.
84
+ useEffect(() => {
85
+ let cancelled = false;
86
+ fetchFirstPage(press.documentUrl).then((page) => {
87
+ if (cancelled) return;
88
+ setState(page ? { status: "ready", page } : { status: "error" });
89
+ }).catch(() => {
90
+ if (!cancelled) setState({ status: "error" });
91
+ });
92
+ return () => { cancelled = true; };
93
+ }, [press.documentUrl]);
94
+
95
+ // Outer card is uniform 4:3 (set in CSS). The page itself letterboxes
96
+ // inside via centered scale, so A4 portrait renders tall-and-narrow,
97
+ // social square renders centered, 16:9 slide stretches edge-to-edge.
98
+ return (
99
+ <div className="openpress-workspace-gallery__thumb" aria-hidden="true">
100
+ {state.status === "ready" ? (
101
+ <PageMiniature page={state.page} press={press} />
102
+ ) : (
103
+ <div className="openpress-workspace-gallery__thumb-placeholder" data-state={state.status}>
104
+ <div className="openpress-workspace-gallery__thumb-skel" style={skelAspectStyle(press)} />
105
+ </div>
106
+ )}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function skelAspectStyle(press: WorkspaceManifestPress): CSSProperties {
112
+ const w = parsePxLength(press.page?.pageWidth);
113
+ const h = parsePxLength(press.page?.pageHeight);
114
+ if (w && h) return { aspectRatio: `${w} / ${h}`, height: "75%" };
115
+ return { aspectRatio: "1 / 1.414", height: "75%" };
116
+ }
117
+
118
+ function PageMiniature({ page, press }: { page: HtmlPageBlock; press: WorkspaceManifestPress }) {
119
+ const containerRef = useRef<HTMLDivElement>(null);
120
+ const [scale, setScale] = useState<number | null>(null);
121
+ const pageWidthPx = parsePxLength(press.page?.pageWidth) ?? 1080;
122
+ const pageHeightPx = parsePxLength(press.page?.pageHeight) ?? pageWidthPx;
123
+
124
+ useEffect(() => {
125
+ const el = containerRef.current;
126
+ if (!el) return;
127
+ const update = () => {
128
+ const w = el.clientWidth;
129
+ const h = el.clientHeight;
130
+ if (w > 0 && h > 0) {
131
+ setScale(Math.min(w / pageWidthPx, h / pageHeightPx));
132
+ }
133
+ };
134
+ update();
135
+ if (typeof ResizeObserver === "undefined") return;
136
+ const ro = new ResizeObserver(update);
137
+ ro.observe(el);
138
+ return () => ro.disconnect();
139
+ }, [pageWidthPx, pageHeightPx]);
140
+
141
+ const scaledWidth = scale ? pageWidthPx * scale : 0;
142
+ const scaledHeight = scale ? pageHeightPx * scale : 0;
143
+ const frameStyle: CSSProperties = {
144
+ width: `${scaledWidth}px`,
145
+ height: `${scaledHeight}px`,
146
+ position: "relative",
147
+ visibility: scale ? "visible" : "hidden",
148
+ };
149
+
150
+ // Match the wrapping used by PublicReaderPage so scoped CSS targeting
151
+ // `.openpress-html-page__html` selectors lights up identically. The
152
+ // outer frame owns centering; the page only scales from its top-left
153
+ // origin, which avoids mixed translate/scale centering drift.
154
+ const pageStyle: CSSProperties = {
155
+ "--openpress-page-width": `${pageWidthPx}px`,
156
+ "--openpress-page-height": `${pageHeightPx}px`,
157
+ width: `${pageWidthPx}px`,
158
+ height: `${pageHeightPx}px`,
159
+ transform: scale ? `scale(${scale})` : undefined,
160
+ transformOrigin: "top left",
161
+ position: "absolute",
162
+ top: 0,
163
+ left: 0,
164
+ } as CSSProperties;
165
+ const pageClass = page.className
166
+ ? `openpress-html-page ${page.className}`
167
+ : "openpress-html-page";
168
+
169
+ return (
170
+ <div className="openpress-workspace-gallery__thumb-stage" ref={containerRef}>
171
+ <div className="openpress-workspace-gallery__thumb-frame" style={frameStyle}>
172
+ <div className={pageClass} style={pageStyle} data-openpress-thumb-page="true">
173
+ <div
174
+ className="openpress-html-page__html"
175
+ // Trusted HTML — same source as the reader's main render path.
176
+ dangerouslySetInnerHTML={{ __html: page.html }}
177
+ />
178
+ </div>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ type ThumbnailState =
185
+ | { status: "loading" }
186
+ | { status: "error" }
187
+ | { status: "ready"; page: HtmlPageBlock };
188
+
189
+ async function fetchFirstPage(url: string): Promise<HtmlPageBlock | null> {
190
+ try {
191
+ const response = await fetch(url, { cache: "no-store" });
192
+ if (!response.ok) return null;
193
+ const doc = (await response.json()) as ReaderDocument;
194
+ const firstPage = doc.blocks.find((b): b is HtmlPageBlock => b.kind === "htmlPage");
195
+ return firstPage ?? null;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ // Convert a CSS length string (px / mm / cm / in) into device pixels
202
+ // at 96 dpi. A4 pages are stored as "210mm" / "297mm" so the gallery
203
+ // and thumbnail scalers need this to compute their fit ratio — using
204
+ // the bare string would always fall back to the default fallback.
205
+ function parsePxLength(value: string | undefined): number | null {
206
+ if (!value) return null;
207
+ const match = value.trim().match(/^([\d.]+)\s*(px|mm|cm|in)$/i);
208
+ if (!match) return null;
209
+ const n = Number(match[1]);
210
+ if (!Number.isFinite(n) || n <= 0) return null;
211
+ const unit = match[2].toLowerCase();
212
+ switch (unit) {
213
+ case "px": return n;
214
+ case "mm": return n * (96 / 25.4);
215
+ case "cm": return n * (96 / 2.54);
216
+ case "in": return n * 96;
217
+ default: return null;
218
+ }
219
+ }
@@ -3,7 +3,7 @@ import { cn } from "./cn";
3
3
  import { FrameContext, type FrameContextValue } from "./FrameContext";
4
4
  import { PressContext } from "./Press";
5
5
  import type { FrameProps } from "./types";
6
- import { createFrameObjectEntityId } from "../document-model/objectEntityModel";
6
+ import { createFrameObjectEntityId, createPageObjectEntityId, createScopedObjectEntityId } from "../document-model/objectEntityModel";
7
7
 
8
8
  // Substring reserved for the overflow extension pipeline.
9
9
  const RESERVED_EXTENDED = ":extended:";
@@ -28,15 +28,22 @@ export function Frame({
28
28
  );
29
29
  }
30
30
 
31
+ const parentFrame = useContext(FrameContext);
31
32
  const press = useContext(PressContext);
32
33
  const allocation = press?.allocation ?? null;
33
34
  const frameAllocation = frameKey && allocation ? allocation[frameKey] : undefined;
35
+ const pageId = parentFrame?.pageId ?? createPageObjectEntityId(frameKey);
36
+ const objectId = parentFrame
37
+ ? createScopedObjectEntityId("frame", parentFrame.objectId, frameKey)
38
+ : createFrameObjectEntityId(frameKey);
34
39
 
35
40
  // Mutable per-render counter. SSR renders a Frame exactly once, so a plain
36
41
  // object is fine — no useRef needed.
37
42
  const areaCounts: Record<string, number> = {};
38
43
  const frameContextValue: FrameContextValue = {
39
44
  frameKey: frameKey ?? "",
45
+ objectId,
46
+ pageId,
40
47
  consumeArea(chainId: string) {
41
48
  const index = areaCounts[chainId] ?? 0;
42
49
  areaCounts[chainId] = index + 1;
@@ -48,18 +55,24 @@ export function Frame({
48
55
  };
49
56
 
50
57
  const pageKind = derivePageKind(role);
58
+ const isNestedFrame = Boolean(parentFrame);
51
59
 
52
60
  return (
53
61
  <FrameContext.Provider value={frameContextValue}>
54
62
  <section
55
63
  {...(rest as Record<string, unknown>)}
56
- className={cn("reader-page", className)}
57
- data-openpress-frame-key={frameKey}
58
- data-openpress-object-id={createFrameObjectEntityId(frameKey)}
64
+ className={cn(isNestedFrame ? undefined : "reader-page", className)}
65
+ data-openpress-frame-key={isNestedFrame ? undefined : frameKey}
66
+ data-openpress-region-frame-key={isNestedFrame ? frameKey : undefined}
67
+ data-openpress-object-id={objectId}
68
+ data-openpress-object-kind="frame"
69
+ data-openpress-object-label={role ?? frameKey}
70
+ data-openpress-object-parent-id={parentFrame?.objectId ?? (isNestedFrame ? undefined : pageId)}
71
+ data-openpress-object-page-id={pageId}
59
72
  data-frame-role={role}
60
- data-page-kind={pageKind}
61
- data-frame-chrome={chrome ? "true" : "false"}
62
- data-page-footer={chrome ? "true" : "false"}
73
+ data-page-kind={isNestedFrame ? undefined : pageKind}
74
+ data-frame-chrome={isNestedFrame ? undefined : chrome ? "true" : "false"}
75
+ data-page-footer={isNestedFrame ? undefined : chrome ? "true" : "false"}
63
76
  >
64
77
  {children}
65
78
  </section>
@@ -18,6 +18,8 @@ export interface ConsumedMdxArea {
18
18
 
19
19
  export interface FrameContextValue {
20
20
  frameKey: string;
21
+ objectId: string;
22
+ pageId: string;
21
23
  consumeArea(chainId: string): ConsumedMdxArea;
22
24
  }
23
25
 
@@ -1,5 +1,5 @@
1
- import { createContext, Fragment, type ReactNode } from "react";
2
- import type { FrameAllocation, ResolvedSource, TocEntry } from "./types";
1
+ import { createContext, Fragment } from "react";
2
+ import type { FrameAllocation, PressProps, ResolvedSource, TocEntry } from "./types";
3
3
 
4
4
  // Marker the engine uses to distinguish a Press default export from any other
5
5
  // React component. Workspaces register a default export whose `type` is this
@@ -14,6 +14,18 @@ export interface AllocationHints {
14
14
  totalPagesPerChain: Record<string, number>;
15
15
  }
16
16
 
17
+ // Metadata read from <Press> props by the engine pipeline. The 1.0 contract
18
+ // declares these on the component; v0.x reads them from openpress.config.mjs
19
+ // instead and leaves these as undefined. The engine merges both sources
20
+ // (props override config) until v1.0 removes config support.
21
+ export interface PressMetadata {
22
+ title?: string;
23
+ page?: PressProps["page"];
24
+ slug?: string;
25
+ theme?: string;
26
+ componentsDir?: string;
27
+ }
28
+
17
29
  export interface PressContextValue {
18
30
  sources: Record<string, ResolvedSource>;
19
31
  // Allocation map keyed by frameKey -> chainId -> areaIndex -> blocks.
@@ -23,12 +35,21 @@ export interface PressContextValue {
23
35
  // the first measurement pass.
24
36
  hints: AllocationHints | null;
25
37
  toc: Record<string, TocEntry[]> | null;
38
+ // Metadata declared on <Press> props in v1.0. Engine providers may
39
+ // omit this on v0.x; consumers should treat undefined as "no metadata
40
+ // declared on Press — fall back to openpress.config.mjs values".
41
+ metadata?: PressMetadata;
26
42
  }
27
43
 
28
44
  export const PressContext = createContext<PressContextValue | null>(null);
29
45
 
30
- export function Press({ children }: { children: ReactNode }) {
31
- return <Fragment>{children}</Fragment>;
46
+ export function Press(props: PressProps) {
47
+ // Press is intentionally inert at render time — the engine reads its
48
+ // props and children through React.Children inspection during the
49
+ // export pipeline, then injects context above any nested helpers.
50
+ // For the v0.x shape (children-only usage), this still passes children
51
+ // through unchanged.
52
+ return <Fragment>{props.children}</Fragment>;
32
53
  }
33
54
 
34
55
  (Press as unknown as { openpressMarker: typeof PRESS_MARKER }).openpressMarker = PRESS_MARKER;
@@ -0,0 +1,36 @@
1
+ import { createContext, Fragment } from "react";
2
+ import type { WorkspaceProps } from "./types";
3
+
4
+ // Marker the engine uses to identify a Workspace default export, in the
5
+ // same way PRESS_MARKER identifies Press. Multi-doc projects nest one
6
+ // or more <Press> children inside <Workspace>; single-doc projects use
7
+ // a Workspace with one Press child (uniform shape — no exceptions).
8
+ export const WORKSPACE_MARKER: unique symbol = Symbol.for("@open-press/core:Workspace");
9
+
10
+ export interface WorkspaceContextValue {
11
+ // Project-level label surfaced in the gallery header / tab bar / PDF
12
+ // metadata. Undefined if the Workspace did not declare a name.
13
+ name?: string;
14
+ // Workspace-level shared theme directory. Press children that don't
15
+ // set their own `theme` prop inherit from this.
16
+ theme?: string;
17
+ // Workspace-level shared media directory.
18
+ media?: string;
19
+ // Number of Press children registered in this Workspace. Set by the
20
+ // engine during expansion; useful to detect "gallery vs single-doc"
21
+ // routing without re-walking the tree.
22
+ pressCount: number;
23
+ }
24
+
25
+ export const WorkspaceContext = createContext<WorkspaceContextValue | null>(null);
26
+
27
+ // Workspace is intentionally inert at render time. The engine inspects
28
+ // its props (name / theme / media) and iterates Press children during
29
+ // the export pipeline; rendering just passes children through so the
30
+ // Press → Frame → MdxArea tree underneath behaves identically to v0.x
31
+ // when there's only one Press child.
32
+ export function Workspace(props: WorkspaceProps) {
33
+ return <Fragment>{props.children}</Fragment>;
34
+ }
35
+
36
+ (Workspace as unknown as { openpressMarker: typeof WORKSPACE_MARKER }).openpressMarker = WORKSPACE_MARKER;