@open-press/core 1.2.0 → 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 (76) hide show
  1. package/README.md +2 -2
  2. package/engine/cli.mjs +1 -1
  3. package/engine/commands/_shared.mjs +10 -5
  4. package/engine/commands/deploy.mjs +19 -4
  5. package/engine/commands/typecheck.mjs +1 -1
  6. package/engine/document-export.mjs +1 -1
  7. package/engine/output/page-block.mjs +11 -2
  8. package/engine/output/public-assets.mjs +41 -6
  9. package/engine/output/static-server.mjs +84 -24
  10. package/engine/react/caption-numbering.mjs +2 -2
  11. package/engine/react/comment-marker.mjs +1 -2
  12. package/engine/react/document-entry.mjs +64 -11
  13. package/engine/react/document-export.d.mts +6 -0
  14. package/engine/react/document-export.mjs +158 -28
  15. package/engine/react/mdx-compile.mjs +4 -4
  16. package/engine/react/measurement-css.mjs +3 -3
  17. package/engine/react/page-folio.mjs +37 -0
  18. package/engine/react/pagination/allocator.mjs +4 -4
  19. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  20. package/engine/react/press-tree-inspection.mjs +43 -13
  21. package/engine/react/project-asset-endpoint.mjs +45 -11
  22. package/engine/react/sources/heading-numbering.mjs +2 -2
  23. package/engine/react/sources/mdx-resolver.mjs +3 -3
  24. package/engine/react/style-discovery.mjs +60 -11
  25. package/engine/react/text-source-transform.mjs +18 -4
  26. package/engine/runtime/config.mjs +22 -22
  27. package/engine/runtime/file-utils.mjs +57 -13
  28. package/engine/runtime/inspection.mjs +40 -15
  29. package/engine/runtime/page-geometry.mjs +6 -6
  30. package/engine/runtime/source-text-tools.mjs +28 -4
  31. package/engine/runtime/source-workspace.mjs +6 -9
  32. package/engine/runtime/validation.mjs +42 -24
  33. package/package.json +1 -1
  34. package/src/openpress/app/OpenPressApp.tsx +10 -16
  35. package/src/openpress/app/OpenPressRuntime.tsx +29 -4
  36. package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
  37. package/src/openpress/core/PageFolio.tsx +115 -0
  38. package/src/openpress/core/Press.tsx +5 -10
  39. package/src/openpress/core/Slide.tsx +11 -0
  40. package/src/openpress/core/index.tsx +4 -0
  41. package/src/openpress/core/types.ts +21 -13
  42. package/src/openpress/core/useSource.ts +1 -1
  43. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  44. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  45. package/src/openpress/reader/SlidePresentationPage.tsx +36 -19
  46. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  47. package/src/openpress/reader/index.ts +1 -0
  48. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  49. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  50. package/src/openpress/workbench/Workbench.tsx +46 -164
  51. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  52. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  53. package/src/openpress/workbench/actions/index.ts +1 -1
  54. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +7 -2
  55. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  56. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  57. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  58. package/src/styles/openpress/app-shell.css +0 -83
  59. package/src/styles/openpress/print-route.css +1 -3
  60. package/src/styles/openpress/project-preview-panel.css +5 -783
  61. package/src/styles/openpress/public-viewer.css +7 -249
  62. package/src/styles/openpress/reader-runtime.css +0 -274
  63. package/src/styles/openpress/slide-presenter.css +150 -0
  64. package/src/styles/openpress/slide-public-viewer.css +222 -0
  65. package/src/styles/openpress/workbench-dialog.css +267 -0
  66. package/src/styles/openpress/workbench-export.css +154 -0
  67. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  68. package/src/styles/openpress/workbench-panels.css +0 -88
  69. package/src/styles/openpress/workbench-search.css +257 -0
  70. package/src/styles/openpress/workbench-toolbar.css +422 -0
  71. package/src/styles/openpress/workbench.css +34 -1263
  72. package/src/styles/openpress/workspace-gallery.css +0 -5
  73. package/src/styles/openpress.css +7 -1
  74. package/vite.config.ts +98 -25
  75. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  76. package/src/styles/openpress/media-workspace.css +0 -230
@@ -17,12 +17,13 @@ import { buildSectionScopedCss } from "./section-css.mjs";
17
17
  import { CORE_ENTRY, createReactSsrServer, loadReactDocumentEntry } from "./document-entry.mjs";
18
18
  import { buildReactMeasurementCss } from "./measurement-css.mjs";
19
19
  import { buildObjectEntities } from "./object-entities.mjs";
20
+ import { resolvePageFoliosInHtml } from "./page-folio.mjs";
20
21
  import { allocateChains } from "./pipeline/allocate.mjs";
21
22
  import { measureFrames } from "./pipeline/frame-measurement.mjs";
22
23
  import { renderFinalPress } from "./pipeline/final-render.mjs";
23
24
  import { expandPressTree } from "./pipeline/press-tree.mjs";
24
25
  import { resolveAllSources } from "./sources/mdx-resolver.mjs";
25
- import { discoverSectionStyles } from "./style-discovery.mjs";
26
+ import { discoverComponentsInRoots, discoverSectionStyles } from "./style-discovery.mjs";
26
27
 
27
28
  const MAX_ITERATIONS = 20;
28
29
  const PRESS_TYPES = new Set(["pages", "slides"]);
@@ -41,10 +42,10 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
41
42
  if (!entry) return null;
42
43
  if (!entry.Press) {
43
44
  throw new Error(
44
- `OpenPress document entry ${entry.entryPath} must default-export a Press component (function) to export. ` +
45
- `Legacy named exports (cover/toc/backCover) are not supported in v0.6 — see the Press Tree spec.`,
45
+ `OpenPress document entry ${entry.entryPath} must default-export a React component that renders one or more <Press> elements.`,
46
46
  );
47
47
  }
48
+ validateDiscoveredPressFolders(entry);
48
49
  // Resolve PressContext + Frame markers from the engine's loaded core module.
49
50
  // Use the absolute file path so the user's `import "@open-press/core"`
50
51
  // (resolved via vite alias) and our load hit the same module cache entry.
@@ -60,6 +61,9 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
60
61
  // workspace can host more than one chapter root.
61
62
  const sectionRoots = collectSectionRoots(entry.presses, entry.config.paths.documentRoot);
62
63
  const workspace = await discoverSectionStyles(workspaceRoot, entry.config, { sectionRoots });
64
+ const workspaceThemeRoots = collectWorkspaceThemeRoots(entry.presses, entry.config);
65
+ const workspaceComponentRoots = collectWorkspaceComponentRoots(entry.presses, entry.config);
66
+ const workspaceMediaRoots = collectWorkspaceMediaRoots(entry.presses, entry.config);
63
67
  const coreAuthorComponents = {};
64
68
  for (const name of ["MediaFigure", "ImageFigure"]) {
65
69
  if (typeof coreModule[name] === "function") coreAuthorComponents[name] = coreModule[name];
@@ -71,7 +75,10 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
71
75
 
72
76
  // Build measurement CSS once at the workspace level — shared by every
73
77
  // Press inside the Workspace.
74
- const measurementCss = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace);
78
+ const measurementCss = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace, {
79
+ themeRoots: workspaceThemeRoots,
80
+ componentRoots: workspaceComponentRoots,
81
+ });
75
82
 
76
83
  // Write chapter-scoped CSS once (workspace shared). Every per-press
77
84
  // readerDocument references the same file via "/openpress/chapter-scoped.css".
@@ -148,7 +155,11 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
148
155
  await fs.writeFile(corpusPath, JSON.stringify(corpus), "utf8");
149
156
 
150
157
  if (syncAssets) {
151
- await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
158
+ await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config, {
159
+ themeRoots: workspaceThemeRoots,
160
+ componentRoots: workspaceComponentRoots,
161
+ mediaRoots: workspaceMediaRoots,
162
+ });
152
163
  }
153
164
 
154
165
  const primary = pressResults[0];
@@ -188,24 +199,29 @@ async function exportSinglePress({
188
199
  // metadata overlaid. Press JSX page prop wins over the workspace page.
189
200
  const effectiveConfig = applyPressOverridesToConfig(entry.config, press.metadata);
190
201
  const documentRoot = effectiveConfig.paths.documentRoot;
202
+ const pressComponentRoots = componentRootsForPress(press, effectiveConfig);
203
+ const pressComponents = await loadComponentModules(
204
+ server,
205
+ await discoverComponentsInRoots(pressComponentRoots, documentRoot, "press"),
206
+ );
207
+ const resolvedComponents = {
208
+ ...globalComponents,
209
+ ...pressComponents,
210
+ };
211
+ const mediaRoots = mediaRootsForPress(press, effectiveConfig);
191
212
 
192
- // Resolve sources for this press. The 1.0 contract reads them from
193
- // <Press sources={[...]}>; the v0.x legacy path uses the synthesized
194
- // record from `export const sources`.
213
+ // Resolve sources for this press. The contract reads them from
214
+ // <Press sources={[...]}>.
195
215
  const sourcesRecord = press.sources ?? {};
196
216
  const { resolved: sources, renderData: renderRegistry } = await resolveAllSources({
197
217
  sources: sourcesRecord,
198
218
  documentRoot,
199
- globalComponents,
219
+ globalComponents: resolvedComponents,
200
220
  });
201
221
 
202
- // Component the render pipeline drives. For Press elements captured
203
- // by inspection (1.0 contract), wrap the captured element in a thin
204
- // function component. For legacy projects without inspection data,
205
- // fall back to the user's whole default export.
206
- const PressComponent = press.element
207
- ? () => press.element
208
- : entry.Press;
222
+ // Component the render pipeline drives. Press elements are captured by
223
+ // inspection, then wrapped in a thin function component.
224
+ const PressComponent = () => press.element;
209
225
 
210
226
  // Iterative allocation loop (identical to v0.x — paginates until the
211
227
  // hints stabilise).
@@ -228,7 +244,7 @@ async function exportSinglePress({
228
244
  renderRegistry,
229
245
  css: measurementCss,
230
246
  baseHref: pathToFileURL(`${documentRoot}${path.sep}`).href,
231
- mediaDir: path.join(documentRoot, "media"),
247
+ mediaDir: mediaRoots,
232
248
  captionNumbering: effectiveConfig.captionNumbering,
233
249
  });
234
250
  const alloc = allocateChains({
@@ -244,7 +260,7 @@ async function exportSinglePress({
244
260
  const blocks = measurement.blockHeights
245
261
  .slice(0, 8)
246
262
  .map((b) => `${b.id} h=${b.height.toFixed(0)}`);
247
- process.stderr.write(`[allocator press=${slug || "(root)"} iter ${iteration}]\n`);
263
+ process.stderr.write(`[allocator press=${slug || "(missing-slug)"} iter ${iteration}]\n`);
248
264
  process.stderr.write(` mdxAreas[0..4]: ${sample.join(" | ")}\n`);
249
265
  process.stderr.write(` blocks[0..7]: ${blocks.join(" | ")}\n`);
250
266
  process.stderr.write(` hints: ${JSON.stringify(alloc.hints.totalPagesPerChain)}\n`);
@@ -261,7 +277,7 @@ async function exportSinglePress({
261
277
  }
262
278
  if (allocation == null) {
263
279
  throw new Error(
264
- `Allocation did not converge after ${MAX_ITERATIONS} iterations (press="${slug || "(root)"}"). ` +
280
+ `Allocation did not converge after ${MAX_ITERATIONS} iterations (press="${slug || "(missing-slug)"}"). ` +
265
281
  `This usually means a chain keeps growing without fitting; check MdxArea capacities and block heights.`,
266
282
  );
267
283
  }
@@ -282,15 +298,18 @@ async function exportSinglePress({
282
298
  // change is metadata.title comes from the per-press Press JSX prop.
283
299
  const blockMap = {};
284
300
  const captionState = createCaptionNumberingState();
301
+ const totalFrames = final.frames.length;
302
+ const pressSourcePath = sourcePathForPress({ entry, slug });
285
303
  const blocks = final.frames.map((frame, index) => {
286
304
  const source = {
287
- file: "index.tsx",
288
- path: slug ? `press/${slug}/index.tsx` : "press/index.tsx",
305
+ file: path.basename(pressSourcePath),
306
+ path: pressSourcePath,
289
307
  kind: frame.role ?? "manuscript.content",
290
308
  slug: frame.frameKey,
291
309
  sectionIndex: index + 1,
292
310
  };
293
- const html = numberCaptionsInHtml(frame.html, effectiveConfig.captionNumbering, captionState);
311
+ const numberedHtml = numberCaptionsInHtml(frame.html, effectiveConfig.captionNumbering, captionState);
312
+ const html = resolvePageFoliosInHtml(numberedHtml, { pageIndex: index, totalPages: totalFrames });
294
313
  for (const id of collectFrameBlockIds(frame.blockIds, html)) {
295
314
  blockMap[id] = { id, pageIndex: index, pageNumber: index + 1, frameKey: frame.frameKey };
296
315
  }
@@ -364,11 +383,10 @@ async function exportSinglePress({
364
383
  blocks,
365
384
  };
366
385
 
367
- // Output path: empty slug → root /openpress/document.json (legacy
368
- // single-Press shape). Non-empty slug /openpress/<slug>/document.json.
369
- const pressOutputDir = slug
370
- ? path.join(effectiveConfig.paths.publicDir, slug)
371
- : effectiveConfig.paths.publicDir;
386
+ if (!slug) {
387
+ throw new Error("<Press slug> is required. Folder-convention workspaces write to /openpress/<slug>/document.json.");
388
+ }
389
+ const pressOutputDir = path.join(effectiveConfig.paths.publicDir, slug);
372
390
  await fs.mkdir(pressOutputDir, { recursive: true });
373
391
  const documentPath = path.join(pressOutputDir, "document.json");
374
392
  await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
@@ -377,7 +395,7 @@ async function exportSinglePress({
377
395
  slug,
378
396
  pressType,
379
397
  documentPath,
380
- documentUrl: slug ? `/openpress/${slug}/document.json` : "/openpress/document.json",
398
+ documentUrl: `/openpress/${slug}/document.json`,
381
399
  readerDocument,
382
400
  pageCount: blocks.length,
383
401
  };
@@ -407,6 +425,114 @@ function applyPressOverridesToConfig(workspaceConfig, pressMetadata) {
407
425
  return out;
408
426
  }
409
427
 
428
+ function collectWorkspaceComponentRoots(presses, config) {
429
+ return uniquePaths(presses.flatMap((press) => componentRootsForPress(press, config)));
430
+ }
431
+
432
+ function collectWorkspaceThemeRoots(presses, config) {
433
+ return uniquePaths(presses.flatMap((press) => themeRootsForPress(press, config)));
434
+ }
435
+
436
+ function collectWorkspaceMediaRoots(presses, config) {
437
+ return uniquePaths(presses.flatMap((press) => mediaRootsForPress(press, config)));
438
+ }
439
+
440
+ function themeRootsForPress(press, config) {
441
+ const documentRoot = config.paths.documentRoot;
442
+ const folder = pressFolderName(press);
443
+ const roots = [];
444
+ if (folder) roots.push(path.join(documentRoot, folder, "theme"));
445
+ roots.push(...declaredRoots(press.metadata?.theme, config, folder, "theme"));
446
+ return uniquePaths(roots);
447
+ }
448
+
449
+ function componentRootsForPress(press, config) {
450
+ const documentRoot = config.paths.documentRoot;
451
+ const folder = pressFolderName(press);
452
+ const roots = [
453
+ config.paths.componentsDir,
454
+ ];
455
+ if (folder) roots.push(path.join(documentRoot, folder, "components"));
456
+ roots.push(...declaredRoots(press.metadata?.componentsDir, config, folder, "componentsDir"));
457
+ return uniquePaths(roots);
458
+ }
459
+
460
+ function mediaRootsForPress(press, config) {
461
+ const documentRoot = config.paths.documentRoot;
462
+ const folder = pressFolderName(press);
463
+ const roots = [
464
+ config.paths.mediaDir,
465
+ ];
466
+ if (folder) roots.push(path.join(documentRoot, folder, "media"));
467
+ roots.push(...declaredRoots(press.metadata?.mediaDir, config, folder, "mediaDir"));
468
+ return uniquePaths(roots);
469
+ }
470
+
471
+ function declaredRoots(value, config, folder, propName) {
472
+ return pathList(value).map((entry) => resolvePressPath(entry, config, folder, propName));
473
+ }
474
+
475
+ function resolvePressPath(value, config, folder, propName) {
476
+ const raw = String(value).trim();
477
+ if (!raw) return null;
478
+ const documentRoot = config.paths.documentRoot;
479
+ const pressRoot = folder ? path.join(documentRoot, folder) : documentRoot;
480
+ const base = raw === "." || raw.startsWith("./") || raw.startsWith("../") ? pressRoot : documentRoot;
481
+ const absolutePath = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(base, raw);
482
+ const relative = path.relative(documentRoot, absolutePath);
483
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
484
+ throw new Error(`<Press ${propName}> path must stay inside press/: ${raw}`);
485
+ }
486
+ return absolutePath;
487
+ }
488
+
489
+ function pathList(value) {
490
+ if (typeof value === "string") return [value];
491
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
492
+ return [];
493
+ }
494
+
495
+ function pressFolderName(press) {
496
+ const slug = typeof press.metadata?.slug === "string" ? press.metadata.slug.trim() : "";
497
+ if (!slug || slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") return "";
498
+ return slug;
499
+ }
500
+
501
+ function validateDiscoveredPressFolders(entry) {
502
+ const folders = Array.isArray(entry.pressFolders) ? entry.pressFolders : [];
503
+ if (folders.length === 0) return;
504
+ if (entry.presses.length !== folders.length) {
505
+ throw new Error(
506
+ `OpenPress found ${folders.length} press folder(s) but ${entry.presses.length} <Press> element(s). ` +
507
+ `Each press/<name>/press.tsx must render exactly one <Press>.`,
508
+ );
509
+ }
510
+ for (const [index, folder] of folders.entries()) {
511
+ const slug = typeof entry.presses[index]?.metadata?.slug === "string"
512
+ ? entry.presses[index].metadata.slug.trim()
513
+ : "";
514
+ if (!slug) {
515
+ throw new Error(`press/${folder}/press.tsx must declare <Press slug="${folder}">.`);
516
+ }
517
+ if (slug !== folder) {
518
+ throw new Error(`press/${folder}/press.tsx declares slug="${slug}", but folder-convention slugs must match the folder name.`);
519
+ }
520
+ }
521
+ }
522
+
523
+ function uniquePaths(paths) {
524
+ const out = [];
525
+ const seen = new Set();
526
+ for (const candidate of paths ?? []) {
527
+ if (!candidate) continue;
528
+ const normalized = path.resolve(candidate);
529
+ if (seen.has(normalized)) continue;
530
+ seen.add(normalized);
531
+ out.push(normalized);
532
+ }
533
+ return out;
534
+ }
535
+
410
536
  async function loadComponentModules(server, components) {
411
537
  const out = {};
412
538
  for (const component of components) {
@@ -473,6 +599,10 @@ function collectFrameBlockIds(allocatedIds, html) {
473
599
  return ids;
474
600
  }
475
601
 
602
+ function sourcePathForPress({ slug }) {
603
+ return `press/${slug}/press.tsx`;
604
+ }
605
+
476
606
  function buildTocContext({ sources, frames, allocation }) {
477
607
  const toc = {};
478
608
  for (const source of Object.values(sources)) {
@@ -291,9 +291,9 @@ function normalizeTableCaptions(node) {
291
291
  const child = node.children[index];
292
292
  normalizeTableCaptions(child);
293
293
 
294
- const legacyCaptionText = legacyTableCaptionText(child);
295
- if (legacyCaptionText) {
296
- throw new Error(`Legacy table caption markers are not supported. Use <TableCaption>${legacyCaptionText}</TableCaption> before the table.`);
294
+ const unsupportedCaptionText = unsupportedTableCaptionText(child);
295
+ if (unsupportedCaptionText) {
296
+ throw new Error(`Table caption marker syntax is not supported. Use <TableCaption>${unsupportedCaptionText}</TableCaption> before the table.`);
297
297
  }
298
298
 
299
299
  const captionText = tableCaptionText(child);
@@ -321,7 +321,7 @@ function normalizeTableCaptions(node) {
321
321
  }
322
322
  }
323
323
 
324
- function legacyTableCaptionText(node) {
324
+ function unsupportedTableCaptionText(node) {
325
325
  if (node?.type !== "element" || node.tagName !== "p") return "";
326
326
  const match = textContent(node).match(LEGACY_TABLE_CAPTION_MARKER_RE);
327
327
  return match?.[1]?.trim() ?? "";
@@ -8,15 +8,15 @@ import { buildSectionScopedCss } from "./section-css.mjs";
8
8
 
9
9
  const require = createRequire(import.meta.url);
10
10
 
11
- export async function buildReactMeasurementCss(root, config, workspace) {
11
+ export async function buildReactMeasurementCss(root, config, workspace, options = {}) {
12
12
  const parts = [];
13
13
  await appendOptionalFile(parts, path.join(config.paths.themeDir, "fonts.css"), "theme/fonts.css");
14
14
  await appendOptionalFile(parts, path.join(config.paths.themeDir, "tokens.css"), "theme/tokens.css");
15
15
  appendPageGeometryCss(parts, config.page);
16
16
  parts.push("/* === public/openpress/content.css === */\n");
17
- parts.push(await buildContentCss(root, config));
17
+ parts.push(await buildContentCss(root, config, { themeRoots: options.themeRoots }));
18
18
  parts.push("\n/* === public/openpress/components.css === */\n");
19
- parts.push(await buildComponentsCss(root, config));
19
+ parts.push(await buildComponentsCss(root, config, { componentRoots: options.componentRoots }));
20
20
  const chapterCss = await buildSectionScopedCss(workspace);
21
21
  if (chapterCss.trim()) {
22
22
  parts.push("\n/* === public/openpress/chapter-scoped.css === */\n");
@@ -0,0 +1,37 @@
1
+ const CURRENT_RE = /(<span\b[^>]*\bdata-openpress-page-folio-current="true"[^>]*>)([\s\S]*?)(<\/span>)/gi;
2
+ const TOTAL_RE = /(<span\b[^>]*\bdata-openpress-page-folio-total="true"[^>]*>)([\s\S]*?)(<\/span>)/gi;
3
+
4
+ export function resolvePageFoliosInHtml(html, { pageIndex, totalPages }) {
5
+ const current = Math.max(1, Math.trunc(pageIndex) + 1);
6
+ const total = Math.max(0, Math.trunc(totalPages));
7
+
8
+ return String(html ?? "")
9
+ .replace(CURRENT_RE, (match, open, _body, close) => {
10
+ const format = pickAttr(open, "data-openpress-page-folio-format") || "plain";
11
+ return `${open}${escapeHtml(formatPageNumber(current, format))}${close}`;
12
+ })
13
+ .replace(TOTAL_RE, (match, open, _body, close) => {
14
+ const format = pickAttr(open, "data-openpress-page-folio-format") || "plain";
15
+ return `${open}${escapeHtml(formatPageNumber(total, format))}${close}`;
16
+ });
17
+ }
18
+
19
+ export function formatPageNumber(value, format = "plain") {
20
+ const normalized = Math.max(0, Math.trunc(Number(value) || 0));
21
+ if (format === "3-digit") return String(normalized).padStart(3, "0");
22
+ if (format === "2-digit") return String(normalized).padStart(2, "0");
23
+ return String(normalized);
24
+ }
25
+
26
+ function pickAttr(attrs, name) {
27
+ const re = new RegExp(`${name}="([^"]*)"`);
28
+ const match = re.exec(attrs);
29
+ return match?.[1];
30
+ }
31
+
32
+ function escapeHtml(value) {
33
+ return String(value ?? "")
34
+ .replace(/&/g, "&amp;")
35
+ .replace(/</g, "&lt;")
36
+ .replace(/>/g, "&gt;");
37
+ }
@@ -114,7 +114,7 @@ export function pagesFromRegions(filledRegions) {
114
114
  return pages;
115
115
  }
116
116
 
117
- // Public wrapper preserving the legacy (blocks, { pageSafeHeightPx }) signature.
117
+ // Public wrapper preserving the existing (blocks, { pageSafeHeightPx }) signature.
118
118
  // New code can pass a `regions` stream directly to opt into multi-column or
119
119
  // heterogeneous layouts.
120
120
  export function paginateMeasuredBlocks(measuredBlocks, options = {}) {
@@ -146,7 +146,7 @@ function infiniteFixedCapacityRegionStream(capacity) {
146
146
  };
147
147
  }
148
148
 
149
- // Translate the new region-shaped warnings back to the legacy
149
+ // Translate the new region-shaped warnings back to the existing
150
150
  // `block-overflows-page` schema that document-export.mjs and downstream
151
151
  // consumers expect. Once consumers migrate, this can drop.
152
152
  function mapWarning(warning, pageSafeHeightPx) {
@@ -161,7 +161,7 @@ function mapWarning(warning, pageSafeHeightPx) {
161
161
  return warning;
162
162
  }
163
163
 
164
- function positiveNumber(value, fallback) {
164
+ function positiveNumber(value, defaultValue) {
165
165
  const number = Number(value);
166
- return Number.isFinite(number) && number > 0 ? number : fallback;
166
+ return Number.isFinite(number) && number > 0 ? number : defaultValue;
167
167
  }
@@ -32,7 +32,7 @@ const CAPACITY_SAFETY_MAX_PX = 96;
32
32
  * @param {Map<string, object>} opts.renderRegistry Internal render data per sourceId.
33
33
  * @param {string} opts.css Combined CSS for measurement context.
34
34
  * @param {string=} opts.baseHref Base URL for relative media paths in MDX.
35
- * @param {string=} opts.mediaDir Local media dir for inlining /openpress/media/* assets.
35
+ * @param {string|string[]=} opts.mediaDir Local media roots for inlining /openpress/media/* assets.
36
36
  * @param {object=} opts.captionNumbering Caption label formatter options.
37
37
  * @param {{width:number,height:number}=} opts.viewport
38
38
  */
@@ -136,7 +136,7 @@ async function runChromiumMeasurement(html, viewport) {
136
136
  const page = await browser.newPage({ viewport });
137
137
  await page.setContent(html, { waitUntil: "load" });
138
138
  // Match the print-ready settle: fonts first (font metrics affect image
139
- // alt-text fallback boxes), then await every image's `complete` AND
139
+ // alt-text placeholder boxes), then await every image's `complete` AND
140
140
  // `decode()` so intrinsic sizes are committed before layout, then two
141
141
  // animation frames so the chromium layout pass observes the final box
142
142
  // model. Without this, `getBoundingClientRect()` on figures that hold
@@ -200,11 +200,11 @@ async function runChromiumMeasurement(html, viewport) {
200
200
  ];
201
201
  for (const candidate of candidates) {
202
202
  if (!candidate) continue;
203
- const fallback = candidate.getBoundingClientRect();
204
- if (fallback.height > rect.height) {
203
+ const alternateRect = candidate.getBoundingClientRect();
204
+ if (alternateRect.height > rect.height) {
205
205
  return {
206
- height: fallback.height,
207
- width: rect.width > 0 ? rect.width : fallback.width,
206
+ height: alternateRect.height,
207
+ width: rect.width > 0 ? rect.width : alternateRect.width,
208
208
  };
209
209
  }
210
210
  }
@@ -250,7 +250,8 @@ function escapeAttr(value) {
250
250
  }
251
251
 
252
252
  async function inlineMeasurementMediaUrls(html, mediaDir) {
253
- if (!mediaDir || !html) return html;
253
+ const mediaRoots = mediaRootList(mediaDir);
254
+ if (mediaRoots.length === 0 || !html) return html;
254
255
  let out = String(html);
255
256
  const matches = new Set();
256
257
  for (const match of out.matchAll(/\bsrc=(['"])([^\1]*?)\1/g)) {
@@ -269,7 +270,7 @@ async function inlineMeasurementMediaUrls(html, mediaDir) {
269
270
  }
270
271
  }
271
272
  for (const rawName of matches) {
272
- const dataUrl = await mediaDataUrl(mediaDir, rawName);
273
+ const dataUrl = await mediaDataUrl(mediaRoots, rawName);
273
274
  if (!dataUrl) continue;
274
275
  out = out.replaceAll(`/openpress/media/${rawName}`, dataUrl);
275
276
  out = out.replaceAll(`media/${rawName}`, dataUrl);
@@ -278,7 +279,7 @@ async function inlineMeasurementMediaUrls(html, mediaDir) {
278
279
  return out;
279
280
  }
280
281
 
281
- async function mediaDataUrl(mediaDir, rawName) {
282
+ async function mediaDataUrl(mediaRoots, rawName) {
282
283
  let fileName;
283
284
  try {
284
285
  fileName = decodeURIComponent(String(rawName));
@@ -286,14 +287,31 @@ async function mediaDataUrl(mediaDir, rawName) {
286
287
  fileName = String(rawName);
287
288
  }
288
289
  if (!fileName || fileName !== path.basename(fileName)) return null;
289
- const filePath = path.join(mediaDir, fileName);
290
- let bytes;
291
- try {
292
- bytes = await fs.readFile(filePath);
293
- } catch {
294
- return null;
290
+ for (const mediaDir of mediaRoots) {
291
+ const filePath = path.join(mediaDir, fileName);
292
+ let bytes;
293
+ try {
294
+ bytes = await fs.readFile(filePath);
295
+ } catch {
296
+ continue;
297
+ }
298
+ return `data:${mediaMimeType(fileName)};base64,${bytes.toString("base64")}`;
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function mediaRootList(mediaDir) {
304
+ const raw = Array.isArray(mediaDir) ? mediaDir : [mediaDir];
305
+ const roots = [];
306
+ const seen = new Set();
307
+ for (const candidate of raw) {
308
+ if (!candidate) continue;
309
+ const normalized = path.resolve(candidate);
310
+ if (seen.has(normalized)) continue;
311
+ seen.add(normalized);
312
+ roots.push(normalized);
295
313
  }
296
- return `data:${mediaMimeType(fileName)};base64,${bytes.toString("base64")}`;
314
+ return roots;
297
315
  }
298
316
 
299
317
  function mediaMimeType(fileName) {
@@ -2,8 +2,8 @@
2
2
  // <Workspace> and <Press> metadata declared as JSX props.
3
3
  //
4
4
  // The 1.0 contract says <Press> carries every per-document setting on
5
- // its props (title, page, sources, slug, theme, componentsDir) and is
6
- // always nested inside <Workspace>. This helper invokes the user's
5
+ // its props (title, page, sources, slug, theme, componentsDir, mediaDir).
6
+ // This helper invokes the user's
7
7
  // component once at load time to inspect those props before the engine
8
8
  // runs its render pipeline.
9
9
  //
@@ -21,7 +21,7 @@ import React from "react";
21
21
  * for the single-Press case.
22
22
  *
23
23
  * @param {object} opts
24
- * @param {Function} opts.UserComponent The default export of press/index.tsx.
24
+ * @param {Function} opts.UserComponent The default export of press/<slug>/press.tsx.
25
25
  * @param {symbol} opts.PRESS_MARKER Marker identifying Press components.
26
26
  * @param {symbol} opts.WORKSPACE_MARKER Marker identifying Workspace components.
27
27
  * @returns {{
@@ -35,7 +35,8 @@ import React from "react";
35
35
  * page?: unknown,
36
36
  * slug?: string,
37
37
  * theme?: string,
38
- * componentsDir?: string,
38
+ * componentsDir?: string | string[],
39
+ * mediaDir?: string | string[],
39
40
  * captionNumbering?: unknown,
40
41
  * },
41
42
  * sources: Record<string, unknown> | null, // mdxSource() descriptors keyed by id
@@ -118,14 +119,14 @@ function extractProps(element) {
118
119
 
119
120
  function collectPressElements(root, PRESS_MARKER) {
120
121
  const found = [];
121
- walk(root);
122
+ walk(root, new Set());
122
123
  return found;
123
124
 
124
- function walk(node) {
125
+ function walk(node, seen) {
125
126
  if (!isReactElement(node)) {
126
127
  // Could be array / fragment / string / number — flatten and recurse.
127
128
  if (Array.isArray(node)) {
128
- for (const child of node) walk(child);
129
+ for (const child of node) walk(child, seen);
129
130
  }
130
131
  return;
131
132
  }
@@ -135,10 +136,32 @@ function collectPressElements(root, PRESS_MARKER) {
135
136
  // not more workspace structure.
136
137
  return;
137
138
  }
139
+ const rendered = renderCompositeElement(node, seen);
140
+ if (rendered !== null) {
141
+ walk(rendered, seen);
142
+ return;
143
+ }
138
144
  // Recurse into children + Fragment-like wrappers.
139
145
  const children = node.props?.children;
140
146
  if (children == null) return;
141
- React.Children.forEach(children, walk);
147
+ React.Children.forEach(children, (child) => walk(child, seen));
148
+ }
149
+ }
150
+
151
+ function renderCompositeElement(element, seen) {
152
+ const type = element?.type;
153
+ if (typeof type !== "function") return null;
154
+ if (seen.has(type)) return null;
155
+ seen.add(type);
156
+ try {
157
+ return type(element.props ?? {});
158
+ } catch {
159
+ // Top-level Press wrapper components should be inert. If a user puts a
160
+ // hookful or effectful component at the Workspace boundary, leave it for
161
+ // the normal React render pipeline to report with full context.
162
+ return null;
163
+ } finally {
164
+ seen.delete(type);
142
165
  }
143
166
  }
144
167
 
@@ -149,15 +172,22 @@ function pickPressMetadata(pressProps) {
149
172
  if (pressProps.page !== undefined) out.page = pressProps.page;
150
173
  if (typeof pressProps.slug === "string") out.slug = pressProps.slug;
151
174
  if (typeof pressProps.theme === "string") out.theme = pressProps.theme;
152
- if (typeof pressProps.componentsDir === "string") out.componentsDir = pressProps.componentsDir;
175
+ if (typeof pressProps.componentsDir === "string" || isStringArray(pressProps.componentsDir)) {
176
+ out.componentsDir = pressProps.componentsDir;
177
+ }
178
+ if (typeof pressProps.mediaDir === "string" || isStringArray(pressProps.mediaDir)) {
179
+ out.mediaDir = pressProps.mediaDir;
180
+ }
153
181
  if (pressProps.captionNumbering !== undefined) out.captionNumbering = pressProps.captionNumbering;
154
182
  return out;
155
183
  }
156
184
 
157
- // Convert the v1.0 <Press sources={[ mdxSource({ id, ... }), ... ]}> array
158
- // into the engine's expected sources record { [id]: descriptor }. Returns
159
- // null if no sources prop was declared (engine falls back to the named
160
- // `export const sources` from the entry module — the v0.x shape).
185
+ function isStringArray(value) {
186
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
187
+ }
188
+
189
+ // Convert <Press sources={[ mdxSource({ id, ... }), ... ]}> into the
190
+ // engine's expected sources record { [id]: descriptor }.
161
191
  function extractSources(pressProps) {
162
192
  if (!Array.isArray(pressProps.sources)) return null;
163
193
  const out = {};