@open-press/core 0.6.0 → 0.7.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 (70) hide show
  1. package/README.md +9 -5
  2. package/engine/cli.mjs +2 -5
  3. package/engine/commands/_shared.mjs +4 -4
  4. package/engine/commands/deploy.mjs +1 -1
  5. package/engine/commands/inspect.mjs +3 -3
  6. package/engine/commands/replace.mjs +1 -1
  7. package/engine/commands/search.mjs +1 -1
  8. package/engine/commands/validate.mjs +2 -2
  9. package/engine/document-export.mjs +1 -1
  10. package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
  11. package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
  12. package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
  13. package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
  14. package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
  15. package/engine/react/caption-numbering.mjs +73 -0
  16. package/engine/react/comment-marker.mjs +54 -10
  17. package/engine/react/document-entry.mjs +124 -64
  18. package/engine/react/document-export.mjs +252 -311
  19. package/engine/react/mdx-compile.mjs +123 -3
  20. package/engine/react/measurement-css.mjs +3 -3
  21. package/engine/react/pagination/allocator.mjs +122 -0
  22. package/engine/react/pagination/regions.mjs +81 -0
  23. package/engine/react/pagination.mjs +9 -121
  24. package/engine/react/pipeline/allocate.mjs +248 -0
  25. package/engine/react/pipeline/final-render.mjs +94 -0
  26. package/engine/react/pipeline/frame-measurement.mjs +271 -0
  27. package/engine/react/pipeline/press-tree.mjs +135 -0
  28. package/engine/react/project-asset-endpoint.mjs +2 -2
  29. package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
  30. package/engine/react/sources/heading-numbering.mjs +132 -0
  31. package/engine/react/sources/mdx-resolver.mjs +441 -0
  32. package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
  33. package/engine/{config.mjs → runtime/config.mjs} +15 -0
  34. package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
  35. package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
  36. package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
  37. package/engine/runtime/source-workspace.mjs +186 -0
  38. package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
  39. package/package.json +5 -2
  40. package/src/openpress/anchorMap.ts +27 -0
  41. package/src/openpress/core/Frame.tsx +80 -0
  42. package/src/openpress/core/FrameContext.tsx +19 -0
  43. package/src/openpress/core/MdxArea.tsx +35 -0
  44. package/src/openpress/core/Press.tsx +34 -0
  45. package/src/openpress/core/index.tsx +34 -15
  46. package/src/openpress/core/primitives.tsx +23 -0
  47. package/src/openpress/core/types.ts +131 -19
  48. package/src/openpress/core/useSource.ts +28 -0
  49. package/src/openpress/manuscript/index.tsx +196 -0
  50. package/src/openpress/mdx/index.ts +88 -0
  51. package/src/openpress/numbering/index.ts +294 -0
  52. package/src/openpress/publicPage.tsx +4 -186
  53. package/src/openpress/reactDocumentMetadata.ts +2 -16
  54. package/src/openpress/types.ts +0 -16
  55. package/src/openpress/workbench.tsx +2 -36
  56. package/src/styles/openpress/responsive.css +0 -14
  57. package/tsconfig.json +4 -1
  58. package/vite.config.ts +10 -3
  59. package/engine/commands/migrate-to-react.mjs +0 -27
  60. package/engine/page-renderer.mjs +0 -217
  61. package/engine/react/migrate-to-react.mjs +0 -355
  62. package/engine/source-workspace.mjs +0 -76
  63. package/src/openpress/core/basePages.tsx +0 -87
  64. package/src/openpress/pagination.ts +0 -845
  65. /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
  66. /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
  67. /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
  68. /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
  69. /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
  70. /package/engine/{issue-report.mjs → runtime/issue-report.mjs} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @open-press/core
2
2
 
3
- Framework runtime, CLI engine, and page primitives for [open-press](https://github.com/quan0715/open-press) — an AI-first fixed-layout document workspace.
3
+ Framework runtime, CLI engine, and Press Tree primitives for [open-press](https://github.com/quan0715/open-press) — an AI-first fixed-layout document workspace.
4
4
 
5
5
  Most users do **not** install this package directly. Instead, scaffold a workspace with the CLI:
6
6
 
@@ -20,15 +20,19 @@ npm install @open-press/core
20
20
 
21
21
  ```tsx
22
22
  import {
23
- BasePage,
24
- BaseCoverPage,
25
- BaseTocPage,
26
- BaseBackCoverPage,
23
+ Press,
24
+ Frame,
25
+ MdxArea,
27
26
  BaseFigure,
28
27
  BaseCallout,
29
28
  } from "@open-press/core";
29
+
30
+ import { mdxSource } from "@open-press/core/mdx";
31
+ import { Sections, Toc } from "@open-press/core/manuscript";
30
32
  ```
31
33
 
34
+ `document/index.tsx` default-exports a `<Press>` tree. `Frame` marks fixed-layout pages, `MdxArea` receives measured MDX blocks, and `mdxSource()` declares which MDX files participate in the render pipeline.
35
+
32
36
  The CLI bin (`open-press`) supports dev / build / preview / validate / pdf / deploy / export commands. It requires a workspace with `openpress.config.mjs` and the surrounding framework files (which the scaffolder installs).
33
37
 
34
38
  ## License
package/engine/cli.mjs CHANGED
@@ -6,7 +6,6 @@ import * as doctorCmd from "./commands/doctor.mjs";
6
6
  import * as exportCmd from "./commands/export.mjs";
7
7
  import * as initCmd from "./commands/init.mjs";
8
8
  import * as inspectCmd from "./commands/inspect.mjs";
9
- import * as migrateToReactCmd from "./commands/migrate-to-react.mjs";
10
9
  import * as pdfCmd from "./commands/pdf.mjs";
11
10
  import * as previewCmd from "./commands/preview.mjs";
12
11
  import * as replaceCmd from "./commands/replace.mjs";
@@ -16,13 +15,12 @@ import * as typecheckCmd from "./commands/typecheck.mjs";
16
15
  import * as upgradeCmd from "./commands/upgrade.mjs";
17
16
  import * as validateCmd from "./commands/validate.mjs";
18
17
  import { parseOptions } from "./commands/_shared.mjs";
19
- import { loadConfig } from "./config.mjs";
18
+ import { loadConfig } from "./runtime/config.mjs";
20
19
  import { listStylePackSkills } from "./init.mjs";
21
- import { discoverWorkspace } from "./validation.mjs";
20
+ import { discoverWorkspace } from "./runtime/validation.mjs";
22
21
 
23
22
  const COMMANDS = {
24
23
  init: initCmd,
25
- "migrate-to-react": migrateToReactCmd,
26
24
  validate: validateCmd,
27
25
  inspect: inspectCmd,
28
26
  search: searchCmd,
@@ -79,7 +77,6 @@ async function printHelp() {
79
77
 
80
78
  Commands:
81
79
  init <target> [--skill <name>] [--force]
82
- migrate-to-react [path] [--dry-run] [--force] [--json]
83
80
  validate
84
81
  inspect [--json] [--no-build] [--dry-run]
85
82
  search [path] <query> [--json] [--scope content|all]
@@ -2,14 +2,14 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { printUrlToPdf, stopChildProcess, waitForPrintReady } from "../chrome-pdf.mjs";
6
- import { loadConfig, publicPdfHref } from "../config.mjs";
5
+ import { printUrlToPdf, stopChildProcess, waitForPrintReady } from "../output/chrome-pdf.mjs";
6
+ import { loadConfig, publicPdfHref } from "../runtime/config.mjs";
7
7
  import { exportDocument } from "../document-export.mjs";
8
- import { optimizePdfMediaForStaticRoot } from "../pdf-media.mjs";
8
+ import { optimizePdfMediaForStaticRoot } from "../output/pdf-media.mjs";
9
9
 
10
10
  export const ENGINE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
11
  export const CLI_ENTRY = path.join(ENGINE_DIR, "cli.mjs");
12
- export const STATIC_SERVER = path.join(ENGINE_DIR, "static-server.mjs");
12
+ export const STATIC_SERVER = path.join(ENGINE_DIR, "output", "static-server.mjs");
13
13
 
14
14
  export function parseOptions(argv) {
15
15
  const options = {};
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { deploySync } from "../deploy-sync.mjs";
2
+ import { deploySync } from "../output/deploy-sync.mjs";
3
3
  import { CLI_ENTRY, buildReactPdf, formatNodeScriptCommand, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
4
4
 
5
5
  export async function run({ root, config, options, recurse }) {
@@ -1,5 +1,5 @@
1
- import { inspectWorkspace } from "../inspection.mjs";
2
- import { exitCodeForIssueReport } from "../issue-report.mjs";
1
+ import { inspectWorkspace } from "../runtime/inspection.mjs";
2
+ import { exitCodeForIssueReport } from "../runtime/issue-report.mjs";
3
3
 
4
4
  export async function run({ root, config, options, recurse }) {
5
5
  const host = options.host ?? "127.0.0.1";
@@ -10,7 +10,7 @@ export async function run({ root, config, options, recurse }) {
10
10
  if (!options.noBuild) {
11
11
  console.log("Command: node engine/cli.mjs render . --renderer react");
12
12
  }
13
- console.log(`Command: node engine/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
13
+ console.log(`Command: node engine/output/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
14
14
  console.log(`Chrome inspection URL: ${url}`);
15
15
  return 0;
16
16
  }
@@ -1,4 +1,4 @@
1
- import { replaceSourceText } from "../source-text-tools.mjs";
1
+ import { replaceSourceText } from "../runtime/source-text-tools.mjs";
2
2
 
3
3
  export async function run({ config, options }) {
4
4
  const args = replaceArgsFromOptions(options);
@@ -1,4 +1,4 @@
1
- import { searchSourceText } from "../source-text-tools.mjs";
1
+ import { searchSourceText } from "../runtime/source-text-tools.mjs";
2
2
 
3
3
  export async function run({ config, options }) {
4
4
  const query = searchQueryFromOptions(options);
@@ -1,5 +1,5 @@
1
- import { validateWorkspace } from "../validation.mjs";
2
- import { exitCodeForIssueReport } from "../issue-report.mjs";
1
+ import { validateWorkspace } from "../runtime/validation.mjs";
2
+ import { exitCodeForIssueReport } from "../runtime/issue-report.mjs";
3
3
 
4
4
  export async function run({ root, options }) {
5
5
  const report = await validateWorkspace(root);
@@ -10,6 +10,6 @@ export async function exportDocument(root = ROOT) {
10
10
  if (reactResult) return reactResult;
11
11
 
12
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.",
13
+ "React/MDX document entry not found. Expected document/index.tsx with a Press default export before exporting.",
14
14
  );
15
15
  }
@@ -203,8 +203,7 @@ export async function waitForPrintReady(client) {
203
203
  awaitPromise: true,
204
204
  expression: `Promise.resolve().then(async () => {
205
205
  const root = document.querySelector('[data-openpress-print-document="true"]');
206
- const ready = root?.getAttribute('data-openpress-pagination') === 'ready';
207
- if (!ready) return 0;
206
+ if (!root || root.querySelectorAll('.openpress-html-page').length === 0) return 0;
208
207
 
209
208
  await document.fonts?.ready;
210
209
  await Promise.all(Array.from(document.images).map(async (img) => {
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { loadConfig } from "./config.mjs";
4
- import { copyDirectory } from "./file-utils.mjs";
3
+ import { loadConfig } from "../runtime/config.mjs";
4
+ import { copyDirectory } from "../runtime/file-utils.mjs";
5
5
 
6
6
  export async function deploySync(root, sourceDir, deployDir) {
7
7
  const config = await loadConfig(root);
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { copyDirectory } from "./file-utils.mjs";
3
+ import { copyDirectory } from "../runtime/file-utils.mjs";
4
4
 
5
5
  export async function copyThemeFonts(root, publicOutputDir, config) {
6
6
  const themeDir = config?.paths?.themeDir ?? path.join(path.resolve(root), "theme");
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { loadConfig } from "./config.mjs";
4
- import { copyDirectory, writeComponentsCss, writeContentCss } from "./file-utils.mjs";
3
+ import { loadConfig } from "../runtime/config.mjs";
4
+ import { copyDirectory, writeComponentsCss, writeContentCss } from "../runtime/file-utils.mjs";
5
5
  import { copyThemeFonts } from "./fonts.mjs";
6
6
  import { copyKatexFonts } from "./katex-assets.mjs";
7
7
 
@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
- import { loadConfig, publicPdfHref } from "./config.mjs";
6
- import { handleProjectAssetRequest } from "./react/project-asset-endpoint.mjs";
5
+ import { loadConfig, publicPdfHref } from "../runtime/config.mjs";
6
+ import { handleProjectAssetRequest } from "../react/project-asset-endpoint.mjs";
7
7
 
8
8
  const [rootArg = "dist", ...rest] = process.argv.slice(2);
9
9
  const host = valueAfter(rest, "--host") ?? "127.0.0.1";
@@ -0,0 +1,73 @@
1
+ const DEFAULT_CAPTION_NUMBERING = {
2
+ figure: "Figure",
3
+ table: "Table",
4
+ separator: " ",
5
+ };
6
+
7
+ export function normalizeCaptionNumbering(value = {}) {
8
+ const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
9
+ return {
10
+ figure: stringOption(input.figure, DEFAULT_CAPTION_NUMBERING.figure),
11
+ table: stringOption(input.table, DEFAULT_CAPTION_NUMBERING.table),
12
+ separator: typeof input.separator === "string" ? input.separator : DEFAULT_CAPTION_NUMBERING.separator,
13
+ };
14
+ }
15
+
16
+ export function createCaptionNumberingState() {
17
+ return {
18
+ figure: 0,
19
+ table: 0,
20
+ seenTables: new Set(),
21
+ };
22
+ }
23
+
24
+ export function numberCaptionsInHtml(html, numbering, state = createCaptionNumberingState()) {
25
+ if (!html) return html;
26
+ const options = normalizeCaptionNumbering(numbering);
27
+ let out = String(html);
28
+ out = numberTableCaptions(out, options, state);
29
+ out = numberFigureCaptions(out, options, state);
30
+ return out;
31
+ }
32
+
33
+ function numberTableCaptions(html, options, state) {
34
+ return html.replace(/<table\b([^>]*)>([\s\S]*?<caption\b([^>]*)>)([\s\S]*?)(<\/caption>[\s\S]*?<\/table>)/g, (match, tableAttrs, beforeCaptionText, captionAttrs, captionText, afterCaptionText) => {
35
+ if (captionText.includes("data-openpress-caption-label=")) return match;
36
+ const tableId = attrValue(tableAttrs, "data-openpress-table-id");
37
+ if (tableId && state.seenTables.has(tableId)) return match;
38
+ if (tableId) state.seenTables.add(tableId);
39
+ state.table += 1;
40
+ const label = captionLabel(options.table, state.table, options.separator);
41
+ return `<table${tableAttrs}>${beforeCaptionText}${captionLabelSpan("table", state.table, label)} ${captionText}${afterCaptionText}`;
42
+ });
43
+ }
44
+
45
+ function numberFigureCaptions(html, options, state) {
46
+ return html.replace(/<figure\b([^>]*)>([\s\S]*?<figcaption\b([^>]*)>)([\s\S]*?)(<\/figcaption>[\s\S]*?<\/figure>)/g, (match, figureAttrs, beforeCaptionText, captionAttrs, captionText, afterCaptionText) => {
47
+ if (captionText.includes("data-openpress-caption-label=")) return match;
48
+ state.figure += 1;
49
+ const label = captionLabel(options.figure, state.figure, options.separator);
50
+ return `<figure${figureAttrs}>${beforeCaptionText}${captionLabelSpan("figure", state.figure, label)} ${captionText}${afterCaptionText}`;
51
+ });
52
+ }
53
+
54
+ function captionLabel(noun, number, separator) {
55
+ return `${noun}${separator}${number}`;
56
+ }
57
+
58
+ function captionLabelSpan(kind, number, label) {
59
+ return `<span class="openpress-caption-label" data-openpress-caption-label="${kind}" data-openpress-caption-number="${number}">${escapeHtml(label)}</span>`;
60
+ }
61
+
62
+ function attrValue(attrs, name) {
63
+ const pattern = new RegExp(`${name}=(["'])(.*?)\\1`);
64
+ return attrs.match(pattern)?.[2] ?? "";
65
+ }
66
+
67
+ function stringOption(value, fallback) {
68
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
69
+ }
70
+
71
+ function escapeHtml(value) {
72
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
73
+ }
@@ -1,15 +1,18 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { loadConfig } from "../config.mjs";
5
- import { collectSourceTextFiles } from "../source-text-tools.mjs";
6
-
4
+ import { loadConfig } from "../runtime/config.mjs";
5
+ import { collectSourceTextFiles } from "../runtime/source-text-tools.mjs";
6
+
7
+ // Any `.mdx` or `.tsx` file under `document/` is a legal comment target.
8
+ // The Press Tree allows arbitrary source layouts — `section-folders`,
9
+ // `section-files`, `file-list`, custom `root` paths, etc. — so we no
10
+ // longer hardcode `document/chapters/<slug>/content/*.mdx`. The boundary
11
+ // is "inside the workspace's authored `document/` directory" and "looks
12
+ // like an editable React/MDX source" by extension.
7
13
  const EDITABLE_COMMENT_SOURCE_PATTERNS = [
8
- /^document\/index\.tsx$/,
9
- /^document\/chapters\/[^/]+\/content\/[^/]+\.mdx$/,
10
- /^document\/chapters\/[^/]+\/chapter\.tsx$/,
11
- /^document\/chapters\/[^/]+\/components\/.+\.tsx$/,
12
- /^document\/components\/.+\.tsx$/,
14
+ /^document\/.+\.mdx$/,
15
+ /^document\/.+\.tsx$/,
13
16
  ];
14
17
  const COMMENT_MARKER_RE = /\{\/\*\s*@openpress-comment\b(?<attrs>[^*]*)\*\/\}/g;
15
18
  const COMMENT_LINE_RE = /^\s*\{\/\*\s*@openpress-comment\b[^*]*\*\/\}\s*$/;
@@ -138,8 +141,14 @@ export function assertEditableCommentPath(relativePath) {
138
141
  }
139
142
  }
140
143
 
144
+ // Strict check against workspace-relative paths. Callers walking the
145
+ // workspace (`applyCommentMarker` via `collectSourceTextFiles`) already
146
+ // receive paths with the `document/` prefix and must not have system
147
+ // paths silently mapped into the editable set.
141
148
  export function isEditableCommentPath(relativePath) {
142
- return EDITABLE_COMMENT_SOURCE_PATTERNS.some((pattern) => pattern.test(relativePath));
149
+ if (typeof relativePath !== "string" || !relativePath) return false;
150
+ const trimmed = relativePath.trim().replaceAll("\\", "/").replace(/^\.\//, "");
151
+ return EDITABLE_COMMENT_SOURCE_PATTERNS.some((pattern) => pattern.test(trimmed));
143
152
  }
144
153
 
145
154
  function normalizeEditableSourcePath(value) {
@@ -150,7 +159,42 @@ function normalizeEditableSourcePath(value) {
150
159
  if (path.posix.isAbsolute(normalized) || normalized.includes("\0") || normalized === "." || normalized.startsWith("../")) {
151
160
  throw new Error(`OpenPress comment target path is invalid: ${value}`);
152
161
  }
153
- return path.posix.normalize(normalized);
162
+ const posix = path.posix.normalize(normalized);
163
+ // The Press Tree source resolver emits paths relative to `document/`
164
+ // (e.g. "chapters/01-start/content/01-start.mdx"). The comment marker
165
+ // works in workspace-relative paths (with the `document/` prefix). If
166
+ // the incoming path is documentRoot-relative, prepend `document/`.
167
+ if (!posix.startsWith("document/") && looksDocumentRelative(posix)) {
168
+ return `document/${posix}`;
169
+ }
170
+ return posix;
171
+ }
172
+
173
+ // Identify paths the Press Tree source resolver emits — those are relative
174
+ // to `document/`. Match `.mdx` / `.tsx` files that don't already have the
175
+ // `document/` prefix and don't look like system / engine paths. The check
176
+ // is intentionally tight so we never silently rewrite engine internals
177
+ // (e.g. `src/openpress/...`) into "editable" workspace paths.
178
+ const SYSTEM_PATH_PREFIXES = [
179
+ "document/",
180
+ "src/",
181
+ "engine/",
182
+ "dist/",
183
+ "dist-react/",
184
+ "node_modules/",
185
+ "tests/",
186
+ "public/",
187
+ "packages/",
188
+ ".openpress/",
189
+ ".deploy/",
190
+ ".changeset/",
191
+ ".github/",
192
+ ];
193
+
194
+ function looksDocumentRelative(posixPath) {
195
+ if (!/\.(mdx|tsx)$/.test(posixPath)) return false;
196
+ if (SYSTEM_PATH_PREFIXES.some((prefix) => posixPath.startsWith(prefix))) return false;
197
+ return true;
154
198
  }
155
199
 
156
200
  function normalizeLineNumber(value) {
@@ -1,3 +1,10 @@
1
+ // Layer 1 — Document entry loader.
2
+ //
3
+ // Loads `document/index.tsx`, validates it exports a Press component as
4
+ // default, reads optional `config` and `sources` named exports, and sets
5
+ // up the vite SSR server with `@open-press/core` aliases (including the
6
+ // subpaths `/mdx` and `/manuscript`).
7
+
1
8
  import fs from "node:fs/promises";
2
9
  import { createRequire } from "node:module";
3
10
  import path from "node:path";
@@ -5,16 +12,19 @@ import { fileURLToPath } from "node:url";
5
12
  import react from "@vitejs/plugin-react";
6
13
  import ts from "typescript";
7
14
  import { createServer as createViteServer } from "vite";
8
- import { normalizeConfig } from "../config.mjs";
15
+ import { normalizeConfig } from "../runtime/config.mjs";
9
16
 
10
17
  const ENGINE_REACT_DIR = path.dirname(fileURLToPath(import.meta.url));
11
18
  const FRAMEWORK_ROOT = path.resolve(ENGINE_REACT_DIR, "..", "..");
12
- const CORE_ENTRY = path.join(FRAMEWORK_ROOT, "src", "openpress", "core", "index.tsx");
19
+ export const CORE_ENTRY = path.join(FRAMEWORK_ROOT, "src", "openpress", "core", "index.tsx");
20
+ export const MDX_ENTRY = path.join(FRAMEWORK_ROOT, "src", "openpress", "mdx", "index.ts");
21
+ export const MANUSCRIPT_ENTRY = path.join(FRAMEWORK_ROOT, "src", "openpress", "manuscript", "index.tsx");
22
+ export const NUMBERING_ENTRY = path.join(FRAMEWORK_ROOT, "src", "openpress", "numbering", "index.ts");
13
23
  const REACT_PACKAGE_ROOT = path.join(FRAMEWORK_ROOT, "node_modules", "react");
14
24
  const require = createRequire(import.meta.url);
15
25
  const REACT_EXPORT_NAMES = Object.keys(require("react")).filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
16
26
 
17
- export async function loadReactDocumentEntry(root = ".") {
27
+ export async function loadReactDocumentEntry(root = ".", { server: externalServer } = {}) {
18
28
  const workspaceRoot = path.resolve(root);
19
29
  const entryPath = path.join(workspaceRoot, "document", "index.tsx");
20
30
  if (!(await fileExists(entryPath))) return null;
@@ -22,22 +32,34 @@ export async function loadReactDocumentEntry(root = ".") {
22
32
  const source = await fs.readFile(entryPath, "utf8");
23
33
  assertNoObviousTopLevelSideEffects(source, entryPath);
24
34
 
25
- const server = await createReactSsrServer(workspaceRoot);
26
-
35
+ // If caller provides a server, reuse it so module identity is shared
36
+ // across the pipeline (PressContext, React, etc.). Otherwise open a
37
+ // temporary server for one-shot config reads.
38
+ const ownServer = externalServer ?? (await createReactSsrServer(workspaceRoot));
27
39
  try {
28
- const mod = await server.ssrLoadModule(entryPath);
40
+ const mod = await ownServer.ssrLoadModule(entryPath);
41
+
42
+ // Press default export is required for export/render but not for
43
+ // config-only commands (search, replace, validate). Validate if present;
44
+ // export pipeline throws separately if it's missing when actually needed.
45
+ const Press = typeof mod.default === "function" ? mod.default : null;
46
+
29
47
  const config = normalizeReactDocumentConfig(workspaceRoot, entryPath, mod.config);
48
+ const sources = mod.sources ?? {};
49
+ if (sources && (typeof sources !== "object" || Array.isArray(sources))) {
50
+ throw new Error(
51
+ `OpenPress document entry ${entryPath} \`sources\` export must be an object literal (or omitted).`,
52
+ );
53
+ }
54
+
30
55
  return {
31
56
  entryPath,
32
57
  config,
33
- shell: {
34
- cover: mod.cover ?? null,
35
- toc: mod.toc ?? null,
36
- backCover: mod.backCover ?? null,
37
- },
58
+ Press,
59
+ sources,
38
60
  };
39
61
  } finally {
40
- await server.close();
62
+ if (!externalServer) await ownServer.close();
41
63
  }
42
64
  }
43
65
 
@@ -51,7 +73,12 @@ export async function createReactSsrServer(workspaceRoot = ".") {
51
73
  plugins: [reactRuntimePlugin(), react()],
52
74
  resolve: {
53
75
  alias: [
54
- { find: "@openpress/core", replacement: CORE_ENTRY },
76
+ // ORDER MATTERS: subpath aliases must precede the base alias so that
77
+ // `@open-press/core/mdx` doesn't resolve to `@open-press/core` + `/mdx`.
78
+ { find: "@open-press/core/mdx", replacement: MDX_ENTRY },
79
+ { find: "@open-press/core/manuscript", replacement: MANUSCRIPT_ENTRY },
80
+ { find: "@open-press/core/numbering", replacement: NUMBERING_ENTRY },
81
+ { find: "@open-press/core", replacement: CORE_ENTRY },
55
82
  { find: "@/components", replacement: path.join(resolvedWorkspaceRoot, "document", "components") },
56
83
  ],
57
84
  },
@@ -64,23 +91,6 @@ export async function createReactSsrServer(workspaceRoot = ".") {
64
91
  });
65
92
  }
66
93
 
67
- function normalizeReactDocumentConfig(workspaceRoot, entryPath, config) {
68
- if (config != null && (typeof config !== "object" || Array.isArray(config))) {
69
- throw new Error("OpenPress React document entry `config` export must be an object when provided.");
70
- }
71
- const rawConfig = config ?? {};
72
- const paths = rawConfig.paths ?? {};
73
- return normalizeConfig(workspaceRoot, {
74
- ...rawConfig,
75
- documentDir: rawConfig.documentDir ?? paths.documentDir ?? "document",
76
- sourceDir: rawConfig.sourceDir ?? paths.chaptersDir ?? paths.sourceDir ?? "chapters",
77
- componentsDir: rawConfig.componentsDir ?? paths.componentsDir ?? "components",
78
- mediaDir: rawConfig.mediaDir ?? paths.mediaDir ?? "media",
79
- themeDir: rawConfig.themeDir ?? paths.themeDir ?? "theme",
80
- designDoc: rawConfig.designDoc ?? paths.designDoc ?? "design.md",
81
- }, entryPath);
82
- }
83
-
84
94
  function assertNoObviousTopLevelSideEffects(source, entryPath) {
85
95
  const sourceFile = ts.createSourceFile(entryPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
86
96
  for (const statement of sourceFile.statements) {
@@ -89,84 +99,117 @@ function assertNoObviousTopLevelSideEffects(source, entryPath) {
89
99
  continue;
90
100
  }
91
101
 
92
- if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
93
- continue;
94
- }
95
-
96
- if (ts.isExportDeclaration(statement) && statement.isTypeOnly) {
97
- continue;
98
- }
99
-
102
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) continue;
103
+ if (ts.isExportDeclaration(statement) && statement.isTypeOnly) continue;
104
+ if (ts.isFunctionDeclaration(statement)) continue;
100
105
  if (ts.isVariableStatement(statement)) {
101
- assertExportedConstStatement(statement, entryPath);
106
+ assertTopLevelVariableStatement(statement, entryPath);
102
107
  continue;
103
108
  }
104
-
105
- if (ts.isExpressionStatement(statement)) {
106
- assertPureTopLevelInitializer(statement.expression, entryPath);
109
+ if (ts.isExportAssignment(statement)) {
110
+ assertPureDefaultExport(statement.expression, entryPath);
111
+ continue;
107
112
  }
108
113
 
109
- throw new Error(`OpenPress React document entry has unsupported top-level code in ${entryPath}: ${statementKindName(statement)}`);
114
+ throw new Error(`OpenPress document entry has unsupported top-level code in ${entryPath}: ${statementKindName(statement)}`);
110
115
  }
111
116
  }
112
117
 
113
118
  function assertPureImport(statement, entryPath) {
114
119
  if (!statement.importClause) {
115
- throw new Error(`OpenPress React document entry has an unsupported side-effect import in ${entryPath}`);
120
+ throw new Error(`OpenPress document entry has an unsupported side-effect import in ${entryPath}`);
116
121
  }
117
122
  const moduleName = stringLiteralText(statement.moduleSpecifier);
118
123
  if (!statement.importClause.isTypeOnly && isFileSystemModule(moduleName)) {
119
- throw new Error(`OpenPress React document entry imports filesystem APIs at top level in ${entryPath}`);
124
+ throw new Error(`OpenPress document entry imports filesystem APIs at top level in ${entryPath}`);
120
125
  }
121
126
  }
122
127
 
123
- function assertExportedConstStatement(statement, entryPath) {
124
- if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) {
125
- throw new Error(`OpenPress React document entry only allows exported const declarations at top level in ${entryPath}`);
126
- }
128
+ function assertTopLevelVariableStatement(statement, entryPath) {
127
129
  if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) {
128
- throw new Error(`OpenPress React document entry only allows exported const declarations at top level in ${entryPath}`);
130
+ throw new Error(`OpenPress document entry only allows top-level const declarations in ${entryPath}`);
129
131
  }
130
132
 
133
+ const exported = hasModifier(statement, ts.SyntaxKind.ExportKeyword);
131
134
  for (const declaration of statement.declarationList.declarations) {
132
- if (declaration.initializer) assertPureTopLevelInitializer(declaration.initializer, entryPath);
135
+ if (!ts.isIdentifier(declaration.name)) {
136
+ throw new Error(`OpenPress document entry only allows identifier const declarations at top level in ${entryPath}`);
137
+ }
138
+ const name = declaration.name.text;
139
+ if (exported && name !== "config" && name !== "sources") {
140
+ throw new Error(`OpenPress document entry only allows exported const config and sources in ${entryPath}`);
141
+ }
142
+ if (!declaration.initializer) {
143
+ throw new Error(`OpenPress document entry const "${name}" must have an initializer in ${entryPath}`);
144
+ }
145
+ if (name === "config") {
146
+ assertPureExpression(declaration.initializer, entryPath, { allowMdxSourceCall: false });
147
+ } else if (name === "sources") {
148
+ assertPureSourcesInitializer(declaration.initializer, entryPath);
149
+ } else {
150
+ assertPureStaticInitializer(declaration.initializer, entryPath);
151
+ }
133
152
  }
134
153
  }
135
154
 
136
- function assertPureTopLevelInitializer(node, entryPath) {
137
- visitNode(node, (child) => {
155
+ function assertPureSourcesInitializer(node, entryPath) {
156
+ const expression = skipExpressionWrappers(node);
157
+ if (!ts.isObjectLiteralExpression(expression)) {
158
+ throw new Error(`OpenPress document entry exported sources must be an object literal in ${entryPath}`);
159
+ }
160
+ assertPureExpression(expression, entryPath, { allowMdxSourceCall: true });
161
+ }
162
+
163
+ function assertPureStaticInitializer(node, entryPath) {
164
+ const expression = skipExpressionWrappers(node);
165
+ if (ts.isFunctionExpression(expression) || ts.isArrowFunction(expression) || ts.isClassExpression(expression)) return;
166
+ assertPureExpression(expression, entryPath, { allowMdxSourceCall: false });
167
+ }
168
+
169
+ function assertPureDefaultExport(node, entryPath) {
170
+ const expression = skipExpressionWrappers(node);
171
+ if (ts.isFunctionExpression(expression) || ts.isArrowFunction(expression) || ts.isIdentifier(expression)) return;
172
+ throw new Error(`OpenPress document entry default export must be a function component in ${entryPath}`);
173
+ }
174
+
175
+ function assertPureExpression(node, entryPath, { allowMdxSourceCall }) {
176
+ visitExpression(node, (child) => {
138
177
  if (ts.isAwaitExpression(child)) {
139
- throw new Error(`OpenPress React document entry has an unsupported top-level side effect: await in ${entryPath}`);
178
+ throw new Error(`OpenPress document entry has an unsupported top-level side effect: await in ${entryPath}`);
140
179
  }
141
-
142
180
  if (ts.isCallExpression(child)) {
143
181
  const callee = skipExpressionWrappers(child.expression);
182
+ if (allowMdxSourceCall && ts.isIdentifier(callee) && callee.text === "mdxSource") return;
144
183
  if (ts.isIdentifier(callee) && callee.text === "fetch") {
145
- throw new Error(`OpenPress React document entry has an unsupported top-level side effect: fetch(...) in ${entryPath}`);
184
+ throw new Error(`OpenPress document entry has an unsupported top-level side effect: fetch(...) in ${entryPath}`);
146
185
  }
147
186
  if (ts.isPropertyAccessExpression(callee) && isIdentifierText(callee.expression, "console")) {
148
- throw new Error(`OpenPress React document entry has an unsupported top-level side effect: console.${callee.name.text}(...) in ${entryPath}`);
187
+ throw new Error(`OpenPress document entry has an unsupported top-level side effect: console.${callee.name.text}(...) in ${entryPath}`);
149
188
  }
150
189
  if (ts.isPropertyAccessExpression(callee) && isIdentifierText(callee.expression, "fs")) {
151
- throw new Error(`OpenPress React document entry has an unsupported top-level side effect: fs.${callee.name.text}(...) in ${entryPath}`);
190
+ throw new Error(`OpenPress document entry has an unsupported top-level side effect: fs.${callee.name.text}(...) in ${entryPath}`);
152
191
  }
153
- throw new Error(`OpenPress React document entry cannot execute top-level function calls in exported config or shell JSX in ${entryPath}`);
192
+ throw new Error(`OpenPress document entry cannot execute top-level function calls outside sources.mdxSource(...) in ${entryPath}`);
154
193
  }
155
-
156
194
  if (ts.isPropertyAccessExpression(child) && isProcessEnvAccess(child)) {
157
- throw new Error(`OpenPress React document entry cannot read process.env in exported config or shell JSX in ${entryPath}`);
195
+ throw new Error(`OpenPress document entry cannot read process.env at top level in ${entryPath}`);
158
196
  }
159
197
  });
160
198
  }
161
199
 
162
- function visitNode(node, visitor) {
200
+ function visitExpression(node, visitor) {
163
201
  visitor(node);
164
- ts.forEachChild(node, (child) => visitNode(child, visitor));
202
+ if (node !== undefined && node !== null && isFunctionLikeExpression(node)) return;
203
+ ts.forEachChild(node, (child) => visitExpression(child, visitor));
204
+ }
205
+
206
+ function isFunctionLikeExpression(node) {
207
+ return ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isClassExpression(node);
165
208
  }
166
209
 
167
210
  function skipExpressionWrappers(node) {
168
211
  let current = node;
169
- while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) {
212
+ while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current)) {
170
213
  current = current.expression;
171
214
  }
172
215
  return current;
@@ -196,6 +239,23 @@ function isFileSystemModule(moduleName) {
196
239
  return moduleName === "fs" || moduleName === "node:fs" || moduleName === "fs/promises" || moduleName === "node:fs/promises";
197
240
  }
198
241
 
242
+ function normalizeReactDocumentConfig(workspaceRoot, entryPath, config) {
243
+ if (config != null && (typeof config !== "object" || Array.isArray(config))) {
244
+ throw new Error("OpenPress React document entry `config` export must be an object when provided.");
245
+ }
246
+ const rawConfig = config ?? {};
247
+ const paths = rawConfig.paths ?? {};
248
+ return normalizeConfig(workspaceRoot, {
249
+ ...rawConfig,
250
+ documentDir: rawConfig.documentDir ?? paths.documentDir ?? "document",
251
+ sourceDir: rawConfig.sourceDir ?? paths.chaptersDir ?? paths.sourceDir ?? "chapters",
252
+ componentsDir: rawConfig.componentsDir ?? paths.componentsDir ?? "components",
253
+ mediaDir: rawConfig.mediaDir ?? paths.mediaDir ?? "media",
254
+ themeDir: rawConfig.themeDir ?? paths.themeDir ?? "theme",
255
+ designDoc: rawConfig.designDoc ?? paths.designDoc ?? "design.md",
256
+ }, entryPath);
257
+ }
258
+
199
259
  async function fileExists(filePath) {
200
260
  try {
201
261
  const stat = await fs.stat(filePath);