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