@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
@@ -14,6 +14,10 @@ export function createFrameObjectEntityId(frameKey) {
14
14
  return createObjectEntityId("frame", frameKey);
15
15
  }
16
16
 
17
+ export function createScopedObjectEntityId(kind, parentId, objectId) {
18
+ return parentId ? createObjectEntityId(kind, parentId, objectId) : createObjectEntityId(kind, objectId);
19
+ }
20
+
17
21
  export function createMdxAreaObjectEntityId(frameKey, chainId, indexInFrame) {
18
22
  return createObjectEntityId("mdx-area", frameKey, chainId, indexInFrame);
19
23
  }
@@ -115,5 +119,86 @@ export function buildObjectEntities({ frames, blocks, blockMap }) {
115
119
  };
116
120
  }
117
121
 
122
+ for (const entity of collectRenderedObjectEntities(frames)) {
123
+ if (!entity.id || entities[entity.id]) continue;
124
+ const pageId = entity.pageId || createPageObjectEntityId(entity.frameKey);
125
+ const frameId = createFrameObjectEntityId(entity.frameKey);
126
+ entities[entity.id] = {
127
+ id: entity.id,
128
+ kind: entity.kind,
129
+ label: entity.label || entity.id,
130
+ parentId: entity.parentId || (entity.id === frameId ? pageId : frameId),
131
+ pageId,
132
+ blockId: entity.blockId,
133
+ frameKey: entity.frameKey,
134
+ chainId: entity.chainId,
135
+ source: entity.source,
136
+ metadata: entity.metadata,
137
+ };
138
+ }
139
+
118
140
  return entities;
119
141
  }
142
+
143
+ const OBJECT_OPEN_RE = /<([a-z][a-z0-9-]*)\b([^>]*)\bdata-openpress-object-id="([^"]+)"([^>]*)>/gi;
144
+ const ATTR_RE = (name) => new RegExp(`\\b${name}="([^"]*)"`);
145
+
146
+ function collectRenderedObjectEntities(frames) {
147
+ const entities = [];
148
+ for (const frame of frames) {
149
+ const pageId = createPageObjectEntityId(frame.frameKey);
150
+ const frameId = createFrameObjectEntityId(frame.frameKey);
151
+ const html = String(frame.html ?? "");
152
+ let match;
153
+ OBJECT_OPEN_RE.lastIndex = 0;
154
+ while ((match = OBJECT_OPEN_RE.exec(html)) !== null) {
155
+ const attrs = `${match[2] ?? ""} data-openpress-object-id="${match[3] ?? ""}" ${match[4] ?? ""}`;
156
+ const id = htmlDecode(match[3] ?? "");
157
+ const kind = htmlDecode(pickAttr(attrs, "data-openpress-object-kind")) || objectKindFromId(id);
158
+ if (!id || !kind) continue;
159
+ entities.push({
160
+ id,
161
+ kind,
162
+ label: htmlDecode(pickAttr(attrs, "data-openpress-object-label")) || id,
163
+ parentId: htmlDecode(pickAttr(attrs, "data-openpress-object-parent-id")) || (id === frameId ? pageId : frameId),
164
+ pageId: htmlDecode(pickAttr(attrs, "data-openpress-object-page-id")) || pageId,
165
+ blockId: htmlDecode(pickAttr(attrs, "data-openpress-block-id")) || undefined,
166
+ frameKey: htmlDecode(pickAttr(attrs, "data-openpress-object-frame-key")) || frame.frameKey,
167
+ chainId: htmlDecode(pickAttr(attrs, "data-openpress-object-chain-id")) || undefined,
168
+ source: parseJsonAttribute(pickAttr(attrs, "data-openpress-object-source")),
169
+ metadata: parseJsonAttribute(pickAttr(attrs, "data-openpress-object-metadata")),
170
+ });
171
+ }
172
+ }
173
+ return entities;
174
+ }
175
+
176
+ function pickAttr(attrs, name) {
177
+ const match = ATTR_RE(name).exec(attrs);
178
+ return match ? match[1] : "";
179
+ }
180
+
181
+ function objectKindFromId(id) {
182
+ const separator = id.indexOf(":");
183
+ return separator === -1 ? "" : id.slice(0, separator);
184
+ }
185
+
186
+ function parseJsonAttribute(value) {
187
+ const decoded = htmlDecode(value);
188
+ if (!decoded) return undefined;
189
+ try {
190
+ return JSON.parse(decoded);
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ function htmlDecode(value) {
197
+ return String(value ?? "")
198
+ .replaceAll("&quot;", '"')
199
+ .replaceAll("&#x27;", "'")
200
+ .replaceAll("&#39;", "'")
201
+ .replaceAll("&lt;", "<")
202
+ .replaceAll("&gt;", ">")
203
+ .replaceAll("&amp;", "&");
204
+ }
@@ -7,7 +7,8 @@ import { singleColumnRegionStream } from "./regions.mjs";
7
7
  // region until adding the next block would exceed capacity, then advance to
8
8
  // the next region. Pages are a derived view (grouping by pageIndex), so the
9
9
  // same code paginates single-column, multi-column, and heterogeneous layouts.
10
- export function allocateBlocksToRegions(measuredBlocks, regionStream) {
10
+ export function allocateBlocksToRegions(measuredBlocks, regionStream, options = {}) {
11
+ const keepWithNext = typeof options.keepWithNext === "function" ? options.keepWithNext : null;
11
12
  const filled = [];
12
13
  const warnings = [];
13
14
  let current = regionStream.next();
@@ -16,6 +17,7 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
16
17
  }
17
18
  let currentBlockIds = [];
18
19
  let currentHeight = 0;
20
+ let consumedCount = 0;
19
21
 
20
22
  const flush = () => {
21
23
  if (currentBlockIds.length === 0) return;
@@ -29,7 +31,9 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
29
31
  currentHeight = 0;
30
32
  };
31
33
 
32
- for (const block of measuredBlocks ?? []) {
34
+ const blocks = measuredBlocks ?? [];
35
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
36
+ const block = blocks[blockIndex];
33
37
  const id = String(block?.id ?? "");
34
38
  if (!id) continue;
35
39
  const height = Math.max(0, Number(block.height) || 0);
@@ -45,6 +49,24 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
45
49
  });
46
50
  }
47
51
 
52
+ const nextBlock = blocks[blockIndex + 1];
53
+ const nextHeight = Math.max(0, Number(nextBlock?.height) || 0);
54
+ const keepWithNextHeight = keepWithNext?.(block, nextBlock) ? height + nextHeight : 0;
55
+
56
+ if (
57
+ currentBlockIds.length > 0 &&
58
+ keepWithNextHeight > 0 &&
59
+ currentHeight + keepWithNextHeight > current.capacity
60
+ ) {
61
+ flush();
62
+ const next = regionStream.next();
63
+ if (!next) {
64
+ warnings.push({ code: "out-of-regions", blockId: id });
65
+ break;
66
+ }
67
+ current = next;
68
+ }
69
+
48
70
  if (currentBlockIds.length > 0 && currentHeight + height > current.capacity) {
49
71
  flush();
50
72
  const next = regionStream.next();
@@ -57,10 +79,17 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
57
79
 
58
80
  currentBlockIds.push(id);
59
81
  currentHeight += height;
82
+ consumedCount += 1;
60
83
  }
61
84
 
62
85
  flush();
63
- return { regions: filled, warnings };
86
+ return { regions: filled, warnings, consumedCount };
87
+ }
88
+
89
+ export function estimateRegionsNeeded(measuredBlocks, regionCapacity, options = {}) {
90
+ const capacity = positiveNumber(regionCapacity, DEFAULT_PAGE_SAFE_HEIGHT_PX);
91
+ const result = allocateBlocksToRegions(measuredBlocks, infiniteFixedCapacityRegionStream(capacity), options);
92
+ return result.regions.length;
64
93
  }
65
94
 
66
95
  // Derive a flat pages[] view from filled regions. Blocks within a page are
@@ -101,6 +130,22 @@ export function paginateMeasuredBlocks(measuredBlocks, options = {}) {
101
130
  };
102
131
  }
103
132
 
133
+ function infiniteFixedCapacityRegionStream(capacity) {
134
+ let index = 0;
135
+ return {
136
+ next() {
137
+ const region = {
138
+ id: `estimate-region-${index}`,
139
+ capacity,
140
+ pageIndex: index,
141
+ columnIndex: 0,
142
+ };
143
+ index += 1;
144
+ return region;
145
+ },
146
+ };
147
+ }
148
+
104
149
  // Translate the new region-shaped warnings back to the legacy
105
150
  // `block-overflows-page` schema that document-export.mjs and downstream
106
151
  // consumers expect. Once consumers migrate, this can drop.
@@ -5,5 +5,5 @@
5
5
  // these helpers. The region kernel is also usable on its own for custom
6
6
  // pipelines or unit tests.
7
7
 
8
- export { paginateMeasuredBlocks, allocateBlocksToRegions, pagesFromRegions } from "./pagination/allocator.mjs";
8
+ export { paginateMeasuredBlocks, allocateBlocksToRegions, estimateRegionsNeeded, pagesFromRegions } from "./pagination/allocator.mjs";
9
9
  export { singleColumnRegionStream, multiColumnRegionStream, fixedRegionStream } from "./pagination/regions.mjs";
@@ -6,6 +6,8 @@
6
6
  // - `hints`: { totalPagesPerChain: { [chainId]: number } } — fed back to <Sections>
7
7
  // - `warnings`: chain-overflowed, etc.
8
8
 
9
+ import { allocateBlocksToRegions, estimateRegionsNeeded } from "../pagination/allocator.mjs";
10
+
9
11
  const SANITY_LIMIT = 200;
10
12
 
11
13
  /**
@@ -58,7 +60,7 @@ export function allocateChains({ frames, mdxAreas, blockHeights, sources }) {
58
60
  continue;
59
61
  }
60
62
 
61
- const { result, neededAreas } = greedyAllocate(blocks, regions);
63
+ const { result, neededAreas } = allocateBlocksToFiniteRegions(blocks, regions);
62
64
  const lastOverflow = regions[regions.length - 1].__overflow;
63
65
  const sourceFramesForChain = uniqueFramesForChain(frames, chainId);
64
66
 
@@ -110,71 +112,26 @@ export function allocateChains({ frames, mdxAreas, blockHeights, sources }) {
110
112
  };
111
113
  }
112
114
 
113
- function greedyAllocate(blocks, regions) {
114
- const filled = [];
115
- let regionIndex = 0;
116
- let currentBlockIds = [];
117
- let currentHeight = 0;
118
- let consumed = 0;
119
- const flush = () => {
120
- if (currentBlockIds.length === 0) return;
121
- filled.push({
122
- region: regions[regionIndex],
123
- blockIds: currentBlockIds,
124
- });
125
- currentBlockIds = [];
126
- currentHeight = 0;
115
+ function allocateBlocksToFiniteRegions(blocks, regions) {
116
+ const byRegionId = new Map(regions.map((region) => [region.id, region]));
117
+ const result = allocateBlocksToRegions(blocks, finiteRegionStream(regions), {
118
+ keepWithNext: shouldKeepWithNext,
119
+ });
120
+ const filled = result.regions.map((region) => ({
121
+ region: byRegionId.get(region.regionId),
122
+ blockIds: region.blockIds,
123
+ })).filter((fill) => fill.region);
124
+ const consumed = result.consumedCount;
125
+ const remaining = blocks.slice(consumed);
126
+ const extraNeeded = remaining.length > 0
127
+ ? estimateRegionsNeeded(remaining, regions[regions.length - 1].capacity, {
128
+ keepWithNext: shouldKeepWithNext,
129
+ })
130
+ : 0;
131
+ return {
132
+ result: { filled, consumed },
133
+ neededAreas: filled.length + extraNeeded,
127
134
  };
128
- for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
129
- const block = blocks[blockIndex];
130
- while (regionIndex < regions.length) {
131
- const region = regions[regionIndex];
132
- const nextBlock = blocks[blockIndex + 1];
133
- const keepWithNextHeight = shouldKeepWithNext(block, nextBlock) ? block.height + nextBlock.height : 0;
134
- if (
135
- currentBlockIds.length > 0 &&
136
- keepWithNextHeight > 0 &&
137
- currentHeight + keepWithNextHeight > region.capacity
138
- ) {
139
- flush();
140
- regionIndex += 1;
141
- continue;
142
- }
143
- if (currentBlockIds.length === 0 || currentHeight + block.height <= region.capacity) {
144
- currentBlockIds.push(block.id);
145
- currentHeight += block.height;
146
- consumed += 1;
147
- break;
148
- }
149
- // Doesn't fit — flush current region and advance
150
- flush();
151
- regionIndex += 1;
152
- }
153
- if (regionIndex >= regions.length) break;
154
- }
155
- flush();
156
- // neededAreas = total regions consumed if we had unlimited supply
157
- // For overflow detection we estimate: if consumed < blocks.length, we need more areas.
158
- let neededAreas = filled.length;
159
- if (consumed < blocks.length) {
160
- // Estimate how many more areas needed
161
- const lastCap = regions[regions.length - 1].capacity;
162
- const remainingBlocks = blocks.slice(consumed);
163
- let h = 0;
164
- let extra = 0;
165
- let inExtra = false;
166
- for (const b of remainingBlocks) {
167
- if (!inExtra || h + b.height > lastCap) {
168
- extra += 1;
169
- h = b.height;
170
- inExtra = true;
171
- } else {
172
- h += b.height;
173
- }
174
- }
175
- neededAreas += extra;
176
- }
177
- return { result: { filled, consumed }, neededAreas };
178
135
  }
179
136
 
180
137
  function shouldKeepWithNext(block, nextBlock) {
@@ -183,6 +140,15 @@ function shouldKeepWithNext(block, nextBlock) {
183
140
  return /^h[1-6]$/.test(name) || name === "caption";
184
141
  }
185
142
 
143
+ function finiteRegionStream(regions) {
144
+ let index = 0;
145
+ return {
146
+ next() {
147
+ return index < regions.length ? regions[index++] : null;
148
+ },
149
+ };
150
+ }
151
+
186
152
  function recordAllocation(allocation, result, regions) {
187
153
  for (const fill of result.filled) {
188
154
  const frameKey = fill.region.__frameKey;
@@ -223,6 +223,10 @@ async function runChromiumMeasurement(html, viewport) {
223
223
  for (const el of Array.from(chain.querySelectorAll("[data-openpress-block-id]"))) {
224
224
  if (el.tagName.toLowerCase() === "caption") continue;
225
225
  if (el.getAttribute("data-openpress-block-layout") === "attached") continue;
226
+ // Cells inherit their row's block-id so the inspector can resolve a
227
+ // SourceBlock when clicking inside a <td>. Skip them here so the
228
+ // row's measured height isn't overwritten by each cell.
229
+ if (el.getAttribute("data-openpress-inherited-block-id") === "true") continue;
226
230
  const rect = el.getBoundingClientRect();
227
231
  out.push({
228
232
  id: el.getAttribute("data-openpress-block-id"),
@@ -0,0 +1,172 @@
1
+ // Walks the user's default-exported component tree to extract
2
+ // <Workspace> and <Press> metadata declared as JSX props.
3
+ //
4
+ // The 1.0 contract says <Press> carries every per-document setting on
5
+ // its props (title, page, sources, slug, theme, componentsDir) and is
6
+ // always nested inside <Workspace>. This helper invokes the user's
7
+ // component once at load time to inspect those props before the engine
8
+ // runs its render pipeline.
9
+ //
10
+ // Safe to call because Workspace, Press, and (typically) the user's
11
+ // default export are inert function components that just return JSX —
12
+ // they don't use hooks at the entry boundary.
13
+
14
+ import React from "react";
15
+
16
+ /**
17
+ * Inspect the user's default export and extract every <Press> child
18
+ * of the (optional) <Workspace> wrapper. The export pipeline iterates
19
+ * the returned `presses` array — single-Press workspaces simply have
20
+ * length 1, multi-Press have length N. There is no separate code path
21
+ * for the single-Press case.
22
+ *
23
+ * @param {object} opts
24
+ * @param {Function} opts.UserComponent The default export of press/index.tsx.
25
+ * @param {symbol} opts.PRESS_MARKER Marker identifying Press components.
26
+ * @param {symbol} opts.WORKSPACE_MARKER Marker identifying Workspace components.
27
+ * @returns {{
28
+ * workspaceProps: Record<string, unknown>,
29
+ * presses: Array<{
30
+ * element: object, // ReactElement
31
+ * props: Record<string, unknown>, // Press JSX props (no children)
32
+ * metadata: {
33
+ * title?: string,
34
+ * page?: unknown,
35
+ * slug?: string,
36
+ * theme?: string,
37
+ * componentsDir?: string,
38
+ * captionNumbering?: unknown,
39
+ * },
40
+ * sources: Record<string, unknown> | null, // mdxSource() descriptors keyed by id
41
+ * children: unknown, // raw children for re-rendering
42
+ * index: number, // position in the Workspace
43
+ * }>,
44
+ * wrappedInWorkspace: boolean,
45
+ * }}
46
+ */
47
+ export function inspectPressTree({ UserComponent, PRESS_MARKER, WORKSPACE_MARKER }) {
48
+ if (typeof UserComponent !== "function") {
49
+ return emptyResult();
50
+ }
51
+
52
+ let root;
53
+ try {
54
+ root = UserComponent({});
55
+ } catch (err) {
56
+ // The user's default export threw before returning JSX. This is rare
57
+ // (function components at the entry boundary don't normally use hooks
58
+ // that could fail), but we treat it as "no Press metadata declared"
59
+ // and let the render pipeline surface the real error later with
60
+ // full React error context.
61
+ return emptyResult();
62
+ }
63
+
64
+ if (!isReactElement(root)) return emptyResult();
65
+
66
+ const workspaceProps = isMarked(root, WORKSPACE_MARKER) ? extractProps(root) : {};
67
+ const wrappedInWorkspace = isMarked(root, WORKSPACE_MARKER);
68
+
69
+ // Find every <Press> element in the tree (Workspace child, or root itself).
70
+ const pressElements = collectPressElements(root, PRESS_MARKER);
71
+
72
+ const presses = pressElements.map((element, index) => {
73
+ const props = extractProps(element);
74
+ return {
75
+ element,
76
+ props,
77
+ metadata: pickPressMetadata(props),
78
+ sources: extractSources(props),
79
+ children: element.props?.children ?? null,
80
+ index,
81
+ };
82
+ });
83
+
84
+ return {
85
+ workspaceProps,
86
+ presses,
87
+ wrappedInWorkspace,
88
+ };
89
+ }
90
+
91
+ function emptyResult() {
92
+ return {
93
+ workspaceProps: {},
94
+ presses: [],
95
+ wrappedInWorkspace: false,
96
+ };
97
+ }
98
+
99
+ function isReactElement(value) {
100
+ return value && typeof value === "object" && "type" in value && "props" in value;
101
+ }
102
+
103
+ function isMarked(element, marker) {
104
+ if (!isReactElement(element)) return false;
105
+ const type = element.type;
106
+ if (!type) return false;
107
+ // Components are tagged via `Component.openpressMarker = MARKER`.
108
+ return type.openpressMarker === marker;
109
+ }
110
+
111
+ function extractProps(element) {
112
+ if (!isReactElement(element) || !element.props) return {};
113
+ // Drop children — props are non-tree metadata only.
114
+ const { children, ...rest } = element.props;
115
+ return rest;
116
+ }
117
+
118
+ function collectPressElements(root, PRESS_MARKER) {
119
+ const found = [];
120
+ walk(root);
121
+ return found;
122
+
123
+ function walk(node) {
124
+ if (!isReactElement(node)) {
125
+ // Could be array / fragment / string / number — flatten and recurse.
126
+ if (Array.isArray(node)) {
127
+ for (const child of node) walk(child);
128
+ }
129
+ return;
130
+ }
131
+ if (isMarked(node, PRESS_MARKER)) {
132
+ found.push(node);
133
+ // Don't descend into Press — its children are the document tree,
134
+ // not more workspace structure.
135
+ return;
136
+ }
137
+ // Recurse into children + Fragment-like wrappers.
138
+ const children = node.props?.children;
139
+ if (children == null) return;
140
+ React.Children.forEach(children, walk);
141
+ }
142
+ }
143
+
144
+ function pickPressMetadata(pressProps) {
145
+ const out = {};
146
+ if (typeof pressProps.title === "string") out.title = pressProps.title;
147
+ if (pressProps.page !== undefined) out.page = pressProps.page;
148
+ if (typeof pressProps.slug === "string") out.slug = pressProps.slug;
149
+ if (typeof pressProps.theme === "string") out.theme = pressProps.theme;
150
+ if (typeof pressProps.componentsDir === "string") out.componentsDir = pressProps.componentsDir;
151
+ if (pressProps.captionNumbering !== undefined) out.captionNumbering = pressProps.captionNumbering;
152
+ return out;
153
+ }
154
+
155
+ // Convert the v1.0 <Press sources={[ mdxSource({ id, ... }), ... ]}> array
156
+ // into the engine's expected sources record { [id]: descriptor }. Returns
157
+ // null if no sources prop was declared (engine falls back to the named
158
+ // `export const sources` from the entry module — the v0.x shape).
159
+ function extractSources(pressProps) {
160
+ if (!Array.isArray(pressProps.sources)) return null;
161
+ const out = {};
162
+ for (const entry of pressProps.sources) {
163
+ if (!entry || typeof entry !== "object") continue;
164
+ const id = typeof entry.id === "string" ? entry.id : null;
165
+ if (!id) continue;
166
+ // Strip the id field — the engine's descriptor shape doesn't carry it
167
+ // (id was the record key in v0.x).
168
+ const { id: _omit, ...descriptor } = entry;
169
+ out[id] = descriptor;
170
+ }
171
+ return out;
172
+ }
@@ -17,7 +17,7 @@ import { createHeadingState, fallbackOutlineItems, headingAttributesForBlock } f
17
17
  const MDX_EXT = ".mdx";
18
18
 
19
19
  /**
20
- * Resolve all sources registered in `document/index.tsx`.
20
+ * Resolve all sources registered in `press/index.tsx`.
21
21
  *
22
22
  * @param {object} opts
23
23
  * @param {Record<string, object>} opts.sources The raw `sources` export.
@@ -9,13 +9,31 @@ import { documentRelativePath } from "../runtime/path-utils.mjs";
9
9
 
10
10
  const COMPONENT_EXT = ".tsx";
11
11
 
12
- export async function discoverSectionStyles(root = ".", config = {}) {
12
+ export async function discoverSectionStyles(root = ".", config = {}, { sectionRoots } = {}) {
13
13
  const workspaceRoot = path.resolve(root);
14
- const documentRoot = config.paths?.documentRoot ?? path.join(workspaceRoot, "document");
14
+ const documentRoot = config.paths?.documentRoot ?? path.join(workspaceRoot, "press");
15
15
  const componentsRoot = config.paths?.componentsDir ?? path.join(documentRoot, "components");
16
- const sectionsRoot = config.paths?.chaptersDir ?? config.paths?.sourceDir ?? path.join(documentRoot, "chapters");
17
16
  const globalComponents = await discoverComponents(componentsRoot, documentRoot, "global");
18
- const sections = await discoverSections(documentRoot, sectionsRoot);
17
+
18
+ // Multi-Press workspaces can place their chapters under per-Press
19
+ // subfolders (e.g. press/userstory/chapters/, press/slidepack/chapters/).
20
+ // The caller passes each Press's resolved section-folders root; we
21
+ // discover sections in every root and merge. Duplicate paths are
22
+ // de-duplicated by absolutePath. Falls back to the workspace-default
23
+ // root (press/chapters/) when no roots are passed in.
24
+ const effectiveRoots = sectionRoots && sectionRoots.length > 0
25
+ ? sectionRoots
26
+ : [config.paths?.chaptersDir ?? config.paths?.sourceDir ?? path.join(documentRoot, "chapters")];
27
+ const seen = new Set();
28
+ const sections = [];
29
+ for (const sectionsRoot of effectiveRoots) {
30
+ const found = await discoverSections(documentRoot, sectionsRoot);
31
+ for (const section of found) {
32
+ if (seen.has(section.absolutePath)) continue;
33
+ seen.add(section.absolutePath);
34
+ sections.push(section);
35
+ }
36
+ }
19
37
 
20
38
  return {
21
39
  root: workspaceRoot,
@@ -10,6 +10,14 @@ export interface ResolvedConfig {
10
10
  componentsDir: string;
11
11
  publicDir: string;
12
12
  outputDir: string;
13
+ page: null | {
14
+ id: string;
15
+ label: string;
16
+ width: string;
17
+ height: string;
18
+ aspectRatio?: string;
19
+ heightRatio?: string;
20
+ };
13
21
  pdf: {
14
22
  filename: string;
15
23
  };