@open-press/cli 0.7.1 → 0.8.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 (119) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/template/core/CHANGELOG.md +45 -0
  4. package/template/core/engine/commands/dev.mjs +2 -2
  5. package/template/core/engine/output/chrome-pdf.mjs +18 -3
  6. package/template/core/engine/output/static-server.mjs +39 -0
  7. package/template/core/engine/react/comment-endpoint.mjs +13 -39
  8. package/template/core/engine/react/comment-marker.mjs +30 -6
  9. package/template/core/engine/react/document-entry.mjs +11 -0
  10. package/template/core/engine/react/document-export.mjs +30 -5
  11. package/template/core/engine/react/http-json.mjs +24 -0
  12. package/template/core/engine/react/mdx-compile.mjs +96 -3
  13. package/template/core/engine/react/measurement-css.mjs +93 -1
  14. package/template/core/engine/react/object-entities.mjs +119 -0
  15. package/template/core/engine/react/pipeline/allocate.mjs +10 -7
  16. package/template/core/engine/react/pipeline/frame-measurement.mjs +2 -0
  17. package/template/core/engine/react/project-asset-endpoint.mjs +6 -24
  18. package/template/core/engine/react/source-edit-endpoint.d.mts +10 -0
  19. package/template/core/engine/react/source-edit-endpoint.mjs +75 -0
  20. package/template/core/engine/react/sources/mdx-resolver.mjs +12 -14
  21. package/template/core/engine/react/style-discovery.mjs +1 -4
  22. package/template/core/engine/runtime/file-walk.mjs +22 -0
  23. package/template/core/engine/runtime/inspection.mjs +1 -20
  24. package/template/core/engine/runtime/path-utils.mjs +20 -0
  25. package/template/core/engine/runtime/source-text-tools.d.mts +102 -0
  26. package/template/core/engine/runtime/source-text-tools.mjs +551 -16
  27. package/template/core/engine/runtime/source-workspace.mjs +4 -31
  28. package/template/core/package.json +1 -1
  29. package/template/core/src/main.tsx +2 -2
  30. package/template/core/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
  31. package/template/core/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
  32. package/template/core/src/openpress/app/index.ts +2 -0
  33. package/template/core/src/openpress/core/Frame.tsx +9 -11
  34. package/template/core/src/openpress/core/FrameContext.tsx +8 -3
  35. package/template/core/src/openpress/core/MdxArea.tsx +11 -12
  36. package/template/core/src/openpress/core/cn.ts +4 -0
  37. package/template/core/src/openpress/core/index.tsx +2 -1
  38. package/template/core/src/openpress/core/primitives.tsx +29 -8
  39. package/template/core/src/openpress/core/types.ts +8 -0
  40. package/template/core/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  41. package/template/core/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  42. package/template/core/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
  43. package/template/core/src/openpress/document-model/index.ts +6 -0
  44. package/template/core/src/openpress/document-model/objectEntityModel.ts +51 -0
  45. package/template/core/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  46. package/template/core/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  47. package/template/core/src/openpress/manuscript/index.tsx +49 -7
  48. package/template/core/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  49. package/template/core/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  50. package/template/core/src/openpress/reader/index.ts +10 -0
  51. package/template/core/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  52. package/template/core/src/openpress/reader/readerTypes.ts +4 -0
  53. package/template/core/src/openpress/reader/usePageViewportScale.ts +119 -0
  54. package/template/core/src/openpress/reader/usePanelState.ts +56 -0
  55. package/template/core/src/openpress/reader/useReaderHashSync.ts +61 -0
  56. package/template/core/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  57. package/template/core/src/openpress/reader/useReaderRuntime.ts +146 -0
  58. package/template/core/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  59. package/template/core/src/openpress/shared/Panel.tsx +77 -0
  60. package/template/core/src/openpress/shared/index.ts +4 -0
  61. package/template/core/src/openpress/shared/numberUtils.ts +3 -0
  62. package/template/core/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  63. package/template/core/src/openpress/workbench/Workbench.tsx +407 -0
  64. package/template/core/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  65. package/template/core/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  66. package/template/core/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  67. package/template/core/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  68. package/template/core/src/openpress/workbench/actions/index.ts +5 -0
  69. package/template/core/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  70. package/template/core/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  71. package/template/core/src/openpress/workbench/dialog/index.ts +1 -0
  72. package/template/core/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  73. package/template/core/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  74. package/template/core/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  75. package/template/core/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  76. package/template/core/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  77. package/template/core/src/openpress/workbench/document/index.ts +10 -0
  78. package/template/core/src/openpress/workbench/index.ts +2 -0
  79. package/template/core/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  80. package/template/core/src/openpress/workbench/inspector/index.ts +5 -0
  81. package/template/core/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  82. package/template/core/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  83. package/template/core/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  84. package/template/core/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
  85. package/template/core/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  86. package/template/core/src/openpress/workbench/mentions/index.ts +2 -0
  87. package/template/core/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  88. package/template/core/src/openpress/workbench/panels/Panel.tsx +1 -0
  89. package/template/core/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
  90. package/template/core/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  91. package/template/core/src/openpress/workbench/panels/index.ts +3 -0
  92. package/template/core/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
  93. package/template/core/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  94. package/template/core/src/openpress/workbench/project/index.ts +2 -0
  95. package/template/core/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  96. package/template/core/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  97. package/template/core/src/openpress/workbench/shell/index.ts +1 -0
  98. package/template/core/src/openpress/workbench/workbenchFormatters.ts +120 -0
  99. package/template/core/src/openpress/workbench/workbenchTypes.ts +35 -0
  100. package/template/core/src/styles/openpress/print-route.css +0 -2
  101. package/template/core/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  102. package/template/core/src/styles/openpress/public-viewer.css +25 -320
  103. package/template/core/src/styles/openpress/reader-runtime.css +243 -55
  104. package/template/core/src/styles/openpress/responsive.css +145 -270
  105. package/template/core/src/styles/openpress/workbench-panels.css +214 -178
  106. package/template/core/src/styles/openpress/workbench.css +986 -451
  107. package/template/core/src/styles/openpress.css +1 -1
  108. package/template/core/vite.config.ts +50 -0
  109. package/template/core/src/openpress/inspector.ts +0 -282
  110. package/template/core/src/openpress/projectWorkspace.tsx +0 -919
  111. package/template/core/src/openpress/readerRuntime.ts +0 -230
  112. package/template/core/src/openpress/workbench.tsx +0 -1265
  113. package/template/core/src/openpress/workbenchTypes.ts +0 -4
  114. /package/template/core/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  115. /package/template/core/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  116. /package/template/core/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  117. /package/template/core/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  118. /package/template/core/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  119. /package/template/core/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -112,6 +112,7 @@ export function rehypeBlockIds(options = {}) {
112
112
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
113
113
 
114
114
  setDataAttribute(node, "data-openpress-block-id", id);
115
+ setDataAttribute(node, "data-openpress-object-id", createBlockObjectEntityId(id));
115
116
  const extraAttributes = blockAttributes.get(id);
116
117
  if (extraAttributes) {
117
118
  for (const [name, value] of Object.entries(extraAttributes)) {
@@ -142,9 +143,19 @@ function applyTableRowBlocks({
142
143
  includeBlockIds,
143
144
  }) {
144
145
  const rows = tableBodyRows(node);
146
+ const header = tableHeaderRow(node);
147
+ const caption = tableCaption(node);
148
+ const captionRecord = caption ? { id: `${id}-caption`, node: caption } : null;
149
+ const headerRecord = header ? { id: `${id}-h0`, node: header } : null;
150
+ const selectedCaption = captionRecord && (!includeBlockIds || includeBlockIds.has(captionRecord.id));
151
+ const selectedHeader = headerRecord && (!includeBlockIds || includeBlockIds.has(headerRecord.id));
152
+ const firstSelectedRowIndex = selectedFirstTableRowIndex(rows, includeBlockIds, id);
153
+ const renderCaption = selectedCaption || (captionRecord && includeBlockIds && firstSelectedRowIndex === 0);
154
+ const renderHeader = Boolean(headerRecord && (!includeBlockIds || firstSelectedRowIndex === 0 || selectedHeader));
145
155
  if (rows.length === 0) {
146
156
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
147
157
  setDataAttribute(node, "data-openpress-block-id", id);
158
+ setDataAttribute(node, "data-openpress-object-id", createBlockObjectEntityId(id));
148
159
  blocks.push({
149
160
  id,
150
161
  kind: "element",
@@ -165,15 +176,56 @@ function applyTableRowBlocks({
165
176
  const selected = includeBlockIds
166
177
  ? rowRecords.filter((row) => includeBlockIds.has(row.id))
167
178
  : rowRecords;
168
- if (selected.length === 0) return false;
179
+ if (selected.length === 0 && !selectedCaption && !selectedHeader) return false;
169
180
 
170
181
  setDataAttribute(node, "data-openpress-table-id", id);
182
+ if (headerRecord && renderHeader) {
183
+ setDataAttribute(headerRecord.node, "data-openpress-block-id", headerRecord.id);
184
+ setDataAttribute(headerRecord.node, "data-openpress-object-id", createBlockObjectEntityId(headerRecord.id));
185
+ setDataAttribute(headerRecord.node, "data-openpress-block-layout", "attached");
186
+ }
187
+ if (captionRecord) {
188
+ if (renderCaption) {
189
+ setDataAttribute(captionRecord.node, "data-openpress-block-id", captionRecord.id);
190
+ setDataAttribute(captionRecord.node, "data-openpress-object-id", createBlockObjectEntityId(captionRecord.id));
191
+ if (selectedCaption) {
192
+ blocks.push({
193
+ id: captionRecord.id,
194
+ kind: "element",
195
+ name: "caption",
196
+ text: textContent(captionRecord.node).trim() || undefined,
197
+ filePath,
198
+ chapterSlug,
199
+ tableId: id,
200
+ layout: "attached",
201
+ source: sourcePosition(captionRecord.node.position ?? node.position),
202
+ });
203
+ }
204
+ } else {
205
+ removeTableCaption(node);
206
+ }
207
+ }
208
+ if (headerRecord && selectedHeader) {
209
+ blocks.push({
210
+ id: headerRecord.id,
211
+ kind: "table-row",
212
+ name: "table-header-row",
213
+ text: textContent(headerRecord.node).trim() || undefined,
214
+ filePath,
215
+ chapterSlug,
216
+ tableId: id,
217
+ rowIndex: -1,
218
+ layout: "attached",
219
+ source: sourcePosition(headerRecord.node.position ?? node.position),
220
+ });
221
+ }
171
222
  const selectedNodes = new Set(selected.map((row) => row.node));
172
223
  pruneUnselectedTableRows(node, new Set(rowRecords.map((row) => row.node)), selectedNodes);
173
- if (selected[0]?.index > 0) stripTableHeader(node);
224
+ if (!renderHeader) stripTableHeader(node);
174
225
 
175
226
  for (const row of selected) {
176
227
  setDataAttribute(row.node, "data-openpress-block-id", row.id);
228
+ setDataAttribute(row.node, "data-openpress-object-id", createBlockObjectEntityId(row.id));
177
229
  blocks.push({
178
230
  id: row.id,
179
231
  kind: "table-row",
@@ -229,6 +281,7 @@ function normalizeTableCaptions(node) {
229
281
  type: "element",
230
282
  tagName: "caption",
231
283
  properties: {},
284
+ position: child.position,
232
285
  children: [{ type: "text", value: captionText }],
233
286
  });
234
287
  }
@@ -272,8 +325,10 @@ function wrapMdxComponents(components) {
272
325
  if (typeof Component !== "function") continue;
273
326
  wrapped[name] = function ComponentBlock(props = {}) {
274
327
  const blockId = props["data-openpress-block-id"];
328
+ const objectId = props["data-openpress-object-id"] || (blockId ? createBlockObjectEntityId(blockId) : undefined);
275
329
  const rest = { ...props };
276
330
  delete rest["data-openpress-block-id"];
331
+ delete rest["data-openpress-object-id"];
277
332
 
278
333
  if (!blockId) return React.createElement(Component, rest);
279
334
 
@@ -281,6 +336,7 @@ function wrapMdxComponents(components) {
281
336
  "div",
282
337
  {
283
338
  "data-openpress-block-id": blockId,
339
+ "data-openpress-object-id": objectId,
284
340
  "data-openpress-component-block": name,
285
341
  },
286
342
  React.createElement(Component, rest),
@@ -336,6 +392,7 @@ function applyListItemBlocks({
336
392
  if (items.length === 0) {
337
393
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
338
394
  setDataAttribute(node, "data-openpress-block-id", id);
395
+ setDataAttribute(node, "data-openpress-object-id", createBlockObjectEntityId(id));
339
396
  blocks.push({
340
397
  id,
341
398
  kind: "element",
@@ -375,6 +432,7 @@ function applyListItemBlocks({
375
432
 
376
433
  for (const item of selected) {
377
434
  setDataAttribute(item.node, "data-openpress-block-id", item.id);
435
+ setDataAttribute(item.node, "data-openpress-object-id", createBlockObjectEntityId(item.id));
378
436
  blocks.push({
379
437
  id: item.id,
380
438
  kind: "list-item",
@@ -419,6 +477,28 @@ function tableBodyRows(table) {
419
477
  return (table.children ?? []).filter((child) => child?.type === "element" && child.tagName === "tr");
420
478
  }
421
479
 
480
+ function tableHeaderRow(table) {
481
+ if (table?.type !== "element" || table.tagName !== "table") return null;
482
+ for (const child of table.children ?? []) {
483
+ if (child?.type !== "element" || child.tagName !== "thead") continue;
484
+ return (child.children ?? []).find((row) => row?.type === "element" && row.tagName === "tr") ?? null;
485
+ }
486
+ return null;
487
+ }
488
+
489
+ function selectedFirstTableRowIndex(rows, includeBlockIds, tableId) {
490
+ if (!includeBlockIds) return 0;
491
+ for (let index = 0; index < rows.length; index += 1) {
492
+ if (includeBlockIds.has(`${tableId}-r${index}`)) return index;
493
+ }
494
+ return -1;
495
+ }
496
+
497
+ function tableCaption(table) {
498
+ if (table?.type !== "element" || table.tagName !== "table") return null;
499
+ return (table.children ?? []).find((child) => child?.type === "element" && child.tagName === "caption") ?? null;
500
+ }
501
+
422
502
  function pruneUnselectedTableRows(node, rowNodes, selectedNodes) {
423
503
  if (!Array.isArray(node?.children)) return;
424
504
  node.children = node.children.filter((child) => {
@@ -432,10 +512,15 @@ function stripTableHeader(table) {
432
512
  if (!Array.isArray(table?.children)) return;
433
513
  table.children = table.children.filter((child) => {
434
514
  if (child?.type !== "element") return true;
435
- return child.tagName !== "caption" && child.tagName !== "thead";
515
+ return child.tagName !== "thead";
436
516
  });
437
517
  }
438
518
 
519
+ function removeTableCaption(table) {
520
+ if (!Array.isArray(table?.children)) return;
521
+ table.children = table.children.filter((child) => child?.type !== "element" || child.tagName !== "caption");
522
+ }
523
+
439
524
  function headingText(node) {
440
525
  if (!/^h[1-6]$/.test(String(node?.tagName ?? ""))) return undefined;
441
526
  return textContent(node).trim() || undefined;
@@ -470,6 +555,14 @@ function setDataAttribute(node, name, value) {
470
555
  node.properties[name] = value;
471
556
  }
472
557
 
558
+ function createObjectEntityId(kind, ...parts) {
559
+ return [kind, ...parts.map((part) => encodeURIComponent(String(part)))].join(":");
560
+ }
561
+
562
+ function createBlockObjectEntityId(blockId) {
563
+ return createObjectEntityId("mdx-block", blockId);
564
+ }
565
+
473
566
  function visit(node, visitor) {
474
567
  visitor(node);
475
568
  if (!Array.isArray(node?.children)) return;
@@ -20,7 +20,7 @@ export async function buildReactMeasurementCss(root, config, workspace) {
20
20
  parts.push("\n/* === public/openpress/chapter-scoped.css === */\n");
21
21
  parts.push(chapterCss);
22
22
  }
23
- return rewriteAssetUrls(parts.join("\n"), config);
23
+ return rewriteAssetUrls(stripViewportMediaQueries(parts.join("\n")), config);
24
24
  }
25
25
 
26
26
  async function appendOptionalFile(parts, filePath, label) {
@@ -42,3 +42,95 @@ function rewriteAssetUrls(css, config) {
42
42
  .replace(/url\((["'])?\/openpress\/fonts\//g, `url($1${themeFontsDir}`)
43
43
  .replace(/url\((["'])?\/openpress\/katex-fonts\//g, `url($1${katexFontsDir}`);
44
44
  }
45
+
46
+ function stripViewportMediaQueries(css) {
47
+ let output = "";
48
+ let cursor = 0;
49
+
50
+ while (cursor < css.length) {
51
+ const mediaIndex = css.indexOf("@media", cursor);
52
+ if (mediaIndex < 0) {
53
+ output += css.slice(cursor);
54
+ break;
55
+ }
56
+
57
+ output += css.slice(cursor, mediaIndex);
58
+ const blockStart = css.indexOf("{", mediaIndex);
59
+ if (blockStart < 0) {
60
+ output += css.slice(mediaIndex);
61
+ break;
62
+ }
63
+
64
+ const prelude = css.slice(mediaIndex + "@media".length, blockStart);
65
+ const blockEnd = findCssBlockEnd(css, blockStart);
66
+ if (blockEnd < 0) {
67
+ output += css.slice(mediaIndex);
68
+ break;
69
+ }
70
+
71
+ if (!isViewportMediaPrelude(prelude)) {
72
+ output += css.slice(mediaIndex, blockEnd + 1);
73
+ }
74
+ cursor = blockEnd + 1;
75
+ }
76
+
77
+ return output;
78
+ }
79
+
80
+ function isViewportMediaPrelude(prelude) {
81
+ if (/\bprint\b/i.test(prelude)) return false;
82
+ return /\(\s*(?:min-|max-)?(?:device-)?(?:width|height)\s*:/i.test(prelude)
83
+ || /\(\s*orientation\s*:/i.test(prelude)
84
+ || /\(\s*(?:min-|max-)?aspect-ratio\s*:/i.test(prelude);
85
+ }
86
+
87
+ function findCssBlockEnd(css, blockStart) {
88
+ let depth = 0;
89
+ let quote = "";
90
+ let inComment = false;
91
+
92
+ for (let index = blockStart; index < css.length; index += 1) {
93
+ const current = css[index];
94
+ const next = css[index + 1];
95
+
96
+ if (inComment) {
97
+ if (current === "*" && next === "/") {
98
+ inComment = false;
99
+ index += 1;
100
+ }
101
+ continue;
102
+ }
103
+
104
+ if (quote) {
105
+ if (current === "\\") {
106
+ index += 1;
107
+ continue;
108
+ }
109
+ if (current === quote) quote = "";
110
+ continue;
111
+ }
112
+
113
+ if (current === "/" && next === "*") {
114
+ inComment = true;
115
+ index += 1;
116
+ continue;
117
+ }
118
+
119
+ if (current === "\"" || current === "'") {
120
+ quote = current;
121
+ continue;
122
+ }
123
+
124
+ if (current === "{") {
125
+ depth += 1;
126
+ continue;
127
+ }
128
+
129
+ if (current === "}") {
130
+ depth -= 1;
131
+ if (depth === 0) return index;
132
+ }
133
+ }
134
+
135
+ return -1;
136
+ }
@@ -0,0 +1,119 @@
1
+ export function createObjectEntityId(kind, ...parts) {
2
+ return [kind, ...parts.map((part) => encodeURIComponent(String(part)))].join(":");
3
+ }
4
+
5
+ export function createBlockObjectEntityId(blockId) {
6
+ return createObjectEntityId("mdx-block", blockId);
7
+ }
8
+
9
+ export function createPageObjectEntityId(frameKey) {
10
+ return createObjectEntityId("page", frameKey);
11
+ }
12
+
13
+ export function createFrameObjectEntityId(frameKey) {
14
+ return createObjectEntityId("frame", frameKey);
15
+ }
16
+
17
+ export function createMdxAreaObjectEntityId(frameKey, chainId, indexInFrame) {
18
+ return createObjectEntityId("mdx-area", frameKey, chainId, indexInFrame);
19
+ }
20
+
21
+ export function buildObjectEntities({ frames, blocks, blockMap }) {
22
+ const entities = {};
23
+ const blockParentIdByBlockId = new Map();
24
+
25
+ for (const block of blocks) {
26
+ const frameKey = block.frameKey ?? block.id;
27
+ const sourcePath = block.source?.path;
28
+ const pageId = createPageObjectEntityId(frameKey);
29
+ const base = {
30
+ frameKey,
31
+ pageId,
32
+ source: sourcePath
33
+ ? {
34
+ path: sourcePath,
35
+ file: block.source?.file,
36
+ line: 1,
37
+ column: 1,
38
+ }
39
+ : undefined,
40
+ };
41
+
42
+ entities[pageId] = {
43
+ id: pageId,
44
+ kind: "page",
45
+ label: block.title || `Page ${block.pageNumber}`,
46
+ ...base,
47
+ };
48
+
49
+ const frameId = createFrameObjectEntityId(frameKey);
50
+ entities[frameId] = {
51
+ id: frameId,
52
+ kind: "frame",
53
+ label: block.role || block.title || frameKey,
54
+ ...base,
55
+ parentId: pageId,
56
+ metadata: { role: block.role ?? null, chrome: block.chrome ?? null },
57
+ };
58
+ }
59
+
60
+ for (const frame of frames) {
61
+ const pageId = createPageObjectEntityId(frame.frameKey);
62
+ const frameId = createFrameObjectEntityId(frame.frameKey);
63
+ for (const area of frame.mdxAreas ?? []) {
64
+ const id = createMdxAreaObjectEntityId(frame.frameKey, area.chainId, area.indexInFrame);
65
+ const firstEditableBlock = (area.blockIds ?? [])
66
+ .map((blockId) => blockMap[blockId])
67
+ .find((block) => block?.path);
68
+ for (const blockId of area.blockIds ?? []) blockParentIdByBlockId.set(blockId, id);
69
+ entities[id] = {
70
+ id,
71
+ kind: "mdx-area",
72
+ label: `${area.chainId} area ${area.indexInFrame + 1}`,
73
+ parentId: frameId,
74
+ pageId,
75
+ frameKey: frame.frameKey,
76
+ chainId: area.chainId,
77
+ source: firstEditableBlock
78
+ ? {
79
+ path: firstEditableBlock.path,
80
+ source: firstEditableBlock.source,
81
+ line: firstEditableBlock.source?.line,
82
+ column: firstEditableBlock.source?.column,
83
+ }
84
+ : undefined,
85
+ metadata: { blockCount: area.blockIds?.length ?? 0 },
86
+ };
87
+ }
88
+ }
89
+
90
+ for (const block of Object.values(blockMap)) {
91
+ if (!block?.id) continue;
92
+ const id = createBlockObjectEntityId(block.id);
93
+ const pageId = block.frameKey ? createPageObjectEntityId(block.frameKey) : undefined;
94
+ entities[id] = {
95
+ id,
96
+ kind: "mdx-block",
97
+ label: block.name ? `${block.name} ${block.id}` : block.id,
98
+ parentId: blockParentIdByBlockId.get(block.id),
99
+ pageId,
100
+ blockId: block.id,
101
+ frameKey: block.frameKey,
102
+ chainId: block.chainId,
103
+ source: block.path
104
+ ? {
105
+ path: block.path,
106
+ source: block.source,
107
+ line: block.source?.line,
108
+ column: block.source?.column,
109
+ }
110
+ : undefined,
111
+ metadata: {
112
+ blockKind: block.kind ?? null,
113
+ componentName: block.kind === "component" ? block.name ?? null : null,
114
+ },
115
+ };
116
+ }
117
+
118
+ return entities;
119
+ }
@@ -179,7 +179,8 @@ function greedyAllocate(blocks, regions) {
179
179
 
180
180
  function shouldKeepWithNext(block, nextBlock) {
181
181
  if (!nextBlock) return false;
182
- return /^h[1-6]$/.test(String(block?.name ?? ""));
182
+ const name = String(block?.name ?? "");
183
+ return /^h[1-6]$/.test(name) || name === "caption";
183
184
  }
184
185
 
185
186
  function recordAllocation(allocation, result, regions) {
@@ -223,12 +224,14 @@ function groupBlockHeights(blockHeights) {
223
224
 
224
225
  function buildBlockStream(chainSource, heightMap) {
225
226
  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
- }));
227
+ return chainSource
228
+ .filter((block) => block.layout !== "attached")
229
+ .map((block) => ({
230
+ id: block.id,
231
+ kind: block.kind,
232
+ name: block.name,
233
+ height: heightMap.get(block.id) ?? 0,
234
+ }));
232
235
  }
233
236
 
234
237
  function* iterateChains(sources) {
@@ -221,6 +221,8 @@ 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;
224
226
  const rect = el.getBoundingClientRect();
225
227
  out.push({
226
228
  id: el.getAttribute("data-openpress-block-id"),
@@ -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,6 +10,7 @@
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
 
@@ -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`.
@@ -102,10 +103,6 @@ function pathRecord(absolutePath, documentRoot) {
102
103
  };
103
104
  }
104
105
 
105
- function documentRelativePath(absolutePath, documentRoot) {
106
- return path.relative(documentRoot, absolutePath).split(path.sep).join("/");
107
- }
108
-
109
106
  function compareSectionDirectories(a, b) {
110
107
  const left = sectionSortKey(a.name);
111
108
  const right = sectionSortKey(b.name);