@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
@@ -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,64 @@ 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
+ annotateTableCells(headerRecord.node, headerRecord.id);
187
+ }
188
+ if (captionRecord) {
189
+ if (renderCaption) {
190
+ setDataAttribute(captionRecord.node, "data-openpress-block-id", captionRecord.id);
191
+ setDataAttribute(captionRecord.node, "data-openpress-object-id", createBlockObjectEntityId(captionRecord.id));
192
+ if (selectedCaption) {
193
+ blocks.push({
194
+ id: captionRecord.id,
195
+ kind: "element",
196
+ name: "caption",
197
+ text: textContent(captionRecord.node).trim() || undefined,
198
+ filePath,
199
+ chapterSlug,
200
+ tableId: id,
201
+ layout: "attached",
202
+ source: sourcePosition(captionRecord.node.position ?? node.position),
203
+ });
204
+ }
205
+ } else {
206
+ removeTableCaption(node);
207
+ }
208
+ }
209
+ if (headerRecord && selectedHeader) {
210
+ blocks.push({
211
+ id: headerRecord.id,
212
+ kind: "table-row",
213
+ name: "table-header-row",
214
+ text: textContent(headerRecord.node).trim() || undefined,
215
+ filePath,
216
+ chapterSlug,
217
+ tableId: id,
218
+ rowIndex: -1,
219
+ layout: "attached",
220
+ source: sourcePosition(headerRecord.node.position ?? node.position),
221
+ });
222
+ }
171
223
  const selectedNodes = new Set(selected.map((row) => row.node));
172
224
  pruneUnselectedTableRows(node, new Set(rowRecords.map((row) => row.node)), selectedNodes);
173
- if (selected[0]?.index > 0) stripTableHeader(node);
225
+ if (!renderHeader) stripTableHeader(node);
174
226
 
175
227
  for (const row of selected) {
176
228
  setDataAttribute(row.node, "data-openpress-block-id", row.id);
229
+ setDataAttribute(row.node, "data-openpress-object-id", createBlockObjectEntityId(row.id));
230
+ // Bake cell-level object ids into every <td>/<th>. The inspector resolves
231
+ // a clicked target via `closest("[data-openpress-object-id]")` — without
232
+ // this, a click inside a cell would walk up to the row and a comment
233
+ // would target the entire row. With the cell-precision id present in the
234
+ // static HTML the inspector targets the individual cell, matching the
235
+ // engine's per-cell source-edit pipeline (`cellIndex`).
236
+ annotateTableCells(row.node, row.id);
177
237
  blocks.push({
178
238
  id: row.id,
179
239
  kind: "table-row",
@@ -189,6 +249,28 @@ function applyTableRowBlocks({
189
249
  return "skip";
190
250
  }
191
251
 
252
+ function annotateTableCells(rowNode, rowBlockId) {
253
+ const children = Array.isArray(rowNode?.children) ? rowNode.children : [];
254
+ let cellIndex = 0;
255
+ for (const child of children) {
256
+ if (child?.type !== "element") continue;
257
+ if (child.tagName !== "td" && child.tagName !== "th") continue;
258
+ // Inherit the row's block id so `findObjectSelection` can resolve the
259
+ // cell's underlying SourceBlock (which lives on the row). The
260
+ // cell-precision `data-openpress-object-id` + cellIndex still let the
261
+ // inspector / source-edit pipeline target a single cell within that row.
262
+ // `data-openpress-inherited-block-id="true"` keeps the same convention
263
+ // the inline editor uses for caption / cell descendants, so block
264
+ // measurement (which queries `[data-openpress-block-id]`) can skip
265
+ // these and not double-count the row's height across N cells.
266
+ setDataAttribute(child, "data-openpress-block-id", rowBlockId);
267
+ setDataAttribute(child, "data-openpress-inherited-block-id", "true");
268
+ setDataAttribute(child, "data-openpress-object-id", `${createBlockObjectEntityId(rowBlockId)}:cell:${cellIndex}`);
269
+ setDataAttribute(child, "data-openpress-table-cell-index", String(cellIndex));
270
+ cellIndex += 1;
271
+ }
272
+ }
273
+
192
274
  export function remarkBlockOnlyMdx(options = {}) {
193
275
  const filePath = String(options.filePath ?? "document.mdx");
194
276
 
@@ -229,6 +311,7 @@ function normalizeTableCaptions(node) {
229
311
  type: "element",
230
312
  tagName: "caption",
231
313
  properties: {},
314
+ position: child.position,
232
315
  children: [{ type: "text", value: captionText }],
233
316
  });
234
317
  }
@@ -272,8 +355,10 @@ function wrapMdxComponents(components) {
272
355
  if (typeof Component !== "function") continue;
273
356
  wrapped[name] = function ComponentBlock(props = {}) {
274
357
  const blockId = props["data-openpress-block-id"];
358
+ const objectId = props["data-openpress-object-id"] || (blockId ? createBlockObjectEntityId(blockId) : undefined);
275
359
  const rest = { ...props };
276
360
  delete rest["data-openpress-block-id"];
361
+ delete rest["data-openpress-object-id"];
277
362
 
278
363
  if (!blockId) return React.createElement(Component, rest);
279
364
 
@@ -281,6 +366,7 @@ function wrapMdxComponents(components) {
281
366
  "div",
282
367
  {
283
368
  "data-openpress-block-id": blockId,
369
+ "data-openpress-object-id": objectId,
284
370
  "data-openpress-component-block": name,
285
371
  },
286
372
  React.createElement(Component, rest),
@@ -336,6 +422,7 @@ function applyListItemBlocks({
336
422
  if (items.length === 0) {
337
423
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
338
424
  setDataAttribute(node, "data-openpress-block-id", id);
425
+ setDataAttribute(node, "data-openpress-object-id", createBlockObjectEntityId(id));
339
426
  blocks.push({
340
427
  id,
341
428
  kind: "element",
@@ -375,6 +462,7 @@ function applyListItemBlocks({
375
462
 
376
463
  for (const item of selected) {
377
464
  setDataAttribute(item.node, "data-openpress-block-id", item.id);
465
+ setDataAttribute(item.node, "data-openpress-object-id", createBlockObjectEntityId(item.id));
378
466
  blocks.push({
379
467
  id: item.id,
380
468
  kind: "list-item",
@@ -419,6 +507,28 @@ function tableBodyRows(table) {
419
507
  return (table.children ?? []).filter((child) => child?.type === "element" && child.tagName === "tr");
420
508
  }
421
509
 
510
+ function tableHeaderRow(table) {
511
+ if (table?.type !== "element" || table.tagName !== "table") return null;
512
+ for (const child of table.children ?? []) {
513
+ if (child?.type !== "element" || child.tagName !== "thead") continue;
514
+ return (child.children ?? []).find((row) => row?.type === "element" && row.tagName === "tr") ?? null;
515
+ }
516
+ return null;
517
+ }
518
+
519
+ function selectedFirstTableRowIndex(rows, includeBlockIds, tableId) {
520
+ if (!includeBlockIds) return 0;
521
+ for (let index = 0; index < rows.length; index += 1) {
522
+ if (includeBlockIds.has(`${tableId}-r${index}`)) return index;
523
+ }
524
+ return -1;
525
+ }
526
+
527
+ function tableCaption(table) {
528
+ if (table?.type !== "element" || table.tagName !== "table") return null;
529
+ return (table.children ?? []).find((child) => child?.type === "element" && child.tagName === "caption") ?? null;
530
+ }
531
+
422
532
  function pruneUnselectedTableRows(node, rowNodes, selectedNodes) {
423
533
  if (!Array.isArray(node?.children)) return;
424
534
  node.children = node.children.filter((child) => {
@@ -432,10 +542,15 @@ function stripTableHeader(table) {
432
542
  if (!Array.isArray(table?.children)) return;
433
543
  table.children = table.children.filter((child) => {
434
544
  if (child?.type !== "element") return true;
435
- return child.tagName !== "caption" && child.tagName !== "thead";
545
+ return child.tagName !== "thead";
436
546
  });
437
547
  }
438
548
 
549
+ function removeTableCaption(table) {
550
+ if (!Array.isArray(table?.children)) return;
551
+ table.children = table.children.filter((child) => child?.type !== "element" || child.tagName !== "caption");
552
+ }
553
+
439
554
  function headingText(node) {
440
555
  if (!/^h[1-6]$/.test(String(node?.tagName ?? ""))) return undefined;
441
556
  return textContent(node).trim() || undefined;
@@ -470,6 +585,14 @@ function setDataAttribute(node, name, value) {
470
585
  node.properties[name] = value;
471
586
  }
472
587
 
588
+ function createObjectEntityId(kind, ...parts) {
589
+ return [kind, ...parts.map((part) => encodeURIComponent(String(part)))].join(":");
590
+ }
591
+
592
+ function createBlockObjectEntityId(blockId) {
593
+ return createObjectEntityId("mdx-block", blockId);
594
+ }
595
+
473
596
  function visit(node, visitor) {
474
597
  visitor(node);
475
598
  if (!Array.isArray(node?.children)) return;
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { buildComponentsCss, buildContentCss } from "../runtime/file-utils.mjs";
6
+ import { pageGeometryToTheme } from "../runtime/page-geometry.mjs";
6
7
  import { buildSectionScopedCss } from "./section-css.mjs";
7
8
 
8
9
  const require = createRequire(import.meta.url);
@@ -11,6 +12,7 @@ export async function buildReactMeasurementCss(root, config, workspace) {
11
12
  const parts = [];
12
13
  await appendOptionalFile(parts, path.join(config.paths.themeDir, "fonts.css"), "theme/fonts.css");
13
14
  await appendOptionalFile(parts, path.join(config.paths.themeDir, "tokens.css"), "theme/tokens.css");
15
+ appendPageGeometryCss(parts, config.page);
14
16
  parts.push("/* === public/openpress/content.css === */\n");
15
17
  parts.push(await buildContentCss(root, config));
16
18
  parts.push("\n/* === public/openpress/components.css === */\n");
@@ -20,7 +22,26 @@ export async function buildReactMeasurementCss(root, config, workspace) {
20
22
  parts.push("\n/* === public/openpress/chapter-scoped.css === */\n");
21
23
  parts.push(chapterCss);
22
24
  }
23
- return rewriteAssetUrls(parts.join("\n"), config);
25
+ return rewriteAssetUrls(stripViewportMediaQueries(parts.join("\n")), config);
26
+ }
27
+
28
+ function appendPageGeometryCss(parts, page) {
29
+ const theme = pageGeometryToTheme(page);
30
+ if (!theme) return;
31
+
32
+ const declarations = [
33
+ ["--openpress-page-width", theme.pageWidth],
34
+ ["--openpress-page-height", theme.pageHeight],
35
+ ["--openpress-page-aspect-ratio", theme.pageAspectRatio],
36
+ ["--openpress-page-height-ratio", theme.pageHeightRatio],
37
+ ].filter(([, value]) => value);
38
+
39
+ parts.push("/* === openpress page geometry === */\n");
40
+ parts.push(":root {\n");
41
+ for (const [name, value] of declarations) {
42
+ parts.push(` ${name}: ${value};\n`);
43
+ }
44
+ parts.push("}\n\n");
24
45
  }
25
46
 
26
47
  async function appendOptionalFile(parts, filePath, label) {
@@ -42,3 +63,95 @@ function rewriteAssetUrls(css, config) {
42
63
  .replace(/url\((["'])?\/openpress\/fonts\//g, `url($1${themeFontsDir}`)
43
64
  .replace(/url\((["'])?\/openpress\/katex-fonts\//g, `url($1${katexFontsDir}`);
44
65
  }
66
+
67
+ function stripViewportMediaQueries(css) {
68
+ let output = "";
69
+ let cursor = 0;
70
+
71
+ while (cursor < css.length) {
72
+ const mediaIndex = css.indexOf("@media", cursor);
73
+ if (mediaIndex < 0) {
74
+ output += css.slice(cursor);
75
+ break;
76
+ }
77
+
78
+ output += css.slice(cursor, mediaIndex);
79
+ const blockStart = css.indexOf("{", mediaIndex);
80
+ if (blockStart < 0) {
81
+ output += css.slice(mediaIndex);
82
+ break;
83
+ }
84
+
85
+ const prelude = css.slice(mediaIndex + "@media".length, blockStart);
86
+ const blockEnd = findCssBlockEnd(css, blockStart);
87
+ if (blockEnd < 0) {
88
+ output += css.slice(mediaIndex);
89
+ break;
90
+ }
91
+
92
+ if (!isViewportMediaPrelude(prelude)) {
93
+ output += css.slice(mediaIndex, blockEnd + 1);
94
+ }
95
+ cursor = blockEnd + 1;
96
+ }
97
+
98
+ return output;
99
+ }
100
+
101
+ function isViewportMediaPrelude(prelude) {
102
+ if (/\bprint\b/i.test(prelude)) return false;
103
+ return /\(\s*(?:min-|max-)?(?:device-)?(?:width|height)\s*:/i.test(prelude)
104
+ || /\(\s*orientation\s*:/i.test(prelude)
105
+ || /\(\s*(?:min-|max-)?aspect-ratio\s*:/i.test(prelude);
106
+ }
107
+
108
+ function findCssBlockEnd(css, blockStart) {
109
+ let depth = 0;
110
+ let quote = "";
111
+ let inComment = false;
112
+
113
+ for (let index = blockStart; index < css.length; index += 1) {
114
+ const current = css[index];
115
+ const next = css[index + 1];
116
+
117
+ if (inComment) {
118
+ if (current === "*" && next === "/") {
119
+ inComment = false;
120
+ index += 1;
121
+ }
122
+ continue;
123
+ }
124
+
125
+ if (quote) {
126
+ if (current === "\\") {
127
+ index += 1;
128
+ continue;
129
+ }
130
+ if (current === quote) quote = "";
131
+ continue;
132
+ }
133
+
134
+ if (current === "/" && next === "*") {
135
+ inComment = true;
136
+ index += 1;
137
+ continue;
138
+ }
139
+
140
+ if (current === "\"" || current === "'") {
141
+ quote = current;
142
+ continue;
143
+ }
144
+
145
+ if (current === "{") {
146
+ depth += 1;
147
+ continue;
148
+ }
149
+
150
+ if (current === "}") {
151
+ depth -= 1;
152
+ if (depth === 0) return index;
153
+ }
154
+ }
155
+
156
+ return -1;
157
+ }
@@ -0,0 +1,204 @@
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 createScopedObjectEntityId(kind, parentId, objectId) {
18
+ return parentId ? createObjectEntityId(kind, parentId, objectId) : createObjectEntityId(kind, objectId);
19
+ }
20
+
21
+ export function createMdxAreaObjectEntityId(frameKey, chainId, indexInFrame) {
22
+ return createObjectEntityId("mdx-area", frameKey, chainId, indexInFrame);
23
+ }
24
+
25
+ export function buildObjectEntities({ frames, blocks, blockMap }) {
26
+ const entities = {};
27
+ const blockParentIdByBlockId = new Map();
28
+
29
+ for (const block of blocks) {
30
+ const frameKey = block.frameKey ?? block.id;
31
+ const sourcePath = block.source?.path;
32
+ const pageId = createPageObjectEntityId(frameKey);
33
+ const base = {
34
+ frameKey,
35
+ pageId,
36
+ source: sourcePath
37
+ ? {
38
+ path: sourcePath,
39
+ file: block.source?.file,
40
+ line: 1,
41
+ column: 1,
42
+ }
43
+ : undefined,
44
+ };
45
+
46
+ entities[pageId] = {
47
+ id: pageId,
48
+ kind: "page",
49
+ label: block.title || `Page ${block.pageNumber}`,
50
+ ...base,
51
+ };
52
+
53
+ const frameId = createFrameObjectEntityId(frameKey);
54
+ entities[frameId] = {
55
+ id: frameId,
56
+ kind: "frame",
57
+ label: block.role || block.title || frameKey,
58
+ ...base,
59
+ parentId: pageId,
60
+ metadata: { role: block.role ?? null, chrome: block.chrome ?? null },
61
+ };
62
+ }
63
+
64
+ for (const frame of frames) {
65
+ const pageId = createPageObjectEntityId(frame.frameKey);
66
+ const frameId = createFrameObjectEntityId(frame.frameKey);
67
+ for (const area of frame.mdxAreas ?? []) {
68
+ const id = createMdxAreaObjectEntityId(frame.frameKey, area.chainId, area.indexInFrame);
69
+ const firstEditableBlock = (area.blockIds ?? [])
70
+ .map((blockId) => blockMap[blockId])
71
+ .find((block) => block?.path);
72
+ for (const blockId of area.blockIds ?? []) blockParentIdByBlockId.set(blockId, id);
73
+ entities[id] = {
74
+ id,
75
+ kind: "mdx-area",
76
+ label: `${area.chainId} area ${area.indexInFrame + 1}`,
77
+ parentId: frameId,
78
+ pageId,
79
+ frameKey: frame.frameKey,
80
+ chainId: area.chainId,
81
+ source: firstEditableBlock
82
+ ? {
83
+ path: firstEditableBlock.path,
84
+ source: firstEditableBlock.source,
85
+ line: firstEditableBlock.source?.line,
86
+ column: firstEditableBlock.source?.column,
87
+ }
88
+ : undefined,
89
+ metadata: { blockCount: area.blockIds?.length ?? 0 },
90
+ };
91
+ }
92
+ }
93
+
94
+ for (const block of Object.values(blockMap)) {
95
+ if (!block?.id) continue;
96
+ const id = createBlockObjectEntityId(block.id);
97
+ const pageId = block.frameKey ? createPageObjectEntityId(block.frameKey) : undefined;
98
+ entities[id] = {
99
+ id,
100
+ kind: "mdx-block",
101
+ label: block.name ? `${block.name} ${block.id}` : block.id,
102
+ parentId: blockParentIdByBlockId.get(block.id),
103
+ pageId,
104
+ blockId: block.id,
105
+ frameKey: block.frameKey,
106
+ chainId: block.chainId,
107
+ source: block.path
108
+ ? {
109
+ path: block.path,
110
+ source: block.source,
111
+ line: block.source?.line,
112
+ column: block.source?.column,
113
+ }
114
+ : undefined,
115
+ metadata: {
116
+ blockKind: block.kind ?? null,
117
+ componentName: block.kind === "component" ? block.name ?? null : null,
118
+ },
119
+ };
120
+ }
121
+
122
+ for (const entity of collectRenderedObjectEntities(frames)) {
123
+ if (!entity.id || entities[entity.id]) continue;
124
+ const pageId = entity.pageId || createPageObjectEntityId(entity.frameKey);
125
+ const frameId = createFrameObjectEntityId(entity.frameKey);
126
+ entities[entity.id] = {
127
+ id: entity.id,
128
+ kind: entity.kind,
129
+ label: entity.label || entity.id,
130
+ parentId: entity.parentId || (entity.id === frameId ? pageId : frameId),
131
+ pageId,
132
+ blockId: entity.blockId,
133
+ frameKey: entity.frameKey,
134
+ chainId: entity.chainId,
135
+ source: entity.source,
136
+ metadata: entity.metadata,
137
+ };
138
+ }
139
+
140
+ return entities;
141
+ }
142
+
143
+ const OBJECT_OPEN_RE = /<([a-z][a-z0-9-]*)\b([^>]*)\bdata-openpress-object-id="([^"]+)"([^>]*)>/gi;
144
+ const ATTR_RE = (name) => new RegExp(`\\b${name}="([^"]*)"`);
145
+
146
+ function collectRenderedObjectEntities(frames) {
147
+ const entities = [];
148
+ for (const frame of frames) {
149
+ const pageId = createPageObjectEntityId(frame.frameKey);
150
+ const frameId = createFrameObjectEntityId(frame.frameKey);
151
+ const html = String(frame.html ?? "");
152
+ let match;
153
+ OBJECT_OPEN_RE.lastIndex = 0;
154
+ while ((match = OBJECT_OPEN_RE.exec(html)) !== null) {
155
+ const attrs = `${match[2] ?? ""} data-openpress-object-id="${match[3] ?? ""}" ${match[4] ?? ""}`;
156
+ const id = htmlDecode(match[3] ?? "");
157
+ const kind = htmlDecode(pickAttr(attrs, "data-openpress-object-kind")) || objectKindFromId(id);
158
+ if (!id || !kind) continue;
159
+ entities.push({
160
+ id,
161
+ kind,
162
+ label: htmlDecode(pickAttr(attrs, "data-openpress-object-label")) || id,
163
+ parentId: htmlDecode(pickAttr(attrs, "data-openpress-object-parent-id")) || (id === frameId ? pageId : frameId),
164
+ pageId: htmlDecode(pickAttr(attrs, "data-openpress-object-page-id")) || pageId,
165
+ blockId: htmlDecode(pickAttr(attrs, "data-openpress-block-id")) || undefined,
166
+ frameKey: htmlDecode(pickAttr(attrs, "data-openpress-object-frame-key")) || frame.frameKey,
167
+ chainId: htmlDecode(pickAttr(attrs, "data-openpress-object-chain-id")) || undefined,
168
+ source: parseJsonAttribute(pickAttr(attrs, "data-openpress-object-source")),
169
+ metadata: parseJsonAttribute(pickAttr(attrs, "data-openpress-object-metadata")),
170
+ });
171
+ }
172
+ }
173
+ return entities;
174
+ }
175
+
176
+ function pickAttr(attrs, name) {
177
+ const match = ATTR_RE(name).exec(attrs);
178
+ return match ? match[1] : "";
179
+ }
180
+
181
+ function objectKindFromId(id) {
182
+ const separator = id.indexOf(":");
183
+ return separator === -1 ? "" : id.slice(0, separator);
184
+ }
185
+
186
+ function parseJsonAttribute(value) {
187
+ const decoded = htmlDecode(value);
188
+ if (!decoded) return undefined;
189
+ try {
190
+ return JSON.parse(decoded);
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ function htmlDecode(value) {
197
+ return String(value ?? "")
198
+ .replaceAll("&quot;", '"')
199
+ .replaceAll("&#x27;", "'")
200
+ .replaceAll("&#39;", "'")
201
+ .replaceAll("&lt;", "<")
202
+ .replaceAll("&gt;", ">")
203
+ .replaceAll("&amp;", "&");
204
+ }
@@ -7,7 +7,8 @@ import { singleColumnRegionStream } from "./regions.mjs";
7
7
  // region until adding the next block would exceed capacity, then advance to
8
8
  // the next region. Pages are a derived view (grouping by pageIndex), so the
9
9
  // same code paginates single-column, multi-column, and heterogeneous layouts.
10
- export function allocateBlocksToRegions(measuredBlocks, regionStream) {
10
+ export function allocateBlocksToRegions(measuredBlocks, regionStream, options = {}) {
11
+ const keepWithNext = typeof options.keepWithNext === "function" ? options.keepWithNext : null;
11
12
  const filled = [];
12
13
  const warnings = [];
13
14
  let current = regionStream.next();
@@ -16,6 +17,7 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
16
17
  }
17
18
  let currentBlockIds = [];
18
19
  let currentHeight = 0;
20
+ let consumedCount = 0;
19
21
 
20
22
  const flush = () => {
21
23
  if (currentBlockIds.length === 0) return;
@@ -29,7 +31,9 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
29
31
  currentHeight = 0;
30
32
  };
31
33
 
32
- for (const block of measuredBlocks ?? []) {
34
+ const blocks = measuredBlocks ?? [];
35
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
36
+ const block = blocks[blockIndex];
33
37
  const id = String(block?.id ?? "");
34
38
  if (!id) continue;
35
39
  const height = Math.max(0, Number(block.height) || 0);
@@ -45,6 +49,24 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
45
49
  });
46
50
  }
47
51
 
52
+ const nextBlock = blocks[blockIndex + 1];
53
+ const nextHeight = Math.max(0, Number(nextBlock?.height) || 0);
54
+ const keepWithNextHeight = keepWithNext?.(block, nextBlock) ? height + nextHeight : 0;
55
+
56
+ if (
57
+ currentBlockIds.length > 0 &&
58
+ keepWithNextHeight > 0 &&
59
+ currentHeight + keepWithNextHeight > current.capacity
60
+ ) {
61
+ flush();
62
+ const next = regionStream.next();
63
+ if (!next) {
64
+ warnings.push({ code: "out-of-regions", blockId: id });
65
+ break;
66
+ }
67
+ current = next;
68
+ }
69
+
48
70
  if (currentBlockIds.length > 0 && currentHeight + height > current.capacity) {
49
71
  flush();
50
72
  const next = regionStream.next();
@@ -57,10 +79,17 @@ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
57
79
 
58
80
  currentBlockIds.push(id);
59
81
  currentHeight += height;
82
+ consumedCount += 1;
60
83
  }
61
84
 
62
85
  flush();
63
- return { regions: filled, warnings };
86
+ return { regions: filled, warnings, consumedCount };
87
+ }
88
+
89
+ export function estimateRegionsNeeded(measuredBlocks, regionCapacity, options = {}) {
90
+ const capacity = positiveNumber(regionCapacity, DEFAULT_PAGE_SAFE_HEIGHT_PX);
91
+ const result = allocateBlocksToRegions(measuredBlocks, infiniteFixedCapacityRegionStream(capacity), options);
92
+ return result.regions.length;
64
93
  }
65
94
 
66
95
  // Derive a flat pages[] view from filled regions. Blocks within a page are
@@ -101,6 +130,22 @@ export function paginateMeasuredBlocks(measuredBlocks, options = {}) {
101
130
  };
102
131
  }
103
132
 
133
+ function infiniteFixedCapacityRegionStream(capacity) {
134
+ let index = 0;
135
+ return {
136
+ next() {
137
+ const region = {
138
+ id: `estimate-region-${index}`,
139
+ capacity,
140
+ pageIndex: index,
141
+ columnIndex: 0,
142
+ };
143
+ index += 1;
144
+ return region;
145
+ },
146
+ };
147
+ }
148
+
104
149
  // Translate the new region-shaped warnings back to the legacy
105
150
  // `block-overflows-page` schema that document-export.mjs and downstream
106
151
  // consumers expect. Once consumers migrate, this can drop.
@@ -5,5 +5,5 @@
5
5
  // these helpers. The region kernel is also usable on its own for custom
6
6
  // pipelines or unit tests.
7
7
 
8
- export { paginateMeasuredBlocks, allocateBlocksToRegions, pagesFromRegions } from "./pagination/allocator.mjs";
8
+ export { paginateMeasuredBlocks, allocateBlocksToRegions, estimateRegionsNeeded, pagesFromRegions } from "./pagination/allocator.mjs";
9
9
  export { singleColumnRegionStream, multiColumnRegionStream, fixedRegionStream } from "./pagination/regions.mjs";