@open-press/core 0.7.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +6 -3
  2. package/engine/cli.mjs +8 -8
  3. package/engine/commands/_shared.mjs +37 -15
  4. package/engine/commands/dev.mjs +2 -2
  5. package/engine/commands/image.mjs +29 -0
  6. package/engine/commands/skills-sync.mjs +71 -0
  7. package/engine/commands/typecheck.mjs +63 -1
  8. package/engine/commands/upgrade.mjs +3 -3
  9. package/engine/document-export.mjs +1 -1
  10. package/engine/output/chrome-pdf.mjs +110 -3
  11. package/engine/output/static-server.mjs +87 -9
  12. package/engine/react/comment-endpoint.mjs +13 -39
  13. package/engine/react/comment-marker.mjs +43 -19
  14. package/engine/react/document-entry.mjs +46 -28
  15. package/engine/react/document-export.mjs +328 -164
  16. package/engine/react/http-json.mjs +24 -0
  17. package/engine/react/mdx-compile.mjs +126 -3
  18. package/engine/react/measurement-css.mjs +114 -1
  19. package/engine/react/object-entities.mjs +204 -0
  20. package/engine/react/pagination/allocator.mjs +48 -3
  21. package/engine/react/pagination.mjs +1 -1
  22. package/engine/react/pipeline/allocate.mjs +41 -72
  23. package/engine/react/pipeline/frame-measurement.mjs +6 -0
  24. package/engine/react/press-tree-inspection.mjs +172 -0
  25. package/engine/react/project-asset-endpoint.mjs +6 -24
  26. package/engine/react/source-edit-endpoint.d.mts +10 -0
  27. package/engine/react/source-edit-endpoint.mjs +75 -0
  28. package/engine/react/sources/mdx-resolver.mjs +13 -15
  29. package/engine/react/style-discovery.mjs +23 -8
  30. package/engine/runtime/config.d.mts +8 -0
  31. package/engine/runtime/config.mjs +57 -60
  32. package/engine/runtime/file-utils.mjs +9 -1
  33. package/engine/runtime/file-walk.mjs +22 -0
  34. package/engine/runtime/inspection.mjs +1 -20
  35. package/engine/runtime/page-geometry.mjs +131 -0
  36. package/engine/runtime/path-utils.mjs +20 -0
  37. package/engine/runtime/source-text-tools.d.mts +102 -0
  38. package/engine/runtime/source-text-tools.mjs +551 -16
  39. package/engine/runtime/source-workspace.mjs +16 -34
  40. package/engine/runtime/validation.mjs +19 -10
  41. package/package.json +3 -5
  42. package/src/openpress/app/OpenPressApp.tsx +296 -0
  43. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +20 -9
  44. package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  45. package/src/openpress/app/index.ts +2 -0
  46. package/src/openpress/core/Frame.tsx +26 -15
  47. package/src/openpress/core/FrameContext.tsx +10 -3
  48. package/src/openpress/core/MdxArea.tsx +11 -12
  49. package/src/openpress/core/Press.tsx +25 -4
  50. package/src/openpress/core/Workspace.tsx +36 -0
  51. package/src/openpress/core/cn.ts +4 -0
  52. package/src/openpress/core/index.tsx +11 -3
  53. package/src/openpress/core/primitives.tsx +74 -6
  54. package/src/openpress/core/types.ts +94 -41
  55. package/src/openpress/core/useSource.ts +1 -1
  56. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  57. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  58. package/src/openpress/{types.ts → document-model/documentTypes.ts} +51 -0
  59. package/src/openpress/document-model/index.ts +7 -0
  60. package/src/openpress/document-model/objectEntityModel.ts +55 -0
  61. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  62. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  63. package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  64. package/src/openpress/manuscript/index.tsx +49 -7
  65. package/src/openpress/mdx/index.ts +15 -7
  66. package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  67. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  68. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  69. package/src/openpress/reader/index.ts +11 -0
  70. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  71. package/src/openpress/reader/readerTypes.ts +4 -0
  72. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  73. package/src/openpress/reader/usePanelState.ts +56 -0
  74. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  75. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  76. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  77. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  78. package/src/openpress/shared/Panel.tsx +77 -0
  79. package/src/openpress/shared/index.ts +4 -0
  80. package/src/openpress/shared/numberUtils.ts +3 -0
  81. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  82. package/src/openpress/workbench/Workbench.tsx +506 -0
  83. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  84. package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  85. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  86. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  87. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  88. package/src/openpress/workbench/actions/index.ts +6 -0
  89. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  90. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  91. package/src/openpress/workbench/dialog/index.ts +1 -0
  92. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  93. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  94. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  95. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  96. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  97. package/src/openpress/workbench/document/index.ts +10 -0
  98. package/src/openpress/workbench/index.ts +2 -0
  99. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  100. package/src/openpress/workbench/inspector/index.ts +5 -0
  101. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  102. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  103. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  104. package/src/openpress/workbench/inspector/useInspectorComments.ts +254 -0
  105. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  106. package/src/openpress/workbench/mentions/index.ts +2 -0
  107. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  108. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  109. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +80 -0
  110. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  111. package/src/openpress/workbench/panels/index.ts +3 -0
  112. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +525 -0
  113. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  114. package/src/openpress/workbench/project/index.ts +2 -0
  115. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  116. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  117. package/src/openpress/workbench/shell/index.ts +1 -0
  118. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  119. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  120. package/src/styles/openpress/print-route.css +0 -2
  121. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  122. package/src/styles/openpress/public-viewer.css +25 -320
  123. package/src/styles/openpress/reader-runtime.css +252 -55
  124. package/src/styles/openpress/responsive.css +145 -270
  125. package/src/styles/openpress/workbench-panels.css +327 -178
  126. package/src/styles/openpress/workbench.css +986 -451
  127. package/src/styles/openpress/workspace-gallery.css +300 -0
  128. package/src/styles/openpress.css +2 -1
  129. package/tsconfig.json +1 -1
  130. package/vite.config.ts +50 -0
  131. package/engine/commands/init.mjs +0 -24
  132. package/engine/init.mjs +0 -90
  133. package/src/openpress/App.tsx +0 -127
  134. package/src/openpress/inspector.ts +0 -282
  135. package/src/openpress/projectWorkspace.tsx +0 -919
  136. package/src/openpress/readerRuntime.ts +0 -230
  137. package/src/openpress/workbench.tsx +0 -1265
  138. package/src/openpress/workbenchTypes.ts +0 -4
  139. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  140. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  141. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  142. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  143. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  144. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -6,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,76 +112,41 @@ 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) {
181
138
  if (!nextBlock) return false;
182
- return /^h[1-6]$/.test(String(block?.name ?? ""));
139
+ const name = String(block?.name ?? "");
140
+ return /^h[1-6]$/.test(name) || name === "caption";
141
+ }
142
+
143
+ function finiteRegionStream(regions) {
144
+ let index = 0;
145
+ return {
146
+ next() {
147
+ return index < regions.length ? regions[index++] : null;
148
+ },
149
+ };
183
150
  }
184
151
 
185
152
  function recordAllocation(allocation, result, regions) {
@@ -223,12 +190,14 @@ function groupBlockHeights(blockHeights) {
223
190
 
224
191
  function buildBlockStream(chainSource, heightMap) {
225
192
  if (!chainSource || !heightMap) return [];
226
- return chainSource.map((block) => ({
227
- id: block.id,
228
- kind: block.kind,
229
- name: block.name,
230
- height: heightMap.get(block.id) ?? 0,
231
- }));
193
+ return chainSource
194
+ .filter((block) => block.layout !== "attached")
195
+ .map((block) => ({
196
+ id: block.id,
197
+ kind: block.kind,
198
+ name: block.name,
199
+ height: heightMap.get(block.id) ?? 0,
200
+ }));
232
201
  }
233
202
 
234
203
  function* iterateChains(sources) {
@@ -221,6 +221,12 @@ async function runChromiumMeasurement(html, viewport) {
221
221
  const parentTop = chain.parentElement?.getBoundingClientRect().top ?? chain.getBoundingClientRect().top;
222
222
  let previousBottom = parentTop;
223
223
  for (const el of Array.from(chain.querySelectorAll("[data-openpress-block-id]"))) {
224
+ if (el.tagName.toLowerCase() === "caption") continue;
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;
224
230
  const rect = el.getBoundingClientRect();
225
231
  out.push({
226
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
+ }
@@ -3,8 +3,7 @@ import path from "node:path";
3
3
  import { loadConfig } from "../runtime/config.mjs";
4
4
  import { collectSourceTextFiles } from "../runtime/source-text-tools.mjs";
5
5
  import { insertCommentMarker } from "./comment-marker.mjs";
6
-
7
- const MAX_PROJECT_ASSET_BODY_BYTES = 64 * 1024;
6
+ import { readJsonBody, writeJson } from "./http-json.mjs";
8
7
 
9
8
  export async function handleProjectAssetRequest(req, res, {
10
9
  root = ".",
@@ -16,7 +15,7 @@ export async function handleProjectAssetRequest(req, res, {
16
15
  }
17
16
 
18
17
  try {
19
- const body = await readJsonBody(req);
18
+ const body = await readJsonBody(req, { bodyLabel: "Project asset request" });
20
19
  const config = await loadConfig(root);
21
20
  const action = stringValue(body?.action);
22
21
  const kind = stringValue(body?.kind);
@@ -53,6 +52,7 @@ export async function handleProjectAssetRequest(req, res, {
53
52
  note: body?.note,
54
53
  commentTarget: body?.commentTarget,
55
54
  currentSource: body?.currentSource,
55
+ objectEntity: body?.objectEntity,
56
56
  timestamp,
57
57
  });
58
58
  writeJson(res, 200, { ok: true, ...result });
@@ -133,6 +133,7 @@ async function createProjectAssetComment({
133
133
  note,
134
134
  commentTarget,
135
135
  currentSource,
136
+ objectEntity,
136
137
  timestamp,
137
138
  }) {
138
139
  const normalizedName = normalizeAssetName(kind, name);
@@ -146,13 +147,14 @@ async function createProjectAssetComment({
146
147
  commentTarget: stringValue(commentTarget),
147
148
  currentSource,
148
149
  });
150
+ const objectHint = stringValue(objectEntity?.id) ? ` object=${stringValue(objectEntity.id)}` : "";
149
151
 
150
152
  const result = await insertCommentMarker({
151
153
  root: config.root,
152
154
  path: target.path,
153
155
  source: { line: target.line, column: 1 },
154
156
  note: `${assetLabel(kind, normalizedName)}:${noteText}`,
155
- hint: `openpress-project-asset kind=${kind} action=comment target=${target.reason} asset=${normalizedName}`,
157
+ hint: `openpress-project-asset kind=${kind} action=comment target=${target.reason} asset=${normalizedName}${objectHint}`,
156
158
  timestamp,
157
159
  });
158
160
 
@@ -357,23 +359,3 @@ async function fileExists(filePath) {
357
359
  return false;
358
360
  }
359
361
  }
360
-
361
- async function readJsonBody(req) {
362
- let body = "";
363
- for await (const chunk of req) {
364
- body += String(chunk);
365
- if (Buffer.byteLength(body, "utf8") > MAX_PROJECT_ASSET_BODY_BYTES) {
366
- throw new Error("Project asset request body is too large.");
367
- }
368
- }
369
- try {
370
- return JSON.parse(body || "{}");
371
- } catch {
372
- throw new Error("Project asset request body must be valid JSON.");
373
- }
374
- }
375
-
376
- function writeJson(res, status, body) {
377
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
378
- res.end(`${JSON.stringify(body, null, 2)}\n`);
379
- }
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ export function handleSourceEditRequest(
4
+ req: IncomingMessage,
5
+ res: ServerResponse,
6
+ options?: {
7
+ root?: string;
8
+ refreshDocument?: boolean;
9
+ },
10
+ ): Promise<void>;
@@ -0,0 +1,75 @@
1
+ import { loadConfig } from "../runtime/config.mjs";
2
+ import { applySourceBlockTextEdit, readSourceBlockText } from "../runtime/source-text-tools.mjs";
3
+ import { exportReactDocument } from "./document-export.mjs";
4
+ import { readJsonBody, writeJson } from "./http-json.mjs";
5
+
6
+ export async function handleSourceEditRequest(req, res, {
7
+ root = ".",
8
+ refreshDocument = true,
9
+ } = {}) {
10
+ if (req.method === "GET") {
11
+ try {
12
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
13
+ const config = await loadConfig(root);
14
+ const sourceText = await readSourceBlockText({
15
+ config,
16
+ path: requestUrl.searchParams.get("path"),
17
+ source: {
18
+ line: Number(requestUrl.searchParams.get("line")),
19
+ column: Number(requestUrl.searchParams.get("column") || 1),
20
+ endLine: Number(requestUrl.searchParams.get("endLine") || requestUrl.searchParams.get("line")),
21
+ endColumn: Number(requestUrl.searchParams.get("endColumn") || requestUrl.searchParams.get("column") || 1),
22
+ },
23
+ });
24
+ writeJson(res, 200, { ok: true, source: sourceText });
25
+ } catch (error) {
26
+ writeJson(res, 400, {
27
+ ok: false,
28
+ message: error instanceof Error ? error.message : String(error),
29
+ });
30
+ }
31
+ return;
32
+ }
33
+
34
+ if (req.method !== "POST") {
35
+ writeJson(res, 405, { ok: false, message: "OpenPress source edit endpoint requires GET or POST." });
36
+ return;
37
+ }
38
+
39
+ try {
40
+ const body = await readJsonBody(req, {
41
+ bodyLabel: "OpenPress source edit request",
42
+ maxBytes: 256 * 1024,
43
+ });
44
+ const config = await loadConfig(root);
45
+ const edit = await applySourceBlockTextEdit({
46
+ config,
47
+ path: body?.path,
48
+ source: body?.source,
49
+ text: body?.text,
50
+ kind: body?.kind,
51
+ name: body?.name,
52
+ blockId: body?.blockId,
53
+ sourceMode: body?.sourceMode === true,
54
+ });
55
+ const exported = refreshDocument && body?.refreshDocument !== false
56
+ ? await exportReactDocument(root, { syncAssets: false })
57
+ : null;
58
+
59
+ writeJson(res, 200, {
60
+ ok: true,
61
+ edit,
62
+ document: exported
63
+ ? {
64
+ path: exported.documentPath,
65
+ pageCount: exported.pageCount,
66
+ }
67
+ : undefined,
68
+ });
69
+ } catch (error) {
70
+ writeJson(res, 400, {
71
+ ok: false,
72
+ message: error instanceof Error ? error.message : String(error),
73
+ });
74
+ }
75
+ }
@@ -10,13 +10,14 @@
10
10
  import fs from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import React from "react";
13
+ import { documentRelativePath, resolveDocumentRelativePath } from "../../runtime/path-utils.mjs";
13
14
  import { compileMdx } from "../mdx-compile.mjs";
14
15
  import { createHeadingState, fallbackOutlineItems, headingAttributesForBlock } from "./heading-numbering.mjs";
15
16
 
16
17
  const MDX_EXT = ".mdx";
17
18
 
18
19
  /**
19
- * Resolve all sources registered in `document/index.tsx`.
20
+ * Resolve all sources registered in `press/index.tsx`.
20
21
  *
21
22
  * @param {object} opts
22
23
  * @param {Record<string, object>} opts.sources The raw `sources` export.
@@ -103,6 +104,7 @@ async function resolveSource({ sourceId, descriptor, documentRoot, globalCompone
103
104
  kind: block.kind,
104
105
  name: block.name,
105
106
  text: block.text,
107
+ layout: block.layout,
106
108
  chainId,
107
109
  sectionSlug: section.slug,
108
110
  path: documentRelative(file.absolutePath, documentRoot),
@@ -323,6 +325,7 @@ function compileTocBlocks({ tocBlocks, chainId, blockIds, toc }) {
323
325
  {
324
326
  className,
325
327
  "data-openpress-block-id": block.id,
328
+ "data-openpress-object-id": createBlockObjectEntityId(block.id),
326
329
  "data-openpress-toc-entry": block.sectionSlug,
327
330
  },
328
331
  React.createElement(
@@ -352,6 +355,14 @@ function locateSection(renderData, chainId) {
352
355
  throw new Error(`No section found for chainId "${chainId}" in source "${renderData.sourceId}".`);
353
356
  }
354
357
 
358
+ function createObjectEntityId(kind, ...parts) {
359
+ return [kind, ...parts.map((part) => encodeURIComponent(String(part)))].join(":");
360
+ }
361
+
362
+ function createBlockObjectEntityId(blockId) {
363
+ return createObjectEntityId("mdx-block", blockId);
364
+ }
365
+
355
366
  // ---------------------------------------------------------------------------
356
367
  // Validation
357
368
  // ---------------------------------------------------------------------------
@@ -425,17 +436,4 @@ function deriveTitleFromDirName(name) {
425
436
  .join(" ");
426
437
  }
427
438
 
428
- function documentRelative(absolutePath, documentRoot) {
429
- return path.relative(documentRoot, absolutePath).split(path.sep).join("/");
430
- }
431
-
432
- function resolveDocumentRelativePath(documentRoot, rel, label) {
433
- if (typeof rel !== "string" || !rel.trim()) throw new Error(`${label} must be a non-empty document-relative path.`);
434
- if (rel.includes("..")) throw new Error(`${label} contains "..", rejected.`);
435
- const absolutePath = path.resolve(documentRoot, rel);
436
- const relCheck = path.relative(documentRoot, absolutePath);
437
- if (relCheck.startsWith("..") || path.isAbsolute(relCheck)) {
438
- throw new Error(`${label} escapes the document root.`);
439
- }
440
- return absolutePath;
441
- }
439
+ const documentRelative = documentRelativePath;
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { documentRelativePath } from "../runtime/path-utils.mjs";
3
4
 
4
5
  // Style discovery — only used to find per-section CSS files for the
5
6
  // section-folders preset. MDX content discovery lives in `sources/mdx-resolver`.
@@ -8,13 +9,31 @@ import path from "node:path";
8
9
 
9
10
  const COMPONENT_EXT = ".tsx";
10
11
 
11
- export async function discoverSectionStyles(root = ".", config = {}) {
12
+ export async function discoverSectionStyles(root = ".", config = {}, { sectionRoots } = {}) {
12
13
  const workspaceRoot = path.resolve(root);
13
- const documentRoot = config.paths?.documentRoot ?? path.join(workspaceRoot, "document");
14
+ const documentRoot = config.paths?.documentRoot ?? path.join(workspaceRoot, "press");
14
15
  const componentsRoot = config.paths?.componentsDir ?? path.join(documentRoot, "components");
15
- const sectionsRoot = config.paths?.chaptersDir ?? config.paths?.sourceDir ?? path.join(documentRoot, "chapters");
16
16
  const globalComponents = await discoverComponents(componentsRoot, documentRoot, "global");
17
- 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
+ }
18
37
 
19
38
  return {
20
39
  root: workspaceRoot,
@@ -102,10 +121,6 @@ function pathRecord(absolutePath, documentRoot) {
102
121
  };
103
122
  }
104
123
 
105
- function documentRelativePath(absolutePath, documentRoot) {
106
- return path.relative(documentRoot, absolutePath).split(path.sep).join("/");
107
- }
108
-
109
124
  function compareSectionDirectories(a, b) {
110
125
  const left = sectionSortKey(a.name);
111
126
  const right = sectionSortKey(b.name);
@@ -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
  };