@open-press/core 0.7.1 → 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 (144) 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/dev.mjs +2 -2
  5. package/engine/commands/image.mjs +29 -0
  6. package/engine/commands/skills-sync.mjs +71 -0
  7. package/engine/commands/typecheck.mjs +63 -1
  8. package/engine/commands/upgrade.mjs +3 -3
  9. package/engine/document-export.mjs +1 -1
  10. package/engine/output/chrome-pdf.mjs +110 -3
  11. package/engine/output/static-server.mjs +87 -9
  12. package/engine/react/comment-endpoint.mjs +13 -39
  13. package/engine/react/comment-marker.mjs +43 -19
  14. package/engine/react/document-entry.mjs +46 -28
  15. package/engine/react/document-export.mjs +328 -164
  16. package/engine/react/http-json.mjs +24 -0
  17. package/engine/react/mdx-compile.mjs +126 -3
  18. package/engine/react/measurement-css.mjs +114 -1
  19. package/engine/react/object-entities.mjs +204 -0
  20. package/engine/react/pagination/allocator.mjs +48 -3
  21. package/engine/react/pagination.mjs +1 -1
  22. package/engine/react/pipeline/allocate.mjs +41 -72
  23. package/engine/react/pipeline/frame-measurement.mjs +6 -0
  24. package/engine/react/press-tree-inspection.mjs +172 -0
  25. package/engine/react/project-asset-endpoint.mjs +6 -24
  26. package/engine/react/source-edit-endpoint.d.mts +10 -0
  27. package/engine/react/source-edit-endpoint.mjs +75 -0
  28. package/engine/react/sources/mdx-resolver.mjs +13 -15
  29. package/engine/react/style-discovery.mjs +23 -8
  30. package/engine/runtime/config.d.mts +8 -0
  31. package/engine/runtime/config.mjs +57 -60
  32. package/engine/runtime/file-utils.mjs +9 -1
  33. package/engine/runtime/file-walk.mjs +22 -0
  34. package/engine/runtime/inspection.mjs +1 -20
  35. package/engine/runtime/page-geometry.mjs +131 -0
  36. package/engine/runtime/path-utils.mjs +20 -0
  37. package/engine/runtime/source-text-tools.d.mts +102 -0
  38. package/engine/runtime/source-text-tools.mjs +551 -16
  39. package/engine/runtime/source-workspace.mjs +16 -34
  40. package/engine/runtime/validation.mjs +19 -10
  41. package/package.json +3 -5
  42. package/src/openpress/app/OpenPressApp.tsx +296 -0
  43. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +20 -9
  44. package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  45. package/src/openpress/app/index.ts +2 -0
  46. package/src/openpress/core/Frame.tsx +26 -15
  47. package/src/openpress/core/FrameContext.tsx +10 -3
  48. package/src/openpress/core/MdxArea.tsx +11 -12
  49. package/src/openpress/core/Press.tsx +25 -4
  50. package/src/openpress/core/Workspace.tsx +36 -0
  51. package/src/openpress/core/cn.ts +4 -0
  52. package/src/openpress/core/index.tsx +11 -3
  53. package/src/openpress/core/primitives.tsx +74 -6
  54. package/src/openpress/core/types.ts +94 -41
  55. package/src/openpress/core/useSource.ts +1 -1
  56. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  57. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  58. package/src/openpress/{types.ts → document-model/documentTypes.ts} +51 -0
  59. package/src/openpress/document-model/index.ts +7 -0
  60. package/src/openpress/document-model/objectEntityModel.ts +55 -0
  61. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  62. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  63. package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  64. package/src/openpress/manuscript/index.tsx +49 -7
  65. package/src/openpress/mdx/index.ts +15 -7
  66. package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  67. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  68. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  69. package/src/openpress/reader/index.ts +11 -0
  70. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  71. package/src/openpress/reader/readerTypes.ts +4 -0
  72. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  73. package/src/openpress/reader/usePanelState.ts +56 -0
  74. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  75. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  76. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  77. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  78. package/src/openpress/shared/Panel.tsx +77 -0
  79. package/src/openpress/shared/index.ts +4 -0
  80. package/src/openpress/shared/numberUtils.ts +3 -0
  81. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  82. package/src/openpress/workbench/Workbench.tsx +506 -0
  83. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  84. package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  85. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  86. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  87. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  88. package/src/openpress/workbench/actions/index.ts +6 -0
  89. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  90. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  91. package/src/openpress/workbench/dialog/index.ts +1 -0
  92. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  93. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  94. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  95. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  96. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  97. package/src/openpress/workbench/document/index.ts +10 -0
  98. package/src/openpress/workbench/index.ts +2 -0
  99. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  100. package/src/openpress/workbench/inspector/index.ts +5 -0
  101. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  102. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  103. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  104. package/src/openpress/workbench/inspector/useInspectorComments.ts +254 -0
  105. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  106. package/src/openpress/workbench/mentions/index.ts +2 -0
  107. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  108. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  109. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +80 -0
  110. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  111. package/src/openpress/workbench/panels/index.ts +3 -0
  112. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +525 -0
  113. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  114. package/src/openpress/workbench/project/index.ts +2 -0
  115. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  116. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  117. package/src/openpress/workbench/shell/index.ts +1 -0
  118. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  119. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  120. package/src/styles/openpress/print-route.css +0 -2
  121. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  122. package/src/styles/openpress/public-viewer.css +25 -320
  123. package/src/styles/openpress/reader-runtime.css +252 -55
  124. package/src/styles/openpress/responsive.css +145 -270
  125. package/src/styles/openpress/workbench-panels.css +327 -178
  126. package/src/styles/openpress/workbench.css +986 -451
  127. package/src/styles/openpress/workspace-gallery.css +300 -0
  128. package/src/styles/openpress.css +2 -1
  129. package/tsconfig.json +1 -1
  130. package/vite.config.ts +50 -0
  131. package/engine/commands/init.mjs +0 -24
  132. package/engine/init.mjs +0 -90
  133. package/src/openpress/App.tsx +0 -127
  134. package/src/openpress/inspector.ts +0 -282
  135. package/src/openpress/projectWorkspace.tsx +0 -919
  136. package/src/openpress/readerRuntime.ts +0 -230
  137. package/src/openpress/workbench.tsx +0 -1265
  138. package/src/openpress/workbenchTypes.ts +0 -4
  139. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  140. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  141. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  142. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  143. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  144. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -6,12 +6,16 @@
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
+ import React from "react";
9
10
  import { documentRelativePath, pageToBlock } from "../output/page-block.mjs";
10
11
  import { syncPublicAssets } from "../output/public-assets.mjs";
12
+ import { pageGeometryToTheme } from "../runtime/page-geometry.mjs";
13
+ import { normalizePageGeometry } from "../runtime/page-geometry.mjs";
11
14
  import { createCaptionNumberingState, numberCaptionsInHtml } from "./caption-numbering.mjs";
12
15
  import { buildSectionScopedCss } from "./section-css.mjs";
13
16
  import { CORE_ENTRY, createReactSsrServer, loadReactDocumentEntry } from "./document-entry.mjs";
14
17
  import { buildReactMeasurementCss } from "./measurement-css.mjs";
18
+ import { buildObjectEntities } from "./object-entities.mjs";
15
19
  import { allocateChains } from "./pipeline/allocate.mjs";
16
20
  import { measureFrames } from "./pipeline/frame-measurement.mjs";
17
21
  import { renderFinalPress } from "./pipeline/final-render.mjs";
@@ -49,197 +53,325 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
49
53
  }
50
54
 
51
55
  // Discover workspace for component scope and chapter-scoped style files.
52
- const workspace = await discoverSectionStyles(workspaceRoot, entry.config);
53
- const globalComponents = await loadComponentModules(server, workspace.globalComponents ?? []);
54
-
55
- // Resolve sources.
56
- const documentRoot = entry.config.paths.documentRoot;
57
- const { resolved: sources, renderData: renderRegistry } = await resolveAllSources({
58
- sources: entry.sources,
59
- documentRoot,
60
- globalComponents,
61
- });
62
-
63
- // Build measurement CSS.
64
- const css = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace);
65
-
66
- // Iterative allocation loop.
67
- let hints = null;
68
- let allocation = null;
69
- let lastFrames = null;
70
- let warnings = [];
71
- for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
72
- const { html, frames } = expandPressTree({
73
- Press: entry.Press,
74
- PressContext,
75
- sources,
76
- hints,
77
- });
78
- lastFrames = frames;
79
- validateAllChainsKnown(frames, sources);
80
- const measurement = await measureFrames({
81
- pressHtml: html,
82
- sources,
83
- renderRegistry,
84
- css,
85
- baseHref: pathToFileURL(`${documentRoot}${path.sep}`).href,
86
- mediaDir: path.join(documentRoot, "media"),
87
- captionNumbering: entry.config.captionNumbering,
88
- });
89
- const alloc = allocateChains({
90
- frames,
91
- mdxAreas: measurement.mdxAreas,
92
- blockHeights: measurement.blockHeights,
93
- sources,
94
- });
95
- if (process.env.OPENPRESS_DEBUG_ALLOC) {
96
- const sample = measurement.mdxAreas
97
- .slice(0, 5)
98
- .map((a) => `${a.frameKey}#${a.indexInFrame} cap=${a.capacity.toFixed(0)} raw=${(a.rawHeight ?? 0).toFixed(0)}`);
99
- const blocks = measurement.blockHeights
100
- .slice(0, 8)
101
- .map((b) => `${b.id} h=${b.height.toFixed(0)}`);
102
- process.stderr.write(`[allocator iter ${iteration}]\n`);
103
- process.stderr.write(` mdxAreas[0..4]: ${sample.join(" | ")}\n`);
104
- process.stderr.write(` blocks[0..7]: ${blocks.join(" | ")}\n`);
105
- process.stderr.write(` hints: ${JSON.stringify(alloc.hints.totalPagesPerChain)}\n`);
106
- if (alloc.warnings.length > 0) {
107
- process.stderr.write(` warnings: ${JSON.stringify(alloc.warnings)}\n`);
108
- }
109
- }
110
- if (hintsEqual(hints, alloc.hints)) {
111
- allocation = alloc.allocation;
112
- warnings = alloc.warnings;
113
- break;
114
- }
115
- hints = alloc.hints;
116
- }
117
- if (allocation == null) {
118
- throw new Error(
119
- `Allocation did not converge after ${MAX_ITERATIONS} iterations. ` +
120
- `This usually means a chain keeps growing without fitting; check MdxArea capacities and block heights.`,
121
- );
56
+ // Pass every Press's resolved section-folders root so per-Press chapter
57
+ // folders (e.g. press/userstory/chapters/) are all picked up — the
58
+ // workspace can host more than one chapter root.
59
+ const sectionRoots = collectSectionRoots(entry.presses, entry.config.paths.documentRoot);
60
+ const workspace = await discoverSectionStyles(workspaceRoot, entry.config, { sectionRoots });
61
+ const coreAuthorComponents = {};
62
+ for (const name of ["MediaFigure", "ImageFigure"]) {
63
+ if (typeof coreModule[name] === "function") coreAuthorComponents[name] = coreModule[name];
122
64
  }
65
+ const globalComponents = {
66
+ ...coreAuthorComponents,
67
+ ...(await loadComponentModules(server, workspace.globalComponents ?? [])),
68
+ };
123
69
 
124
- const toc = buildTocContext({ sources, frames: lastFrames ?? [], allocation });
125
-
126
- // Final render.
127
- const final = await renderFinalPress({
128
- Press: entry.Press,
129
- PressContext,
130
- sources,
131
- hints,
132
- toc,
133
- allocation,
134
- renderRegistry,
135
- });
70
+ // Build measurement CSS once at the workspace level shared by every
71
+ // Press inside the Workspace.
72
+ const measurementCss = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace);
136
73
 
137
- // Write chapter-scoped CSS (under section-scoped rules but filename
138
- // unchanged for now).
74
+ // Write chapter-scoped CSS once (workspace shared). Every per-press
75
+ // readerDocument references the same file via "/openpress/chapter-scoped.css".
139
76
  const chapterCss = await buildSectionScopedCss(workspace);
140
- const styles = [];
77
+ const sharedStyles = [];
141
78
  await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
142
79
  if (chapterCss.trim()) {
143
80
  await fs.writeFile(path.join(entry.config.paths.publicDir, "chapter-scoped.css"), chapterCss, "utf8");
144
- styles.push({
81
+ sharedStyles.push({
145
82
  kind: "chapter-scoped-css",
146
83
  href: "/openpress/chapter-scoped.css",
147
84
  path: "chapter-scoped.css",
148
85
  });
149
86
  }
150
87
 
151
- // Build document.json. The reader filters blocks by kind === "htmlPage",
152
- // so wrap each frame through `pageToBlock` to inherit that contract.
153
- const blockMap = {};
154
- const captionState = createCaptionNumberingState();
155
- const blocks = final.frames.map((frame, index) => {
156
- for (const id of frame.blockIds) {
157
- blockMap[id] = { id, pageIndex: index, pageNumber: index + 1 };
158
- }
159
- const source = {
160
- file: "index.tsx",
161
- path: "document/index.tsx",
162
- kind: frame.role ?? "manuscript.content",
163
- slug: frame.frameKey,
164
- sectionIndex: index + 1,
165
- };
166
- const html = numberCaptionsInHtml(frame.html, entry.config.captionNumbering, captionState);
167
- const block = pageToBlock(index, html, source, entry.config, {
168
- idPrefix: "openpress-page",
169
- anchorPrefix: "page",
170
- titleFallback: "Page",
88
+ // Iterate every Press declared inside <Workspace>. Single-doc
89
+ // workspaces just have length-1 here; the code path is uniform.
90
+ const pressResults = [];
91
+ for (const press of entry.presses) {
92
+ const result = await exportSinglePress({
93
+ press,
94
+ entry,
95
+ workspaceRoot,
96
+ server,
97
+ coreModule,
98
+ PressContext,
99
+ workspace,
100
+ globalComponents,
101
+ measurementCss,
102
+ sharedStyles,
171
103
  });
172
- return {
173
- ...block,
174
- frameKey: frame.frameKey,
175
- role: frame.role ?? null,
176
- chrome: frame.chrome ?? true,
177
- blockIds: frame.blockIds,
178
- };
179
- });
180
-
181
- // Enrich blockMap with source records from resolved chains so comment
182
- // tooling can resolve block IDs back to MDX positions.
183
- const sourceBlockIndex = buildSourceBlockIndex(sources);
184
- for (const id of Object.keys(blockMap)) {
185
- const sourceRecord = sourceBlockIndex.get(id);
186
- if (sourceRecord) {
187
- blockMap[id] = {
188
- ...blockMap[id],
189
- kind: sourceRecord.kind,
190
- name: sourceRecord.name,
191
- path: sourceRecord.path,
192
- source: sourceRecord.source,
193
- chainId: sourceRecord.chainId,
194
- sectionSlug: sourceRecord.sectionSlug,
195
- };
196
- }
104
+ pressResults.push(result);
197
105
  }
198
106
 
199
- const readerDocument = {
200
- meta: {
201
- title: trimmedString(entry.config.title) ?? "Untitled Document",
202
- subtitle: trimmedString(entry.config.subtitle) ?? "",
203
- organization: trimmedString(entry.config.organization) ?? "",
204
- workspaceLabel: trimmedString(entry.config.workspaceLabel) ?? trimmedString(entry.config.title) ?? "Untitled Document",
205
- version: "openpress-press-tree-v1",
206
- },
207
- source: {
208
- type: "openpress-press-tree-mdx",
209
- contentDir: documentRelativePath(entry.config, entry.config.sourceDir),
210
- editable: true,
211
- editMode: "source-mdx",
212
- styles,
213
- blockMap,
214
- frames: final.frames.map((frame, index) => ({
215
- frameKey: frame.frameKey,
216
- role: frame.role ?? null,
217
- pageIndex: index,
218
- mdxAreas: frame.mdxAreas.map((area) => ({
219
- chainId: area.chainId,
220
- indexInFrame: area.indexInFrame,
221
- blockIds: area.blockIds,
222
- })),
223
- })),
224
- chains: Object.keys(sources).flatMap((id) => Object.keys(sources[id].chains)),
225
- warnings,
226
- },
227
- blocks,
107
+ // Build workspace.json — one entry per Press. The reader fetches
108
+ // this first to decide between gallery (length > 1) and direct
109
+ // load (length 1).
110
+ const workspaceManifest = {
111
+ version: 1,
112
+ name: typeof entry.workspaceProps?.name === "string" && entry.workspaceProps.name.trim()
113
+ ? entry.workspaceProps.name.trim()
114
+ : null,
115
+ presses: pressResults.map((r) => ({
116
+ slug: r.slug,
117
+ title: r.readerDocument.meta.title,
118
+ page: r.readerDocument.theme ?? null,
119
+ pageCount: r.pageCount,
120
+ documentUrl: r.documentUrl,
121
+ })),
228
122
  };
229
-
230
- const documentPath = path.join(entry.config.paths.publicDir, "document.json");
231
- await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
123
+ const workspacePath = path.join(entry.config.paths.publicDir, "workspace.json");
124
+ await fs.writeFile(workspacePath, JSON.stringify(workspaceManifest, null, 2), "utf8");
232
125
 
233
126
  if (syncAssets) {
234
127
  await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
235
128
  }
236
129
 
237
- return { documentPath, pageCount: blocks.length, document: readerDocument };
130
+ const primary = pressResults[0];
131
+ return {
132
+ documentPath: primary?.documentPath,
133
+ pageCount: primary?.pageCount ?? 0,
134
+ document: primary?.readerDocument,
135
+ presses: pressResults,
136
+ };
238
137
  } finally {
239
138
  await server.close();
240
139
  }
241
140
  }
242
141
 
142
+ // Render one Press from the Workspace into its own document.json.
143
+ // Called once per <Press> child; single-doc workspaces just call this
144
+ // once with the only Press. Returns the per-press summary the
145
+ // workspace manifest is built from.
146
+ async function exportSinglePress({
147
+ press,
148
+ entry,
149
+ workspaceRoot,
150
+ server,
151
+ coreModule,
152
+ PressContext,
153
+ workspace,
154
+ globalComponents,
155
+ measurementCss,
156
+ sharedStyles,
157
+ }) {
158
+ const slug = typeof press.metadata?.slug === "string" && press.metadata.slug.trim()
159
+ ? press.metadata.slug.trim()
160
+ : "";
161
+
162
+ // Effective config for this press: workspace config with per-press
163
+ // metadata overlaid. Press JSX page prop wins over the workspace page.
164
+ const effectiveConfig = applyPressOverridesToConfig(entry.config, press.metadata);
165
+ const documentRoot = effectiveConfig.paths.documentRoot;
166
+
167
+ // Resolve sources for this press. The 1.0 contract reads them from
168
+ // <Press sources={[...]}>; the v0.x legacy path uses the synthesized
169
+ // record from `export const sources`.
170
+ const sourcesRecord = press.sources ?? {};
171
+ const { resolved: sources, renderData: renderRegistry } = await resolveAllSources({
172
+ sources: sourcesRecord,
173
+ documentRoot,
174
+ globalComponents,
175
+ });
176
+
177
+ // Component the render pipeline drives. For Press elements captured
178
+ // by inspection (1.0 contract), wrap the captured element in a thin
179
+ // function component. For legacy projects without inspection data,
180
+ // fall back to the user's whole default export.
181
+ const PressComponent = press.element
182
+ ? () => press.element
183
+ : entry.Press;
184
+
185
+ // Iterative allocation loop (identical to v0.x — paginates until the
186
+ // hints stabilise).
187
+ let hints = null;
188
+ let allocation = null;
189
+ let lastFrames = null;
190
+ let warnings = [];
191
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
192
+ const { html, frames } = expandPressTree({
193
+ Press: PressComponent,
194
+ PressContext,
195
+ sources,
196
+ hints,
197
+ });
198
+ lastFrames = frames;
199
+ validateAllChainsKnown(frames, sources);
200
+ const measurement = await measureFrames({
201
+ pressHtml: html,
202
+ sources,
203
+ renderRegistry,
204
+ css: measurementCss,
205
+ baseHref: pathToFileURL(`${documentRoot}${path.sep}`).href,
206
+ mediaDir: path.join(documentRoot, "media"),
207
+ captionNumbering: effectiveConfig.captionNumbering,
208
+ });
209
+ const alloc = allocateChains({
210
+ frames,
211
+ mdxAreas: measurement.mdxAreas,
212
+ blockHeights: measurement.blockHeights,
213
+ sources,
214
+ });
215
+ if (process.env.OPENPRESS_DEBUG_ALLOC) {
216
+ const sample = measurement.mdxAreas
217
+ .slice(0, 5)
218
+ .map((a) => `${a.frameKey}#${a.indexInFrame} cap=${a.capacity.toFixed(0)} raw=${(a.rawHeight ?? 0).toFixed(0)}`);
219
+ const blocks = measurement.blockHeights
220
+ .slice(0, 8)
221
+ .map((b) => `${b.id} h=${b.height.toFixed(0)}`);
222
+ process.stderr.write(`[allocator press=${slug || "(root)"} iter ${iteration}]\n`);
223
+ process.stderr.write(` mdxAreas[0..4]: ${sample.join(" | ")}\n`);
224
+ process.stderr.write(` blocks[0..7]: ${blocks.join(" | ")}\n`);
225
+ process.stderr.write(` hints: ${JSON.stringify(alloc.hints.totalPagesPerChain)}\n`);
226
+ if (alloc.warnings.length > 0) {
227
+ process.stderr.write(` warnings: ${JSON.stringify(alloc.warnings)}\n`);
228
+ }
229
+ }
230
+ if (hintsEqual(hints, alloc.hints)) {
231
+ allocation = alloc.allocation;
232
+ warnings = alloc.warnings;
233
+ break;
234
+ }
235
+ hints = alloc.hints;
236
+ }
237
+ if (allocation == null) {
238
+ throw new Error(
239
+ `Allocation did not converge after ${MAX_ITERATIONS} iterations (press="${slug || "(root)"}"). ` +
240
+ `This usually means a chain keeps growing without fitting; check MdxArea capacities and block heights.`,
241
+ );
242
+ }
243
+
244
+ const toc = buildTocContext({ sources, frames: lastFrames ?? [], allocation });
245
+
246
+ const final = await renderFinalPress({
247
+ Press: PressComponent,
248
+ PressContext,
249
+ sources,
250
+ hints,
251
+ toc,
252
+ allocation,
253
+ renderRegistry,
254
+ });
255
+
256
+ // Build the reader's document.json. Same shape as v0.x; the only
257
+ // change is metadata.title comes from the per-press Press JSX prop.
258
+ const blockMap = {};
259
+ const captionState = createCaptionNumberingState();
260
+ const blocks = final.frames.map((frame, index) => {
261
+ const source = {
262
+ file: "index.tsx",
263
+ path: slug ? `press/${slug}/index.tsx` : "press/index.tsx",
264
+ kind: frame.role ?? "manuscript.content",
265
+ slug: frame.frameKey,
266
+ sectionIndex: index + 1,
267
+ };
268
+ const html = numberCaptionsInHtml(frame.html, effectiveConfig.captionNumbering, captionState);
269
+ for (const id of collectFrameBlockIds(frame.blockIds, html)) {
270
+ blockMap[id] = { id, pageIndex: index, pageNumber: index + 1, frameKey: frame.frameKey };
271
+ }
272
+ const block = pageToBlock(index, html, source, effectiveConfig, {
273
+ idPrefix: "openpress-page",
274
+ anchorPrefix: "page",
275
+ titleFallback: "Page",
276
+ });
277
+ return {
278
+ ...block,
279
+ frameKey: frame.frameKey,
280
+ role: frame.role ?? null,
281
+ chrome: frame.chrome ?? true,
282
+ blockIds: frame.blockIds,
283
+ };
284
+ });
285
+
286
+ const sourceBlockIndex = buildSourceBlockIndex(sources);
287
+ for (const id of Object.keys(blockMap)) {
288
+ const sourceRecord = sourceBlockIndex.get(id);
289
+ if (sourceRecord) {
290
+ blockMap[id] = {
291
+ ...blockMap[id],
292
+ kind: sourceRecord.kind,
293
+ name: sourceRecord.name,
294
+ path: sourceRecord.path,
295
+ source: sourceRecord.source,
296
+ chainId: sourceRecord.chainId,
297
+ sectionSlug: sourceRecord.sectionSlug,
298
+ };
299
+ }
300
+ }
301
+
302
+ const objectEntities = buildObjectEntities({
303
+ frames: final.frames.map((frame, index) => ({ ...frame, pageIndex: index })),
304
+ blocks,
305
+ blockMap,
306
+ });
307
+
308
+ const readerDocument = {
309
+ meta: {
310
+ title: trimmedString(effectiveConfig.title) ?? "Untitled Document",
311
+ subtitle: trimmedString(effectiveConfig.subtitle) ?? "",
312
+ organization: trimmedString(effectiveConfig.organization) ?? "",
313
+ workspaceLabel: trimmedString(effectiveConfig.workspaceLabel) ?? "",
314
+ version: "openpress-press-tree-v1",
315
+ },
316
+ theme: pageGeometryToTheme(effectiveConfig.page),
317
+ source: {
318
+ type: "openpress-press-tree-mdx",
319
+ contentDir: documentRelativePath(effectiveConfig, effectiveConfig.sourceDir),
320
+ editable: true,
321
+ editMode: "source-mdx",
322
+ styles: sharedStyles,
323
+ blockMap,
324
+ objectEntities,
325
+ frames: final.frames.map((frame, index) => ({
326
+ frameKey: frame.frameKey,
327
+ role: frame.role ?? null,
328
+ pageIndex: index,
329
+ mdxAreas: frame.mdxAreas.map((area) => ({
330
+ chainId: area.chainId,
331
+ indexInFrame: area.indexInFrame,
332
+ blockIds: area.blockIds,
333
+ })),
334
+ })),
335
+ chains: Object.keys(sources).flatMap((id) => Object.keys(sources[id].chains)),
336
+ warnings,
337
+ },
338
+ blocks,
339
+ };
340
+
341
+ // Output path: empty slug → root /openpress/document.json (legacy
342
+ // single-Press shape). Non-empty slug → /openpress/<slug>/document.json.
343
+ const pressOutputDir = slug
344
+ ? path.join(effectiveConfig.paths.publicDir, slug)
345
+ : effectiveConfig.paths.publicDir;
346
+ await fs.mkdir(pressOutputDir, { recursive: true });
347
+ const documentPath = path.join(pressOutputDir, "document.json");
348
+ await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
349
+
350
+ return {
351
+ slug,
352
+ documentPath,
353
+ documentUrl: slug ? `/openpress/${slug}/document.json` : "/openpress/document.json",
354
+ readerDocument,
355
+ pageCount: blocks.length,
356
+ };
357
+ }
358
+
359
+ // Apply per-Press JSX prop overrides onto the workspace-level config.
360
+ // Returns a new config object — the original is untouched so other
361
+ // presses in the same workspace get a clean base.
362
+ function applyPressOverridesToConfig(workspaceConfig, pressMetadata) {
363
+ if (!pressMetadata) return workspaceConfig;
364
+ const out = { ...workspaceConfig };
365
+ if (pressMetadata.title) out.title = pressMetadata.title;
366
+ if (pressMetadata.page !== undefined) {
367
+ out.page = normalizePageGeometry(pressMetadata.page);
368
+ }
369
+ if (pressMetadata.captionNumbering !== undefined) {
370
+ out.captionNumbering = { ...workspaceConfig.captionNumbering, ...pressMetadata.captionNumbering };
371
+ }
372
+ return out;
373
+ }
374
+
243
375
  async function loadComponentModules(server, components) {
244
376
  const out = {};
245
377
  for (const component of components) {
@@ -296,6 +428,16 @@ function buildSourceBlockIndex(sources) {
296
428
  return index;
297
429
  }
298
430
 
431
+ function collectFrameBlockIds(allocatedIds, html) {
432
+ const ids = new Set(allocatedIds ?? []);
433
+ const pattern = /\sdata-openpress-block-id="([^"]+)"/g;
434
+ let match;
435
+ while ((match = pattern.exec(String(html ?? "")))) {
436
+ if (match[1]) ids.add(match[1]);
437
+ }
438
+ return ids;
439
+ }
440
+
299
441
  function buildTocContext({ sources, frames, allocation }) {
300
442
  const toc = {};
301
443
  for (const source of Object.values(sources)) {
@@ -346,3 +488,25 @@ function trimmedString(value) {
346
488
  const trimmed = value.trim();
347
489
  return trimmed ? trimmed : null;
348
490
  }
491
+
492
+ // Walk every Press's mdxSource descriptors and collect the absolute
493
+ // path each section-folders root resolves to. discoverSectionStyles
494
+ // iterates these to find section-scoped CSS across a multi-Press
495
+ // workspace where chapters live under per-Press subfolders.
496
+ function collectSectionRoots(presses, documentRoot) {
497
+ const roots = new Set();
498
+ for (const press of presses ?? []) {
499
+ const sources = press?.sources;
500
+ if (!sources || typeof sources !== "object") continue;
501
+ for (const descriptor of Object.values(sources)) {
502
+ if (descriptor?.type !== "mdx") continue;
503
+ if (descriptor?.preset !== "section-folders") continue;
504
+ const rel = typeof descriptor.root === "string" && descriptor.root.trim()
505
+ ? descriptor.root.trim()
506
+ : "chapters";
507
+ roots.add(path.resolve(documentRoot, rel));
508
+ }
509
+ }
510
+ return [...roots];
511
+ }
512
+
@@ -0,0 +1,24 @@
1
+ const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
2
+
3
+ export async function readJsonBody(req, {
4
+ maxBytes = DEFAULT_MAX_BODY_BYTES,
5
+ bodyLabel = "Request",
6
+ } = {}) {
7
+ let body = "";
8
+ for await (const chunk of req) {
9
+ body += String(chunk);
10
+ if (Buffer.byteLength(body, "utf8") > maxBytes) {
11
+ throw new Error(`${bodyLabel} body is too large.`);
12
+ }
13
+ }
14
+ try {
15
+ return JSON.parse(body || "{}");
16
+ } catch {
17
+ throw new Error(`${bodyLabel} body must be valid JSON.`);
18
+ }
19
+ }
20
+
21
+ export function writeJson(res, status, body) {
22
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
23
+ res.end(`${JSON.stringify(body, null, 2)}\n`);
24
+ }