@open-press/core 0.6.0 → 0.7.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 (71) 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/upgrade.mjs +47 -5
  9. package/engine/commands/validate.mjs +2 -2
  10. package/engine/document-export.mjs +1 -1
  11. package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
  12. package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
  13. package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
  14. package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
  15. package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
  16. package/engine/react/caption-numbering.mjs +73 -0
  17. package/engine/react/comment-marker.mjs +54 -10
  18. package/engine/react/document-entry.mjs +124 -64
  19. package/engine/react/document-export.mjs +266 -310
  20. package/engine/react/mdx-compile.mjs +214 -3
  21. package/engine/react/measurement-css.mjs +3 -3
  22. package/engine/react/pagination/allocator.mjs +122 -0
  23. package/engine/react/pagination/regions.mjs +81 -0
  24. package/engine/react/pagination.mjs +9 -121
  25. package/engine/react/pipeline/allocate.mjs +248 -0
  26. package/engine/react/pipeline/final-render.mjs +94 -0
  27. package/engine/react/pipeline/frame-measurement.mjs +300 -0
  28. package/engine/react/pipeline/press-tree.mjs +135 -0
  29. package/engine/react/project-asset-endpoint.mjs +2 -2
  30. package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
  31. package/engine/react/sources/heading-numbering.mjs +132 -0
  32. package/engine/react/sources/mdx-resolver.mjs +441 -0
  33. package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
  34. package/engine/{config.mjs → runtime/config.mjs} +15 -0
  35. package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
  36. package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
  37. package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
  38. package/engine/runtime/source-workspace.mjs +186 -0
  39. package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
  40. package/package.json +5 -2
  41. package/src/openpress/anchorMap.ts +27 -0
  42. package/src/openpress/core/Frame.tsx +80 -0
  43. package/src/openpress/core/FrameContext.tsx +19 -0
  44. package/src/openpress/core/MdxArea.tsx +35 -0
  45. package/src/openpress/core/Press.tsx +34 -0
  46. package/src/openpress/core/index.tsx +34 -15
  47. package/src/openpress/core/primitives.tsx +23 -0
  48. package/src/openpress/core/types.ts +131 -19
  49. package/src/openpress/core/useSource.ts +28 -0
  50. package/src/openpress/manuscript/index.tsx +196 -0
  51. package/src/openpress/mdx/index.ts +88 -0
  52. package/src/openpress/numbering/index.ts +294 -0
  53. package/src/openpress/publicPage.tsx +4 -186
  54. package/src/openpress/reactDocumentMetadata.ts +2 -16
  55. package/src/openpress/types.ts +0 -16
  56. package/src/openpress/workbench.tsx +2 -36
  57. package/src/styles/openpress/responsive.css +0 -14
  58. package/tsconfig.json +4 -1
  59. package/vite.config.ts +10 -3
  60. package/engine/commands/migrate-to-react.mjs +0 -27
  61. package/engine/page-renderer.mjs +0 -217
  62. package/engine/react/migrate-to-react.mjs +0 -355
  63. package/engine/source-workspace.mjs +0 -76
  64. package/src/openpress/core/basePages.tsx +0 -87
  65. package/src/openpress/pagination.ts +0 -845
  66. /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
  67. /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
  68. /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
  69. /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
  70. /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
  71. /package/engine/{issue-report.mjs → runtime/issue-report.mjs} +0 -0
@@ -1,148 +1,145 @@
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
- }));
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`);
125
108
  }
126
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
+ );
127
122
  }
128
123
 
129
- addShellPage(pageJobs, entry.shell.backCover, shellSource(entry.config, "back-cover"));
124
+ const toc = buildTocContext({ sources, frames: lastFrames ?? [], allocation });
130
125
 
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);
126
+ // Final render.
127
+ const final = await renderFinalPress({
128
+ Press: entry.Press,
129
+ PressContext,
130
+ sources,
131
+ hints,
132
+ toc,
133
+ allocation,
134
+ renderRegistry,
141
135
  });
142
- const chapterCss = await buildChapterScopedCss(workspace);
136
+
137
+ // Write chapter-scoped CSS (under section-scoped rules but filename
138
+ // unchanged for now).
139
+ const chapterCss = await buildSectionScopedCss(workspace);
143
140
  const styles = [];
141
+ await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
144
142
  if (chapterCss.trim()) {
145
- await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
146
143
  await fs.writeFile(path.join(entry.config.paths.publicDir, "chapter-scoped.css"), chapterCss, "utf8");
147
144
  styles.push({
148
145
  kind: "chapter-scoped-css",
@@ -151,238 +148,197 @@ export async function exportReactDocument(root = ".", { syncAssets = true, pagin
151
148
  });
152
149
  }
153
150
 
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",
171
+ });
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
+ }
197
+ }
198
+
154
199
  const readerDocument = {
155
200
  meta: {
156
201
  title: trimmedString(entry.config.title) ?? "Untitled Document",
157
202
  subtitle: trimmedString(entry.config.subtitle) ?? "",
158
203
  organization: trimmedString(entry.config.organization) ?? "",
159
204
  workspaceLabel: trimmedString(entry.config.workspaceLabel) ?? trimmedString(entry.config.title) ?? "Untitled Document",
160
- version: "openpress-react-export-v1",
205
+ version: "openpress-press-tree-v1",
161
206
  },
162
207
  source: {
163
- type: "openpress-react-mdx",
208
+ type: "openpress-press-tree-mdx",
164
209
  contentDir: documentRelativePath(entry.config, entry.config.sourceDir),
165
210
  editable: true,
166
211
  editMode: "source-mdx",
167
212
  styles,
168
213
  blockMap,
169
- ...(paginationOptions.enabled ? {
170
- pagination: {
171
- mode: "build-time-block-measurement",
172
- ...(paginationOptions.pageSafeHeightPx ? { pageSafeHeightPx: paginationOptions.pageSafeHeightPx } : {}),
173
- warnings: paginationWarnings,
174
- },
175
- } : {}),
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,
176
226
  },
177
227
  blocks,
178
228
  };
179
229
 
180
230
  const documentPath = path.join(entry.config.paths.publicDir, "document.json");
181
- await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
182
231
  await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
232
+
183
233
  if (syncAssets) {
184
234
  await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
185
235
  }
236
+
186
237
  return { documentPath, pageCount: blocks.length, document: readerDocument };
187
238
  } finally {
188
239
  await server.close();
189
240
  }
190
241
  }
191
242
 
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));
243
+ async function loadComponentModules(server, components) {
244
+ const out = {};
245
+ for (const component of components) {
246
+ const mod = await server.ssrLoadModule(component.absolutePath);
247
+ if (typeof mod.default !== "function") {
248
+ throw new Error(
249
+ `OpenPress component module ${component.documentPath} must default-export a React component.`,
250
+ );
251
+ }
252
+ out[component.name] = mod.default;
198
253
  }
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
- }));
254
+ return out;
208
255
  }
209
256
 
210
- function alignInjectedTocRecords(records, injectedHtml) {
211
- if (injectedHtml.length === records.length) {
212
- return records.map((record, index) => ({
213
- ...record,
214
- html: injectedHtml[index],
215
- }));
257
+ function validateAllChainsKnown(frames, sources) {
258
+ const known = new Set();
259
+ for (const source of Object.values(sources)) {
260
+ for (const chainId of Object.keys(source.chains)) known.add(chainId);
216
261
  }
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}`);
222
- }
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
- };
262
+ for (const frame of frames) {
263
+ for (const area of frame.mdxAreas) {
264
+ if (!known.has(area.chainId)) {
265
+ const list = [...known].sort().slice(0, 10).join(", ");
266
+ throw new Error(
267
+ `Unknown chainId "${area.chainId}" referenced by frame "${frame.frameKey}". ` +
268
+ `Known chains: ${list || "(none)"}${known.size > 10 ? ", ..." : ""}.`,
269
+ );
270
+ }
235
271
  }
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
- };
272
+ }
277
273
  }
278
274
 
279
- async function loadChapterModule(server, chapter) {
280
- if (!chapter.chapterEntry) return {};
281
- return server.ssrLoadModule(chapter.chapterEntry.absolutePath);
275
+ function hintsEqual(a, b) {
276
+ if (a === b) return true;
277
+ if (!a || !b) return false;
278
+ const aMap = a.totalPagesPerChain ?? {};
279
+ const bMap = b.totalPagesPerChain ?? {};
280
+ const keys = new Set([...Object.keys(aMap), ...Object.keys(bMap)]);
281
+ for (const key of keys) {
282
+ if (aMap[key] !== bMap[key]) return false;
283
+ }
284
+ return true;
282
285
  }
283
286
 
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}`);
287
+ function buildSourceBlockIndex(sources) {
288
+ const index = new Map();
289
+ for (const source of Object.values(sources)) {
290
+ for (const [chainId, blocks] of Object.entries(source.chains)) {
291
+ for (const block of blocks) {
292
+ index.set(block.id, { ...block, chainId });
293
+ }
290
294
  }
291
- components[name] = mod.default;
292
295
  }
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
- };
296
+ return index;
326
297
  }
327
298
 
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 };
299
+ function buildTocContext({ sources, frames, allocation }) {
300
+ const toc = {};
301
+ for (const source of Object.values(sources)) {
302
+ for (const [tocChainId, tocBlocks] of Object.entries(source.chains).filter(([chainId]) => chainId.startsWith(`toc:${source.id}`))) {
303
+ if (tocBlocks.length === 0) continue;
304
+ toc[tocChainId] = tocBlocks.map((block) => ({
305
+ id: `${source.id}:${block.sectionSlug}`,
306
+ blockId: block.id,
307
+ sourceId: source.id,
308
+ sectionSlug: block.sectionSlug,
309
+ title: block.title,
310
+ href: block.href,
311
+ level: block.level,
312
+ label: block.label,
313
+ pageNumber: firstAllocatedPageNumberForBlock(frames, allocation, block.targetBlockId)
314
+ ?? firstAllocatedPageNumber(frames, allocation, `${source.id}:${block.sectionSlug}`),
315
+ }));
316
+ }
353
317
  }
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
- };
318
+ return toc;
365
319
  }
366
320
 
367
- function positiveNumber(value, fallback) {
368
- const number = Number(value);
369
- return Number.isFinite(number) && number > 0 ? number : fallback;
321
+ function firstAllocatedPageNumberForBlock(frames, allocation, blockId) {
322
+ if (!blockId) return undefined;
323
+ for (let index = 0; index < frames.length; index += 1) {
324
+ const frameAllocation = allocation?.[frames[index].frameKey] ?? {};
325
+ for (const areaArr of Object.values(frameAllocation)) {
326
+ if (areaArr?.some((area) => Array.isArray(area) && area.includes(blockId))) return index + 1;
327
+ }
328
+ }
329
+ return undefined;
370
330
  }
371
331
 
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
- );
332
+ function firstAllocatedPageNumber(frames, allocation, chainId) {
333
+ for (let index = 0; index < frames.length; index += 1) {
334
+ const frame = frames[index];
335
+ const allocated = allocation?.[frame.frameKey]?.[chainId];
336
+ if (allocated?.some((area) => Array.isArray(area) && area.length > 0)) return index + 1;
337
+ }
338
+ for (let index = 0; index < frames.length; index += 1) {
339
+ if (frames[index].mdxAreas.some((area) => area.chainId === chainId)) return index + 1;
340
+ }
341
+ return undefined;
386
342
  }
387
343
 
388
344
  function trimmedString(value) {