@open-press/core 0.7.0 → 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 (116) hide show
  1. package/engine/commands/dev.mjs +2 -2
  2. package/engine/commands/upgrade.mjs +47 -5
  3. package/engine/output/chrome-pdf.mjs +18 -3
  4. package/engine/output/static-server.mjs +39 -0
  5. package/engine/react/comment-endpoint.mjs +13 -39
  6. package/engine/react/comment-marker.mjs +30 -6
  7. package/engine/react/document-entry.mjs +11 -0
  8. package/engine/react/document-export.mjs +45 -5
  9. package/engine/react/http-json.mjs +24 -0
  10. package/engine/react/mdx-compile.mjs +187 -3
  11. package/engine/react/measurement-css.mjs +93 -1
  12. package/engine/react/object-entities.mjs +119 -0
  13. package/engine/react/pipeline/allocate.mjs +10 -7
  14. package/engine/react/pipeline/frame-measurement.mjs +40 -9
  15. package/engine/react/project-asset-endpoint.mjs +6 -24
  16. package/engine/react/source-edit-endpoint.d.mts +10 -0
  17. package/engine/react/source-edit-endpoint.mjs +75 -0
  18. package/engine/react/sources/mdx-resolver.mjs +12 -14
  19. package/engine/react/style-discovery.mjs +1 -4
  20. package/engine/runtime/file-walk.mjs +22 -0
  21. package/engine/runtime/inspection.mjs +1 -20
  22. package/engine/runtime/path-utils.mjs +20 -0
  23. package/engine/runtime/source-text-tools.d.mts +102 -0
  24. package/engine/runtime/source-text-tools.mjs +551 -16
  25. package/engine/runtime/source-workspace.mjs +4 -31
  26. package/package.json +1 -1
  27. package/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
  28. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
  29. package/src/openpress/app/index.ts +2 -0
  30. package/src/openpress/core/Frame.tsx +9 -11
  31. package/src/openpress/core/FrameContext.tsx +8 -3
  32. package/src/openpress/core/MdxArea.tsx +11 -12
  33. package/src/openpress/core/cn.ts +4 -0
  34. package/src/openpress/core/index.tsx +2 -1
  35. package/src/openpress/core/primitives.tsx +29 -8
  36. package/src/openpress/core/types.ts +8 -0
  37. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  38. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  39. package/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
  40. package/src/openpress/document-model/index.ts +6 -0
  41. package/src/openpress/document-model/objectEntityModel.ts +51 -0
  42. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  43. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  44. package/src/openpress/manuscript/index.tsx +49 -7
  45. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  46. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  47. package/src/openpress/reader/index.ts +10 -0
  48. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  49. package/src/openpress/reader/readerTypes.ts +4 -0
  50. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  51. package/src/openpress/reader/usePanelState.ts +56 -0
  52. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  53. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  54. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  55. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  56. package/src/openpress/shared/Panel.tsx +77 -0
  57. package/src/openpress/shared/index.ts +4 -0
  58. package/src/openpress/shared/numberUtils.ts +3 -0
  59. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  60. package/src/openpress/workbench/Workbench.tsx +407 -0
  61. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  62. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  63. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  64. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  65. package/src/openpress/workbench/actions/index.ts +5 -0
  66. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  67. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  68. package/src/openpress/workbench/dialog/index.ts +1 -0
  69. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  70. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  71. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  72. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  73. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  74. package/src/openpress/workbench/document/index.ts +10 -0
  75. package/src/openpress/workbench/index.ts +2 -0
  76. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  77. package/src/openpress/workbench/inspector/index.ts +5 -0
  78. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  79. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  80. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  81. package/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
  82. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  83. package/src/openpress/workbench/mentions/index.ts +2 -0
  84. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  85. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  86. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
  87. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  88. package/src/openpress/workbench/panels/index.ts +3 -0
  89. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
  90. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  91. package/src/openpress/workbench/project/index.ts +2 -0
  92. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  93. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  94. package/src/openpress/workbench/shell/index.ts +1 -0
  95. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  96. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  97. package/src/styles/openpress/print-route.css +0 -2
  98. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  99. package/src/styles/openpress/public-viewer.css +25 -320
  100. package/src/styles/openpress/reader-runtime.css +243 -55
  101. package/src/styles/openpress/responsive.css +145 -270
  102. package/src/styles/openpress/workbench-panels.css +214 -178
  103. package/src/styles/openpress/workbench.css +986 -451
  104. package/src/styles/openpress.css +1 -1
  105. package/vite.config.ts +50 -0
  106. package/src/openpress/inspector.ts +0 -282
  107. package/src/openpress/projectWorkspace.tsx +0 -919
  108. package/src/openpress/readerRuntime.ts +0 -230
  109. package/src/openpress/workbench.tsx +0 -1265
  110. package/src/openpress/workbenchTypes.ts +0 -4
  111. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  112. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  113. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  114. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  115. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  116. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -99,9 +99,20 @@ export function rehypeBlockIds(options = {}) {
99
99
  includeBlockIds,
100
100
  });
101
101
  }
102
+ if (block.name === "ul" || block.name === "ol") {
103
+ return applyListItemBlocks({
104
+ node,
105
+ id,
106
+ blocks,
107
+ filePath,
108
+ chapterSlug,
109
+ includeBlockIds,
110
+ });
111
+ }
102
112
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
103
113
 
104
114
  setDataAttribute(node, "data-openpress-block-id", id);
115
+ setDataAttribute(node, "data-openpress-object-id", createBlockObjectEntityId(id));
105
116
  const extraAttributes = blockAttributes.get(id);
106
117
  if (extraAttributes) {
107
118
  for (const [name, value] of Object.entries(extraAttributes)) {
@@ -132,9 +143,19 @@ function applyTableRowBlocks({
132
143
  includeBlockIds,
133
144
  }) {
134
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));
135
155
  if (rows.length === 0) {
136
156
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
137
157
  setDataAttribute(node, "data-openpress-block-id", id);
158
+ setDataAttribute(node, "data-openpress-object-id", createBlockObjectEntityId(id));
138
159
  blocks.push({
139
160
  id,
140
161
  kind: "element",
@@ -155,15 +176,56 @@ function applyTableRowBlocks({
155
176
  const selected = includeBlockIds
156
177
  ? rowRecords.filter((row) => includeBlockIds.has(row.id))
157
178
  : rowRecords;
158
- if (selected.length === 0) return false;
179
+ if (selected.length === 0 && !selectedCaption && !selectedHeader) return false;
159
180
 
160
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
+ }
161
222
  const selectedNodes = new Set(selected.map((row) => row.node));
162
223
  pruneUnselectedTableRows(node, new Set(rowRecords.map((row) => row.node)), selectedNodes);
163
- if (selected[0]?.index > 0) stripTableHeader(node);
224
+ if (!renderHeader) stripTableHeader(node);
164
225
 
165
226
  for (const row of selected) {
166
227
  setDataAttribute(row.node, "data-openpress-block-id", row.id);
228
+ setDataAttribute(row.node, "data-openpress-object-id", createBlockObjectEntityId(row.id));
167
229
  blocks.push({
168
230
  id: row.id,
169
231
  kind: "table-row",
@@ -219,6 +281,7 @@ function normalizeTableCaptions(node) {
219
281
  type: "element",
220
282
  tagName: "caption",
221
283
  properties: {},
284
+ position: child.position,
222
285
  children: [{ type: "text", value: captionText }],
223
286
  });
224
287
  }
@@ -262,8 +325,10 @@ function wrapMdxComponents(components) {
262
325
  if (typeof Component !== "function") continue;
263
326
  wrapped[name] = function ComponentBlock(props = {}) {
264
327
  const blockId = props["data-openpress-block-id"];
328
+ const objectId = props["data-openpress-object-id"] || (blockId ? createBlockObjectEntityId(blockId) : undefined);
265
329
  const rest = { ...props };
266
330
  delete rest["data-openpress-block-id"];
331
+ delete rest["data-openpress-object-id"];
267
332
 
268
333
  if (!blockId) return React.createElement(Component, rest);
269
334
 
@@ -271,6 +336,7 @@ function wrapMdxComponents(components) {
271
336
  "div",
272
337
  {
273
338
  "data-openpress-block-id": blockId,
339
+ "data-openpress-object-id": objectId,
274
340
  "data-openpress-component-block": name,
275
341
  },
276
342
  React.createElement(Component, rest),
@@ -314,6 +380,89 @@ function blockInfo(node) {
314
380
  return null;
315
381
  }
316
382
 
383
+ function applyListItemBlocks({
384
+ node,
385
+ id,
386
+ blocks,
387
+ filePath,
388
+ chapterSlug,
389
+ includeBlockIds,
390
+ }) {
391
+ const items = listItems(node);
392
+ if (items.length === 0) {
393
+ if (includeBlockIds && !includeBlockIds.has(id)) return false;
394
+ setDataAttribute(node, "data-openpress-block-id", id);
395
+ setDataAttribute(node, "data-openpress-object-id", createBlockObjectEntityId(id));
396
+ blocks.push({
397
+ id,
398
+ kind: "element",
399
+ name: node.tagName,
400
+ text: textContent(node).trim() || undefined,
401
+ filePath,
402
+ chapterSlug,
403
+ source: sourcePosition(node.position),
404
+ });
405
+ return "skip";
406
+ }
407
+
408
+ const itemRecords = items.map((item, index) => ({
409
+ id: `${id}-i${index}`,
410
+ node: item,
411
+ index,
412
+ }));
413
+ const selected = includeBlockIds
414
+ ? itemRecords.filter((item) => includeBlockIds.has(item.id))
415
+ : itemRecords;
416
+ if (selected.length === 0) return false;
417
+
418
+ setDataAttribute(node, "data-openpress-list-id", id);
419
+
420
+ // For ordered lists, continuation pages must keep numbering picking up
421
+ // from the first surviving item. `start` is the 1-based number of the
422
+ // first `<li>` rendered, so if the original list had `start="5"` and we
423
+ // dropped the first three items, continuation starts at 5 + 3 = 8.
424
+ if (node.tagName === "ol" && selected[0]?.index > 0) {
425
+ const baseStart = Number(node.properties?.start ?? 1);
426
+ const continuationStart = baseStart + selected[0].index;
427
+ node.properties = { ...node.properties, start: continuationStart };
428
+ }
429
+
430
+ const selectedNodes = new Set(selected.map((item) => item.node));
431
+ pruneUnselectedListItems(node, new Set(itemRecords.map((item) => item.node)), selectedNodes);
432
+
433
+ for (const item of selected) {
434
+ setDataAttribute(item.node, "data-openpress-block-id", item.id);
435
+ setDataAttribute(item.node, "data-openpress-object-id", createBlockObjectEntityId(item.id));
436
+ blocks.push({
437
+ id: item.id,
438
+ kind: "list-item",
439
+ name: "list-item",
440
+ text: textContent(item.node).trim() || undefined,
441
+ filePath,
442
+ chapterSlug,
443
+ listId: id,
444
+ listTag: node.tagName,
445
+ itemIndex: item.index,
446
+ source: sourcePosition(item.node.position ?? node.position),
447
+ });
448
+ }
449
+ return "skip";
450
+ }
451
+
452
+ function listItems(list) {
453
+ if (list?.type !== "element") return [];
454
+ if (list.tagName !== "ul" && list.tagName !== "ol") return [];
455
+ return (list.children ?? []).filter((child) => child?.type === "element" && child.tagName === "li");
456
+ }
457
+
458
+ function pruneUnselectedListItems(node, itemNodes, selectedNodes) {
459
+ if (!Array.isArray(node?.children)) return;
460
+ node.children = node.children.filter((child) => {
461
+ if (!itemNodes.has(child)) return true;
462
+ return selectedNodes.has(child);
463
+ });
464
+ }
465
+
317
466
  function tableBodyRows(table) {
318
467
  if (table?.type !== "element" || table.tagName !== "table") return [];
319
468
  const rows = [];
@@ -328,6 +477,28 @@ function tableBodyRows(table) {
328
477
  return (table.children ?? []).filter((child) => child?.type === "element" && child.tagName === "tr");
329
478
  }
330
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
+
331
502
  function pruneUnselectedTableRows(node, rowNodes, selectedNodes) {
332
503
  if (!Array.isArray(node?.children)) return;
333
504
  node.children = node.children.filter((child) => {
@@ -341,10 +512,15 @@ function stripTableHeader(table) {
341
512
  if (!Array.isArray(table?.children)) return;
342
513
  table.children = table.children.filter((child) => {
343
514
  if (child?.type !== "element") return true;
344
- return child.tagName !== "caption" && child.tagName !== "thead";
515
+ return child.tagName !== "thead";
345
516
  });
346
517
  }
347
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
+
348
524
  function headingText(node) {
349
525
  if (!/^h[1-6]$/.test(String(node?.tagName ?? ""))) return undefined;
350
526
  return textContent(node).trim() || undefined;
@@ -379,6 +555,14 @@ function setDataAttribute(node, name, value) {
379
555
  node.properties[name] = value;
380
556
  }
381
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
+
382
566
  function visit(node, visitor) {
383
567
  visitor(node);
384
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) {
@@ -135,15 +135,30 @@ async function runChromiumMeasurement(html, viewport) {
135
135
  try {
136
136
  const page = await browser.newPage({ viewport });
137
137
  await page.setContent(html, { waitUntil: "load" });
138
+ // Match the print-ready settle: fonts first (font metrics affect image
139
+ // alt-text fallback boxes), then await every image's `complete` AND
140
+ // `decode()` so intrinsic sizes are committed before layout, then two
141
+ // animation frames so the chromium layout pass observes the final box
142
+ // model. Without this, `getBoundingClientRect()` on figures that hold
143
+ // images can race the decode and return collapsed heights, causing the
144
+ // allocator to pack too many blocks per page.
138
145
  await page.evaluate(async () => {
139
- await Promise.all(Array.from(document.images).map((img) => {
140
- if (img.complete) return Promise.resolve();
141
- return new Promise((resolve) => {
142
- img.addEventListener("load", resolve, { once: true });
143
- img.addEventListener("error", resolve, { once: true });
144
- });
145
- }));
146
146
  if (document.fonts?.ready) await document.fonts.ready;
147
+ await Promise.all(Array.from(document.images).map(async (img) => {
148
+ if (!img.complete) {
149
+ await new Promise((resolve) => {
150
+ const settle = () => {
151
+ img.removeEventListener("load", settle);
152
+ img.removeEventListener("error", settle);
153
+ resolve(undefined);
154
+ };
155
+ img.addEventListener("load", settle, { once: true });
156
+ img.addEventListener("error", settle, { once: true });
157
+ });
158
+ }
159
+ await img.decode?.().catch(() => undefined);
160
+ }));
161
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
147
162
  });
148
163
 
149
164
  const mdxAreas = await page.evaluate((safety) => {
@@ -206,6 +221,8 @@ async function runChromiumMeasurement(html, viewport) {
206
221
  const parentTop = chain.parentElement?.getBoundingClientRect().top ?? chain.getBoundingClientRect().top;
207
222
  let previousBottom = parentTop;
208
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;
209
226
  const rect = el.getBoundingClientRect();
210
227
  out.push({
211
228
  id: el.getAttribute("data-openpress-block-id"),
@@ -232,13 +249,27 @@ async function inlineMeasurementMediaUrls(html, mediaDir) {
232
249
  if (!mediaDir || !html) return html;
233
250
  let out = String(html);
234
251
  const matches = new Set();
235
- for (const match of out.matchAll(/\/openpress\/media\/([^"')\s>]+)/g)) {
236
- matches.add(match[1]);
252
+ for (const match of out.matchAll(/\bsrc=(['"])([^\1]*?)\1/g)) {
253
+ const src = match[2];
254
+ if (!src) continue;
255
+ if (src.startsWith('/openpress/media/')) {
256
+ matches.add(src.slice('/openpress/media/'.length));
257
+ continue;
258
+ }
259
+ if (src.startsWith('media/')) {
260
+ matches.add(src.slice('media/'.length));
261
+ continue;
262
+ }
263
+ if (src.startsWith('./media/')) {
264
+ matches.add(src.slice('./media/'.length));
265
+ }
237
266
  }
238
267
  for (const rawName of matches) {
239
268
  const dataUrl = await mediaDataUrl(mediaDir, rawName);
240
269
  if (!dataUrl) continue;
241
270
  out = out.replaceAll(`/openpress/media/${rawName}`, dataUrl);
271
+ out = out.replaceAll(`media/${rawName}`, dataUrl);
272
+ out = out.replaceAll(`./media/${rawName}`, dataUrl);
242
273
  }
243
274
  return out;
244
275
  }
@@ -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>;