@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,31 @@
1
+ import path from "node:path";
2
+ import { deploySync } from "../deploy-sync.mjs";
3
+ import { buildReactPdf, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
4
+
5
+ export async function run({ root, config, options, recurse }) {
6
+ if (config.deploy.requiresConfirmation === true && !options.confirm) {
7
+ console.error("OpenPress deploy requires --confirm before updating a public Cloudflare Pages site.");
8
+ return 2;
9
+ }
10
+ const source = config.deploy.source;
11
+ const projectName = config.deploy.projectName;
12
+ const commitDirty = config.deploy.commitDirty;
13
+ if (options.dryRun) {
14
+ console.log("OpenPress deploy dry run");
15
+ console.log("Command: node engine/cli.mjs render . --renderer react");
16
+ console.log(`Step: deploy-sync (copy ${config.outputDir} → ${source})`);
17
+ console.log(`Command: node engine/cli.mjs pdf . --output ${source}/${config.pdf.filename}`);
18
+ console.log(`Step: write ${source}/openpress/deploy.json with deployment metadata`);
19
+ console.log(`Command: npx wrangler pages deploy ${source}${projectName ? ` --project-name=${projectName}` : ""}${commitDirty ? " --commit-dirty=true" : ""}`);
20
+ return 0;
21
+ }
22
+ const renderCode = await recurse("render", [root, "--renderer", "react"]);
23
+ if (renderCode !== 0) return renderCode;
24
+ await deploySync(root, config.outputDir, source);
25
+ await buildReactPdf({ root, config, outPath: path.resolve(root, source, config.pdf.filename), noBuild: true, recurse });
26
+ await writePdfStageDeployConfig(root, source, config);
27
+ const wranglerArgs = ["wrangler", "pages", "deploy", source];
28
+ if (projectName) wranglerArgs.push(`--project-name=${projectName}`);
29
+ if (commitDirty) wranglerArgs.push("--commit-dirty=true");
30
+ return runCommand("npx", wranglerArgs, root);
31
+ }
@@ -0,0 +1,26 @@
1
+ import { exportDocument } from "../document-export.mjs";
2
+ import { runCommand } from "./_shared.mjs";
3
+
4
+ export async function run({ root, options }) {
5
+ const renderer = options.renderer ?? "react";
6
+ if (renderer !== "react") {
7
+ console.error(`Unknown renderer: ${renderer}`);
8
+ return 2;
9
+ }
10
+ const host = options.host ?? "127.0.0.1";
11
+ const port = options.port ?? "5173";
12
+ const url = `http://${host}:${port}/?dev=1`;
13
+ if (options.dryRun) {
14
+ console.log(`OpenPress dev URL: ${url}`);
15
+ if (!options.noBuild) {
16
+ console.log("Command: node engine/cli.mjs export .");
17
+ }
18
+ console.log(`Command: npx vite --config vite.config.ts --host ${host} --port ${port}`);
19
+ return 0;
20
+ }
21
+ if (!options.noBuild) {
22
+ await exportDocument(root);
23
+ }
24
+ console.log(`OpenPress dev: ${url}`);
25
+ return runCommand("npx", ["vite", "--config", "vite.config.ts", "--host", host, "--port", port], root);
26
+ }
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+ import { exportDocument } from "../document-export.mjs";
3
+
4
+ export async function run({ root }) {
5
+ const result = await exportDocument(root);
6
+ console.log(`OpenPress export: ${path.relative(root, result.documentPath)} (${result.pageCount} pages)`);
7
+ return 0;
8
+ }
@@ -0,0 +1,24 @@
1
+ import { initWorkspace, listStylePackSkills } from "../init.mjs";
2
+ import { formatDisplayPath, parseInitOptions } from "./_shared.mjs";
3
+
4
+ export const needsWorkspace = false;
5
+
6
+ export async function run({ argv }) {
7
+ const options = parseInitOptions(argv);
8
+ if (!options.target) {
9
+ console.error("openpress init: target path is required");
10
+ console.error("Usage: openpress init <target> [--skill <name>] [--force]");
11
+ const available = await listStylePackSkills();
12
+ if (available.length) console.error(`Style packs available: ${available.join(", ")}`);
13
+ return 1;
14
+ }
15
+ const result = await initWorkspace(options);
16
+ const displayPath = formatDisplayPath(result.targetPath);
17
+ console.log(`OpenPress init: created ${displayPath} from style pack "${result.skill}".`);
18
+ console.log("Next steps:");
19
+ console.log(` cd ${displayPath}`);
20
+ console.log(" # 填入 openpress.config.mjs 的 title / subtitle / organization");
21
+ console.log(" # 改 document/index.tsx 與 document/chapters/**/*.mdx 為實際內容");
22
+ console.log(" node engine/cli.mjs validate");
23
+ return 0;
24
+ }
@@ -0,0 +1,35 @@
1
+ import { inspectWorkspace } from "../inspection.mjs";
2
+ import { exitCodeForIssueReport } from "../issue-report.mjs";
3
+
4
+ export async function run({ root, config, options, recurse }) {
5
+ const host = options.host ?? "127.0.0.1";
6
+ const port = options.port ?? "5186";
7
+ const url = `http://${host}:${port}/?print=1`;
8
+
9
+ if (options.dryRun) {
10
+ if (!options.noBuild) {
11
+ console.log("Command: node engine/cli.mjs render . --renderer react");
12
+ }
13
+ console.log(`Command: node engine/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
14
+ console.log(`Chrome inspection URL: ${url}`);
15
+ return 0;
16
+ }
17
+
18
+ const report = await inspectWorkspace({ root, config, options, recurse });
19
+ if (options.json) {
20
+ console.log(JSON.stringify(report, null, 2));
21
+ return exitCodeForIssueReport(report);
22
+ }
23
+
24
+ if (report.ok) {
25
+ console.log(report.format());
26
+ console.log(`Checked: ${report.checked.join(", ")}`);
27
+ if (report.summary) {
28
+ console.log(`Summary: ${JSON.stringify(report.summary)}`);
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ console.log(report.format());
34
+ return exitCodeForIssueReport(report);
35
+ }
@@ -0,0 +1,27 @@
1
+ import { migrateLegacyWorkspaceToReact } from "../react/migrate-to-react.mjs";
2
+ import { validateWorkspace } from "../validation.mjs";
3
+
4
+ export async function run({ root, config, options }) {
5
+ const result = await migrateLegacyWorkspaceToReact(root, config, {
6
+ dryRun: options.dryRun,
7
+ force: options.force,
8
+ });
9
+
10
+ if (options.json) {
11
+ console.log(JSON.stringify(result, null, 2));
12
+ return 0;
13
+ }
14
+
15
+ const verb = options.dryRun ? "would create" : "created";
16
+ console.log(`OpenPress migrate-to-react ${verb} ${result.files.length} paths from ${result.sourceFiles} legacy files:`);
17
+ for (const file of result.files) {
18
+ console.log(` ${file.action.padEnd(5)} ${file.path}`);
19
+ }
20
+
21
+ if (!options.dryRun) {
22
+ const report = await validateWorkspace(root);
23
+ console.log(report.ok ? `OpenPress validation OK\nChecked: ${report.checked.join(", ")}` : report.format());
24
+ return report.ok ? 0 : 1;
25
+ }
26
+ return 0;
27
+ }
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+ import { buildReactPdf } from "./_shared.mjs";
3
+
4
+ export async function run({ root, config, options, recurse }) {
5
+ const outputPath = options.output ? path.resolve(root, options.output) : undefined;
6
+ if (options.dryRun) {
7
+ const relOutput = path.relative(root, outputPath ?? config.paths.pdf);
8
+ const host = options.host ?? "127.0.0.1";
9
+ const port = options.port ?? "5185";
10
+ console.log("Command: node engine/cli.mjs render . --renderer react");
11
+ console.log(`Command: node engine/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
12
+ console.log(`Command: Chrome --print-to-pdf=${relOutput} http://${host}:${port}/?print=1`);
13
+ return 0;
14
+ }
15
+ const result = await buildReactPdf({
16
+ root,
17
+ config,
18
+ outPath: outputPath,
19
+ host: options.host,
20
+ port: options.port,
21
+ noBuild: options.noBuild,
22
+ recurse,
23
+ });
24
+ console.log(`OpenPress PDF: ${path.relative(root, result.pdfPath)}`);
25
+ return 0;
26
+ }
@@ -0,0 +1,26 @@
1
+ import { runCommand } from "./_shared.mjs";
2
+
3
+ export async function run({ root, config, options, recurse }) {
4
+ const renderer = options.renderer ?? "react";
5
+ if (renderer !== "react") {
6
+ console.error(`Unknown renderer: ${renderer}`);
7
+ return 2;
8
+ }
9
+ const host = options.host ?? "127.0.0.1";
10
+ const port = options.port ?? "5173";
11
+ const url = `http://${host}:${port}`;
12
+ if (options.dryRun) {
13
+ console.log(`OpenPress preview URL: ${url}`);
14
+ if (!options.noBuild) {
15
+ console.log("Command: node engine/cli.mjs render . --renderer react");
16
+ }
17
+ console.log(`Command: node engine/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
18
+ return 0;
19
+ }
20
+ if (!options.noBuild) {
21
+ const renderCode = await recurse("render", [root, "--renderer", renderer]);
22
+ if (renderCode !== 0) return renderCode;
23
+ }
24
+ console.log(`OpenPress preview: ${url}`);
25
+ return runCommand("node", ["engine/static-server.mjs", config.outputDir, "--host", host, "--port", port, "--workspace", "."], root);
26
+ }
@@ -0,0 +1,17 @@
1
+ import { exportDocument } from "../document-export.mjs";
2
+ import { runCommand } from "./_shared.mjs";
3
+
4
+ export async function run({ root, options }) {
5
+ const renderer = options.renderer ?? "react";
6
+ if (renderer !== "react") {
7
+ console.error(`Unknown renderer: ${renderer}`);
8
+ return 2;
9
+ }
10
+ if (options.dryRun) {
11
+ console.log("Command: node engine/cli.mjs export .");
12
+ console.log("Command: npx vite build --config vite.config.ts");
13
+ return 0;
14
+ }
15
+ await exportDocument(root);
16
+ return runCommand("npx", ["vite", "build", "--config", "vite.config.ts"], root);
17
+ }
@@ -0,0 +1,41 @@
1
+ import { replaceSourceText } from "../source-text-tools.mjs";
2
+
3
+ export async function run({ config, options }) {
4
+ const args = replaceArgsFromOptions(options);
5
+ if (!args) {
6
+ console.error("Usage: node engine/cli.mjs replace [path] <from> <to> [--json] [--apply] [--scope content|all] [--include-code] [--case-sensitive]");
7
+ return 2;
8
+ }
9
+
10
+ const report = await replaceSourceText({
11
+ config,
12
+ from: args.from,
13
+ to: args.to,
14
+ scope: options.scope ?? "content",
15
+ caseSensitive: options.caseSensitive === true,
16
+ includeCode: options.includeCode === true,
17
+ apply: options.apply === true,
18
+ });
19
+
20
+ if (options.json) {
21
+ console.log(JSON.stringify(report, null, 2));
22
+ return 0;
23
+ }
24
+
25
+ console.log(`OpenPress replace ${report.applied ? "applied" : "preview"}: "${args.from}" -> "${args.to}" (${report.matchCount} matches in ${report.fileCount} files)`);
26
+ if (!report.applied) console.log("No files written. Re-run with --apply to update sources.");
27
+ for (const change of report.changes) {
28
+ console.log(`${change.path}: ${change.replacements.length} replacements`);
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ function replaceArgsFromOptions(options) {
34
+ const positional = options.positional ?? [];
35
+ const args = positional.length >= 3 ? positional.slice(1) : positional;
36
+ if (args.length < 2) return null;
37
+ return {
38
+ from: args[0],
39
+ to: args[1],
40
+ };
41
+ }
@@ -0,0 +1,33 @@
1
+ import { searchSourceText } from "../source-text-tools.mjs";
2
+
3
+ export async function run({ config, options }) {
4
+ const query = searchQueryFromOptions(options);
5
+ if (!query) {
6
+ console.error("Usage: node engine/cli.mjs search [path] <query> [--json] [--scope content|all] [--case-sensitive]");
7
+ return 2;
8
+ }
9
+
10
+ const report = await searchSourceText({
11
+ config,
12
+ query,
13
+ scope: options.scope ?? "content",
14
+ caseSensitive: options.caseSensitive === true,
15
+ });
16
+
17
+ if (options.json) {
18
+ console.log(JSON.stringify(report, null, 2));
19
+ return 0;
20
+ }
21
+
22
+ console.log(`OpenPress search: "${query}" (${report.matchCount} matches)`);
23
+ for (const match of report.matches) {
24
+ console.log(`${match.id} ${match.path}:${match.line}:${match.column} ${match.preview}`);
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ function searchQueryFromOptions(options) {
30
+ const positional = options.positional ?? [];
31
+ if (positional.length > 1) return positional.slice(1).join(" ");
32
+ return positional[0] ?? "";
33
+ }
@@ -0,0 +1,5 @@
1
+ import { runCommand } from "./_shared.mjs";
2
+
3
+ export async function run({ root }) {
4
+ return runCommand("npx", ["tsc", "--noEmit", "-p", "tsconfig.json"], root);
5
+ }
@@ -0,0 +1,17 @@
1
+ import { validateWorkspace } from "../validation.mjs";
2
+ import { exitCodeForIssueReport } from "../issue-report.mjs";
3
+
4
+ export async function run({ root, options }) {
5
+ const report = await validateWorkspace(root);
6
+ if (options.json) {
7
+ console.log(JSON.stringify(report, null, 2));
8
+ return exitCodeForIssueReport(report);
9
+ }
10
+ if (report.ok) {
11
+ console.log("OpenPress validation OK");
12
+ console.log(`Checked: ${report.checked.join(", ")}`);
13
+ return 0;
14
+ }
15
+ console.log(report.format());
16
+ return 1;
17
+ }
@@ -0,0 +1,40 @@
1
+ export interface ResolvedConfig {
2
+ root: string;
3
+ configPath: string;
4
+ title: string;
5
+ documentDir: string;
6
+ sourceDir: string;
7
+ mediaDir: string;
8
+ themeDir: string;
9
+ designDoc: string;
10
+ componentsDir: string;
11
+ publicDir: string;
12
+ outputDir: string;
13
+ pdf: {
14
+ filename: string;
15
+ };
16
+ deploy: {
17
+ adapter: string;
18
+ source: string;
19
+ projectName: string | null;
20
+ commitDirty: boolean;
21
+ requiresConfirmation: boolean;
22
+ };
23
+ paths: {
24
+ documentRoot: string;
25
+ sourceDir: string;
26
+ mediaDir: string;
27
+ themeDir: string;
28
+ designDoc: string;
29
+ componentsDir: string;
30
+ publicDir: string;
31
+ outputDir: string;
32
+ pdf: string;
33
+ deploySource: string;
34
+ deployMetadata: string;
35
+ };
36
+ }
37
+
38
+ export function loadConfig(root?: string): Promise<ResolvedConfig>;
39
+ export function normalizeConfig(root: string, userConfig?: Record<string, unknown>, configPath?: string): ResolvedConfig;
40
+ export function publicPdfHref(config: ResolvedConfig): string;
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const DEFAULT_CONFIG = {
6
+ title: "OpenPress Document",
7
+ subtitle: "",
8
+ organization: "",
9
+ workspaceLabel: "",
10
+ documentDir: ".",
11
+ sourceDir: "content",
12
+ mediaDir: "media",
13
+ themeDir: "theme",
14
+ designDoc: "design.md",
15
+ componentsDir: "components",
16
+ publicDir: "public/openpress",
17
+ outputDir: "dist",
18
+ pdf: {
19
+ filename: "document.pdf",
20
+ },
21
+ deploy: {
22
+ adapter: "cloudflare-pages",
23
+ source: ".deploy/openpress",
24
+ projectName: null,
25
+ commitDirty: false,
26
+ requiresConfirmation: true,
27
+ },
28
+ };
29
+
30
+ export async function loadConfig(root = ".") {
31
+ const workspaceRoot = path.resolve(root);
32
+ const configPath = path.join(workspaceRoot, "openpress.config.mjs");
33
+ const rootConfig = await readUserConfig(configPath);
34
+ const { config, sourceConfigPath } = await resolveUserConfig(workspaceRoot, rootConfig, configPath);
35
+ return normalizeConfig(workspaceRoot, config, sourceConfigPath);
36
+ }
37
+
38
+ export function normalizeConfig(root, userConfig = {}, configPath = path.join(root, "openpress.config.mjs")) {
39
+ const config = {
40
+ root: path.resolve(root),
41
+ configPath,
42
+ title: stringValue(userConfig.title, DEFAULT_CONFIG.title),
43
+ subtitle: optionalStringValue(userConfig.subtitle, DEFAULT_CONFIG.subtitle) ?? "",
44
+ organization: optionalStringValue(userConfig.organization, DEFAULT_CONFIG.organization) ?? "",
45
+ workspaceLabel: optionalStringValue(userConfig.workspaceLabel, DEFAULT_CONFIG.workspaceLabel) ?? "",
46
+ documentDir: documentPathValue(userConfig.documentDir, DEFAULT_CONFIG.documentDir),
47
+ sourceDir: relativePathValue(userConfig.sourceDir, DEFAULT_CONFIG.sourceDir),
48
+ mediaDir: relativePathValue(userConfig.mediaDir, DEFAULT_CONFIG.mediaDir),
49
+ themeDir: relativePathValue(userConfig.themeDir, DEFAULT_CONFIG.themeDir),
50
+ designDoc: relativePathValue(userConfig.designDoc, DEFAULT_CONFIG.designDoc),
51
+ componentsDir: relativePathValue(userConfig.componentsDir, DEFAULT_CONFIG.componentsDir),
52
+ publicDir: relativePathValue(userConfig.publicDir, DEFAULT_CONFIG.publicDir),
53
+ outputDir: relativePathValue(userConfig.outputDir, DEFAULT_CONFIG.outputDir),
54
+ pdf: {
55
+ filename: fileNameValue(userConfig.pdf?.filename, DEFAULT_CONFIG.pdf.filename),
56
+ },
57
+ deploy: {
58
+ adapter: stringValue(userConfig.deploy?.adapter, DEFAULT_CONFIG.deploy.adapter),
59
+ source: relativePathValue(userConfig.deploy?.source, DEFAULT_CONFIG.deploy.source),
60
+ projectName: optionalStringValue(userConfig.deploy?.projectName, DEFAULT_CONFIG.deploy.projectName),
61
+ commitDirty: booleanValue(userConfig.deploy?.commitDirty, DEFAULT_CONFIG.deploy.commitDirty),
62
+ requiresConfirmation: booleanValue(userConfig.deploy?.requiresConfirmation, DEFAULT_CONFIG.deploy.requiresConfirmation),
63
+ },
64
+ };
65
+
66
+ const documentRoot = config.documentDir === "." ? config.root : path.join(config.root, config.documentDir);
67
+ config.paths = {
68
+ documentRoot,
69
+ sourceDir: path.join(documentRoot, config.sourceDir),
70
+ mediaDir: path.join(documentRoot, config.mediaDir),
71
+ themeDir: path.join(documentRoot, config.themeDir),
72
+ designDoc: path.join(documentRoot, config.designDoc),
73
+ componentsDir: path.join(documentRoot, config.componentsDir),
74
+ publicDir: path.join(config.root, config.publicDir),
75
+ outputDir: path.join(config.root, config.outputDir),
76
+ pdf: path.join(config.root, config.outputDir, config.pdf.filename),
77
+ deploySource: path.join(config.root, config.deploy.source),
78
+ deployMetadata: path.join(config.root, config.deploy.source, "openpress", "deploy.json"),
79
+ };
80
+
81
+ return config;
82
+ }
83
+
84
+ export function publicPdfHref(config) {
85
+ return `/${config.pdf.filename}`;
86
+ }
87
+
88
+ async function readUserConfig(configPath) {
89
+ try {
90
+ const stat = await fs.stat(configPath);
91
+ const configUrl = `${pathToFileURL(configPath).href}?mtime=${stat.mtimeMs}`;
92
+ const mod = await import(configUrl);
93
+ return mod.default ?? {};
94
+ } catch (error) {
95
+ if (error?.code === "ENOENT") return {};
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ async function resolveUserConfig(root, rootConfig, configPath) {
101
+ const documentConfigPath = configPathValue(rootConfig.config ?? rootConfig.documentConfig);
102
+ if (!documentConfigPath) {
103
+ return { config: rootConfig, sourceConfigPath: configPath };
104
+ }
105
+
106
+ const sourceConfigPath = path.resolve(root, documentConfigPath);
107
+ const documentConfig = await readUserConfig(sourceConfigPath);
108
+ return {
109
+ config: { ...documentConfig, ...rootConfig },
110
+ sourceConfigPath,
111
+ };
112
+ }
113
+
114
+ function stringValue(value, fallback) {
115
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
116
+ }
117
+
118
+ function optionalStringValue(value, fallback) {
119
+ if (value === null) return null;
120
+ if (typeof value === "string" && value.trim()) return value.trim();
121
+ return fallback;
122
+ }
123
+
124
+ function booleanValue(value, fallback) {
125
+ return typeof value === "boolean" ? value : fallback;
126
+ }
127
+
128
+ function fileNameValue(value, fallback) {
129
+ const fileName = stringValue(value, fallback);
130
+ if (fileName.includes("/") || fileName.includes("\\") || fileName === "." || fileName === "..") {
131
+ throw new Error(`OpenPress config pdf.filename must be a file name, got: ${fileName}`);
132
+ }
133
+ return fileName;
134
+ }
135
+
136
+ function configPathValue(value) {
137
+ if (typeof value !== "string" || !value.trim()) return null;
138
+ return relativePathValue(value, null);
139
+ }
140
+
141
+ function documentPathValue(value, fallback) {
142
+ const raw = stringValue(value, fallback).replaceAll("\\", "/");
143
+ if (path.isAbsolute(raw)) throw new Error(`OpenPress config paths must be relative, got: ${raw}`);
144
+ const normalized = path.posix.normalize(raw).replace(/^\.\//, "");
145
+ if (normalized === ".") return ".";
146
+ if (!normalized || normalized === ".." || normalized.startsWith("../")) {
147
+ throw new Error(`OpenPress config path escapes workspace: ${raw}`);
148
+ }
149
+ return normalized;
150
+ }
151
+
152
+ function relativePathValue(value, fallback) {
153
+ const raw = stringValue(value, fallback).replaceAll("\\", "/");
154
+ if (path.isAbsolute(raw)) throw new Error(`OpenPress config paths must be relative, got: ${raw}`);
155
+ const normalized = path.posix.normalize(raw).replace(/^\.\//, "");
156
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
157
+ throw new Error(`OpenPress config path escapes workspace: ${raw}`);
158
+ }
159
+ return normalized;
160
+ }
@@ -0,0 +1,15 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { loadConfig } from "./config.mjs";
4
+ import { copyDirectory } from "./file-utils.mjs";
5
+
6
+ export async function deploySync(root, sourceDir, deployDir) {
7
+ const config = await loadConfig(root);
8
+ sourceDir ??= config.outputDir;
9
+ deployDir ??= config.deploy.source;
10
+ const dist = path.join(root, sourceDir);
11
+ const deploy = path.join(root, deployDir);
12
+ await fs.rm(deploy, { recursive: true, force: true });
13
+ await fs.mkdir(deploy, { recursive: true });
14
+ await copyDirectory(dist, deploy);
15
+ }
@@ -0,0 +1,15 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { exportReactDocument } from "./react/document-export.mjs";
4
+
5
+ const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
6
+ const ROOT = path.resolve(SELF_DIR, "..");
7
+
8
+ export async function exportDocument(root = ROOT) {
9
+ const reactResult = await exportReactDocument(root);
10
+ if (reactResult) return reactResult;
11
+
12
+ throw new Error(
13
+ "React/MDX document entry not found. Expected document/index.tsx; run `node engine/cli.mjs migrate-to-react .` before exporting.",
14
+ );
15
+ }
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { loadConfig } from "./config.mjs";
4
+ import { readKatexCss } from "./katex-assets.mjs";
5
+
6
+ const CONTENT_CSS_LAYERS = [
7
+ "base/page-contract.css",
8
+ "base/typography.css",
9
+ "page-surfaces/cover.css",
10
+ { path: "page-surfaces/chapter-opener.css", optional: true },
11
+ "page-surfaces/back-cover.css",
12
+ "page-surfaces/toc.css",
13
+ "shell/reader-controls.css",
14
+ "base/print.css",
15
+ ];
16
+
17
+ export async function copyDirectory(src, dst) {
18
+ await fs.rm(dst, { recursive: true, force: true });
19
+ await fs.mkdir(path.dirname(dst), { recursive: true });
20
+ await fs.cp(src, dst, { recursive: true });
21
+ }
22
+
23
+ export async function writeContentCss(root, targetDir, config) {
24
+ config ??= await loadConfig(root);
25
+ const css = await buildContentCss(root, config);
26
+ await fs.mkdir(targetDir, { recursive: true });
27
+ await fs.writeFile(path.join(targetDir, "content.css"), css, "utf8");
28
+ }
29
+
30
+ export async function buildContentCss(root, config) {
31
+ config ??= await loadConfig(root);
32
+ const contentAssetsDir = config.paths.themeDir;
33
+ const parts = [];
34
+ for (const layer of CONTENT_CSS_LAYERS) {
35
+ const relativePath = typeof layer === "string" ? layer : layer.path;
36
+ const cssPath = path.join(contentAssetsDir, relativePath);
37
+ let css;
38
+ try {
39
+ css = await fs.readFile(cssPath, "utf8");
40
+ } catch (error) {
41
+ if (typeof layer !== "string" && layer.optional && error.code === "ENOENT") continue;
42
+ throw error;
43
+ }
44
+ parts.push(`/* === ${relativePath} === */\n`);
45
+ parts.push(css.trimEnd());
46
+ parts.push("\n\n");
47
+ }
48
+ parts.push("/* === engine/katex.css === */\n");
49
+ parts.push((await readKatexCss()).trimEnd());
50
+ parts.push("\n\n");
51
+ return parts.join("");
52
+ }
53
+
54
+ export async function writeComponentsCss(root, targetDir, config) {
55
+ config ??= await loadConfig(root);
56
+ const css = await buildComponentsCss(root, config);
57
+ await fs.mkdir(targetDir, { recursive: true });
58
+ await fs.writeFile(path.join(targetDir, "components.css"), css, "utf8");
59
+ }
60
+
61
+ export async function buildComponentsCss(root, config) {
62
+ config ??= await loadConfig(root);
63
+ const parts = [];
64
+ await appendCssDirectory(parts, path.join(config.paths.themeDir, "patterns"), "theme/patterns");
65
+ await appendComponentScopedCss(parts, config.paths.componentsDir);
66
+ return parts.join("");
67
+ }
68
+
69
+ async function appendCssDirectory(parts, directory, labelPrefix) {
70
+ let entries;
71
+ try {
72
+ entries = await fs.readdir(directory);
73
+ } catch {
74
+ return;
75
+ }
76
+ for (const name of entries.filter((entry) => entry.endsWith(".css")).sort()) {
77
+ parts.push(`/* === ${labelPrefix}/${name} === */\n`);
78
+ parts.push((await fs.readFile(path.join(directory, name), "utf8")).trimEnd());
79
+ parts.push("\n\n");
80
+ }
81
+ }
82
+
83
+ async function appendComponentScopedCss(parts, componentsDir) {
84
+ let entries;
85
+ try {
86
+ entries = await fs.readdir(componentsDir, { withFileTypes: true });
87
+ } catch {
88
+ return;
89
+ }
90
+
91
+ for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
92
+ const cssPath = path.join(componentsDir, entry.name, "style.css");
93
+ let css;
94
+ try {
95
+ css = await fs.readFile(cssPath, "utf8");
96
+ } catch (error) {
97
+ if (error.code === "ENOENT") {
98
+ continue;
99
+ }
100
+ throw error;
101
+ }
102
+ parts.push(`/* === components/${entry.name}/style.css === */\n`);
103
+ parts.push(css.trimEnd());
104
+ parts.push("\n\n");
105
+ }
106
+ }