@open-press/core 0.8.0 → 1.1.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 (77) hide show
  1. package/README.md +17 -5
  2. package/engine/cli.mjs +9 -9
  3. package/engine/commands/_shared.mjs +70 -18
  4. package/engine/commands/deploy.mjs +3 -3
  5. package/engine/commands/dev.mjs +13 -4
  6. package/engine/commands/image.mjs +29 -0
  7. package/engine/commands/inspect.mjs +3 -2
  8. package/engine/commands/pdf.mjs +2 -2
  9. package/engine/commands/preview.mjs +2 -2
  10. package/engine/commands/render.mjs +6 -4
  11. package/engine/commands/replace.mjs +1 -1
  12. package/engine/commands/search.mjs +1 -1
  13. package/engine/commands/skills-sync.mjs +71 -0
  14. package/engine/commands/typecheck.mjs +71 -1
  15. package/engine/commands/upgrade.mjs +3 -3
  16. package/engine/document-export.mjs +1 -1
  17. package/engine/output/chrome-pdf.mjs +92 -0
  18. package/engine/output/static-server.mjs +60 -17
  19. package/engine/react/comment-marker.mjs +13 -13
  20. package/engine/react/document-entry.mjs +35 -28
  21. package/engine/react/document-export.mjs +309 -170
  22. package/engine/react/mdx-compile.mjs +30 -0
  23. package/engine/react/measurement-css.mjs +21 -0
  24. package/engine/react/object-entities.mjs +85 -0
  25. package/engine/react/pagination/allocator.mjs +48 -3
  26. package/engine/react/pagination.mjs +1 -1
  27. package/engine/react/pipeline/allocate.mjs +31 -65
  28. package/engine/react/pipeline/frame-measurement.mjs +4 -0
  29. package/engine/react/press-tree-inspection.mjs +172 -0
  30. package/engine/react/sources/mdx-resolver.mjs +1 -1
  31. package/engine/react/style-discovery.mjs +22 -4
  32. package/engine/runtime/config.d.mts +8 -0
  33. package/engine/runtime/config.mjs +57 -60
  34. package/engine/runtime/file-utils.mjs +9 -1
  35. package/engine/runtime/page-geometry.mjs +131 -0
  36. package/engine/runtime/source-text-tools.mjs +1 -1
  37. package/engine/runtime/source-workspace.mjs +12 -3
  38. package/engine/runtime/validation.mjs +19 -10
  39. package/index.html +4 -0
  40. package/package.json +9 -12
  41. package/src/main.tsx +16 -0
  42. package/src/openpress/app/OpenPressApp.tsx +173 -17
  43. package/src/openpress/app/OpenPressRuntime.tsx +10 -2
  44. package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  45. package/src/openpress/core/Frame.tsx +20 -7
  46. package/src/openpress/core/FrameContext.tsx +2 -0
  47. package/src/openpress/core/Press.tsx +25 -4
  48. package/src/openpress/core/Workspace.tsx +36 -0
  49. package/src/openpress/core/index.tsx +10 -3
  50. package/src/openpress/core/primitives.tsx +48 -1
  51. package/src/openpress/core/types.ts +86 -41
  52. package/src/openpress/core/useSource.ts +1 -1
  53. package/src/openpress/document-model/documentTypes.ts +9 -0
  54. package/src/openpress/document-model/index.ts +1 -0
  55. package/src/openpress/document-model/objectEntityModel.ts +4 -0
  56. package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  57. package/src/openpress/mdx/index.ts +15 -7
  58. package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  59. package/src/openpress/reader/index.ts +1 -0
  60. package/src/openpress/workbench/Workbench.tsx +120 -21
  61. package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  62. package/src/openpress/workbench/actions/SearchControl.tsx +3 -3
  63. package/src/openpress/workbench/actions/index.ts +1 -0
  64. package/src/openpress/workbench/inspector/useInspectorComments.ts +7 -1
  65. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +5 -1
  66. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +4 -2
  67. package/src/openpress/workbench/project/projectSourceModel.ts +2 -2
  68. package/src/openpress/workbench/workbenchFormatters.ts +2 -2
  69. package/src/styles/openpress/reader-runtime.css +9 -0
  70. package/src/styles/openpress/workbench-panels.css +113 -0
  71. package/src/styles/openpress/workspace-gallery.css +300 -0
  72. package/src/styles/openpress.css +1 -5
  73. package/src/vite-env.d.ts +8 -0
  74. package/tsconfig.json +1 -1
  75. package/vite.config.ts +6 -6
  76. package/engine/commands/init.mjs +0 -24
  77. package/engine/init.mjs +0 -90
@@ -6,8 +6,11 @@
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";
@@ -50,7 +53,11 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
50
53
  }
51
54
 
52
55
  // Discover workspace for component scope and chapter-scoped style files.
53
- const workspace = await discoverSectionStyles(workspaceRoot, entry.config);
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 });
54
61
  const coreAuthorComponents = {};
55
62
  for (const name of ["MediaFigure", "ImageFigure"]) {
56
63
  if (typeof coreModule[name] === "function") coreAuthorComponents[name] = coreModule[name];
@@ -60,199 +67,309 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
60
67
  ...(await loadComponentModules(server, workspace.globalComponents ?? [])),
61
68
  };
62
69
 
63
- // Resolve sources.
64
- const documentRoot = entry.config.paths.documentRoot;
65
- const { resolved: sources, renderData: renderRegistry } = await resolveAllSources({
66
- sources: entry.sources,
67
- documentRoot,
68
- globalComponents,
69
- });
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);
70
73
 
71
- // Build measurement CSS.
72
- const css = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace);
73
-
74
- // Iterative allocation loop.
75
- let hints = null;
76
- let allocation = null;
77
- let lastFrames = null;
78
- let warnings = [];
79
- for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
80
- const { html, frames } = expandPressTree({
81
- Press: entry.Press,
82
- PressContext,
83
- sources,
84
- hints,
85
- });
86
- lastFrames = frames;
87
- validateAllChainsKnown(frames, sources);
88
- const measurement = await measureFrames({
89
- pressHtml: html,
90
- sources,
91
- renderRegistry,
92
- css,
93
- baseHref: pathToFileURL(`${documentRoot}${path.sep}`).href,
94
- mediaDir: path.join(documentRoot, "media"),
95
- captionNumbering: entry.config.captionNumbering,
74
+ // Write chapter-scoped CSS once (workspace shared). Every per-press
75
+ // readerDocument references the same file via "/openpress/chapter-scoped.css".
76
+ const chapterCss = await buildSectionScopedCss(workspace);
77
+ const sharedStyles = [];
78
+ await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
79
+ if (chapterCss.trim()) {
80
+ await fs.writeFile(path.join(entry.config.paths.publicDir, "chapter-scoped.css"), chapterCss, "utf8");
81
+ sharedStyles.push({
82
+ kind: "chapter-scoped-css",
83
+ href: "/openpress/chapter-scoped.css",
84
+ path: "chapter-scoped.css",
96
85
  });
97
- const alloc = allocateChains({
98
- frames,
99
- mdxAreas: measurement.mdxAreas,
100
- blockHeights: measurement.blockHeights,
101
- sources,
86
+ }
87
+
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,
102
103
  });
103
- if (process.env.OPENPRESS_DEBUG_ALLOC) {
104
- const sample = measurement.mdxAreas
105
- .slice(0, 5)
106
- .map((a) => `${a.frameKey}#${a.indexInFrame} cap=${a.capacity.toFixed(0)} raw=${(a.rawHeight ?? 0).toFixed(0)}`);
107
- const blocks = measurement.blockHeights
108
- .slice(0, 8)
109
- .map((b) => `${b.id} h=${b.height.toFixed(0)}`);
110
- process.stderr.write(`[allocator iter ${iteration}]\n`);
111
- process.stderr.write(` mdxAreas[0..4]: ${sample.join(" | ")}\n`);
112
- process.stderr.write(` blocks[0..7]: ${blocks.join(" | ")}\n`);
113
- process.stderr.write(` hints: ${JSON.stringify(alloc.hints.totalPagesPerChain)}\n`);
114
- if (alloc.warnings.length > 0) {
115
- process.stderr.write(` warnings: ${JSON.stringify(alloc.warnings)}\n`);
116
- }
117
- }
118
- if (hintsEqual(hints, alloc.hints)) {
119
- allocation = alloc.allocation;
120
- warnings = alloc.warnings;
121
- break;
122
- }
123
- hints = alloc.hints;
104
+ pressResults.push(result);
124
105
  }
125
- if (allocation == null) {
126
- throw new Error(
127
- `Allocation did not converge after ${MAX_ITERATIONS} iterations. ` +
128
- `This usually means a chain keeps growing without fitting; check MdxArea capacities and block heights.`,
129
- );
106
+
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
+ })),
122
+ };
123
+ const workspacePath = path.join(entry.config.paths.publicDir, "workspace.json");
124
+ await fs.writeFile(workspacePath, JSON.stringify(workspaceManifest, null, 2), "utf8");
125
+
126
+ if (syncAssets) {
127
+ await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
130
128
  }
131
129
 
132
- const toc = buildTocContext({ sources, frames: lastFrames ?? [], allocation });
130
+ const primary = pressResults[0];
131
+ return {
132
+ documentPath: primary?.documentPath,
133
+ pageCount: primary?.pageCount ?? 0,
134
+ document: primary?.readerDocument,
135
+ presses: pressResults,
136
+ };
137
+ } finally {
138
+ await server.close();
139
+ }
140
+ }
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;
133
184
 
134
- // Final render.
135
- const final = await renderFinalPress({
136
- Press: entry.Press,
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,
137
194
  PressContext,
138
195
  sources,
139
196
  hints,
140
- toc,
141
- allocation,
197
+ });
198
+ lastFrames = frames;
199
+ validateAllChainsKnown(frames, sources);
200
+ const measurement = await measureFrames({
201
+ pressHtml: html,
202
+ sources,
142
203
  renderRegistry,
204
+ css: measurementCss,
205
+ baseHref: pathToFileURL(`${documentRoot}${path.sep}`).href,
206
+ mediaDir: path.join(documentRoot, "media"),
207
+ captionNumbering: effectiveConfig.captionNumbering,
143
208
  });
144
-
145
- // Write chapter-scoped CSS (under section-scoped rules but filename
146
- // unchanged for now).
147
- const chapterCss = await buildSectionScopedCss(workspace);
148
- const styles = [];
149
- await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
150
- if (chapterCss.trim()) {
151
- await fs.writeFile(path.join(entry.config.paths.publicDir, "chapter-scoped.css"), chapterCss, "utf8");
152
- styles.push({
153
- kind: "chapter-scoped-css",
154
- href: "/openpress/chapter-scoped.css",
155
- path: "chapter-scoped.css",
156
- });
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;
157
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
+ }
158
243
 
159
- // Build document.json. The reader filters blocks by kind === "htmlPage",
160
- // so wrap each frame through `pageToBlock` to inherit that contract.
161
- const blockMap = {};
162
- const captionState = createCaptionNumberingState();
163
- const blocks = final.frames.map((frame, index) => {
164
- const source = {
165
- file: "index.tsx",
166
- path: "document/index.tsx",
167
- kind: frame.role ?? "manuscript.content",
168
- slug: frame.frameKey,
169
- sectionIndex: index + 1,
170
- };
171
- const html = numberCaptionsInHtml(frame.html, entry.config.captionNumbering, captionState);
172
- for (const id of collectFrameBlockIds(frame.blockIds, html)) {
173
- blockMap[id] = { id, pageIndex: index, pageNumber: index + 1, frameKey: frame.frameKey };
174
- }
175
- const block = pageToBlock(index, html, source, entry.config, {
176
- idPrefix: "openpress-page",
177
- anchorPrefix: "page",
178
- titleFallback: "Page",
179
- });
180
- return {
181
- ...block,
182
- frameKey: frame.frameKey,
183
- role: frame.role ?? null,
184
- chrome: frame.chrome ?? true,
185
- blockIds: frame.blockIds,
186
- };
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",
187
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
+ });
188
285
 
189
- // Enrich blockMap with source records from resolved chains so comment
190
- // tooling can resolve block IDs back to MDX positions.
191
- const sourceBlockIndex = buildSourceBlockIndex(sources);
192
- for (const id of Object.keys(blockMap)) {
193
- const sourceRecord = sourceBlockIndex.get(id);
194
- if (sourceRecord) {
195
- blockMap[id] = {
196
- ...blockMap[id],
197
- kind: sourceRecord.kind,
198
- name: sourceRecord.name,
199
- path: sourceRecord.path,
200
- source: sourceRecord.source,
201
- chainId: sourceRecord.chainId,
202
- sectionSlug: sourceRecord.sectionSlug,
203
- };
204
- }
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
+ };
205
299
  }
300
+ }
206
301
 
207
- const objectEntities = buildObjectEntities({
208
- frames: final.frames.map((frame, index) => ({ ...frame, pageIndex: index })),
209
- blocks,
210
- blockMap,
211
- });
302
+ const objectEntities = buildObjectEntities({
303
+ frames: final.frames.map((frame, index) => ({ ...frame, pageIndex: index })),
304
+ blocks,
305
+ blockMap,
306
+ });
212
307
 
213
- const readerDocument = {
214
- meta: {
215
- title: trimmedString(entry.config.title) ?? "Untitled Document",
216
- subtitle: trimmedString(entry.config.subtitle) ?? "",
217
- organization: trimmedString(entry.config.organization) ?? "",
218
- workspaceLabel: trimmedString(entry.config.workspaceLabel) ?? "",
219
- version: "openpress-press-tree-v1",
220
- },
221
- source: {
222
- type: "openpress-press-tree-mdx",
223
- contentDir: documentRelativePath(entry.config, entry.config.sourceDir),
224
- editable: true,
225
- editMode: "source-mdx",
226
- styles,
227
- blockMap,
228
- objectEntities,
229
- frames: final.frames.map((frame, index) => ({
230
- frameKey: frame.frameKey,
231
- role: frame.role ?? null,
232
- pageIndex: index,
233
- mdxAreas: frame.mdxAreas.map((area) => ({
234
- chainId: area.chainId,
235
- indexInFrame: area.indexInFrame,
236
- blockIds: area.blockIds,
237
- })),
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,
238
333
  })),
239
- chains: Object.keys(sources).flatMap((id) => Object.keys(sources[id].chains)),
240
- warnings,
241
- },
242
- blocks,
243
- };
334
+ })),
335
+ chains: Object.keys(sources).flatMap((id) => Object.keys(sources[id].chains)),
336
+ warnings,
337
+ },
338
+ blocks,
339
+ };
244
340
 
245
- const documentPath = path.join(entry.config.paths.publicDir, "document.json");
246
- await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
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");
247
349
 
248
- if (syncAssets) {
249
- await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
250
- }
350
+ return {
351
+ slug,
352
+ documentPath,
353
+ documentUrl: slug ? `/openpress/${slug}/document.json` : "/openpress/document.json",
354
+ readerDocument,
355
+ pageCount: blocks.length,
356
+ };
357
+ }
251
358
 
252
- return { documentPath, pageCount: blocks.length, document: readerDocument };
253
- } finally {
254
- await server.close();
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);
255
368
  }
369
+ if (pressMetadata.captionNumbering !== undefined) {
370
+ out.captionNumbering = { ...workspaceConfig.captionNumbering, ...pressMetadata.captionNumbering };
371
+ }
372
+ return out;
256
373
  }
257
374
 
258
375
  async function loadComponentModules(server, components) {
@@ -371,3 +488,25 @@ function trimmedString(value) {
371
488
  const trimmed = value.trim();
372
489
  return trimmed ? trimmed : null;
373
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
+
@@ -183,6 +183,7 @@ function applyTableRowBlocks({
183
183
  setDataAttribute(headerRecord.node, "data-openpress-block-id", headerRecord.id);
184
184
  setDataAttribute(headerRecord.node, "data-openpress-object-id", createBlockObjectEntityId(headerRecord.id));
185
185
  setDataAttribute(headerRecord.node, "data-openpress-block-layout", "attached");
186
+ annotateTableCells(headerRecord.node, headerRecord.id);
186
187
  }
187
188
  if (captionRecord) {
188
189
  if (renderCaption) {
@@ -226,6 +227,13 @@ function applyTableRowBlocks({
226
227
  for (const row of selected) {
227
228
  setDataAttribute(row.node, "data-openpress-block-id", row.id);
228
229
  setDataAttribute(row.node, "data-openpress-object-id", createBlockObjectEntityId(row.id));
230
+ // Bake cell-level object ids into every <td>/<th>. The inspector resolves
231
+ // a clicked target via `closest("[data-openpress-object-id]")` — without
232
+ // this, a click inside a cell would walk up to the row and a comment
233
+ // would target the entire row. With the cell-precision id present in the
234
+ // static HTML the inspector targets the individual cell, matching the
235
+ // engine's per-cell source-edit pipeline (`cellIndex`).
236
+ annotateTableCells(row.node, row.id);
229
237
  blocks.push({
230
238
  id: row.id,
231
239
  kind: "table-row",
@@ -241,6 +249,28 @@ function applyTableRowBlocks({
241
249
  return "skip";
242
250
  }
243
251
 
252
+ function annotateTableCells(rowNode, rowBlockId) {
253
+ const children = Array.isArray(rowNode?.children) ? rowNode.children : [];
254
+ let cellIndex = 0;
255
+ for (const child of children) {
256
+ if (child?.type !== "element") continue;
257
+ if (child.tagName !== "td" && child.tagName !== "th") continue;
258
+ // Inherit the row's block id so `findObjectSelection` can resolve the
259
+ // cell's underlying SourceBlock (which lives on the row). The
260
+ // cell-precision `data-openpress-object-id` + cellIndex still let the
261
+ // inspector / source-edit pipeline target a single cell within that row.
262
+ // `data-openpress-inherited-block-id="true"` keeps the same convention
263
+ // the inline editor uses for caption / cell descendants, so block
264
+ // measurement (which queries `[data-openpress-block-id]`) can skip
265
+ // these and not double-count the row's height across N cells.
266
+ setDataAttribute(child, "data-openpress-block-id", rowBlockId);
267
+ setDataAttribute(child, "data-openpress-inherited-block-id", "true");
268
+ setDataAttribute(child, "data-openpress-object-id", `${createBlockObjectEntityId(rowBlockId)}:cell:${cellIndex}`);
269
+ setDataAttribute(child, "data-openpress-table-cell-index", String(cellIndex));
270
+ cellIndex += 1;
271
+ }
272
+ }
273
+
244
274
  export function remarkBlockOnlyMdx(options = {}) {
245
275
  const filePath = String(options.filePath ?? "document.mdx");
246
276
 
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { buildComponentsCss, buildContentCss } from "../runtime/file-utils.mjs";
6
+ import { pageGeometryToTheme } from "../runtime/page-geometry.mjs";
6
7
  import { buildSectionScopedCss } from "./section-css.mjs";
7
8
 
8
9
  const require = createRequire(import.meta.url);
@@ -11,6 +12,7 @@ export async function buildReactMeasurementCss(root, config, workspace) {
11
12
  const parts = [];
12
13
  await appendOptionalFile(parts, path.join(config.paths.themeDir, "fonts.css"), "theme/fonts.css");
13
14
  await appendOptionalFile(parts, path.join(config.paths.themeDir, "tokens.css"), "theme/tokens.css");
15
+ appendPageGeometryCss(parts, config.page);
14
16
  parts.push("/* === public/openpress/content.css === */\n");
15
17
  parts.push(await buildContentCss(root, config));
16
18
  parts.push("\n/* === public/openpress/components.css === */\n");
@@ -23,6 +25,25 @@ export async function buildReactMeasurementCss(root, config, workspace) {
23
25
  return rewriteAssetUrls(stripViewportMediaQueries(parts.join("\n")), config);
24
26
  }
25
27
 
28
+ function appendPageGeometryCss(parts, page) {
29
+ const theme = pageGeometryToTheme(page);
30
+ if (!theme) return;
31
+
32
+ const declarations = [
33
+ ["--openpress-page-width", theme.pageWidth],
34
+ ["--openpress-page-height", theme.pageHeight],
35
+ ["--openpress-page-aspect-ratio", theme.pageAspectRatio],
36
+ ["--openpress-page-height-ratio", theme.pageHeightRatio],
37
+ ].filter(([, value]) => value);
38
+
39
+ parts.push("/* === openpress page geometry === */\n");
40
+ parts.push(":root {\n");
41
+ for (const [name, value] of declarations) {
42
+ parts.push(` ${name}: ${value};\n`);
43
+ }
44
+ parts.push("}\n\n");
45
+ }
46
+
26
47
  async function appendOptionalFile(parts, filePath, label) {
27
48
  try {
28
49
  const css = await fs.readFile(filePath, "utf8");