@open-press/core 1.2.1 → 1.3.1

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 (41) hide show
  1. package/README.md +2 -2
  2. package/engine/commands/typecheck.mjs +1 -1
  3. package/engine/document-export.mjs +1 -1
  4. package/engine/output/page-block.mjs +11 -2
  5. package/engine/output/public-assets.mjs +41 -6
  6. package/engine/output/static-server.mjs +68 -15
  7. package/engine/react/caption-numbering.mjs +2 -2
  8. package/engine/react/comment-marker.mjs +1 -2
  9. package/engine/react/document-entry.mjs +64 -11
  10. package/engine/react/document-export.d.mts +6 -0
  11. package/engine/react/document-export.mjs +158 -28
  12. package/engine/react/mdx-compile.mjs +4 -4
  13. package/engine/react/measurement-css.mjs +3 -3
  14. package/engine/react/page-folio.mjs +37 -0
  15. package/engine/react/pagination/allocator.mjs +4 -4
  16. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  17. package/engine/react/press-tree-inspection.mjs +43 -13
  18. package/engine/react/project-asset-endpoint.mjs +45 -11
  19. package/engine/react/sources/heading-numbering.mjs +2 -2
  20. package/engine/react/sources/mdx-resolver.mjs +3 -3
  21. package/engine/react/style-discovery.mjs +60 -11
  22. package/engine/react/text-source-transform.mjs +18 -4
  23. package/engine/runtime/config.mjs +22 -22
  24. package/engine/runtime/file-utils.mjs +57 -13
  25. package/engine/runtime/inspection.mjs +40 -15
  26. package/engine/runtime/page-geometry.mjs +6 -6
  27. package/engine/runtime/source-text-tools.mjs +28 -4
  28. package/engine/runtime/source-workspace.mjs +6 -9
  29. package/engine/runtime/validation.mjs +42 -24
  30. package/package.json +1 -1
  31. package/src/openpress/app/OpenPressApp.tsx +6 -15
  32. package/src/openpress/app/OpenPressRuntime.tsx +3 -3
  33. package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
  34. package/src/openpress/core/PageFolio.tsx +115 -0
  35. package/src/openpress/core/Press.tsx +5 -10
  36. package/src/openpress/core/Slide.tsx +11 -0
  37. package/src/openpress/core/index.tsx +4 -0
  38. package/src/openpress/core/types.ts +21 -13
  39. package/src/openpress/core/useSource.ts +1 -1
  40. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  41. package/vite.config.ts +82 -16
@@ -451,10 +451,13 @@ function applySourceBlockTableCellEditToText(documentText, {
451
451
  export async function collectSourceTextFiles(config, { scope = "content" } = {}) {
452
452
  const roots = await sourceRoots(config, scope);
453
453
  const files = [];
454
+ const seen = new Set();
454
455
  for (const rootInfo of roots) {
455
456
  const visit = async (absolutePath) => {
457
+ if (seen.has(absolutePath)) return;
456
458
  const extension = path.extname(absolutePath);
457
459
  if (!rootInfo.extensions.has(extension)) return;
460
+ seen.add(absolutePath);
458
461
  const relativePath = path.relative(config.root, absolutePath).replaceAll("\\", "/");
459
462
  files.push({
460
463
  scope: rootInfo.scope,
@@ -557,18 +560,30 @@ async function sourceRoots(config, scope) {
557
560
  const roots = [
558
561
  ...contentRoots,
559
562
  { scope: "design-doc", kind: "file", absolutePath: sourceConfig.paths.designDoc, extensions: MARKDOWN_EXTENSIONS },
560
- { scope: "components", kind: "dir", absolutePath: sourceConfig.paths.componentsDir, extensions: ALL_SOURCE_EXTENSIONS },
561
- { scope: "document-entry", kind: "file", absolutePath: sourceWorkspace.entryPath, extensions: REACT_IMPLEMENTATION_EXTENSIONS },
562
- ...implementationRoots(sourceWorkspace),
563
+ ...await implementationRoots(sourceWorkspace),
563
564
  ];
564
565
  return roots;
565
566
  }
566
567
  return contentRoots;
567
568
  }
568
569
 
569
- function implementationRoots(sourceWorkspace) {
570
+ async function implementationRoots(sourceWorkspace) {
570
571
  const roots = [];
571
572
  const seen = new Set();
573
+ const documentRoot = sourceWorkspace.config.paths.documentRoot;
574
+ const entries = await readDirectoryEntries(documentRoot);
575
+ for (const entry of entries) {
576
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
577
+ const absolutePath = path.join(documentRoot, entry.name);
578
+ if (seen.has(absolutePath)) continue;
579
+ seen.add(absolutePath);
580
+ roots.push({
581
+ scope: entry.name === "shared" ? "shared-source" : "press-source",
582
+ kind: "dir",
583
+ absolutePath,
584
+ extensions: ALL_SOURCE_EXTENSIONS,
585
+ });
586
+ }
572
587
  for (const root of sourceWorkspace.contentRoots ?? [{ kind: "dir", absolutePath: sourceWorkspace.sourceDir }]) {
573
588
  const absolutePath = root.kind === "dir" ? root.absolutePath : path.dirname(root.absolutePath);
574
589
  if (seen.has(absolutePath)) continue;
@@ -583,6 +598,15 @@ function implementationRoots(sourceWorkspace) {
583
598
  return roots;
584
599
  }
585
600
 
601
+ async function readDirectoryEntries(directory) {
602
+ try {
603
+ return await fs.readdir(directory, { withFileTypes: true });
604
+ } catch (error) {
605
+ if (error?.code === "ENOENT") return [];
606
+ throw error;
607
+ }
608
+ }
609
+
586
610
  function forEachLine(text, visit) {
587
611
  const lineRe = /([^\r\n]*)(\r\n|\n|\r|$)/g;
588
612
  let lineNumber = 1;
@@ -12,7 +12,7 @@ export async function resolveActiveSourceWorkspace(config) {
12
12
  const reactEntry = await loadReactDocumentEntry(config.root);
13
13
  if (!reactEntry) {
14
14
  throw new Error(
15
- "React/MDX document entry not found. Expected press/index.tsx with a Press default export before using workspace source tools.",
15
+ "React/MDX document entry not found. Expected one or more press/*/press.tsx files before using workspace source tools.",
16
16
  );
17
17
  }
18
18
  // Aggregate sources across every Press in the Workspace. Workspace
@@ -26,6 +26,7 @@ export async function resolveActiveSourceWorkspace(config) {
26
26
  }
27
27
  const contentRoots = contentRootsFromSources(aggregateSources, reactEntry.config);
28
28
  const sourceDir = firstDirectoryRoot(contentRoots) ?? reactEntry.config.paths.documentRoot;
29
+ const hasRegisteredSources = contentRoots.length > 0;
29
30
 
30
31
  return {
31
32
  kind: "react-mdx",
@@ -34,11 +35,12 @@ export async function resolveActiveSourceWorkspace(config) {
34
35
  entryPath: reactEntry.entryPath,
35
36
  sourceDir,
36
37
  contentRoots,
38
+ hasRegisteredSources,
37
39
  contentExtensions: REACT_MDX_CONTENT_EXTENSIONS,
38
40
  contentLabel: "React MDX chapter source",
39
41
  missingCode: "react-source.missing",
40
42
  emptyCode: "react-source.empty",
41
- missingMessage: "Registered React MDX sources do not exist yet; create the files or roots declared in press/index.tsx `sources` before running export.",
43
+ missingMessage: "Registered React MDX sources do not exist yet; create the files or roots declared in press/*/press.tsx `sources` before running export.",
42
44
  emptyMessage: "Registered React MDX sources contain no `*.mdx` files; the document will export with zero source blocks.",
43
45
  };
44
46
  }
@@ -78,6 +80,7 @@ export async function collectActiveContentFiles(sourceWorkspace, { skipUnderscor
78
80
 
79
81
  export async function sourceDirectoryExists(sourceWorkspace) {
80
82
  const roots = sourceWorkspace.contentRoots ?? [{ kind: "dir", absolutePath: sourceWorkspace.sourceDir }];
83
+ if (roots.length === 0) return true;
81
84
  for (const root of roots) {
82
85
  try {
83
86
  const stat = await fs.stat(root.absolutePath);
@@ -93,13 +96,7 @@ export async function sourceDirectoryExists(sourceWorkspace) {
93
96
  function contentRootsFromSources(sources, config) {
94
97
  const entries = Object.entries(sources ?? {});
95
98
  if (entries.length === 0) {
96
- return [{
97
- kind: "dir",
98
- absolutePath: config.paths.sourceDir,
99
- basePath: config.paths.sourceDir,
100
- sourceId: "default",
101
- preset: "section-folders",
102
- }];
99
+ return [];
103
100
  }
104
101
 
105
102
  const roots = [];
@@ -17,12 +17,20 @@ const PUBLIC_DEPLOY_ADAPTERS = new Set([
17
17
  "vercel",
18
18
  ]);
19
19
 
20
- // A directory is an OpenPress workspace if it contains a
21
- // press/index.tsx entry, or a package.json with an "openpress" field.
20
+ // A directory is an OpenPress workspace if it contains folder-convention
21
+ // Press entries, or a package.json with an "openpress" field.
22
22
  async function isWorkspaceRoot(dir) {
23
23
  try {
24
- await fs.access(path.join(dir, "press", "index.tsx"));
25
- return true;
24
+ const pressEntries = await fs.readdir(path.join(dir, "press"), { withFileTypes: true });
25
+ if (pressEntries.some((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "shared")) {
26
+ for (const entry of pressEntries) {
27
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "shared") continue;
28
+ try {
29
+ await fs.access(path.join(dir, "press", entry.name, "press.tsx"));
30
+ return true;
31
+ } catch {}
32
+ }
33
+ }
26
34
  } catch {}
27
35
  try {
28
36
  const pkg = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8"));
@@ -60,11 +68,7 @@ export async function validateWorkspace(root) {
60
68
 
61
69
  mark("config");
62
70
  for (const [key, target] of [
63
- ["sourceDir", sourceWorkspace.sourceDir],
64
- ["mediaDir", activeConfig.paths.mediaDir],
65
- ["themeDir", activeConfig.paths.themeDir],
66
71
  ["designDoc", activeConfig.paths.designDoc],
67
- ["componentsDir", activeConfig.paths.componentsDir],
68
72
  ]) {
69
73
  if (!(await exists(target))) add("error", `config.${key}`, `Configured OpenPress path \`${key}\` does not exist.`, target);
70
74
  }
@@ -87,11 +91,11 @@ export async function validateWorkspace(root) {
87
91
 
88
92
  mark(sourceWorkspace.checkedName);
89
93
  if (!(typeof activeConfig.title === "string" && activeConfig.title.trim())) {
90
- add("warning", "press.title", "<Press title> is missing in press/index.tsx; the workbench will show the default placeholder.", activeConfig.configPath);
94
+ add("warning", "press.title", "<Press title> is missing in press/*/press.tsx; the workbench will show the default placeholder.", activeConfig.configPath);
91
95
  }
92
- if (!(await sourceDirectoryExists(sourceWorkspace))) {
96
+ if (sourceWorkspace.hasRegisteredSources && !(await sourceDirectoryExists(sourceWorkspace))) {
93
97
  add("warning", sourceWorkspace.missingCode, sourceWorkspace.missingMessage, sourceWorkspace.sourceDir);
94
- } else {
98
+ } else if (sourceWorkspace.hasRegisteredSources) {
95
99
  const contentFiles = await collectActiveContentFiles(sourceWorkspace, { skipUnderscoreFiles: true });
96
100
  if (contentFiles.length === 0) {
97
101
  add("warning", sourceWorkspace.emptyCode, sourceWorkspace.emptyMessage, sourceWorkspace.sourceDir);
@@ -119,19 +123,19 @@ export async function validateWorkspace(root) {
119
123
  }
120
124
 
121
125
  mark("react-source");
122
- const documentJsonPath = path.join(activeConfig.paths.publicDir, "document.json");
123
- const exportedDocument = await readJsonIfExists(documentJsonPath);
124
- const pressWarnings = exportedDocument?.source?.warnings;
125
- if (Array.isArray(pressWarnings)) {
126
- for (const warning of pressWarnings) {
127
- const code = typeof warning?.code === "string" && warning.code ? warning.code : "warning";
128
- add(
129
- "warning",
130
- `react-source.${code}`,
131
- pressWarningMessage(warning),
132
- documentJsonPath,
133
- warning,
134
- );
126
+ for (const exported of await readExportedPressDocuments(activeConfig.paths.publicDir)) {
127
+ const pressWarnings = exported.document?.source?.warnings;
128
+ if (Array.isArray(pressWarnings)) {
129
+ for (const warning of pressWarnings) {
130
+ const code = typeof warning?.code === "string" && warning.code ? warning.code : "warning";
131
+ add(
132
+ "warning",
133
+ `react-source.${code}`,
134
+ pressWarningMessage(warning),
135
+ exported.path,
136
+ warning,
137
+ );
138
+ }
135
139
  }
136
140
  }
137
141
 
@@ -173,6 +177,20 @@ async function readJsonIfExists(filePath) {
173
177
  }
174
178
  }
175
179
 
180
+ async function readExportedPressDocuments(publicDir) {
181
+ const manifestPath = path.join(publicDir, "workspace.json");
182
+ const manifest = await readJsonIfExists(manifestPath);
183
+ if (!Array.isArray(manifest?.presses)) return [];
184
+ const out = [];
185
+ for (const press of manifest.presses) {
186
+ if (typeof press?.slug !== "string" || !press.slug.trim()) continue;
187
+ const documentJsonPath = path.join(publicDir, press.slug.trim(), "document.json");
188
+ const document = await readJsonIfExists(documentJsonPath);
189
+ if (document) out.push({ path: documentJsonPath, document });
190
+ }
191
+ return out;
192
+ }
193
+
176
194
  async function exists(filePath) {
177
195
  try {
178
196
  await fs.access(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
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",
@@ -24,9 +24,7 @@ type LoadState =
24
24
  document: ReaderDocument;
25
25
  deploymentInfo: DeploymentInfo;
26
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.
27
+ // Active Press slug, used by refresh/back/forward to re-resolve.
30
28
  activeSlug: string;
31
29
  runtimeMode: OpenPressRuntimeMode;
32
30
  }
@@ -75,16 +73,10 @@ export function OpenPressApp() {
75
73
  route: WorkspaceRoute,
76
74
  deploymentInfo: DeploymentInfo,
77
75
  ) => {
78
- // No manifest (legacy deploy): always load /openpress/document.json.
79
76
  if (!manifest || manifest.presses.length === 0) {
80
- const document = await loadReaderDocument("/openpress/document.json");
81
77
  setState({
82
- status: "ready",
83
- document,
84
- deploymentInfo,
85
- manifest,
86
- activeSlug: "",
87
- runtimeMode: resolveRuntimeMode(document, route.mode),
78
+ status: "error",
79
+ message: "OpenPress workspace manifest is missing or empty. Run open-press render to generate /openpress/workspace.json.",
88
80
  });
89
81
  return;
90
82
  }
@@ -124,8 +116,8 @@ export function OpenPressApp() {
124
116
  const press = state.manifest
125
117
  ? findManifestPress(state.manifest, state.activeSlug)
126
118
  : null;
127
- const url = press?.documentUrl ?? "/openpress/document.json";
128
- const document = await loadReaderDocument(url);
119
+ if (!press) return;
120
+ const document = await loadReaderDocument(press.documentUrl);
129
121
  setState((latest) => {
130
122
  if (latest.status !== "ready") return latest;
131
123
  return { ...latest, document };
@@ -133,8 +125,7 @@ export function OpenPressApp() {
133
125
  }, [state]);
134
126
 
135
127
  // Gallery click → pushState + load. Bypasses resolveFromRoute's
136
- // "empty slug + multi-Press → gallery" branch: an explicit click on
137
- // the unslugged root Press must enter it, not bounce back to gallery.
128
+ // "empty slug + multi-Press → gallery" branch.
138
129
  const enterPress = useCallback(async (press: WorkspaceManifestPress) => {
139
130
  if (state.status !== "gallery") return;
140
131
  pushPressRoute(press.slug, "preview");
@@ -48,7 +48,7 @@ export function OpenPressRuntime({
48
48
  // (e.g. /<slug>/present -> /<slug>/preview after exiting the slide
49
49
  // presenter), so the SlidePresentationPage exits to the wrong
50
50
  // route-driven branch (PublicViewer instead of HtmlWorkbench) and the
51
- // user sees the legacy public-viewer chrome until a hard reload.
51
+ // user sees stale public-viewer chrome until a hard reload.
52
52
  // Bump a version on every pathname/search change so the memos
53
53
  // re-evaluate exactly when the URL does.
54
54
  const routeVersion = useLocationVersion();
@@ -125,11 +125,11 @@ function EmptyState({ style, workspaceMode }: { style: CSSProperties; workspaceM
125
125
  <p className="openpress-empty-state__eyebrow">OpenPress</p>
126
126
  <h1 className="openpress-empty-state__title">This document has no content yet.</h1>
127
127
  <p className="openpress-empty-state__body">
128
- Add React MDX chapter files under <code>press/chapters/**/content/</code>, then re-build.
128
+ Add React MDX chapter files under <code>press/&lt;slug&gt;/chapters/**/content/</code>, then re-build.
129
129
  </p>
130
130
  {workspaceMode ? (
131
131
  <ol className="openpress-empty-state__steps">
132
- <li><code>npm run build</code> &nbsp;— validates and refreshes <code>public/openpress/document.json</code></li>
132
+ <li><code>npm run build</code> &nbsp;— validates and refreshes <code>public/openpress/workspace.json</code></li>
133
133
  <li>Reload this page</li>
134
134
  </ol>
135
135
  ) : (
@@ -201,7 +201,7 @@ async function fetchFirstPage(url: string): Promise<HtmlPageBlock | null> {
201
201
  // Convert a CSS length string (px / mm / cm / in) into device pixels
202
202
  // at 96 dpi. A4 pages are stored as "210mm" / "297mm" so the gallery
203
203
  // and thumbnail scalers need this to compute their fit ratio — using
204
- // the bare string would always fall back to the default fallback.
204
+ // the bare string would otherwise resolve to the default asset path.
205
205
  function parsePxLength(value: string | undefined): number | null {
206
206
  if (!value) return null;
207
207
  const match = value.trim().match(/^([\d.]+)\s*(px|mm|cm|in)$/i);
@@ -0,0 +1,115 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "./cn";
3
+
4
+ export type PageFolioVariant = "current" | "total" | "slash" | "of" | "prefix";
5
+ export type PageFolioNumberFormat = "plain" | "2-digit" | "3-digit";
6
+
7
+ export type PageFolioProps = Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
8
+ variant?: PageFolioVariant;
9
+ currentFormat?: PageFolioNumberFormat;
10
+ totalFormat?: PageFolioNumberFormat;
11
+ prefix?: string;
12
+ separator?: string;
13
+ ofLabel?: string;
14
+ ariaLabel?: string;
15
+ };
16
+
17
+ export function PageFolio({
18
+ variant = "current",
19
+ currentFormat = "plain",
20
+ totalFormat = "plain",
21
+ prefix = "",
22
+ separator = "/",
23
+ ofLabel = "of",
24
+ ariaLabel,
25
+ className,
26
+ ...rest
27
+ }: PageFolioProps) {
28
+ const current = placeholderForFormat(currentFormat);
29
+ const total = placeholderForFormat(totalFormat);
30
+ const label = ariaLabel ?? defaultAriaLabel(variant);
31
+
32
+ return (
33
+ <span
34
+ {...rest}
35
+ className={cn("openpress-page-folio", `openpress-page-folio--${variant}`, className)}
36
+ data-openpress-page-folio="true"
37
+ data-openpress-page-folio-variant={variant}
38
+ data-openpress-page-folio-current-format={currentFormat}
39
+ data-openpress-page-folio-total-format={totalFormat}
40
+ data-openpress-page-folio-prefix={prefix}
41
+ data-openpress-page-folio-separator={separator}
42
+ data-openpress-page-folio-of-label={ofLabel}
43
+ aria-label={label}
44
+ >
45
+ {renderFolioParts({ variant, current, total, currentFormat, totalFormat, prefix, separator, ofLabel })}
46
+ </span>
47
+ );
48
+ }
49
+
50
+ function renderFolioParts({
51
+ variant,
52
+ current,
53
+ total,
54
+ currentFormat,
55
+ totalFormat,
56
+ prefix,
57
+ separator,
58
+ ofLabel,
59
+ }: {
60
+ variant: PageFolioVariant;
61
+ current: string;
62
+ total: string;
63
+ currentFormat: PageFolioNumberFormat;
64
+ totalFormat: PageFolioNumberFormat;
65
+ prefix: string;
66
+ separator: string;
67
+ ofLabel: string;
68
+ }) {
69
+ if (variant === "total") {
70
+ return <span className="openpress-page-folio__total" data-openpress-page-folio-total="true" data-openpress-page-folio-format={totalFormat}>{total}</span>;
71
+ }
72
+
73
+ if (variant === "slash") {
74
+ return (
75
+ <>
76
+ <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>
77
+ <span className="openpress-page-folio__separator" data-openpress-page-folio-separator-text="true">{separator}</span>
78
+ <span className="openpress-page-folio__total" data-openpress-page-folio-total="true" data-openpress-page-folio-format={totalFormat}>{total}</span>
79
+ </>
80
+ );
81
+ }
82
+
83
+ if (variant === "of") {
84
+ return (
85
+ <>
86
+ <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>
87
+ <span className="openpress-page-folio__separator" data-openpress-page-folio-of-text="true">{ofLabel}</span>
88
+ <span className="openpress-page-folio__total" data-openpress-page-folio-total="true" data-openpress-page-folio-format={totalFormat}>{total}</span>
89
+ </>
90
+ );
91
+ }
92
+
93
+ if (variant === "prefix") {
94
+ return (
95
+ <>
96
+ <span className="openpress-page-folio__prefix" data-openpress-page-folio-prefix-text="true">{prefix}</span>
97
+ <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>
98
+ </>
99
+ );
100
+ }
101
+
102
+ return <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>;
103
+ }
104
+
105
+ function placeholderForFormat(format: PageFolioNumberFormat) {
106
+ if (format === "3-digit") return "000";
107
+ if (format === "2-digit") return "00";
108
+ return "0";
109
+ }
110
+
111
+ function defaultAriaLabel(variant: PageFolioVariant) {
112
+ if (variant === "total") return "Total pages";
113
+ if (variant === "slash" || variant === "of") return "Page number and total pages";
114
+ return "Page number";
115
+ }
@@ -14,17 +14,16 @@ 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.
17
+ // Metadata read from <Press> props by the engine pipeline. Each
18
+ // press/<slug>/press.tsx entry declares its document metadata here.
21
19
  export interface PressMetadata {
22
20
  title?: string;
23
21
  type?: PressProps["type"];
24
22
  page?: PressProps["page"];
25
23
  slug?: string;
26
24
  theme?: string;
27
- componentsDir?: string;
25
+ componentsDir?: PressProps["componentsDir"];
26
+ mediaDir?: PressProps["mediaDir"];
28
27
  }
29
28
 
30
29
  export interface PressContextValue {
@@ -36,9 +35,7 @@ export interface PressContextValue {
36
35
  // the first measurement pass.
37
36
  hints: AllocationHints | null;
38
37
  toc: Record<string, TocEntry[]> | null;
39
- // Metadata declared on <Press> props in v1.0. Engine providers may
40
- // omit this on v0.x; consumers should treat undefined as "no metadata
41
- // declared on Press — fall back to openpress.config.mjs values".
38
+ // Metadata declared on <Press> props.
42
39
  metadata?: PressMetadata;
43
40
  }
44
41
 
@@ -48,8 +45,6 @@ export function Press(props: PressProps) {
48
45
  // Press is intentionally inert at render time — the engine reads its
49
46
  // props and children through React.Children inspection during the
50
47
  // export pipeline, then injects context above any nested helpers.
51
- // For the v0.x shape (children-only usage), this still passes children
52
- // through unchanged.
53
48
  return <Fragment>{props.children}</Fragment>;
54
49
  }
55
50
 
@@ -0,0 +1,11 @@
1
+ import { Frame } from "./Frame";
2
+ import type { SlideProps } from "./types";
3
+
4
+ export function Slide({
5
+ id,
6
+ role = "canvas.slide",
7
+ chrome = false,
8
+ ...rest
9
+ }: SlideProps) {
10
+ return <Frame {...rest} frameKey={id} role={role} chrome={chrome} />;
11
+ }
@@ -8,13 +8,16 @@
8
8
  export { Press, PressContext, PRESS_MARKER } from "./Press";
9
9
  export { Workspace, WorkspaceContext, WORKSPACE_MARKER } from "./Workspace";
10
10
  export { Frame, FRAME_MARKER } from "./Frame";
11
+ export { Slide } from "./Slide";
11
12
  export { FrameContext } from "./FrameContext";
13
+ export { PageFolio } from "./PageFolio";
12
14
  export { MdxArea } from "./MdxArea";
13
15
  export { useSource } from "./useSource";
14
16
  export { ObjectEntity, Text, BaseFigure, BaseCallout, MediaFigure, ImageFigure } from "./primitives";
15
17
 
16
18
  export type {
17
19
  FrameProps,
20
+ SlideProps,
18
21
  FrameRole,
19
22
  MdxAreaProps,
20
23
  MdxAreaOverflow,
@@ -45,3 +48,4 @@ export type {
45
48
  export type { PressContextValue, AllocationHints, PressMetadata } from "./Press";
46
49
  export type { WorkspaceContextValue } from "./Workspace";
47
50
  export type { FrameContextValue } from "./FrameContext";
51
+ export type { PageFolioNumberFormat, PageFolioProps, PageFolioVariant } from "./PageFolio";
@@ -18,6 +18,12 @@ export type FrameProps = Omit<HTMLAttributes<HTMLElement>, "role" | "children">
18
18
  children?: ReactNode;
19
19
  };
20
20
 
21
+ export type SlideProps = Omit<FrameProps, "frameKey" | "role" | "chrome" | "title"> & {
22
+ id: string;
23
+ role?: FrameRole;
24
+ chrome?: boolean;
25
+ };
26
+
21
27
  export type MdxAreaOverflow = "extend" | "truncate" | "error";
22
28
 
23
29
  export type MdxAreaProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
@@ -47,9 +53,9 @@ export interface PressProps {
47
53
  // Document tree — Frames, manuscript helpers, etc.
48
54
  children: ReactNode;
49
55
  // -------------------------------------------------------------------------
50
- // 1.0 metadata props — optional during v0.x deprecation, required in v1.0.
56
+ // Press metadata props.
51
57
  // -------------------------------------------------------------------------
52
- // Document title. Required in 1.0. Used for PDF metadata, HTML <title>,
58
+ // Document title. Used for PDF metadata, HTML <title>,
53
59
  // OG tags, and the Workspace gallery / tab-bar label.
54
60
  title?: string;
55
61
  // Creation mode. Pages are source-driven with MDX allocation; slides are
@@ -61,15 +67,17 @@ export interface PressProps {
61
67
  // Array of source registrations from mdxSource(). Replaces the v0.x
62
68
  // `export const sources` named export.
63
69
  sources?: ReadonlyArray<PressSource>;
64
- // URL / output slug for this Press inside a Workspace. Defaults to
65
- // "/" when only one Press exists in the Workspace; required when the
66
- // Workspace has multiple Press children.
70
+ // URL / output slug for this Press. Must match the Press folder name.
67
71
  slug?: string;
68
- // Optional per-Press theme directory. Defaults to "./theme" relative
69
- // to the document file; inherits from <Workspace theme> if not set.
72
+ // Optional per-Press theme directory. Defaults include the folder-local
73
+ // "./theme"; shared CSS lives in press/shared/theme.
70
74
  theme?: string;
71
- // Optional per-Press components directory. Default "./components".
72
- componentsDir?: string;
75
+ // Optional per-Press components directories. Defaults include the
76
+ // folder-local "./components" and workspace shared components.
77
+ componentsDir?: string | string[];
78
+ // Optional per-Press media directories. Defaults include the folder-local
79
+ // "./media" and workspace shared media.
80
+ mediaDir?: string | string[];
73
81
  // Optional caption numbering overrides. Engine defaults to
74
82
  // { figure: "Figure", table: "Table", separator: " " }.
75
83
  captionNumbering?: {
@@ -80,7 +88,8 @@ export interface PressProps {
80
88
  }
81
89
 
82
90
  // ---------------------------------------------------------------------------
83
- // Workspace — root component holding one or more Press children
91
+ // Workspace — engine-owned grouping component holding one or more Press
92
+ // children from discovered press/*/press.tsx entries.
84
93
  // ---------------------------------------------------------------------------
85
94
 
86
95
  export interface WorkspaceProps {
@@ -90,10 +99,9 @@ export interface WorkspaceProps {
90
99
  // Project label surfaced in the gallery header, tab bar, and PDF
91
100
  // metadata. Optional.
92
101
  name?: string;
93
- // Workspace-level shared theme directory. Press children that don't
94
- // set their own `theme` prop inherit from this. Default "./theme".
102
+ // Reserved for future workspace-level shared theme overrides.
95
103
  theme?: string;
96
- // Workspace-level shared media directory. Default "./media".
104
+ // Reserved for future workspace-level shared media overrides.
97
105
  media?: string;
98
106
  }
99
107
 
@@ -21,7 +21,7 @@ export function useSource<T extends ResolvedSource = ResolvedSource>(id: string)
21
21
  const knownText = known.length > 0 ? known.join(", ") : "(none)";
22
22
  throw new Error(
23
23
  `Unknown source "${id}". Available sources: ${knownText}. ` +
24
- `Register it as a <Press sources={[mdxSource({ id: "${id}", ... })]}> entry in press/index.tsx.`,
24
+ `Register it as a <Press sources={[mdxSource({ id: "${id}", ... })]}> entry in press/<slug>/press.tsx.`,
25
25
  );
26
26
  }
27
27
  return source as T;
@@ -1,10 +1,6 @@
1
1
  // Shape of /openpress/workspace.json — the reader fetches this on
2
2
  // boot to decide between gallery routing (multi-Press) and direct
3
- // load (single Press). One entry per <Press> child of <Workspace>.
4
- //
5
- // Single-Press workspaces emit one entry with slug = "" and the
6
- // legacy /openpress/document.json path. Multi-Press emits one entry
7
- // per slug; each `documentUrl` resolves to /openpress/<slug>/document.json.
3
+ // load (single Press). One entry per discovered Press folder.
8
4
  import type { PressType } from "./documentTypes";
9
5
 
10
6
  export interface WorkspaceManifest {
@@ -16,13 +12,12 @@ export interface WorkspaceManifest {
16
12
  }
17
13
 
18
14
  export interface WorkspaceManifestPress {
19
- // Slug for this Press. Empty string for single-Press workspaces
20
- // (legacy root); slug-shaped string for multi-Press.
15
+ // Slug for this Press. Matches the folder-convention Press slug.
21
16
  slug: string;
22
17
  // <Press title="..."> prop. Required in v1.0 contract.
23
18
  title: string;
24
- // Creation mode declared by <Press type>. Defaults to "pages" for older
25
- // documents. The reader uses this for mode-specific navigation affordances.
19
+ // Creation mode declared by <Press type>. The reader uses this for
20
+ // mode-specific navigation affordances.
26
21
  type: PressType;
27
22
  // Page geometry summary. Same shape as the reader's
28
23
  // ReaderDocument.theme — readers can show a thumb in the gallery