@open-press/core 0.3.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/engine/chrome-pdf.d.mts +34 -0
  4. package/engine/chrome-pdf.mjs +344 -0
  5. package/engine/cli.mjs +93 -0
  6. package/engine/commands/_shared.mjs +170 -0
  7. package/engine/commands/deploy.mjs +31 -0
  8. package/engine/commands/dev.mjs +26 -0
  9. package/engine/commands/export.mjs +8 -0
  10. package/engine/commands/init.mjs +24 -0
  11. package/engine/commands/inspect.mjs +35 -0
  12. package/engine/commands/migrate-to-react.mjs +27 -0
  13. package/engine/commands/pdf.mjs +26 -0
  14. package/engine/commands/preview.mjs +26 -0
  15. package/engine/commands/render.mjs +17 -0
  16. package/engine/commands/replace.mjs +41 -0
  17. package/engine/commands/search.mjs +33 -0
  18. package/engine/commands/typecheck.mjs +5 -0
  19. package/engine/commands/validate.mjs +17 -0
  20. package/engine/config.d.mts +40 -0
  21. package/engine/config.mjs +160 -0
  22. package/engine/deploy-sync.mjs +15 -0
  23. package/engine/document-export.mjs +15 -0
  24. package/engine/file-utils.mjs +106 -0
  25. package/engine/fonts.mjs +62 -0
  26. package/engine/init.mjs +90 -0
  27. package/engine/inspection.mjs +348 -0
  28. package/engine/issue-report.mjs +44 -0
  29. package/engine/katex-assets.mjs +45 -0
  30. package/engine/page-block.mjs +30 -0
  31. package/engine/page-renderer.mjs +217 -0
  32. package/engine/pdf-media.mjs +45 -0
  33. package/engine/public-assets.mjs +19 -0
  34. package/engine/react/chapter-css.mjs +53 -0
  35. package/engine/react/comment-endpoint.d.mts +11 -0
  36. package/engine/react/comment-endpoint.mjs +128 -0
  37. package/engine/react/comment-marker.mjs +306 -0
  38. package/engine/react/document-entry.mjs +253 -0
  39. package/engine/react/document-export.mjs +392 -0
  40. package/engine/react/mdx-compile.mjs +295 -0
  41. package/engine/react/measurement-css.mjs +44 -0
  42. package/engine/react/migrate-to-react.mjs +355 -0
  43. package/engine/react/pagination-constants.mjs +3 -0
  44. package/engine/react/pagination.mjs +121 -0
  45. package/engine/react/project-asset-endpoint.d.mts +10 -0
  46. package/engine/react/project-asset-endpoint.mjs +379 -0
  47. package/engine/react/workspace-discovery.mjs +156 -0
  48. package/engine/source-text-tools.mjs +280 -0
  49. package/engine/source-workspace.mjs +76 -0
  50. package/engine/static-server.mjs +493 -0
  51. package/engine/validation.mjs +172 -0
  52. package/index.html +13 -0
  53. package/package.json +86 -0
  54. package/src/openpress/App.tsx +127 -0
  55. package/src/openpress/composerMentions.ts +188 -0
  56. package/src/openpress/core/basePages.tsx +87 -0
  57. package/src/openpress/core/index.tsx +20 -0
  58. package/src/openpress/core/types.ts +71 -0
  59. package/src/openpress/frameScheduler.ts +32 -0
  60. package/src/openpress/indexes.ts +329 -0
  61. package/src/openpress/inspector.ts +282 -0
  62. package/src/openpress/pageRoute.ts +21 -0
  63. package/src/openpress/pagination.ts +845 -0
  64. package/src/openpress/projectIdentity.ts +15 -0
  65. package/src/openpress/projectSources.ts +24 -0
  66. package/src/openpress/projectWorkspace.tsx +919 -0
  67. package/src/openpress/publicPage.tsx +469 -0
  68. package/src/openpress/reactDocumentMetadata.ts +41 -0
  69. package/src/openpress/readerPageRegistry.ts +41 -0
  70. package/src/openpress/readerRuntime.ts +230 -0
  71. package/src/openpress/readerScroll.ts +92 -0
  72. package/src/openpress/readerState.ts +15 -0
  73. package/src/openpress/renderer.tsx +91 -0
  74. package/src/openpress/runtimeMode.ts +22 -0
  75. package/src/openpress/types.ts +112 -0
  76. package/src/openpress/workbench.tsx +1299 -0
  77. package/src/openpress/workbenchPanels.tsx +122 -0
  78. package/src/openpress/workbenchTypes.ts +4 -0
  79. package/src/styles/openpress/app-shell.css +251 -0
  80. package/src/styles/openpress/media-workspace.css +230 -0
  81. package/src/styles/openpress/print-route.css +186 -0
  82. package/src/styles/openpress/project-workspace.css +1318 -0
  83. package/src/styles/openpress/public-viewer.css +983 -0
  84. package/src/styles/openpress/reader-runtime.css +792 -0
  85. package/src/styles/openpress/responsive.css +384 -0
  86. package/src/styles/openpress/workbench-panels.css +558 -0
  87. package/src/styles/openpress/workbench.css +720 -0
  88. package/src/styles/openpress.css +14 -0
  89. package/tsconfig.json +37 -0
  90. package/vite.config.ts +512 -0
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { buildComponentsCss, buildContentCss } from "../file-utils.mjs";
6
+ import { buildChapterScopedCss } from "./chapter-css.mjs";
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ export async function buildReactMeasurementCss(root, config, workspace) {
11
+ const parts = [];
12
+ await appendOptionalFile(parts, path.join(config.paths.themeDir, "fonts.css"), "theme/fonts.css");
13
+ await appendOptionalFile(parts, path.join(config.paths.themeDir, "tokens.css"), "theme/tokens.css");
14
+ parts.push("/* === public/openpress/content.css === */\n");
15
+ parts.push(await buildContentCss(root, config));
16
+ parts.push("\n/* === public/openpress/components.css === */\n");
17
+ parts.push(await buildComponentsCss(root, config));
18
+ const chapterCss = await buildChapterScopedCss(workspace);
19
+ if (chapterCss.trim()) {
20
+ parts.push("\n/* === public/openpress/chapter-scoped.css === */\n");
21
+ parts.push(chapterCss);
22
+ }
23
+ return rewriteAssetUrls(parts.join("\n"), config);
24
+ }
25
+
26
+ async function appendOptionalFile(parts, filePath, label) {
27
+ try {
28
+ const css = await fs.readFile(filePath, "utf8");
29
+ parts.push(`/* === ${label} === */\n`);
30
+ parts.push(css.trimEnd());
31
+ parts.push("\n");
32
+ } catch (error) {
33
+ if (error?.code !== "ENOENT") throw error;
34
+ }
35
+ }
36
+
37
+ function rewriteAssetUrls(css, config) {
38
+ const themeFontsDir = pathToFileURL(path.join(config.paths.themeDir, "fonts") + path.sep).href;
39
+ const katexFont = require.resolve("katex/dist/fonts/KaTeX_Main-Regular.woff2");
40
+ const katexFontsDir = pathToFileURL(path.dirname(katexFont) + path.sep).href;
41
+ return css
42
+ .replace(/url\((["'])?\/openpress\/fonts\//g, `url($1${themeFontsDir}`)
43
+ .replace(/url\((["'])?\/openpress\/katex-fonts\//g, `url($1${katexFontsDir}`);
44
+ }
@@ -0,0 +1,355 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+
5
+ const SHELL_KINDS = new Set(["cover", "toc", "back-cover"]);
6
+
7
+ export async function migrateLegacyWorkspaceToReact(root, config, { dryRun = false, force = false } = {}) {
8
+ const migration = await planLegacyWorkspaceMigration(root, config, { force });
9
+ if (!dryRun) {
10
+ await applyMigrationPlan(migration, { force });
11
+ }
12
+ return {
13
+ kind: "migrate-to-react",
14
+ dryRun,
15
+ sourceFiles: migration.sourceFiles.length,
16
+ files: migration.actions.map(({ absolutePath, action }) => ({
17
+ path: rootRelative(root, absolutePath),
18
+ action,
19
+ })),
20
+ };
21
+ }
22
+
23
+ async function planLegacyWorkspaceMigration(root, config, { force = false } = {}) {
24
+ const sourceFiles = await collectLegacyContentFiles(config.paths.sourceDir);
25
+ const parsedFiles = await Promise.all(sourceFiles.map(async (filePath) => {
26
+ const text = await fs.readFile(filePath, "utf8");
27
+ const [meta, body] = parseFrontmatter(text);
28
+ const fileName = path.basename(filePath);
29
+ return {
30
+ filePath,
31
+ fileName,
32
+ meta,
33
+ body,
34
+ kind: normalizedKind(meta.kind),
35
+ slug: slugValue(meta.slug) ?? slugFromFileName(fileName),
36
+ title: stringValue(meta.title),
37
+ chapter: numberValue(meta.chapter),
38
+ };
39
+ }));
40
+
41
+ const documentRoot = path.join(path.resolve(root), "document");
42
+ const chaptersRoot = path.join(documentRoot, "chapters");
43
+ const shell = Object.fromEntries(SHELL_KINDS.values().map((kind) => [kind, null]));
44
+ const chapterStates = new Map();
45
+ let chapterCounter = 0;
46
+
47
+ for (const file of parsedFiles) {
48
+ if (SHELL_KINDS.has(file.kind)) {
49
+ shell[file.kind] ??= file;
50
+ continue;
51
+ }
52
+
53
+ const chapter = file.chapter ?? (chapterCounter + 1);
54
+ if (file.kind !== "chapter-opener") {
55
+ chapterCounter = Math.max(chapterCounter, chapter);
56
+ }
57
+ const slug = file.slug || `chapter-${chapter}`;
58
+ const key = `${chapter}:${slug}`;
59
+ const current = chapterStates.get(key) ?? {
60
+ chapter,
61
+ slug,
62
+ title: file.title ?? titleFromSlug(slug),
63
+ opener: null,
64
+ contentFiles: [],
65
+ };
66
+ current.title = file.title ?? current.title;
67
+ if (file.kind === "chapter-opener") current.opener = file;
68
+ else current.contentFiles.push(file);
69
+ chapterStates.set(key, current);
70
+ }
71
+
72
+ const actions = [];
73
+ const add = (absolutePath, content, action = "write") => actions.push({ absolutePath, content, action });
74
+ const addCopy = (source, absolutePath) => {
75
+ if (samePath(source, absolutePath)) return;
76
+ actions.push({ source, absolutePath, action: "copy" });
77
+ };
78
+ const addDir = (absolutePath) => actions.push({ absolutePath, action: "mkdir" });
79
+
80
+ addDir(documentRoot);
81
+ add(path.join(documentRoot, "index.tsx"), renderDocumentIndex(config, shell));
82
+ addCopy(config.paths.designDoc, path.join(documentRoot, "design.md"));
83
+ addCopy(config.paths.themeDir, path.join(documentRoot, "theme"));
84
+ addCopy(config.paths.mediaDir, path.join(documentRoot, "media"));
85
+ addCopy(config.paths.componentsDir, path.join(documentRoot, "components"));
86
+
87
+ const chapters = Array.from(chapterStates.values()).sort((a, b) => {
88
+ if (a.chapter !== b.chapter) return a.chapter - b.chapter;
89
+ return a.slug.localeCompare(b.slug);
90
+ });
91
+
92
+ for (const chapterState of chapters) {
93
+ const chapterDir = path.join(chaptersRoot, chapterDirectoryName(chapterState.chapter, chapterState.slug));
94
+ const contentDir = path.join(chapterDir, "content");
95
+ addDir(contentDir);
96
+ if (chapterState.opener) {
97
+ add(path.join(chapterDir, "chapter.tsx"), renderChapterFile(chapterState));
98
+ }
99
+ chapterState.contentFiles.forEach((file, index) => {
100
+ const contentName = `${String(index + 1).padStart(2, "0")}-${file.slug || chapterState.slug}.mdx`;
101
+ add(path.join(contentDir, contentName), normalizeMdxBody(file.body));
102
+ });
103
+ }
104
+
105
+ await assertWritable(actions, { force });
106
+ return { sourceFiles: parsedFiles, actions };
107
+ }
108
+
109
+ async function applyMigrationPlan(migration, { force = false } = {}) {
110
+ for (const item of migration.actions) {
111
+ if (item.action === "mkdir") {
112
+ await fs.mkdir(item.absolutePath, { recursive: true });
113
+ } else if (item.action === "write") {
114
+ await fs.mkdir(path.dirname(item.absolutePath), { recursive: true });
115
+ if (!force) await assertMissing(item.absolutePath);
116
+ await fs.writeFile(item.absolutePath, item.content, "utf8");
117
+ } else if (item.action === "copy") {
118
+ await copyPathIfExists(item.source, item.absolutePath, { force });
119
+ }
120
+ }
121
+ }
122
+
123
+ async function collectLegacyContentFiles(sourceDir) {
124
+ let entries;
125
+ try {
126
+ entries = await fs.readdir(sourceDir, { withFileTypes: true });
127
+ } catch (error) {
128
+ if (error?.code === "ENOENT") return [];
129
+ throw error;
130
+ }
131
+
132
+ return entries
133
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md") && !entry.name.startsWith("_"))
134
+ .map((entry) => path.join(sourceDir, entry.name))
135
+ .sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
136
+ }
137
+
138
+ function renderDocumentIndex(config, shell) {
139
+ const configLines = [
140
+ ` title: ${jsString(config.title)},`,
141
+ config.subtitle ? ` subtitle: ${jsString(config.subtitle)},` : null,
142
+ config.organization ? ` organization: ${jsString(config.organization)},` : null,
143
+ config.workspaceLabel ? ` workspaceLabel: ${jsString(config.workspaceLabel)},` : null,
144
+ ` sourceDir: "chapters",`,
145
+ ` mediaDir: "media",`,
146
+ ` themeDir: "theme",`,
147
+ ` designDoc: "design.md",`,
148
+ ` componentsDir: "components",`,
149
+ ` publicDir: ${jsString(config.publicDir)},`,
150
+ ` outputDir: ${jsString(config.outputDir)},`,
151
+ ` pdf: { filename: ${jsString(config.pdf.filename)} },`,
152
+ ` deploy: {`,
153
+ ` adapter: ${jsString(config.deploy.adapter)},`,
154
+ ` source: ${jsString(config.deploy.source)},`,
155
+ ` projectName: ${config.deploy.projectName == null ? "null" : jsString(config.deploy.projectName)},`,
156
+ ` commitDirty: ${config.deploy.commitDirty ? "true" : "false"},`,
157
+ ` requiresConfirmation: ${config.deploy.requiresConfirmation ? "true" : "false"},`,
158
+ ` },`,
159
+ ].filter(Boolean).join("\n");
160
+
161
+ return [
162
+ 'import type { Manifest } from "@openpress/core";',
163
+ 'import { BaseBackCoverPage, BaseCoverPage, BaseTocPage } from "@openpress/core";',
164
+ "",
165
+ "export const config: Manifest = {",
166
+ configLines,
167
+ "};",
168
+ "",
169
+ renderShellExport("cover", shell.cover, "cover", "OpenPress"),
170
+ "",
171
+ renderTocExport(shell.toc),
172
+ "",
173
+ renderShellExport("backCover", shell["back-cover"], "back-cover", "End"),
174
+ "",
175
+ ].join("\n");
176
+ }
177
+
178
+ function renderShellExport(exportName, file, pageKind, fallbackTitle) {
179
+ const title = file?.title ?? fallbackTitle;
180
+ const bodyText = summaryText(file?.body);
181
+ const Page = pageKind === "back-cover" ? "BaseBackCoverPage" : "BaseCoverPage";
182
+ const mainClass = pageKind === "back-cover" ? "back-cover-main" : "cover-main";
183
+ const titleClass = pageKind === "back-cover" ? "back-cover-statement" : "cover-title";
184
+ const summaryClass = pageKind === "back-cover" ? "back-cover-summary" : "cover-summary";
185
+
186
+ return `export const ${exportName} = (
187
+ <${Page} data-page-title="${jsxAttr(title)}">
188
+ <div className="${mainClass}">
189
+ <h1 className="${titleClass}">${jsxText(title)}</h1>
190
+ ${bodyText ? `<p className="${summaryClass}">${jsxText(bodyText)}</p>` : ""}
191
+ </div>
192
+ </${Page}>
193
+ );`;
194
+ }
195
+
196
+ function renderTocExport(file) {
197
+ const title = file?.title ?? "Contents";
198
+ return `export const toc = (
199
+ <BaseTocPage data-page-title="${jsxAttr(title)}" id="toc">
200
+ <div className="page-frame">
201
+ <header className="page-header" aria-hidden="true"></header>
202
+ <main className="page-body">
203
+ <h2 id="toc-title" className="toc-heading">${jsxText(title)}</h2>
204
+ </main>
205
+ </div>
206
+ </BaseTocPage>
207
+ );`;
208
+ }
209
+
210
+ function renderChapterFile(chapterState) {
211
+ const title = chapterState.opener?.title ?? chapterState.title;
212
+ const label = `Chapter ${chapterState.chapter}`;
213
+ const summary = summaryText(chapterState.opener?.body);
214
+ return [
215
+ "export const meta = {",
216
+ ` slug: ${jsString(chapterState.slug)},`,
217
+ ` title: ${jsString(title)},`,
218
+ ` chapter: ${chapterState.chapter},`,
219
+ "};",
220
+ "",
221
+ "export const opener = (",
222
+ ` <section className="reader-page reader-page--chapter-opener no-footer" data-page-kind="chapter-opener" data-page-footer="false" data-page-title="${jsxAttr(title)}">`,
223
+ ' <div className="page-frame">',
224
+ ' <header className="page-header" aria-hidden="true"></header>',
225
+ ' <main className="page-body">',
226
+ ` <p className="chapter-opener-kicker">${jsxText(label)}</p>`,
227
+ ` <h2 className="chapter-opener-title">${jsxText(title)}</h2>`,
228
+ summary ? ` <p className="chapter-opener-summary">${jsxText(summary)}</p>` : null,
229
+ " </main>",
230
+ " </div>",
231
+ " </section>",
232
+ ");",
233
+ "",
234
+ ].filter(Boolean).join("\n");
235
+ }
236
+
237
+ function parseFrontmatter(text) {
238
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
239
+ if (!match) return [{}, text];
240
+ return [yaml.load(match[1]) ?? {}, text.slice(match[0].length)];
241
+ }
242
+
243
+ function normalizeMdxBody(body) {
244
+ const normalized = String(body ?? "").replace(/^\s+/, "").replace(/\s+$/, "");
245
+ return `${normalized}\n`;
246
+ }
247
+
248
+ function normalizedKind(value) {
249
+ if (typeof value !== "string" || !value.trim()) return "chapter";
250
+ return value.trim();
251
+ }
252
+
253
+ function stringValue(value) {
254
+ return typeof value === "string" && value.trim() ? value.trim() : null;
255
+ }
256
+
257
+ function slugValue(value) {
258
+ return typeof value === "string" && value.trim() ? safeSlug(value.trim()) : null;
259
+ }
260
+
261
+ function numberValue(value) {
262
+ return Number.isFinite(value) ? Number(value) : null;
263
+ }
264
+
265
+ function slugFromFileName(fileName) {
266
+ return safeSlug(fileName.replace(/\.md$/i, "").replace(/^\d+[-_]?/, "").replace(/-opener$/, ""));
267
+ }
268
+
269
+ function safeSlug(value) {
270
+ const slug = value
271
+ .normalize("NFKD")
272
+ .replace(/[^\p{Letter}\p{Number}]+/gu, "-")
273
+ .replace(/^-+|-+$/g, "")
274
+ .toLowerCase();
275
+ return slug || "chapter";
276
+ }
277
+
278
+ function titleFromSlug(slug) {
279
+ return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
280
+ }
281
+
282
+ function chapterDirectoryName(chapter, slug) {
283
+ return `${String(chapter).padStart(2, "0")}-${safeSlug(slug)}`;
284
+ }
285
+
286
+ function summaryText(markdown = "") {
287
+ const text = String(markdown)
288
+ .replace(/<[^>]+>/g, " ")
289
+ .replace(/```[\s\S]*?```/g, " ")
290
+ .replace(/`([^`]+)`/g, "$1")
291
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
292
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
293
+ .replace(/^#{1,6}\s+/gm, "")
294
+ .replace(/^[-*+]\s+/gm, "")
295
+ .replace(/\s+/g, " ")
296
+ .trim();
297
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
298
+ }
299
+
300
+ function jsString(value) {
301
+ return JSON.stringify(String(value ?? ""));
302
+ }
303
+
304
+ function jsxText(value) {
305
+ return String(value ?? "")
306
+ .replaceAll("&", "&amp;")
307
+ .replaceAll("<", "&lt;")
308
+ .replaceAll(">", "&gt;")
309
+ .replaceAll("{", "&#123;")
310
+ .replaceAll("}", "&#125;");
311
+ }
312
+
313
+ function jsxAttr(value) {
314
+ return jsxText(value).replaceAll('"', "&quot;");
315
+ }
316
+
317
+ async function assertWritable(actions, { force = false } = {}) {
318
+ if (force) return;
319
+ const writeTargets = actions.filter((item) => item.action === "write" || item.action === "copy");
320
+ for (const item of writeTargets) {
321
+ await assertMissing(item.absolutePath);
322
+ }
323
+ }
324
+
325
+ async function assertMissing(filePath) {
326
+ try {
327
+ await fs.access(filePath);
328
+ throw new Error(`Refusing to overwrite existing path: ${filePath}. Re-run with --force if this is intentional.`);
329
+ } catch (error) {
330
+ if (error?.code === "ENOENT") return;
331
+ throw error;
332
+ }
333
+ }
334
+
335
+ async function copyPathIfExists(source, destination, { force = false } = {}) {
336
+ let stat;
337
+ try {
338
+ stat = await fs.stat(source);
339
+ } catch (error) {
340
+ if (error?.code === "ENOENT") return;
341
+ throw error;
342
+ }
343
+ await fs.mkdir(path.dirname(destination), { recursive: true });
344
+ if (force) await fs.rm(destination, { recursive: true, force: true });
345
+ else await assertMissing(destination);
346
+ await fs.cp(source, destination, { recursive: stat.isDirectory(), force: true });
347
+ }
348
+
349
+ function rootRelative(root, absolutePath) {
350
+ return path.relative(root, absolutePath).replaceAll("\\", "/");
351
+ }
352
+
353
+ function samePath(left, right) {
354
+ return path.resolve(left) === path.resolve(right);
355
+ }
@@ -0,0 +1,3 @@
1
+ export const DEFAULT_PAGE_SAFE_HEIGHT_PX = 930;
2
+ export const PAGE_BODY_FIT_SAFETY_RATIO = 0.08;
3
+ export const PAGE_BODY_FIT_SAFETY_MAX_PX = 96;
@@ -0,0 +1,121 @@
1
+ import { chromium } from "playwright";
2
+ import {
3
+ DEFAULT_PAGE_SAFE_HEIGHT_PX,
4
+ PAGE_BODY_FIT_SAFETY_MAX_PX,
5
+ PAGE_BODY_FIT_SAFETY_RATIO,
6
+ } from "./pagination-constants.mjs";
7
+
8
+ const DEFAULT_VIEWPORT = { width: 794, height: 1123 };
9
+
10
+ export function paginateMeasuredBlocks(measuredBlocks, { pageSafeHeightPx = DEFAULT_PAGE_SAFE_HEIGHT_PX } = {}) {
11
+ const safeHeight = positiveNumber(pageSafeHeightPx, DEFAULT_PAGE_SAFE_HEIGHT_PX);
12
+ const pages = [];
13
+ const warnings = [];
14
+ let currentBlockIds = [];
15
+ let currentHeight = 0;
16
+
17
+ for (const block of measuredBlocks ?? []) {
18
+ const id = String(block?.id ?? "");
19
+ if (!id) continue;
20
+ const height = Math.max(0, Number(block.height) || 0);
21
+
22
+ if (height > safeHeight) {
23
+ warnings.push({
24
+ code: "block-overflows-page",
25
+ blockId: id,
26
+ height,
27
+ pageSafeHeightPx: safeHeight,
28
+ });
29
+ }
30
+
31
+ if (currentBlockIds.length > 0 && currentHeight + height > safeHeight) {
32
+ pages.push(pageRecord(pages.length, currentBlockIds));
33
+ currentBlockIds = [];
34
+ currentHeight = 0;
35
+ }
36
+
37
+ currentBlockIds.push(id);
38
+ currentHeight += height;
39
+ }
40
+
41
+ if (currentBlockIds.length > 0) {
42
+ pages.push(pageRecord(pages.length, currentBlockIds));
43
+ }
44
+
45
+ return { pages, warnings };
46
+ }
47
+
48
+ export async function measureBlocksInChromium({
49
+ html,
50
+ css = "",
51
+ pageSafeHeightPx,
52
+ viewport = DEFAULT_VIEWPORT,
53
+ } = {}) {
54
+ const browser = await chromium.launch();
55
+ try {
56
+ const page = await browser.newPage({ viewport });
57
+ await page.setContent(measurementDocument(html, css), { waitUntil: "load" });
58
+ await page.evaluate(async () => {
59
+ if (document.fonts?.ready) await document.fonts.ready;
60
+ });
61
+ const measurements = await page.evaluate(() => (
62
+ Array.from(document.querySelectorAll("[data-openpress-block-id]")).map((element) => ({
63
+ id: element.getAttribute("data-openpress-block-id"),
64
+ height: element.getBoundingClientRect().height,
65
+ }))
66
+ ));
67
+ const safeHeight = positiveNumber(pageSafeHeightPx, null)
68
+ ?? await measurePageSafeHeight(page)
69
+ ?? DEFAULT_PAGE_SAFE_HEIGHT_PX;
70
+ return {
71
+ measurements,
72
+ pageSafeHeightPx: safeHeight,
73
+ ...paginateMeasuredBlocks(measurements, { pageSafeHeightPx: safeHeight }),
74
+ };
75
+ } finally {
76
+ await browser.close();
77
+ }
78
+ }
79
+
80
+ async function measurePageSafeHeight(page) {
81
+ return page.evaluate(({ ratio, maxPx }) => {
82
+ const body = document.querySelector(".reader-page[data-page-kind='content'] .page-body")
83
+ ?? document.querySelector(".reader-page--content .page-body")
84
+ ?? document.querySelector(".page-body");
85
+ if (!body) return null;
86
+ const height = body.getBoundingClientRect().height;
87
+ if (!Number.isFinite(height) || height <= 0) return null;
88
+ const safetyInset = Math.min(maxPx, Math.max(0, height * ratio));
89
+ return Math.max(1, height - safetyInset);
90
+ }, {
91
+ ratio: PAGE_BODY_FIT_SAFETY_RATIO,
92
+ maxPx: PAGE_BODY_FIT_SAFETY_MAX_PX,
93
+ });
94
+ }
95
+
96
+ function pageRecord(pageIndex, blockIds) {
97
+ return {
98
+ pageIndex,
99
+ blockIds,
100
+ breakAfter: blockIds.at(-1),
101
+ };
102
+ }
103
+
104
+ function positiveNumber(value, fallback) {
105
+ const number = Number(value);
106
+ return Number.isFinite(number) && number > 0 ? number : fallback;
107
+ }
108
+
109
+ function measurementDocument(html = "", css = "") {
110
+ return `<!doctype html>
111
+ <html>
112
+ <head>
113
+ <meta charset="utf-8">
114
+ <style>
115
+ body { margin: 0; }
116
+ ${css}
117
+ </style>
118
+ </head>
119
+ <body>${html}</body>
120
+ </html>`;
121
+ }
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ export function handleProjectAssetRequest(
4
+ req: IncomingMessage,
5
+ res: ServerResponse,
6
+ options?: {
7
+ root?: string;
8
+ timestamp?: string;
9
+ },
10
+ ): Promise<void>;