@open-press/core 0.6.0 → 0.7.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 (70) hide show
  1. package/README.md +9 -5
  2. package/engine/cli.mjs +2 -5
  3. package/engine/commands/_shared.mjs +4 -4
  4. package/engine/commands/deploy.mjs +1 -1
  5. package/engine/commands/inspect.mjs +3 -3
  6. package/engine/commands/replace.mjs +1 -1
  7. package/engine/commands/search.mjs +1 -1
  8. package/engine/commands/validate.mjs +2 -2
  9. package/engine/document-export.mjs +1 -1
  10. package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
  11. package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
  12. package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
  13. package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
  14. package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
  15. package/engine/react/caption-numbering.mjs +73 -0
  16. package/engine/react/comment-marker.mjs +54 -10
  17. package/engine/react/document-entry.mjs +124 -64
  18. package/engine/react/document-export.mjs +252 -311
  19. package/engine/react/mdx-compile.mjs +123 -3
  20. package/engine/react/measurement-css.mjs +3 -3
  21. package/engine/react/pagination/allocator.mjs +122 -0
  22. package/engine/react/pagination/regions.mjs +81 -0
  23. package/engine/react/pagination.mjs +9 -121
  24. package/engine/react/pipeline/allocate.mjs +248 -0
  25. package/engine/react/pipeline/final-render.mjs +94 -0
  26. package/engine/react/pipeline/frame-measurement.mjs +271 -0
  27. package/engine/react/pipeline/press-tree.mjs +135 -0
  28. package/engine/react/project-asset-endpoint.mjs +2 -2
  29. package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
  30. package/engine/react/sources/heading-numbering.mjs +132 -0
  31. package/engine/react/sources/mdx-resolver.mjs +441 -0
  32. package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
  33. package/engine/{config.mjs → runtime/config.mjs} +15 -0
  34. package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
  35. package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
  36. package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
  37. package/engine/runtime/source-workspace.mjs +186 -0
  38. package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
  39. package/package.json +5 -2
  40. package/src/openpress/anchorMap.ts +27 -0
  41. package/src/openpress/core/Frame.tsx +80 -0
  42. package/src/openpress/core/FrameContext.tsx +19 -0
  43. package/src/openpress/core/MdxArea.tsx +35 -0
  44. package/src/openpress/core/Press.tsx +34 -0
  45. package/src/openpress/core/index.tsx +34 -15
  46. package/src/openpress/core/primitives.tsx +23 -0
  47. package/src/openpress/core/types.ts +131 -19
  48. package/src/openpress/core/useSource.ts +28 -0
  49. package/src/openpress/manuscript/index.tsx +196 -0
  50. package/src/openpress/mdx/index.ts +88 -0
  51. package/src/openpress/numbering/index.ts +294 -0
  52. package/src/openpress/publicPage.tsx +4 -186
  53. package/src/openpress/reactDocumentMetadata.ts +2 -16
  54. package/src/openpress/types.ts +0 -16
  55. package/src/openpress/workbench.tsx +2 -36
  56. package/src/styles/openpress/responsive.css +0 -14
  57. package/tsconfig.json +4 -1
  58. package/vite.config.ts +10 -3
  59. package/engine/commands/migrate-to-react.mjs +0 -27
  60. package/engine/page-renderer.mjs +0 -217
  61. package/engine/react/migrate-to-react.mjs +0 -355
  62. package/engine/source-workspace.mjs +0 -76
  63. package/src/openpress/core/basePages.tsx +0 -87
  64. package/src/openpress/pagination.ts +0 -845
  65. /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
  66. /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
  67. /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
  68. /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
  69. /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
  70. /package/engine/{issue-report.mjs → runtime/issue-report.mjs} +0 -0
@@ -1,148 +1,130 @@
1
+ // Layer 6 orchestrator.
2
+ //
3
+ // Wires Layer 1 (entry load) -> source resolution -> Layer 2/3/4 iteration
4
+ // -> Layer 5 final render -> document.json + asset sync.
5
+
1
6
  import fs from "node:fs/promises";
2
7
  import path from "node:path";
3
- import React from "react";
4
- import { renderToStaticMarkup } from "react-dom/server";
5
- import { documentRelativePath, pageToBlock } from "../page-block.mjs";
6
- import { injectStaticToc } from "../page-renderer.mjs";
7
- import { syncPublicAssets } from "../public-assets.mjs";
8
- import { buildChapterScopedCss } from "./chapter-css.mjs";
9
- import { loadReactDocumentEntry, createReactSsrServer } from "./document-entry.mjs";
8
+ import { pathToFileURL } from "node:url";
9
+ import { documentRelativePath, pageToBlock } from "../output/page-block.mjs";
10
+ import { syncPublicAssets } from "../output/public-assets.mjs";
11
+ import { createCaptionNumberingState, numberCaptionsInHtml } from "./caption-numbering.mjs";
12
+ import { buildSectionScopedCss } from "./section-css.mjs";
13
+ import { CORE_ENTRY, createReactSsrServer, loadReactDocumentEntry } from "./document-entry.mjs";
10
14
  import { buildReactMeasurementCss } from "./measurement-css.mjs";
11
- import { compileMdx } from "./mdx-compile.mjs";
12
- import { measureBlocksInChromium } from "./pagination.mjs";
13
- import { discoverReactWorkspace } from "./workspace-discovery.mjs";
15
+ import { allocateChains } from "./pipeline/allocate.mjs";
16
+ import { measureFrames } from "./pipeline/frame-measurement.mjs";
17
+ import { renderFinalPress } from "./pipeline/final-render.mjs";
18
+ import { expandPressTree } from "./pipeline/press-tree.mjs";
19
+ import { resolveAllSources } from "./sources/mdx-resolver.mjs";
20
+ import { discoverSectionStyles } from "./style-discovery.mjs";
21
+
22
+ const MAX_ITERATIONS = 20;
14
23
 
15
- export async function exportReactDocument(root = ".", { syncAssets = true, pagination = null } = {}) {
24
+ export async function exportReactDocument(root = ".", { syncAssets = true } = {}) {
16
25
  const workspaceRoot = path.resolve(root);
17
- const entry = await loadReactDocumentEntry(workspaceRoot);
18
- if (!entry) return null;
26
+ // Quick existence check without opening an SSR server.
27
+ const fastCheck = await loadReactDocumentEntry(workspaceRoot);
28
+ if (!fastCheck) return null;
19
29
 
20
- const workspace = await discoverReactWorkspace(workspaceRoot, entry.config);
21
- const paginationOptions = normalizePaginationOptions(pagination);
22
- if (paginationOptions.enabled && paginationOptions.needsMeasurementCss) {
23
- paginationOptions.css = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace);
24
- }
25
30
  const server = await createReactSsrServer(workspaceRoot);
26
31
  try {
27
- const pageJobs = [];
28
- const blockMap = {};
29
- const paginationWarnings = [];
30
- addShellPage(pageJobs, entry.shell.cover, shellSource(entry.config, "cover"));
31
- addShellPage(pageJobs, entry.shell.toc, shellSource(entry.config, "toc"));
32
-
33
- for (const [chapterIndex, chapter] of workspace.chapters.entries()) {
34
- const chapterModule = await loadChapterModule(server, chapter);
35
- const chapterMeta = normalizeChapterMeta(chapter, chapterModule.meta);
36
- const components = await loadComponentScope(server, chapter.componentScope);
37
- const Page = typeof chapterModule.Page === "function" ? chapterModule.Page : components.Page ?? DefaultContentPage;
38
-
39
- addShellPage(
40
- pageJobs,
41
- chapterModule.opener ?? null,
42
- chapterSource(entry.config, chapter, {
43
- chapterIndex,
44
- kind: "chapter-opener",
45
- slug: chapterMeta.slug,
46
- title: chapterMeta.title,
47
- }),
32
+ // Reload the entry through THIS server so the module identity matches
33
+ // what the rest of the pipeline (PressContext, hooks) sees.
34
+ const entry = await loadReactDocumentEntry(workspaceRoot, { server });
35
+ if (!entry) return null;
36
+ if (!entry.Press) {
37
+ throw new Error(
38
+ `OpenPress document entry ${entry.entryPath} must default-export a Press component (function) to export. ` +
39
+ `Legacy named exports (cover/toc/backCover) are not supported in v0.6 — see the Press Tree spec.`,
48
40
  );
41
+ }
42
+ // Resolve PressContext + Frame markers from the engine's loaded core module.
43
+ // Use the absolute file path so the user's `import "@open-press/core"`
44
+ // (resolved via vite alias) and our load hit the same module cache entry.
45
+ const coreModule = await server.ssrLoadModule(CORE_ENTRY);
46
+ const PressContext = coreModule.PressContext;
47
+ if (!PressContext) {
48
+ throw new Error("Engine could not resolve PressContext from @open-press/core.");
49
+ }
49
50
 
50
- for (const contentFile of chapter.contentFiles) {
51
- const source = await fs.readFile(contentFile.absolutePath, "utf8");
52
- const compiled = await compileMdx({
53
- source,
54
- filePath: contentFile.absolutePath,
55
- components,
56
- chapterSlug: chapterMeta.slug,
57
- });
58
- const sourceRecord = chapterSource(entry.config, chapter, {
59
- chapterIndex,
60
- contentFile,
61
- kind: "content",
62
- slug: chapterMeta.slug,
63
- title: chapterMeta.title,
64
- });
65
- const mdxBlocks = compiled.blocks.map((block) => sanitizeMdxBlock(entry.config, contentFile, block));
51
+ // 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 ?? []);
66
54
 
67
- if (!paginationOptions.enabled || mdxBlocks.length === 0) {
68
- pageJobs.push(mdxPageJob({
69
- Page,
70
- Content: compiled.Content,
71
- source: sourceRecord,
72
- mdxBlocks,
73
- chapterMeta,
74
- }));
75
- continue;
76
- }
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
+ });
77
62
 
78
- const measurementHtml = renderToStaticMarkup(React.createElement(
79
- Page,
80
- {
81
- pageIndex: 0,
82
- totalPages: 1,
83
- chapterSlug: chapterMeta.slug,
84
- chapterTone: chapterMeta.tone,
85
- },
86
- React.createElement(compiled.Content),
87
- ));
88
- const measured = await paginationOptions.measureBlocks({
89
- html: measurementHtml,
90
- blockIds: mdxBlocks.map((block) => block.id),
91
- pageSafeHeightPx: paginationOptions.pageSafeHeightPx,
92
- css: paginationOptions.css,
93
- chapterSlug: chapterMeta.slug,
94
- contentFile,
95
- source: sourceRecord,
96
- });
97
- const blockLookup = Object.fromEntries(mdxBlocks.map((block) => [block.id, block]));
98
- for (const warning of measured.warnings ?? []) {
99
- paginationWarnings.push(enrichPaginationWarning(warning, blockLookup));
100
- }
63
+ // Build measurement CSS.
64
+ const css = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace);
101
65
 
102
- for (const measuredPage of measured.pages ?? []) {
103
- const pageCompiled = await compileMdx({
104
- source,
105
- filePath: contentFile.absolutePath,
106
- components,
107
- chapterSlug: chapterMeta.slug,
108
- includeBlockIds: measuredPage.blockIds,
109
- });
110
- const pageBlockSet = new Set(measuredPage.blockIds);
111
- pageJobs.push(mdxPageJob({
112
- Page,
113
- Content: pageCompiled.Content,
114
- source: {
115
- ...sourceRecord,
116
- sectionIndex: measuredPage.pageIndex + 1,
117
- },
118
- mdxBlocks: mdxBlocks.filter((block) => pageBlockSet.has(block.id)),
119
- chapterMeta,
120
- pagination: {
121
- blockIds: measuredPage.blockIds,
122
- breakAfter: measuredPage.breakAfter,
123
- },
124
- }));
125
- }
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 (hintsEqual(hints, alloc.hints)) {
96
+ allocation = alloc.allocation;
97
+ warnings = alloc.warnings;
98
+ break;
126
99
  }
100
+ hints = alloc.hints;
101
+ }
102
+ if (allocation == null) {
103
+ throw new Error(
104
+ `Allocation did not converge after ${MAX_ITERATIONS} iterations. ` +
105
+ `This usually means a chain keeps growing without fitting; check MdxArea capacities and block heights.`,
106
+ );
127
107
  }
128
108
 
129
- addShellPage(pageJobs, entry.shell.backCover, shellSource(entry.config, "back-cover"));
109
+ const toc = buildTocContext({ sources, frames: lastFrames ?? [], allocation });
130
110
 
131
- const renderedPages = renderPageJobsWithInjectedToc(pageJobs);
132
- const blocks = renderedPages.map((page, index) => {
133
- for (const block of page.mdxBlocks ?? []) {
134
- blockMap[block.id] = {
135
- ...block,
136
- pageIndex: index,
137
- pageNumber: index + 1,
138
- };
139
- }
140
- return pageToBlock(index, page.html, page.source, entry.config);
111
+ // Final render.
112
+ const final = await renderFinalPress({
113
+ Press: entry.Press,
114
+ PressContext,
115
+ sources,
116
+ hints,
117
+ toc,
118
+ allocation,
119
+ renderRegistry,
141
120
  });
142
- const chapterCss = await buildChapterScopedCss(workspace);
121
+
122
+ // Write chapter-scoped CSS (under section-scoped rules but filename
123
+ // unchanged for now).
124
+ const chapterCss = await buildSectionScopedCss(workspace);
143
125
  const styles = [];
126
+ await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
144
127
  if (chapterCss.trim()) {
145
- await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
146
128
  await fs.writeFile(path.join(entry.config.paths.publicDir, "chapter-scoped.css"), chapterCss, "utf8");
147
129
  styles.push({
148
130
  kind: "chapter-scoped-css",
@@ -151,238 +133,197 @@ export async function exportReactDocument(root = ".", { syncAssets = true, pagin
151
133
  });
152
134
  }
153
135
 
136
+ // Build document.json. The reader filters blocks by kind === "htmlPage",
137
+ // so wrap each frame through `pageToBlock` to inherit that contract.
138
+ const blockMap = {};
139
+ const captionState = createCaptionNumberingState();
140
+ const blocks = final.frames.map((frame, index) => {
141
+ for (const id of frame.blockIds) {
142
+ blockMap[id] = { id, pageIndex: index, pageNumber: index + 1 };
143
+ }
144
+ const source = {
145
+ file: "index.tsx",
146
+ path: "document/index.tsx",
147
+ kind: frame.role ?? "manuscript.content",
148
+ slug: frame.frameKey,
149
+ sectionIndex: index + 1,
150
+ };
151
+ const html = numberCaptionsInHtml(frame.html, entry.config.captionNumbering, captionState);
152
+ const block = pageToBlock(index, html, source, entry.config, {
153
+ idPrefix: "openpress-page",
154
+ anchorPrefix: "page",
155
+ titleFallback: "Page",
156
+ });
157
+ return {
158
+ ...block,
159
+ frameKey: frame.frameKey,
160
+ role: frame.role ?? null,
161
+ chrome: frame.chrome ?? true,
162
+ blockIds: frame.blockIds,
163
+ };
164
+ });
165
+
166
+ // Enrich blockMap with source records from resolved chains so comment
167
+ // tooling can resolve block IDs back to MDX positions.
168
+ const sourceBlockIndex = buildSourceBlockIndex(sources);
169
+ for (const id of Object.keys(blockMap)) {
170
+ const sourceRecord = sourceBlockIndex.get(id);
171
+ if (sourceRecord) {
172
+ blockMap[id] = {
173
+ ...blockMap[id],
174
+ kind: sourceRecord.kind,
175
+ name: sourceRecord.name,
176
+ path: sourceRecord.path,
177
+ source: sourceRecord.source,
178
+ chainId: sourceRecord.chainId,
179
+ sectionSlug: sourceRecord.sectionSlug,
180
+ };
181
+ }
182
+ }
183
+
154
184
  const readerDocument = {
155
185
  meta: {
156
186
  title: trimmedString(entry.config.title) ?? "Untitled Document",
157
187
  subtitle: trimmedString(entry.config.subtitle) ?? "",
158
188
  organization: trimmedString(entry.config.organization) ?? "",
159
189
  workspaceLabel: trimmedString(entry.config.workspaceLabel) ?? trimmedString(entry.config.title) ?? "Untitled Document",
160
- version: "openpress-react-export-v1",
190
+ version: "openpress-press-tree-v1",
161
191
  },
162
192
  source: {
163
- type: "openpress-react-mdx",
193
+ type: "openpress-press-tree-mdx",
164
194
  contentDir: documentRelativePath(entry.config, entry.config.sourceDir),
165
195
  editable: true,
166
196
  editMode: "source-mdx",
167
197
  styles,
168
198
  blockMap,
169
- ...(paginationOptions.enabled ? {
170
- pagination: {
171
- mode: "build-time-block-measurement",
172
- ...(paginationOptions.pageSafeHeightPx ? { pageSafeHeightPx: paginationOptions.pageSafeHeightPx } : {}),
173
- warnings: paginationWarnings,
174
- },
175
- } : {}),
199
+ frames: final.frames.map((frame, index) => ({
200
+ frameKey: frame.frameKey,
201
+ role: frame.role ?? null,
202
+ pageIndex: index,
203
+ mdxAreas: frame.mdxAreas.map((area) => ({
204
+ chainId: area.chainId,
205
+ indexInFrame: area.indexInFrame,
206
+ blockIds: area.blockIds,
207
+ })),
208
+ })),
209
+ chains: Object.keys(sources).flatMap((id) => Object.keys(sources[id].chains)),
210
+ warnings,
176
211
  },
177
212
  blocks,
178
213
  };
179
214
 
180
215
  const documentPath = path.join(entry.config.paths.publicDir, "document.json");
181
- await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
182
216
  await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
217
+
183
218
  if (syncAssets) {
184
219
  await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
185
220
  }
221
+
186
222
  return { documentPath, pageCount: blocks.length, document: readerDocument };
187
223
  } finally {
188
224
  await server.close();
189
225
  }
190
226
  }
191
227
 
192
- function renderPageJobsWithInjectedToc(pageJobs) {
193
- let records = renderPageJobs(pageJobs, pageJobs.length);
194
- let injectedHtml = injectStaticToc(records.map((record) => record.html));
195
- if (injectedHtml.length !== records.length) {
196
- records = renderPageJobs(pageJobs, injectedHtml.length);
197
- injectedHtml = injectStaticToc(records.map((record) => record.html));
228
+ async function loadComponentModules(server, components) {
229
+ const out = {};
230
+ for (const component of components) {
231
+ const mod = await server.ssrLoadModule(component.absolutePath);
232
+ if (typeof mod.default !== "function") {
233
+ throw new Error(
234
+ `OpenPress component module ${component.documentPath} must default-export a React component.`,
235
+ );
236
+ }
237
+ out[component.name] = mod.default;
198
238
  }
199
- return alignInjectedTocRecords(records, injectedHtml);
200
- }
201
-
202
- function renderPageJobs(pageJobs, totalPages) {
203
- return pageJobs.map((job, index) => ({
204
- html: renderToStaticMarkup(job.render(index, totalPages)),
205
- source: job.source,
206
- mdxBlocks: job.mdxBlocks ?? [],
207
- }));
239
+ return out;
208
240
  }
209
241
 
210
- function alignInjectedTocRecords(records, injectedHtml) {
211
- if (injectedHtml.length === records.length) {
212
- return records.map((record, index) => ({
213
- ...record,
214
- html: injectedHtml[index],
215
- }));
216
- }
217
-
218
- const tocIndex = records.findIndex((record) => hasReaderPageKind(record.html, "toc"));
219
- const extra = injectedHtml.length - records.length;
220
- if (tocIndex < 0 || extra < 1) {
221
- throw new Error(`React TOC injection changed page count unexpectedly: ${records.length} -> ${injectedHtml.length}`);
242
+ function validateAllChainsKnown(frames, sources) {
243
+ const known = new Set();
244
+ for (const source of Object.values(sources)) {
245
+ for (const chainId of Object.keys(source.chains)) known.add(chainId);
222
246
  }
223
-
224
- return injectedHtml.map((html, index) => {
225
- if (index < tocIndex) return { ...records[index], html };
226
- if (index <= tocIndex + extra) {
227
- return {
228
- html,
229
- source: {
230
- ...records[tocIndex].source,
231
- sectionIndex: index - tocIndex + 1,
232
- },
233
- mdxBlocks: [],
234
- };
247
+ for (const frame of frames) {
248
+ for (const area of frame.mdxAreas) {
249
+ if (!known.has(area.chainId)) {
250
+ const list = [...known].sort().slice(0, 10).join(", ");
251
+ throw new Error(
252
+ `Unknown chainId "${area.chainId}" referenced by frame "${frame.frameKey}". ` +
253
+ `Known chains: ${list || "(none)"}${known.size > 10 ? ", ..." : ""}.`,
254
+ );
255
+ }
235
256
  }
236
- const sourceRecord = records[index - extra];
237
- return {
238
- ...sourceRecord,
239
- html,
240
- };
241
- });
242
- }
243
-
244
- function hasReaderPageKind(html, kind) {
245
- const openingTag = String(html).match(/^<section[^>]*>/i)?.[0] ?? "";
246
- return openingTag.match(/\bdata-page-kind="([^"]*)"/i)?.[1] === kind;
247
- }
248
-
249
- function addShellPage(pageJobs, element, source) {
250
- if (element == null) return;
251
- pageJobs.push({
252
- source,
253
- render() {
254
- return element;
255
- },
256
- });
257
- }
258
-
259
- function mdxPageJob({ Page, Content, source, mdxBlocks, chapterMeta, pagination = null }) {
260
- return {
261
- source,
262
- mdxBlocks,
263
- pagination,
264
- render(pageIndex, totalPages) {
265
- return React.createElement(
266
- Page,
267
- {
268
- pageIndex,
269
- totalPages,
270
- chapterSlug: chapterMeta.slug,
271
- chapterTone: chapterMeta.tone,
272
- },
273
- React.createElement(Content),
274
- );
275
- },
276
- };
257
+ }
277
258
  }
278
259
 
279
- async function loadChapterModule(server, chapter) {
280
- if (!chapter.chapterEntry) return {};
281
- return server.ssrLoadModule(chapter.chapterEntry.absolutePath);
260
+ function hintsEqual(a, b) {
261
+ if (a === b) return true;
262
+ if (!a || !b) return false;
263
+ const aMap = a.totalPagesPerChain ?? {};
264
+ const bMap = b.totalPagesPerChain ?? {};
265
+ const keys = new Set([...Object.keys(aMap), ...Object.keys(bMap)]);
266
+ for (const key of keys) {
267
+ if (aMap[key] !== bMap[key]) return false;
268
+ }
269
+ return true;
282
270
  }
283
271
 
284
- async function loadComponentScope(server, componentScope) {
285
- const components = {};
286
- for (const [name, component] of Object.entries(componentScope ?? {})) {
287
- const mod = await server.ssrLoadModule(component.absolutePath);
288
- if (typeof mod.default !== "function") {
289
- throw new Error(`OpenPress React component must default-export a component: ${component.documentPath}`);
272
+ function buildSourceBlockIndex(sources) {
273
+ const index = new Map();
274
+ for (const source of Object.values(sources)) {
275
+ for (const [chainId, blocks] of Object.entries(source.chains)) {
276
+ for (const block of blocks) {
277
+ index.set(block.id, { ...block, chainId });
278
+ }
290
279
  }
291
- components[name] = mod.default;
292
280
  }
293
- return components;
294
- }
295
-
296
- function normalizeChapterMeta(chapter, meta) {
297
- const rawMeta = meta && typeof meta === "object" ? meta : {};
298
- return {
299
- slug: trimmedString(rawMeta.slug) ?? chapter.slug,
300
- title: trimmedString(rawMeta.title) ?? chapter.slug,
301
- tone: trimmedString(rawMeta.tone) ?? undefined,
302
- };
303
- }
304
-
305
- function shellSource(config, kind) {
306
- return {
307
- file: "index.tsx",
308
- path: documentRelativePath(config, "index.tsx"),
309
- kind,
310
- slug: kind,
311
- sectionIndex: 1,
312
- };
313
- }
314
-
315
- function chapterSource(config, chapter, { chapterIndex, contentFile, kind, slug, title }) {
316
- const file = contentFile?.documentPath ?? chapter.chapterEntry?.documentPath ?? chapter.documentPath;
317
- return {
318
- file: path.basename(file),
319
- path: documentRelativePath(config, file),
320
- kind,
321
- chapter: chapterIndex + 1,
322
- slug,
323
- title,
324
- sectionIndex: 1,
325
- };
281
+ return index;
326
282
  }
327
283
 
328
- function sanitizeMdxBlock(config, contentFile, block) {
329
- return {
330
- id: block.id,
331
- kind: block.kind,
332
- name: block.name,
333
- chapterSlug: block.chapterSlug,
334
- path: documentRelativePath(config, contentFile.documentPath),
335
- source: block.source,
336
- };
337
- }
338
-
339
- function enrichPaginationWarning(warning, blockLookup) {
340
- const block = blockLookup[warning.blockId];
341
- return {
342
- ...warning,
343
- ...(block ? {
344
- path: block.path,
345
- source: block.source,
346
- } : {}),
347
- };
348
- }
349
-
350
- function normalizePaginationOptions(pagination) {
351
- if (!pagination?.enabled) {
352
- return { enabled: false };
284
+ function buildTocContext({ sources, frames, allocation }) {
285
+ const toc = {};
286
+ for (const source of Object.values(sources)) {
287
+ for (const [tocChainId, tocBlocks] of Object.entries(source.chains).filter(([chainId]) => chainId.startsWith(`toc:${source.id}`))) {
288
+ if (tocBlocks.length === 0) continue;
289
+ toc[tocChainId] = tocBlocks.map((block) => ({
290
+ id: `${source.id}:${block.sectionSlug}`,
291
+ blockId: block.id,
292
+ sourceId: source.id,
293
+ sectionSlug: block.sectionSlug,
294
+ title: block.title,
295
+ href: block.href,
296
+ level: block.level,
297
+ label: block.label,
298
+ pageNumber: firstAllocatedPageNumberForBlock(frames, allocation, block.targetBlockId)
299
+ ?? firstAllocatedPageNumber(frames, allocation, `${source.id}:${block.sectionSlug}`),
300
+ }));
301
+ }
353
302
  }
354
- const pageSafeHeightPx = positiveNumber(pagination.pageSafeHeightPx, null);
355
- return {
356
- enabled: true,
357
- pageSafeHeightPx,
358
- needsMeasurementCss: typeof pagination.measureBlocks !== "function",
359
- measureBlocks: pagination.measureBlocks ?? ((input) => measureBlocksInChromium({
360
- html: input.html,
361
- css: input.css,
362
- pageSafeHeightPx,
363
- })),
364
- };
303
+ return toc;
365
304
  }
366
305
 
367
- function positiveNumber(value, fallback) {
368
- const number = Number(value);
369
- return Number.isFinite(number) && number > 0 ? number : fallback;
306
+ function firstAllocatedPageNumberForBlock(frames, allocation, blockId) {
307
+ if (!blockId) return undefined;
308
+ for (let index = 0; index < frames.length; index += 1) {
309
+ const frameAllocation = allocation?.[frames[index].frameKey] ?? {};
310
+ for (const areaArr of Object.values(frameAllocation)) {
311
+ if (areaArr?.some((area) => Array.isArray(area) && area.includes(blockId))) return index + 1;
312
+ }
313
+ }
314
+ return undefined;
370
315
  }
371
316
 
372
- function DefaultContentPage({ pageIndex, totalPages, chapterSlug, chapterTone, children }) {
373
- return React.createElement(
374
- "section",
375
- {
376
- className: "reader-page reader-page--content",
377
- "data-page-footer": "true",
378
- "data-page-kind": "content",
379
- "data-page-index": pageIndex,
380
- "data-total-pages": totalPages,
381
- "data-chapter-slug": chapterSlug,
382
- "data-chapter-tone": chapterTone,
383
- },
384
- children,
385
- );
317
+ function firstAllocatedPageNumber(frames, allocation, chainId) {
318
+ for (let index = 0; index < frames.length; index += 1) {
319
+ const frame = frames[index];
320
+ const allocated = allocation?.[frame.frameKey]?.[chainId];
321
+ if (allocated?.some((area) => Array.isArray(area) && area.length > 0)) return index + 1;
322
+ }
323
+ for (let index = 0; index < frames.length; index += 1) {
324
+ if (frames[index].mdxAreas.some((area) => area.chainId === chainId)) return index + 1;
325
+ }
326
+ return undefined;
386
327
  }
387
328
 
388
329
  function trimmedString(value) {