@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,62 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { copyDirectory } from "./file-utils.mjs";
4
+
5
+ export async function copyThemeFonts(root, publicOutputDir, config) {
6
+ const themeDir = config?.paths?.themeDir ?? path.join(path.resolve(root), "theme");
7
+ const target = path.join(publicOutputDir, "fonts.css");
8
+ const themeFonts = path.join(themeDir, "fonts.css");
9
+ if (await isFile(themeFonts)) {
10
+ await fs.copyFile(themeFonts, target);
11
+ } else {
12
+ await fs.writeFile(target, defaultFontsCss(), "utf8");
13
+ }
14
+
15
+ const themeFontFiles = path.join(themeDir, "fonts");
16
+ if (await isDirectory(themeFontFiles)) {
17
+ await copyDirectory(themeFontFiles, path.join(publicOutputDir, "fonts"));
18
+ } else {
19
+ await fs.rm(path.join(publicOutputDir, "fonts"), { recursive: true, force: true });
20
+ }
21
+ }
22
+
23
+ async function isFile(filePath) {
24
+ try {
25
+ return (await fs.stat(filePath)).isFile();
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function isDirectory(filePath) {
32
+ try {
33
+ return (await fs.stat(filePath)).isDirectory();
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function defaultFontsCss() {
40
+ return `@font-face {
41
+ font-family: "OpenPress Body";
42
+ src: local("PingFang TC"), local("Noto Sans TC"), local("Hiragino Sans"), local("Microsoft JhengHei");
43
+ font-weight: 300 700;
44
+ font-style: normal;
45
+ font-display: swap;
46
+ }
47
+
48
+ @font-face {
49
+ font-family: "OpenPress Serif";
50
+ src: local("Noto Serif TC"), local("Songti TC"), local("Source Han Serif TC"), local("PMingLiU");
51
+ font-weight: 300 700;
52
+ font-style: normal;
53
+ font-display: swap;
54
+ }
55
+
56
+ :root {
57
+ --openpress-font-body: "OpenPress Body", "PingFang TC", "Noto Sans TC", "Hiragino Sans", "Microsoft JhengHei", sans-serif;
58
+ --openpress-font-serif: "OpenPress Serif", "Noto Serif TC", "Songti TC", "Source Han Serif TC", "PMingLiU", serif;
59
+ --openpress-font-mono: "SFMono-Regular", "Menlo", "Consolas", monospace;
60
+ }
61
+ `;
62
+ }
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
6
+ const ENGINE_ROOT = path.resolve(SELF_DIR, "..");
7
+ const SKILLS_DIR = path.join(ENGINE_ROOT, "skills");
8
+
9
+ const DEFAULT_SKILL = "editorial-monograph";
10
+
11
+ export async function initWorkspace({ target, skill = DEFAULT_SKILL, force = false }) {
12
+ if (!target) throw new Error("openpress init: target path is required");
13
+ const targetPath = path.resolve(target);
14
+
15
+ const starterPath = path.join(SKILLS_DIR, skill, "starter");
16
+ try {
17
+ const stat = await fs.stat(starterPath);
18
+ if (!stat.isDirectory()) {
19
+ throw new Error(`openpress init: skill "${skill}" has no starter/ directory at ${starterPath}`);
20
+ }
21
+ } catch (error) {
22
+ if (error?.code === "ENOENT") {
23
+ const available = await listStylePackSkills();
24
+ throw new Error(
25
+ `openpress init: skill "${skill}" not found or has no starter. ` +
26
+ `Available style packs: ${available.join(", ") || "(none)"}`,
27
+ );
28
+ }
29
+ throw error;
30
+ }
31
+
32
+ if (!force) {
33
+ try {
34
+ const stat = await fs.stat(targetPath);
35
+ if (stat.isDirectory()) {
36
+ const entries = await fs.readdir(targetPath);
37
+ if (entries.length > 0) {
38
+ throw new Error(`openpress init: target ${targetPath} exists and is not empty. Pass --force to overwrite.`);
39
+ }
40
+ } else {
41
+ throw new Error(`openpress init: target ${targetPath} exists and is not a directory.`);
42
+ }
43
+ } catch (error) {
44
+ if (error?.code !== "ENOENT") throw error;
45
+ }
46
+ }
47
+
48
+ await fs.mkdir(targetPath, { recursive: true });
49
+ await copyDirectory(starterPath, targetPath);
50
+
51
+ return { targetPath, skill };
52
+ }
53
+
54
+ export async function listStylePackSkills() {
55
+ try {
56
+ const entries = await fs.readdir(SKILLS_DIR, { withFileTypes: true });
57
+ const names = [];
58
+ for (const entry of entries) {
59
+ if (!entry.isDirectory()) continue;
60
+ const starter = path.join(SKILLS_DIR, entry.name, "starter");
61
+ try {
62
+ const stat = await fs.stat(starter);
63
+ if (stat.isDirectory()) names.push(entry.name);
64
+ } catch {
65
+ // skill without starter/ is not a style pack — skip
66
+ }
67
+ }
68
+ return names.sort();
69
+ } catch (error) {
70
+ if (error?.code === "ENOENT") return [];
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ async function copyDirectory(source, destination) {
76
+ await fs.mkdir(destination, { recursive: true });
77
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
78
+ if (entry.name === ".DS_Store") continue;
79
+ const sourcePath = path.join(source, entry.name);
80
+ const destPath = path.join(destination, entry.name);
81
+ if (entry.isDirectory()) {
82
+ await copyDirectory(sourcePath, destPath);
83
+ } else if (entry.isFile()) {
84
+ await fs.copyFile(sourcePath, destPath);
85
+ } else if (entry.isSymbolicLink()) {
86
+ const link = await fs.readlink(sourcePath);
87
+ await fs.symlink(link, destPath);
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,348 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { evaluateUrlWithChrome, stopChildProcess } from "./chrome-pdf.mjs";
4
+ import { buildReactStatic, startStaticServer } from "./commands/_shared.mjs";
5
+ import { createIssue, createIssueReport } from "./issue-report.mjs";
6
+ import { collectActiveContentFiles, resolveActiveSourceWorkspace } from "./source-workspace.mjs";
7
+
8
+ const MEDIA_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
9
+ const SOURCE_EXTENSIONS = new Set([".css", ".html", ".js", ".json", ".md", ".mjs", ".ts", ".tsx"]);
10
+
11
+ export async function inspectWorkspace({ root, config, options = {}, recurse = null }) {
12
+ const checked = [];
13
+ const issues = [];
14
+
15
+ const sourceScan = await collectInspectionSources(config);
16
+ checked.push(sourceScan.checkedName);
17
+ sourceScan.contentFiles.forEach((file) => {
18
+ if (!file.text.trim()) {
19
+ issues.push(createIssue({
20
+ level: "warning",
21
+ code: "react-source.empty-file",
22
+ message: `${sourceScan.contentLabel} \`${file.relativePath}\` is empty.`,
23
+ path: file.absolutePath,
24
+ }));
25
+ }
26
+ });
27
+
28
+ checked.push("media");
29
+ const mediaFiles = await readMediaFiles(sourceScan.config.paths.mediaDir);
30
+ const sourceText = sourceScan.sourceFiles.map((file) => file.text).join("\n");
31
+ const unusedMedia = mediaFiles.filter((file) => !sourceText.includes(file.name) && !sourceText.includes(file.relativePath));
32
+ unusedMedia.forEach((file) => {
33
+ issues.push(createIssue({
34
+ level: "warning",
35
+ code: "media.unused",
36
+ message: `Media asset \`${file.relativePath}\` is not referenced by document sources.`,
37
+ path: file.absolutePath,
38
+ detail: {
39
+ file: file.name,
40
+ relativePath: file.relativePath,
41
+ },
42
+ }));
43
+ });
44
+
45
+ checked.push("components");
46
+ const componentUsage = sourceScan.componentUsage;
47
+
48
+ const summary = {
49
+ ...sourceScan.summary,
50
+ mediaFiles: mediaFiles.length,
51
+ unusedMedia: unusedMedia.length,
52
+ componentUsage,
53
+ };
54
+
55
+ checked.push("overflow");
56
+ const renderCode = await buildReactStatic({
57
+ root,
58
+ noBuild: options.noBuild,
59
+ recurse,
60
+ silent: options.json,
61
+ });
62
+ if (renderCode !== 0) {
63
+ issues.push(createIssue({
64
+ level: "error",
65
+ code: "inspect.render",
66
+ message: `React render failed before overflow inspection (exit code ${renderCode}).`,
67
+ path: config.configPath,
68
+ }));
69
+ return createIssueReport({
70
+ kind: "inspection",
71
+ checked,
72
+ issues,
73
+ summary,
74
+ okMessage: "OpenPress inspection OK",
75
+ });
76
+ }
77
+
78
+ const overflowMeasurements = await inspectRenderedOverflow({
79
+ root,
80
+ config,
81
+ host: options.host ?? "127.0.0.1",
82
+ port: options.port ?? "5186",
83
+ });
84
+ issues.push(...overflowIssuesFromMeasurements(overflowMeasurements));
85
+ summary.pages = overflowMeasurements.length;
86
+ summary.overflowPages = overflowMeasurements.filter((page) => (page.overflows ?? []).length > 0).length;
87
+
88
+ return createIssueReport({
89
+ kind: "inspection",
90
+ checked,
91
+ issues,
92
+ summary,
93
+ okMessage: "OpenPress inspection OK",
94
+ });
95
+ }
96
+
97
+ export async function collectInspectionSources(config) {
98
+ const sourceWorkspace = await resolveActiveSourceWorkspace(config);
99
+ const sourceConfig = sourceWorkspace.config;
100
+ const contentFiles = await collectActiveContentFiles(sourceWorkspace);
101
+ const sourceFiles = [
102
+ ...contentFiles,
103
+ ...await readSourceFiles(sourceConfig.paths.componentsDir),
104
+ ...await readSingleFile(sourceConfig.paths.designDoc),
105
+ ];
106
+ const componentUsage = summarizeComponentUsage(contentFiles);
107
+
108
+ return {
109
+ sourceKind: sourceWorkspace.kind,
110
+ checkedName: sourceWorkspace.checkedName,
111
+ contentLabel: sourceWorkspace.contentLabel,
112
+ config: sourceConfig,
113
+ contentFiles,
114
+ sourceFiles,
115
+ componentUsage,
116
+ summary: {
117
+ sourceKind: sourceWorkspace.kind,
118
+ sourceFiles: contentFiles.length,
119
+ mdxFiles: contentFiles.length,
120
+ },
121
+ };
122
+ }
123
+
124
+ export async function inspectRenderedOverflow({ root, config, host = "127.0.0.1", port = "5186" }) {
125
+ const server = await startStaticServer(root, config, host, port);
126
+ try {
127
+ return await evaluateUrlWithChrome({
128
+ root,
129
+ url: `http://${host}:${port}/?print=1`,
130
+ debuggingPortBase: 9900,
131
+ debuggingPortRange: 600,
132
+ profilePrefix: "chrome-inspect",
133
+ emulatedMedia: "print",
134
+ evaluate: waitForInspectionReady,
135
+ });
136
+ } finally {
137
+ await stopChildProcess(server);
138
+ }
139
+ }
140
+
141
+ export async function waitForInspectionReady(client) {
142
+ const deadline = Date.now() + 30000;
143
+ while (Date.now() < deadline) {
144
+ const result = await client.send("Runtime.evaluate", {
145
+ returnByValue: true,
146
+ awaitPromise: true,
147
+ expression: inspectionExpression(),
148
+ });
149
+ const value = result.result?.value;
150
+ if (Array.isArray(value)) return value;
151
+ await delay(100);
152
+ }
153
+ throw new Error("Timed out waiting for OpenPress pagination before inspection.");
154
+ }
155
+
156
+ export function overflowIssuesFromMeasurements(measurements) {
157
+ return measurements.flatMap((page) => {
158
+ const pageLabel = String(page.pageNumber).padStart(2, "0");
159
+ return (page.overflows ?? []).map((overflow) => createIssue({
160
+ level: "warning",
161
+ code: `overflow.${overflow.code}`,
162
+ message: `Page ${pageLabel} exceeds ${humanOverflowTarget(overflow.code)} by ${Math.ceil(overflow.overflowPx)}px.`,
163
+ path: page.source?.path,
164
+ detail: {
165
+ pageNumber: page.pageNumber,
166
+ title: page.title,
167
+ sourceFile: page.source?.file,
168
+ selector: overflow.selector,
169
+ tagName: overflow.tagName,
170
+ text: overflow.text,
171
+ overflowPx: Math.ceil(overflow.overflowPx),
172
+ },
173
+ }));
174
+ });
175
+ }
176
+
177
+ async function readSourceFiles(directory, extension = null) {
178
+ const files = [];
179
+ await walkFiles(directory, async (absolutePath) => {
180
+ if (extension && path.extname(absolutePath) !== extension) return;
181
+ if (!SOURCE_EXTENSIONS.has(path.extname(absolutePath))) return;
182
+ files.push({
183
+ absolutePath,
184
+ relativePath: path.relative(directory, absolutePath).replaceAll("\\", "/"),
185
+ text: await fs.readFile(absolutePath, "utf8"),
186
+ });
187
+ });
188
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
189
+ return files;
190
+ }
191
+
192
+ async function readSingleFile(absolutePath) {
193
+ try {
194
+ const text = await fs.readFile(absolutePath, "utf8");
195
+ return [{
196
+ absolutePath,
197
+ relativePath: path.basename(absolutePath),
198
+ text,
199
+ }];
200
+ } catch (error) {
201
+ if (error?.code === "ENOENT") return [];
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ async function readMediaFiles(directory) {
207
+ const files = [];
208
+ await walkFiles(directory, async (absolutePath) => {
209
+ if (!MEDIA_EXTENSIONS.has(path.extname(absolutePath).toLowerCase())) return;
210
+ files.push({
211
+ absolutePath,
212
+ name: path.basename(absolutePath),
213
+ relativePath: path.relative(directory, absolutePath).replaceAll("\\", "/"),
214
+ });
215
+ });
216
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
217
+ return files;
218
+ }
219
+
220
+ async function walkFiles(directory, visit) {
221
+ let entries;
222
+ try {
223
+ entries = await fs.readdir(directory, { withFileTypes: true });
224
+ } catch (error) {
225
+ if (error?.code === "ENOENT") return;
226
+ throw error;
227
+ }
228
+
229
+ for (const entry of entries) {
230
+ if (entry.name.startsWith(".")) continue;
231
+ const absolutePath = path.join(directory, entry.name);
232
+ if (entry.isDirectory()) {
233
+ await walkFiles(absolutePath, visit);
234
+ } else if (entry.isFile()) {
235
+ await visit(absolutePath);
236
+ }
237
+ }
238
+ }
239
+
240
+ function summarizeComponentUsage(contentFiles) {
241
+ const usages = new Map();
242
+ for (const file of contentFiles) {
243
+ for (const match of file.text.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g)) {
244
+ const name = match[1];
245
+ const current = usages.get(name) ?? { name, count: 0, files: [] };
246
+ current.count += 1;
247
+ if (!current.files.includes(file.relativePath)) current.files.push(file.relativePath);
248
+ usages.set(name, current);
249
+ }
250
+ }
251
+ return Array.from(usages.values()).sort((a, b) => a.name.localeCompare(b.name));
252
+ }
253
+
254
+ function humanOverflowTarget(code) {
255
+ if (code === "page-body") return "page body";
256
+ if (code === "page-frame") return "page frame";
257
+ return code.replaceAll("-", " ");
258
+ }
259
+
260
+ function inspectionExpression() {
261
+ return `Promise.resolve().then(async () => {
262
+ const root = document.querySelector('[data-openpress-print-document="true"]');
263
+ const ready = root?.getAttribute('data-openpress-pagination') === 'ready';
264
+ if (!ready) return null;
265
+
266
+ await document.fonts?.ready;
267
+ await Promise.all(Array.from(document.images).map(async (img) => {
268
+ if (!img.complete) {
269
+ await new Promise((resolve) => {
270
+ const settle = () => {
271
+ img.removeEventListener('load', settle);
272
+ img.removeEventListener('error', settle);
273
+ resolve();
274
+ };
275
+ img.addEventListener('load', settle, { once: true });
276
+ img.addEventListener('error', settle, { once: true });
277
+ });
278
+ }
279
+ await img.decode?.().catch(() => undefined);
280
+ }));
281
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
282
+
283
+ const textFor = (element) => (element?.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120);
284
+ const overflowAmount = (outer, inner) => {
285
+ if (!outer || !inner) return 0;
286
+ const outerRect = outer.getBoundingClientRect();
287
+ const innerRect = inner.getBoundingClientRect();
288
+ return Math.max(0, innerRect.bottom - outerRect.bottom, innerRect.right - outerRect.right);
289
+ };
290
+ const contentBottomOverflow = (body) => {
291
+ if (!body) return 0;
292
+ const bodyRect = body.getBoundingClientRect();
293
+ const contentBottom = Array.from(body.children).reduce((bottom, child) => {
294
+ if (getComputedStyle(child).display === 'none') return bottom;
295
+ const marginBottom = Number.parseFloat(getComputedStyle(child).marginBottom) || 0;
296
+ return Math.max(bottom, child.getBoundingClientRect().bottom + marginBottom);
297
+ }, bodyRect.top);
298
+ return Math.max(0, contentBottom - bodyRect.bottom);
299
+ };
300
+ const addElementOverflow = (overflows, code, selector, container, element) => {
301
+ const px = overflowAmount(container, element);
302
+ if (px <= 1) return;
303
+ overflows.push({
304
+ code,
305
+ selector,
306
+ overflowPx: Math.ceil(px),
307
+ tagName: element.tagName,
308
+ text: textFor(element),
309
+ });
310
+ };
311
+
312
+ const wrappers = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
313
+ if (wrappers.length === 0) return null;
314
+ return wrappers.map((wrapper, index) => {
315
+ const page = wrapper.querySelector('.reader-page') || wrapper;
316
+ const frame = page.querySelector('.page-frame') || page;
317
+ const body = page.querySelector('.page-body') || frame;
318
+ const sourcePath = wrapper.getAttribute('data-source-path') || undefined;
319
+ const sourceFile = wrapper.getAttribute('data-source-file') || sourcePath?.split('/').pop();
320
+ const overflows = [];
321
+ const bodyOverflow = contentBottomOverflow(body);
322
+ if (bodyOverflow > 1) {
323
+ overflows.push({
324
+ code: 'page-body',
325
+ selector: '.page-body',
326
+ overflowPx: Math.ceil(bodyOverflow),
327
+ tagName: body.tagName,
328
+ text: textFor(body),
329
+ });
330
+ }
331
+ addElementOverflow(overflows, 'page-frame', '.page-frame', page, frame);
332
+ body.querySelectorAll('table, img, pre, figure, [data-openpress-component]').forEach((element) => {
333
+ const tag = element.tagName.toLowerCase();
334
+ addElementOverflow(overflows, tag, tag, body, element);
335
+ });
336
+ return {
337
+ pageNumber: index + 1,
338
+ title: page.getAttribute('data-page-title') || wrapper.getAttribute('aria-label') || '',
339
+ source: sourcePath ? { file: sourceFile, path: sourcePath } : undefined,
340
+ overflows,
341
+ };
342
+ });
343
+ })`;
344
+ }
345
+
346
+ function delay(ms) {
347
+ return new Promise((resolve) => setTimeout(resolve, ms));
348
+ }
@@ -0,0 +1,44 @@
1
+ export function createIssue({ level = "warning", code, message, path = null, detail = undefined }) {
2
+ return {
3
+ level,
4
+ code,
5
+ message,
6
+ ...(path ? { path } : {}),
7
+ ...(detail !== undefined ? { detail } : {}),
8
+ };
9
+ }
10
+
11
+ export function createIssueReport({ kind, checked = [], issues = [], summary = undefined, okMessage = undefined }) {
12
+ const report = {
13
+ kind,
14
+ ok: issues.every((issue) => issue.level !== "error"),
15
+ checked,
16
+ issues,
17
+ ...(summary !== undefined ? { summary } : {}),
18
+ };
19
+ return {
20
+ ...report,
21
+ format() {
22
+ return formatIssueReport(report, { okMessage });
23
+ },
24
+ };
25
+ }
26
+
27
+ export function exitCodeForIssueReport(report) {
28
+ return report.ok ? 0 : 1;
29
+ }
30
+
31
+ export function formatIssueReport(report, { okMessage } = {}) {
32
+ if (report.issues.length === 0) return okMessage ?? `${capitalize(report.kind)} OK`;
33
+ return report.issues.map(formatIssue).join("\n");
34
+ }
35
+
36
+ function formatIssue(issue) {
37
+ const suffix = issue.path ? ` (${issue.path})` : "";
38
+ return `[${issue.level}] ${issue.code}: ${issue.message}${suffix}`;
39
+ }
40
+
41
+ function capitalize(value) {
42
+ const text = String(value ?? "");
43
+ return text ? `${text.charAt(0).toUpperCase()}${text.slice(1)}` : "Report";
44
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ const KATEX_OVERRIDES = `
8
+
9
+ /* OpenPress math sizing keeps inline equations aligned with document typography. */
10
+ .reader-page--content .katex {
11
+ font-size: 1em;
12
+ }
13
+
14
+ .reader-page--content .katex-display {
15
+ display: block;
16
+ width: fit-content;
17
+ max-width: 100%;
18
+ margin: var(--openpress-space-2) auto var(--openpress-space-3);
19
+ border-block: 1px solid color-mix(in srgb, var(--openpress-color-line) 76%, transparent);
20
+ padding: 0.55em 1.15em;
21
+ background: color-mix(in srgb, var(--openpress-color-panel) 78%, var(--openpress-color-document));
22
+ color: var(--openpress-color-ink);
23
+ overflow-x: auto;
24
+ overflow-y: hidden;
25
+ text-align: center;
26
+ }
27
+
28
+ .reader-page--content .katex-display > .katex {
29
+ font-size: 1.05em;
30
+ }
31
+ `;
32
+
33
+ export async function readKatexCss() {
34
+ const cssPath = require.resolve("katex/dist/katex.min.css");
35
+ const css = await fs.readFile(cssPath, "utf8");
36
+ return `${css.replace(/url\(fonts\/([^)]+)\)/g, 'url("/openpress/katex-fonts/$1")')}${KATEX_OVERRIDES}`;
37
+ }
38
+
39
+ export async function copyKatexFonts(publicOutputDir) {
40
+ const sampleFont = require.resolve("katex/dist/fonts/KaTeX_Main-Regular.woff2");
41
+ const sourceDir = path.dirname(sampleFont);
42
+ const targetDir = path.join(publicOutputDir, "katex-fonts");
43
+ await fs.rm(targetDir, { recursive: true, force: true });
44
+ await fs.cp(sourceDir, targetDir, { recursive: true });
45
+ }
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+
3
+ export function documentRelativePath(config, ...parts) {
4
+ return path.posix.join(
5
+ ...(config.documentDir === "." ? [] : [config.documentDir]),
6
+ ...parts,
7
+ );
8
+ }
9
+
10
+ function rewriteAssetPaths(pageHtml, config) {
11
+ const mediaDir = config.mediaDir.replace(/^\/+|\/+$/g, "");
12
+ return pageHtml
13
+ .replaceAll(`src="${mediaDir}/`, 'src="/openpress/media/')
14
+ .replaceAll(`src='${mediaDir}/`, "src='/openpress/media/");
15
+ }
16
+
17
+ export function pageToBlock(index, pageHtml, source, config, { idPrefix = "openpress-page", anchorPrefix = "page", titleFallback = "Page" } = {}) {
18
+ const paddedIndex = String(index + 1).padStart(2, "0");
19
+ const title = pageHtml.match(/data-page-title="([^"]*)"/)?.[1] ?? `${titleFallback} ${index + 1}`;
20
+ const anchor = pageHtml.match(/\bid="([^"]+)"/)?.[1] ?? `${anchorPrefix}-${paddedIndex}`;
21
+ return {
22
+ id: `${idPrefix}-${paddedIndex}`,
23
+ kind: "htmlPage",
24
+ title,
25
+ pageNumber: index + 1,
26
+ source,
27
+ html: rewriteAssetPaths(pageHtml, config),
28
+ anchors: [anchor],
29
+ };
30
+ }