@open-press/core 1.2.1 → 1.3.1
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 +2 -2
- package/engine/commands/typecheck.mjs +1 -1
- package/engine/document-export.mjs +1 -1
- package/engine/output/page-block.mjs +11 -2
- package/engine/output/public-assets.mjs +41 -6
- package/engine/output/static-server.mjs +68 -15
- package/engine/react/caption-numbering.mjs +2 -2
- package/engine/react/comment-marker.mjs +1 -2
- package/engine/react/document-entry.mjs +64 -11
- package/engine/react/document-export.d.mts +6 -0
- package/engine/react/document-export.mjs +158 -28
- package/engine/react/mdx-compile.mjs +4 -4
- package/engine/react/measurement-css.mjs +3 -3
- package/engine/react/page-folio.mjs +37 -0
- package/engine/react/pagination/allocator.mjs +4 -4
- package/engine/react/pipeline/frame-measurement.mjs +34 -16
- package/engine/react/press-tree-inspection.mjs +43 -13
- package/engine/react/project-asset-endpoint.mjs +45 -11
- package/engine/react/sources/heading-numbering.mjs +2 -2
- package/engine/react/sources/mdx-resolver.mjs +3 -3
- package/engine/react/style-discovery.mjs +60 -11
- package/engine/react/text-source-transform.mjs +18 -4
- package/engine/runtime/config.mjs +22 -22
- package/engine/runtime/file-utils.mjs +57 -13
- package/engine/runtime/inspection.mjs +40 -15
- package/engine/runtime/page-geometry.mjs +6 -6
- package/engine/runtime/source-text-tools.mjs +28 -4
- package/engine/runtime/source-workspace.mjs +6 -9
- package/engine/runtime/validation.mjs +42 -24
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +6 -15
- package/src/openpress/app/OpenPressRuntime.tsx +3 -3
- package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
- package/src/openpress/core/PageFolio.tsx +115 -0
- package/src/openpress/core/Press.tsx +5 -10
- package/src/openpress/core/Slide.tsx +11 -0
- package/src/openpress/core/index.tsx +4 -0
- package/src/openpress/core/types.ts +21 -13
- package/src/openpress/core/useSource.ts +1 -1
- package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
- package/vite.config.ts +82 -16
|
@@ -17,12 +17,13 @@ import { buildSectionScopedCss } from "./section-css.mjs";
|
|
|
17
17
|
import { CORE_ENTRY, createReactSsrServer, loadReactDocumentEntry } from "./document-entry.mjs";
|
|
18
18
|
import { buildReactMeasurementCss } from "./measurement-css.mjs";
|
|
19
19
|
import { buildObjectEntities } from "./object-entities.mjs";
|
|
20
|
+
import { resolvePageFoliosInHtml } from "./page-folio.mjs";
|
|
20
21
|
import { allocateChains } from "./pipeline/allocate.mjs";
|
|
21
22
|
import { measureFrames } from "./pipeline/frame-measurement.mjs";
|
|
22
23
|
import { renderFinalPress } from "./pipeline/final-render.mjs";
|
|
23
24
|
import { expandPressTree } from "./pipeline/press-tree.mjs";
|
|
24
25
|
import { resolveAllSources } from "./sources/mdx-resolver.mjs";
|
|
25
|
-
import { discoverSectionStyles } from "./style-discovery.mjs";
|
|
26
|
+
import { discoverComponentsInRoots, discoverSectionStyles } from "./style-discovery.mjs";
|
|
26
27
|
|
|
27
28
|
const MAX_ITERATIONS = 20;
|
|
28
29
|
const PRESS_TYPES = new Set(["pages", "slides"]);
|
|
@@ -41,10 +42,10 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
|
|
|
41
42
|
if (!entry) return null;
|
|
42
43
|
if (!entry.Press) {
|
|
43
44
|
throw new Error(
|
|
44
|
-
`OpenPress document entry ${entry.entryPath} must default-export a
|
|
45
|
-
`Legacy named exports (cover/toc/backCover) are not supported in v0.6 — see the Press Tree spec.`,
|
|
45
|
+
`OpenPress document entry ${entry.entryPath} must default-export a React component that renders one or more <Press> elements.`,
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
|
+
validateDiscoveredPressFolders(entry);
|
|
48
49
|
// Resolve PressContext + Frame markers from the engine's loaded core module.
|
|
49
50
|
// Use the absolute file path so the user's `import "@open-press/core"`
|
|
50
51
|
// (resolved via vite alias) and our load hit the same module cache entry.
|
|
@@ -60,6 +61,9 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
|
|
|
60
61
|
// workspace can host more than one chapter root.
|
|
61
62
|
const sectionRoots = collectSectionRoots(entry.presses, entry.config.paths.documentRoot);
|
|
62
63
|
const workspace = await discoverSectionStyles(workspaceRoot, entry.config, { sectionRoots });
|
|
64
|
+
const workspaceThemeRoots = collectWorkspaceThemeRoots(entry.presses, entry.config);
|
|
65
|
+
const workspaceComponentRoots = collectWorkspaceComponentRoots(entry.presses, entry.config);
|
|
66
|
+
const workspaceMediaRoots = collectWorkspaceMediaRoots(entry.presses, entry.config);
|
|
63
67
|
const coreAuthorComponents = {};
|
|
64
68
|
for (const name of ["MediaFigure", "ImageFigure"]) {
|
|
65
69
|
if (typeof coreModule[name] === "function") coreAuthorComponents[name] = coreModule[name];
|
|
@@ -71,7 +75,10 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
|
|
|
71
75
|
|
|
72
76
|
// Build measurement CSS once at the workspace level — shared by every
|
|
73
77
|
// Press inside the Workspace.
|
|
74
|
-
const measurementCss = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace
|
|
78
|
+
const measurementCss = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace, {
|
|
79
|
+
themeRoots: workspaceThemeRoots,
|
|
80
|
+
componentRoots: workspaceComponentRoots,
|
|
81
|
+
});
|
|
75
82
|
|
|
76
83
|
// Write chapter-scoped CSS once (workspace shared). Every per-press
|
|
77
84
|
// readerDocument references the same file via "/openpress/chapter-scoped.css".
|
|
@@ -148,7 +155,11 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
|
|
|
148
155
|
await fs.writeFile(corpusPath, JSON.stringify(corpus), "utf8");
|
|
149
156
|
|
|
150
157
|
if (syncAssets) {
|
|
151
|
-
await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config
|
|
158
|
+
await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config, {
|
|
159
|
+
themeRoots: workspaceThemeRoots,
|
|
160
|
+
componentRoots: workspaceComponentRoots,
|
|
161
|
+
mediaRoots: workspaceMediaRoots,
|
|
162
|
+
});
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
const primary = pressResults[0];
|
|
@@ -188,24 +199,29 @@ async function exportSinglePress({
|
|
|
188
199
|
// metadata overlaid. Press JSX page prop wins over the workspace page.
|
|
189
200
|
const effectiveConfig = applyPressOverridesToConfig(entry.config, press.metadata);
|
|
190
201
|
const documentRoot = effectiveConfig.paths.documentRoot;
|
|
202
|
+
const pressComponentRoots = componentRootsForPress(press, effectiveConfig);
|
|
203
|
+
const pressComponents = await loadComponentModules(
|
|
204
|
+
server,
|
|
205
|
+
await discoverComponentsInRoots(pressComponentRoots, documentRoot, "press"),
|
|
206
|
+
);
|
|
207
|
+
const resolvedComponents = {
|
|
208
|
+
...globalComponents,
|
|
209
|
+
...pressComponents,
|
|
210
|
+
};
|
|
211
|
+
const mediaRoots = mediaRootsForPress(press, effectiveConfig);
|
|
191
212
|
|
|
192
|
-
// Resolve sources for this press. The
|
|
193
|
-
// <Press sources={[...]}
|
|
194
|
-
// record from `export const sources`.
|
|
213
|
+
// Resolve sources for this press. The contract reads them from
|
|
214
|
+
// <Press sources={[...]}>.
|
|
195
215
|
const sourcesRecord = press.sources ?? {};
|
|
196
216
|
const { resolved: sources, renderData: renderRegistry } = await resolveAllSources({
|
|
197
217
|
sources: sourcesRecord,
|
|
198
218
|
documentRoot,
|
|
199
|
-
globalComponents,
|
|
219
|
+
globalComponents: resolvedComponents,
|
|
200
220
|
});
|
|
201
221
|
|
|
202
|
-
// Component the render pipeline drives.
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
// fall back to the user's whole default export.
|
|
206
|
-
const PressComponent = press.element
|
|
207
|
-
? () => press.element
|
|
208
|
-
: entry.Press;
|
|
222
|
+
// Component the render pipeline drives. Press elements are captured by
|
|
223
|
+
// inspection, then wrapped in a thin function component.
|
|
224
|
+
const PressComponent = () => press.element;
|
|
209
225
|
|
|
210
226
|
// Iterative allocation loop (identical to v0.x — paginates until the
|
|
211
227
|
// hints stabilise).
|
|
@@ -228,7 +244,7 @@ async function exportSinglePress({
|
|
|
228
244
|
renderRegistry,
|
|
229
245
|
css: measurementCss,
|
|
230
246
|
baseHref: pathToFileURL(`${documentRoot}${path.sep}`).href,
|
|
231
|
-
mediaDir:
|
|
247
|
+
mediaDir: mediaRoots,
|
|
232
248
|
captionNumbering: effectiveConfig.captionNumbering,
|
|
233
249
|
});
|
|
234
250
|
const alloc = allocateChains({
|
|
@@ -244,7 +260,7 @@ async function exportSinglePress({
|
|
|
244
260
|
const blocks = measurement.blockHeights
|
|
245
261
|
.slice(0, 8)
|
|
246
262
|
.map((b) => `${b.id} h=${b.height.toFixed(0)}`);
|
|
247
|
-
process.stderr.write(`[allocator press=${slug || "(
|
|
263
|
+
process.stderr.write(`[allocator press=${slug || "(missing-slug)"} iter ${iteration}]\n`);
|
|
248
264
|
process.stderr.write(` mdxAreas[0..4]: ${sample.join(" | ")}\n`);
|
|
249
265
|
process.stderr.write(` blocks[0..7]: ${blocks.join(" | ")}\n`);
|
|
250
266
|
process.stderr.write(` hints: ${JSON.stringify(alloc.hints.totalPagesPerChain)}\n`);
|
|
@@ -261,7 +277,7 @@ async function exportSinglePress({
|
|
|
261
277
|
}
|
|
262
278
|
if (allocation == null) {
|
|
263
279
|
throw new Error(
|
|
264
|
-
`Allocation did not converge after ${MAX_ITERATIONS} iterations (press="${slug || "(
|
|
280
|
+
`Allocation did not converge after ${MAX_ITERATIONS} iterations (press="${slug || "(missing-slug)"}"). ` +
|
|
265
281
|
`This usually means a chain keeps growing without fitting; check MdxArea capacities and block heights.`,
|
|
266
282
|
);
|
|
267
283
|
}
|
|
@@ -282,15 +298,18 @@ async function exportSinglePress({
|
|
|
282
298
|
// change is metadata.title comes from the per-press Press JSX prop.
|
|
283
299
|
const blockMap = {};
|
|
284
300
|
const captionState = createCaptionNumberingState();
|
|
301
|
+
const totalFrames = final.frames.length;
|
|
302
|
+
const pressSourcePath = sourcePathForPress({ entry, slug });
|
|
285
303
|
const blocks = final.frames.map((frame, index) => {
|
|
286
304
|
const source = {
|
|
287
|
-
file:
|
|
288
|
-
path:
|
|
305
|
+
file: path.basename(pressSourcePath),
|
|
306
|
+
path: pressSourcePath,
|
|
289
307
|
kind: frame.role ?? "manuscript.content",
|
|
290
308
|
slug: frame.frameKey,
|
|
291
309
|
sectionIndex: index + 1,
|
|
292
310
|
};
|
|
293
|
-
const
|
|
311
|
+
const numberedHtml = numberCaptionsInHtml(frame.html, effectiveConfig.captionNumbering, captionState);
|
|
312
|
+
const html = resolvePageFoliosInHtml(numberedHtml, { pageIndex: index, totalPages: totalFrames });
|
|
294
313
|
for (const id of collectFrameBlockIds(frame.blockIds, html)) {
|
|
295
314
|
blockMap[id] = { id, pageIndex: index, pageNumber: index + 1, frameKey: frame.frameKey };
|
|
296
315
|
}
|
|
@@ -364,11 +383,10 @@ async function exportSinglePress({
|
|
|
364
383
|
blocks,
|
|
365
384
|
};
|
|
366
385
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
: effectiveConfig.paths.publicDir;
|
|
386
|
+
if (!slug) {
|
|
387
|
+
throw new Error("<Press slug> is required. Folder-convention workspaces write to /openpress/<slug>/document.json.");
|
|
388
|
+
}
|
|
389
|
+
const pressOutputDir = path.join(effectiveConfig.paths.publicDir, slug);
|
|
372
390
|
await fs.mkdir(pressOutputDir, { recursive: true });
|
|
373
391
|
const documentPath = path.join(pressOutputDir, "document.json");
|
|
374
392
|
await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
|
|
@@ -377,7 +395,7 @@ async function exportSinglePress({
|
|
|
377
395
|
slug,
|
|
378
396
|
pressType,
|
|
379
397
|
documentPath,
|
|
380
|
-
documentUrl:
|
|
398
|
+
documentUrl: `/openpress/${slug}/document.json`,
|
|
381
399
|
readerDocument,
|
|
382
400
|
pageCount: blocks.length,
|
|
383
401
|
};
|
|
@@ -407,6 +425,114 @@ function applyPressOverridesToConfig(workspaceConfig, pressMetadata) {
|
|
|
407
425
|
return out;
|
|
408
426
|
}
|
|
409
427
|
|
|
428
|
+
function collectWorkspaceComponentRoots(presses, config) {
|
|
429
|
+
return uniquePaths(presses.flatMap((press) => componentRootsForPress(press, config)));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function collectWorkspaceThemeRoots(presses, config) {
|
|
433
|
+
return uniquePaths(presses.flatMap((press) => themeRootsForPress(press, config)));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function collectWorkspaceMediaRoots(presses, config) {
|
|
437
|
+
return uniquePaths(presses.flatMap((press) => mediaRootsForPress(press, config)));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function themeRootsForPress(press, config) {
|
|
441
|
+
const documentRoot = config.paths.documentRoot;
|
|
442
|
+
const folder = pressFolderName(press);
|
|
443
|
+
const roots = [];
|
|
444
|
+
if (folder) roots.push(path.join(documentRoot, folder, "theme"));
|
|
445
|
+
roots.push(...declaredRoots(press.metadata?.theme, config, folder, "theme"));
|
|
446
|
+
return uniquePaths(roots);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function componentRootsForPress(press, config) {
|
|
450
|
+
const documentRoot = config.paths.documentRoot;
|
|
451
|
+
const folder = pressFolderName(press);
|
|
452
|
+
const roots = [
|
|
453
|
+
config.paths.componentsDir,
|
|
454
|
+
];
|
|
455
|
+
if (folder) roots.push(path.join(documentRoot, folder, "components"));
|
|
456
|
+
roots.push(...declaredRoots(press.metadata?.componentsDir, config, folder, "componentsDir"));
|
|
457
|
+
return uniquePaths(roots);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function mediaRootsForPress(press, config) {
|
|
461
|
+
const documentRoot = config.paths.documentRoot;
|
|
462
|
+
const folder = pressFolderName(press);
|
|
463
|
+
const roots = [
|
|
464
|
+
config.paths.mediaDir,
|
|
465
|
+
];
|
|
466
|
+
if (folder) roots.push(path.join(documentRoot, folder, "media"));
|
|
467
|
+
roots.push(...declaredRoots(press.metadata?.mediaDir, config, folder, "mediaDir"));
|
|
468
|
+
return uniquePaths(roots);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function declaredRoots(value, config, folder, propName) {
|
|
472
|
+
return pathList(value).map((entry) => resolvePressPath(entry, config, folder, propName));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function resolvePressPath(value, config, folder, propName) {
|
|
476
|
+
const raw = String(value).trim();
|
|
477
|
+
if (!raw) return null;
|
|
478
|
+
const documentRoot = config.paths.documentRoot;
|
|
479
|
+
const pressRoot = folder ? path.join(documentRoot, folder) : documentRoot;
|
|
480
|
+
const base = raw === "." || raw.startsWith("./") || raw.startsWith("../") ? pressRoot : documentRoot;
|
|
481
|
+
const absolutePath = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(base, raw);
|
|
482
|
+
const relative = path.relative(documentRoot, absolutePath);
|
|
483
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
484
|
+
throw new Error(`<Press ${propName}> path must stay inside press/: ${raw}`);
|
|
485
|
+
}
|
|
486
|
+
return absolutePath;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function pathList(value) {
|
|
490
|
+
if (typeof value === "string") return [value];
|
|
491
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function pressFolderName(press) {
|
|
496
|
+
const slug = typeof press.metadata?.slug === "string" ? press.metadata.slug.trim() : "";
|
|
497
|
+
if (!slug || slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") return "";
|
|
498
|
+
return slug;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function validateDiscoveredPressFolders(entry) {
|
|
502
|
+
const folders = Array.isArray(entry.pressFolders) ? entry.pressFolders : [];
|
|
503
|
+
if (folders.length === 0) return;
|
|
504
|
+
if (entry.presses.length !== folders.length) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`OpenPress found ${folders.length} press folder(s) but ${entry.presses.length} <Press> element(s). ` +
|
|
507
|
+
`Each press/<name>/press.tsx must render exactly one <Press>.`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
for (const [index, folder] of folders.entries()) {
|
|
511
|
+
const slug = typeof entry.presses[index]?.metadata?.slug === "string"
|
|
512
|
+
? entry.presses[index].metadata.slug.trim()
|
|
513
|
+
: "";
|
|
514
|
+
if (!slug) {
|
|
515
|
+
throw new Error(`press/${folder}/press.tsx must declare <Press slug="${folder}">.`);
|
|
516
|
+
}
|
|
517
|
+
if (slug !== folder) {
|
|
518
|
+
throw new Error(`press/${folder}/press.tsx declares slug="${slug}", but folder-convention slugs must match the folder name.`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function uniquePaths(paths) {
|
|
524
|
+
const out = [];
|
|
525
|
+
const seen = new Set();
|
|
526
|
+
for (const candidate of paths ?? []) {
|
|
527
|
+
if (!candidate) continue;
|
|
528
|
+
const normalized = path.resolve(candidate);
|
|
529
|
+
if (seen.has(normalized)) continue;
|
|
530
|
+
seen.add(normalized);
|
|
531
|
+
out.push(normalized);
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
|
|
410
536
|
async function loadComponentModules(server, components) {
|
|
411
537
|
const out = {};
|
|
412
538
|
for (const component of components) {
|
|
@@ -473,6 +599,10 @@ function collectFrameBlockIds(allocatedIds, html) {
|
|
|
473
599
|
return ids;
|
|
474
600
|
}
|
|
475
601
|
|
|
602
|
+
function sourcePathForPress({ slug }) {
|
|
603
|
+
return `press/${slug}/press.tsx`;
|
|
604
|
+
}
|
|
605
|
+
|
|
476
606
|
function buildTocContext({ sources, frames, allocation }) {
|
|
477
607
|
const toc = {};
|
|
478
608
|
for (const source of Object.values(sources)) {
|
|
@@ -291,9 +291,9 @@ function normalizeTableCaptions(node) {
|
|
|
291
291
|
const child = node.children[index];
|
|
292
292
|
normalizeTableCaptions(child);
|
|
293
293
|
|
|
294
|
-
const
|
|
295
|
-
if (
|
|
296
|
-
throw new Error(`
|
|
294
|
+
const unsupportedCaptionText = unsupportedTableCaptionText(child);
|
|
295
|
+
if (unsupportedCaptionText) {
|
|
296
|
+
throw new Error(`Table caption marker syntax is not supported. Use <TableCaption>${unsupportedCaptionText}</TableCaption> before the table.`);
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
const captionText = tableCaptionText(child);
|
|
@@ -321,7 +321,7 @@ function normalizeTableCaptions(node) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
function
|
|
324
|
+
function unsupportedTableCaptionText(node) {
|
|
325
325
|
if (node?.type !== "element" || node.tagName !== "p") return "";
|
|
326
326
|
const match = textContent(node).match(LEGACY_TABLE_CAPTION_MARKER_RE);
|
|
327
327
|
return match?.[1]?.trim() ?? "";
|
|
@@ -8,15 +8,15 @@ import { buildSectionScopedCss } from "./section-css.mjs";
|
|
|
8
8
|
|
|
9
9
|
const require = createRequire(import.meta.url);
|
|
10
10
|
|
|
11
|
-
export async function buildReactMeasurementCss(root, config, workspace) {
|
|
11
|
+
export async function buildReactMeasurementCss(root, config, workspace, options = {}) {
|
|
12
12
|
const parts = [];
|
|
13
13
|
await appendOptionalFile(parts, path.join(config.paths.themeDir, "fonts.css"), "theme/fonts.css");
|
|
14
14
|
await appendOptionalFile(parts, path.join(config.paths.themeDir, "tokens.css"), "theme/tokens.css");
|
|
15
15
|
appendPageGeometryCss(parts, config.page);
|
|
16
16
|
parts.push("/* === public/openpress/content.css === */\n");
|
|
17
|
-
parts.push(await buildContentCss(root, config));
|
|
17
|
+
parts.push(await buildContentCss(root, config, { themeRoots: options.themeRoots }));
|
|
18
18
|
parts.push("\n/* === public/openpress/components.css === */\n");
|
|
19
|
-
parts.push(await buildComponentsCss(root, config));
|
|
19
|
+
parts.push(await buildComponentsCss(root, config, { componentRoots: options.componentRoots }));
|
|
20
20
|
const chapterCss = await buildSectionScopedCss(workspace);
|
|
21
21
|
if (chapterCss.trim()) {
|
|
22
22
|
parts.push("\n/* === public/openpress/chapter-scoped.css === */\n");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const CURRENT_RE = /(<span\b[^>]*\bdata-openpress-page-folio-current="true"[^>]*>)([\s\S]*?)(<\/span>)/gi;
|
|
2
|
+
const TOTAL_RE = /(<span\b[^>]*\bdata-openpress-page-folio-total="true"[^>]*>)([\s\S]*?)(<\/span>)/gi;
|
|
3
|
+
|
|
4
|
+
export function resolvePageFoliosInHtml(html, { pageIndex, totalPages }) {
|
|
5
|
+
const current = Math.max(1, Math.trunc(pageIndex) + 1);
|
|
6
|
+
const total = Math.max(0, Math.trunc(totalPages));
|
|
7
|
+
|
|
8
|
+
return String(html ?? "")
|
|
9
|
+
.replace(CURRENT_RE, (match, open, _body, close) => {
|
|
10
|
+
const format = pickAttr(open, "data-openpress-page-folio-format") || "plain";
|
|
11
|
+
return `${open}${escapeHtml(formatPageNumber(current, format))}${close}`;
|
|
12
|
+
})
|
|
13
|
+
.replace(TOTAL_RE, (match, open, _body, close) => {
|
|
14
|
+
const format = pickAttr(open, "data-openpress-page-folio-format") || "plain";
|
|
15
|
+
return `${open}${escapeHtml(formatPageNumber(total, format))}${close}`;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatPageNumber(value, format = "plain") {
|
|
20
|
+
const normalized = Math.max(0, Math.trunc(Number(value) || 0));
|
|
21
|
+
if (format === "3-digit") return String(normalized).padStart(3, "0");
|
|
22
|
+
if (format === "2-digit") return String(normalized).padStart(2, "0");
|
|
23
|
+
return String(normalized);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pickAttr(attrs, name) {
|
|
27
|
+
const re = new RegExp(`${name}="([^"]*)"`);
|
|
28
|
+
const match = re.exec(attrs);
|
|
29
|
+
return match?.[1];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function escapeHtml(value) {
|
|
33
|
+
return String(value ?? "")
|
|
34
|
+
.replace(/&/g, "&")
|
|
35
|
+
.replace(/</g, "<")
|
|
36
|
+
.replace(/>/g, ">");
|
|
37
|
+
}
|
|
@@ -114,7 +114,7 @@ export function pagesFromRegions(filledRegions) {
|
|
|
114
114
|
return pages;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
// Public wrapper preserving the
|
|
117
|
+
// Public wrapper preserving the existing (blocks, { pageSafeHeightPx }) signature.
|
|
118
118
|
// New code can pass a `regions` stream directly to opt into multi-column or
|
|
119
119
|
// heterogeneous layouts.
|
|
120
120
|
export function paginateMeasuredBlocks(measuredBlocks, options = {}) {
|
|
@@ -146,7 +146,7 @@ function infiniteFixedCapacityRegionStream(capacity) {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// Translate the new region-shaped warnings back to the
|
|
149
|
+
// Translate the new region-shaped warnings back to the existing
|
|
150
150
|
// `block-overflows-page` schema that document-export.mjs and downstream
|
|
151
151
|
// consumers expect. Once consumers migrate, this can drop.
|
|
152
152
|
function mapWarning(warning, pageSafeHeightPx) {
|
|
@@ -161,7 +161,7 @@ function mapWarning(warning, pageSafeHeightPx) {
|
|
|
161
161
|
return warning;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
function positiveNumber(value,
|
|
164
|
+
function positiveNumber(value, defaultValue) {
|
|
165
165
|
const number = Number(value);
|
|
166
|
-
return Number.isFinite(number) && number > 0 ? number :
|
|
166
|
+
return Number.isFinite(number) && number > 0 ? number : defaultValue;
|
|
167
167
|
}
|
|
@@ -32,7 +32,7 @@ const CAPACITY_SAFETY_MAX_PX = 96;
|
|
|
32
32
|
* @param {Map<string, object>} opts.renderRegistry Internal render data per sourceId.
|
|
33
33
|
* @param {string} opts.css Combined CSS for measurement context.
|
|
34
34
|
* @param {string=} opts.baseHref Base URL for relative media paths in MDX.
|
|
35
|
-
* @param {string=} opts.mediaDir Local media
|
|
35
|
+
* @param {string|string[]=} opts.mediaDir Local media roots for inlining /openpress/media/* assets.
|
|
36
36
|
* @param {object=} opts.captionNumbering Caption label formatter options.
|
|
37
37
|
* @param {{width:number,height:number}=} opts.viewport
|
|
38
38
|
*/
|
|
@@ -136,7 +136,7 @@ async function runChromiumMeasurement(html, viewport) {
|
|
|
136
136
|
const page = await browser.newPage({ viewport });
|
|
137
137
|
await page.setContent(html, { waitUntil: "load" });
|
|
138
138
|
// Match the print-ready settle: fonts first (font metrics affect image
|
|
139
|
-
// alt-text
|
|
139
|
+
// alt-text placeholder boxes), then await every image's `complete` AND
|
|
140
140
|
// `decode()` so intrinsic sizes are committed before layout, then two
|
|
141
141
|
// animation frames so the chromium layout pass observes the final box
|
|
142
142
|
// model. Without this, `getBoundingClientRect()` on figures that hold
|
|
@@ -200,11 +200,11 @@ async function runChromiumMeasurement(html, viewport) {
|
|
|
200
200
|
];
|
|
201
201
|
for (const candidate of candidates) {
|
|
202
202
|
if (!candidate) continue;
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
203
|
+
const alternateRect = candidate.getBoundingClientRect();
|
|
204
|
+
if (alternateRect.height > rect.height) {
|
|
205
205
|
return {
|
|
206
|
-
height:
|
|
207
|
-
width: rect.width > 0 ? rect.width :
|
|
206
|
+
height: alternateRect.height,
|
|
207
|
+
width: rect.width > 0 ? rect.width : alternateRect.width,
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
210
|
}
|
|
@@ -250,7 +250,8 @@ function escapeAttr(value) {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
async function inlineMeasurementMediaUrls(html, mediaDir) {
|
|
253
|
-
|
|
253
|
+
const mediaRoots = mediaRootList(mediaDir);
|
|
254
|
+
if (mediaRoots.length === 0 || !html) return html;
|
|
254
255
|
let out = String(html);
|
|
255
256
|
const matches = new Set();
|
|
256
257
|
for (const match of out.matchAll(/\bsrc=(['"])([^\1]*?)\1/g)) {
|
|
@@ -269,7 +270,7 @@ async function inlineMeasurementMediaUrls(html, mediaDir) {
|
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
272
|
for (const rawName of matches) {
|
|
272
|
-
const dataUrl = await mediaDataUrl(
|
|
273
|
+
const dataUrl = await mediaDataUrl(mediaRoots, rawName);
|
|
273
274
|
if (!dataUrl) continue;
|
|
274
275
|
out = out.replaceAll(`/openpress/media/${rawName}`, dataUrl);
|
|
275
276
|
out = out.replaceAll(`media/${rawName}`, dataUrl);
|
|
@@ -278,7 +279,7 @@ async function inlineMeasurementMediaUrls(html, mediaDir) {
|
|
|
278
279
|
return out;
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
async function mediaDataUrl(
|
|
282
|
+
async function mediaDataUrl(mediaRoots, rawName) {
|
|
282
283
|
let fileName;
|
|
283
284
|
try {
|
|
284
285
|
fileName = decodeURIComponent(String(rawName));
|
|
@@ -286,14 +287,31 @@ async function mediaDataUrl(mediaDir, rawName) {
|
|
|
286
287
|
fileName = String(rawName);
|
|
287
288
|
}
|
|
288
289
|
if (!fileName || fileName !== path.basename(fileName)) return null;
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
for (const mediaDir of mediaRoots) {
|
|
291
|
+
const filePath = path.join(mediaDir, fileName);
|
|
292
|
+
let bytes;
|
|
293
|
+
try {
|
|
294
|
+
bytes = await fs.readFile(filePath);
|
|
295
|
+
} catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
return `data:${mediaMimeType(fileName)};base64,${bytes.toString("base64")}`;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function mediaRootList(mediaDir) {
|
|
304
|
+
const raw = Array.isArray(mediaDir) ? mediaDir : [mediaDir];
|
|
305
|
+
const roots = [];
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const candidate of raw) {
|
|
308
|
+
if (!candidate) continue;
|
|
309
|
+
const normalized = path.resolve(candidate);
|
|
310
|
+
if (seen.has(normalized)) continue;
|
|
311
|
+
seen.add(normalized);
|
|
312
|
+
roots.push(normalized);
|
|
295
313
|
}
|
|
296
|
-
return
|
|
314
|
+
return roots;
|
|
297
315
|
}
|
|
298
316
|
|
|
299
317
|
function mediaMimeType(fileName) {
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// <Workspace> and <Press> metadata declared as JSX props.
|
|
3
3
|
//
|
|
4
4
|
// The 1.0 contract says <Press> carries every per-document setting on
|
|
5
|
-
// its props (title, page, sources, slug, theme, componentsDir)
|
|
6
|
-
//
|
|
5
|
+
// its props (title, page, sources, slug, theme, componentsDir, mediaDir).
|
|
6
|
+
// This helper invokes the user's
|
|
7
7
|
// component once at load time to inspect those props before the engine
|
|
8
8
|
// runs its render pipeline.
|
|
9
9
|
//
|
|
@@ -21,7 +21,7 @@ import React from "react";
|
|
|
21
21
|
* for the single-Press case.
|
|
22
22
|
*
|
|
23
23
|
* @param {object} opts
|
|
24
|
-
* @param {Function} opts.UserComponent The default export of press
|
|
24
|
+
* @param {Function} opts.UserComponent The default export of press/<slug>/press.tsx.
|
|
25
25
|
* @param {symbol} opts.PRESS_MARKER Marker identifying Press components.
|
|
26
26
|
* @param {symbol} opts.WORKSPACE_MARKER Marker identifying Workspace components.
|
|
27
27
|
* @returns {{
|
|
@@ -35,7 +35,8 @@ import React from "react";
|
|
|
35
35
|
* page?: unknown,
|
|
36
36
|
* slug?: string,
|
|
37
37
|
* theme?: string,
|
|
38
|
-
* componentsDir?: string,
|
|
38
|
+
* componentsDir?: string | string[],
|
|
39
|
+
* mediaDir?: string | string[],
|
|
39
40
|
* captionNumbering?: unknown,
|
|
40
41
|
* },
|
|
41
42
|
* sources: Record<string, unknown> | null, // mdxSource() descriptors keyed by id
|
|
@@ -118,14 +119,14 @@ function extractProps(element) {
|
|
|
118
119
|
|
|
119
120
|
function collectPressElements(root, PRESS_MARKER) {
|
|
120
121
|
const found = [];
|
|
121
|
-
walk(root);
|
|
122
|
+
walk(root, new Set());
|
|
122
123
|
return found;
|
|
123
124
|
|
|
124
|
-
function walk(node) {
|
|
125
|
+
function walk(node, seen) {
|
|
125
126
|
if (!isReactElement(node)) {
|
|
126
127
|
// Could be array / fragment / string / number — flatten and recurse.
|
|
127
128
|
if (Array.isArray(node)) {
|
|
128
|
-
for (const child of node) walk(child);
|
|
129
|
+
for (const child of node) walk(child, seen);
|
|
129
130
|
}
|
|
130
131
|
return;
|
|
131
132
|
}
|
|
@@ -135,10 +136,32 @@ function collectPressElements(root, PRESS_MARKER) {
|
|
|
135
136
|
// not more workspace structure.
|
|
136
137
|
return;
|
|
137
138
|
}
|
|
139
|
+
const rendered = renderCompositeElement(node, seen);
|
|
140
|
+
if (rendered !== null) {
|
|
141
|
+
walk(rendered, seen);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
138
144
|
// Recurse into children + Fragment-like wrappers.
|
|
139
145
|
const children = node.props?.children;
|
|
140
146
|
if (children == null) return;
|
|
141
|
-
React.Children.forEach(children, walk);
|
|
147
|
+
React.Children.forEach(children, (child) => walk(child, seen));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderCompositeElement(element, seen) {
|
|
152
|
+
const type = element?.type;
|
|
153
|
+
if (typeof type !== "function") return null;
|
|
154
|
+
if (seen.has(type)) return null;
|
|
155
|
+
seen.add(type);
|
|
156
|
+
try {
|
|
157
|
+
return type(element.props ?? {});
|
|
158
|
+
} catch {
|
|
159
|
+
// Top-level Press wrapper components should be inert. If a user puts a
|
|
160
|
+
// hookful or effectful component at the Workspace boundary, leave it for
|
|
161
|
+
// the normal React render pipeline to report with full context.
|
|
162
|
+
return null;
|
|
163
|
+
} finally {
|
|
164
|
+
seen.delete(type);
|
|
142
165
|
}
|
|
143
166
|
}
|
|
144
167
|
|
|
@@ -149,15 +172,22 @@ function pickPressMetadata(pressProps) {
|
|
|
149
172
|
if (pressProps.page !== undefined) out.page = pressProps.page;
|
|
150
173
|
if (typeof pressProps.slug === "string") out.slug = pressProps.slug;
|
|
151
174
|
if (typeof pressProps.theme === "string") out.theme = pressProps.theme;
|
|
152
|
-
if (typeof pressProps.componentsDir === "string"
|
|
175
|
+
if (typeof pressProps.componentsDir === "string" || isStringArray(pressProps.componentsDir)) {
|
|
176
|
+
out.componentsDir = pressProps.componentsDir;
|
|
177
|
+
}
|
|
178
|
+
if (typeof pressProps.mediaDir === "string" || isStringArray(pressProps.mediaDir)) {
|
|
179
|
+
out.mediaDir = pressProps.mediaDir;
|
|
180
|
+
}
|
|
153
181
|
if (pressProps.captionNumbering !== undefined) out.captionNumbering = pressProps.captionNumbering;
|
|
154
182
|
return out;
|
|
155
183
|
}
|
|
156
184
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
185
|
+
function isStringArray(value) {
|
|
186
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Convert <Press sources={[ mdxSource({ id, ... }), ... ]}> into the
|
|
190
|
+
// engine's expected sources record { [id]: descriptor }.
|
|
161
191
|
function extractSources(pressProps) {
|
|
162
192
|
if (!Array.isArray(pressProps.sources)) return null;
|
|
163
193
|
const out = {};
|