@open-press/core 1.2.1 → 1.3.2

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 (45) hide show
  1. package/README.md +2 -2
  2. package/engine/commands/typecheck.mjs +1 -1
  3. package/engine/document-export.mjs +1 -1
  4. package/engine/output/page-block.mjs +11 -2
  5. package/engine/output/public-assets.mjs +41 -6
  6. package/engine/output/static-server.mjs +68 -15
  7. package/engine/react/caption-numbering.mjs +2 -2
  8. package/engine/react/comment-marker.mjs +1 -2
  9. package/engine/react/document-entry.mjs +64 -11
  10. package/engine/react/document-export.d.mts +6 -0
  11. package/engine/react/document-export.mjs +158 -28
  12. package/engine/react/mdx-compile.mjs +4 -4
  13. package/engine/react/measurement-css.mjs +3 -3
  14. package/engine/react/page-folio.mjs +37 -0
  15. package/engine/react/pagination/allocator.mjs +4 -4
  16. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  17. package/engine/react/press-tree-inspection.mjs +43 -13
  18. package/engine/react/project-asset-endpoint.mjs +45 -11
  19. package/engine/react/sources/heading-numbering.mjs +2 -2
  20. package/engine/react/sources/mdx-resolver.mjs +3 -3
  21. package/engine/react/style-discovery.mjs +60 -11
  22. package/engine/react/text-source-transform.mjs +18 -4
  23. package/engine/runtime/config.mjs +22 -22
  24. package/engine/runtime/file-utils.mjs +57 -13
  25. package/engine/runtime/inspection.mjs +40 -15
  26. package/engine/runtime/page-geometry.mjs +6 -6
  27. package/engine/runtime/source-text-tools.mjs +28 -4
  28. package/engine/runtime/source-workspace.mjs +6 -9
  29. package/engine/runtime/validation.mjs +42 -24
  30. package/package.json +1 -1
  31. package/src/openpress/app/OpenPressApp.tsx +20 -18
  32. package/src/openpress/app/OpenPressRuntime.tsx +3 -3
  33. package/src/openpress/app/WorkspaceGalleryPage.tsx +65 -39
  34. package/src/openpress/core/PageFolio.tsx +115 -0
  35. package/src/openpress/core/Press.tsx +5 -10
  36. package/src/openpress/core/Slide.tsx +11 -0
  37. package/src/openpress/core/index.tsx +4 -0
  38. package/src/openpress/core/types.ts +21 -13
  39. package/src/openpress/core/useSource.ts +1 -1
  40. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  41. package/src/openpress/reader/SlidePresentationPage.tsx +7 -3
  42. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +46 -43
  43. package/src/styles/openpress/workbench-toolbar.css +33 -0
  44. package/src/styles/openpress/workspace-gallery.css +130 -47
  45. package/vite.config.ts +82 -16
package/README.md CHANGED
@@ -5,7 +5,7 @@ Package-owned runtime, render engine, and Press Tree primitives for [open-press]
5
5
  Most users do **not** install this package directly. Instead, scaffold a workspace with the CLI:
6
6
 
7
7
  ```bash
8
- npx @open-press/cli init my-doc
8
+ npx @open-press/cli init my-doc --type pages
9
9
  ```
10
10
 
11
11
  The scaffolded workspace depends on this package; it does not vendor a copy of the runtime. Starter files are supplied by skills or by project-specific `press/` source files.
@@ -31,7 +31,7 @@ import { mdxSource } from "@open-press/core/mdx";
31
31
  import { Sections, Toc } from "@open-press/core/manuscript";
32
32
  ```
33
33
 
34
- `press/index.tsx` or transitional `document/index.tsx` default-exports a `<Workspace>/<Press>` tree. `Frame` marks fixed-layout pages, `MdxArea` receives measured MDX blocks, and `mdxSource()` declares which MDX files participate in the render pipeline.
34
+ Each `press/<slug>/press.tsx` default-exports a component that renders one `<Press>`. `Frame` marks fixed-layout pages, `MdxArea` receives measured MDX blocks, and `mdxSource()` declares which MDX files participate in the render pipeline.
35
35
 
36
36
  For the maintenance contract around Press Tree, page geometry presets, and the
37
37
  allocation pipeline, see [`docs/press-tree.md`](https://github.com/quan0715/open-press/blob/main/docs/press-tree.md).
@@ -8,7 +8,7 @@ import { loadConfig } from "../runtime/config.mjs";
8
8
  // Run typecheck via the locally installed typescript. The previous
9
9
  // implementation used `npx tsc`; npm 11 + Node 24 (our CI / release
10
10
  // pin) changed npx's bin lookup so it no longer walks pnpm's nested
11
- // `.bin/` symlink farm and falls back to fetching the legacy
11
+ // `.bin/` symlink farm and may fetch the wrong
12
12
  // `tsc@2.0.4` shim, which crashes.
13
13
  //
14
14
  // Resolution order:
@@ -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 press/index.tsx with a Press default export before exporting.",
13
+ "React/MDX document entry not found. Expected one or more press/*/press.tsx files before exporting.",
14
14
  );
15
15
  }
@@ -11,12 +11,21 @@ function rewriteAssetPaths(pageHtml, config) {
11
11
  const mediaDir = config.mediaDir.replace(/^\/+|\/+$/g, "");
12
12
  return pageHtml
13
13
  .replaceAll(`src="${mediaDir}/`, 'src="/openpress/media/')
14
- .replaceAll(`src='${mediaDir}/`, "src='/openpress/media/");
14
+ .replaceAll(`src='${mediaDir}/`, "src='/openpress/media/")
15
+ .replaceAll('src="media/', 'src="/openpress/media/')
16
+ .replaceAll("src='media/", "src='/openpress/media/")
17
+ .replaceAll(`src="./${mediaDir}/`, 'src="/openpress/media/')
18
+ .replaceAll(`src='./${mediaDir}/`, "src='/openpress/media/")
19
+ .replaceAll('src="./media/', 'src="/openpress/media/')
20
+ .replaceAll("src='./media/", "src='/openpress/media/");
15
21
  }
16
22
 
17
23
  export function pageToBlock(index, pageHtml, source, config, { idPrefix = "openpress-page", anchorPrefix = "page", titleFallback = "Page" } = {}) {
18
24
  const paddedIndex = String(index + 1).padStart(2, "0");
19
- const title = pageHtml.match(/data-page-title="([^"]*)"/)?.[1] ?? `${titleFallback} ${index + 1}`;
25
+ const title =
26
+ pageHtml.match(/data-page-title="([^"]*)"/)?.[1] ??
27
+ pageHtml.match(/data-openpress-frame-key="([^"]*)"/)?.[1] ??
28
+ `${titleFallback} ${index + 1}`;
20
29
  const anchor = pageHtml.match(/\bid="([^"]+)"/)?.[1] ?? `${anchorPrefix}-${paddedIndex}`;
21
30
  return {
22
31
  id: `${idPrefix}-${paddedIndex}`,
@@ -1,19 +1,54 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { loadConfig } from "../runtime/config.mjs";
4
- import { copyDirectory, writeComponentsCss, writeContentCss } from "../runtime/file-utils.mjs";
4
+ import { writeComponentsCss, writeContentCss } from "../runtime/file-utils.mjs";
5
5
  import { copyThemeFonts } from "./fonts.mjs";
6
6
  import { copyKatexFonts } from "./katex-assets.mjs";
7
7
 
8
- export async function syncPublicAssets(root, publicOutputDir, config) {
8
+ export async function syncPublicAssets(root, publicOutputDir, config, options = {}) {
9
9
  config ??= await loadConfig(root);
10
10
  await fs.rm(path.join(publicOutputDir, "report.css"), { force: true });
11
11
  for (const name of ["tokens.css"]) {
12
- await fs.copyFile(path.join(config.paths.themeDir, name), path.join(publicOutputDir, name));
12
+ await copyOptionalFile(path.join(config.paths.themeDir, name), path.join(publicOutputDir, name));
13
13
  }
14
- await writeContentCss(root, publicOutputDir, config);
14
+ await writeContentCss(root, publicOutputDir, config, { themeRoots: options.themeRoots });
15
15
  await copyThemeFonts(root, publicOutputDir, config);
16
16
  await copyKatexFonts(publicOutputDir);
17
- await writeComponentsCss(root, publicOutputDir, config);
18
- await copyDirectory(config.paths.mediaDir, path.join(publicOutputDir, "media"));
17
+ await writeComponentsCss(root, publicOutputDir, config, { componentRoots: options.componentRoots });
18
+ await copyMediaRoots(options.mediaRoots ?? [config.paths.mediaDir], path.join(publicOutputDir, "media"));
19
+ }
20
+
21
+ async function copyOptionalFile(src, dst) {
22
+ try {
23
+ await fs.copyFile(src, dst);
24
+ } catch (error) {
25
+ if (error?.code === "ENOENT") return;
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ async function copyMediaRoots(mediaRoots, dst) {
31
+ await fs.rm(dst, { recursive: true, force: true });
32
+ await fs.mkdir(dst, { recursive: true });
33
+ for (const mediaRoot of uniquePaths(mediaRoots)) {
34
+ try {
35
+ await fs.cp(mediaRoot, dst, { recursive: true, force: true });
36
+ } catch (error) {
37
+ if (error?.code === "ENOENT") continue;
38
+ throw error;
39
+ }
40
+ }
41
+ }
42
+
43
+ function uniquePaths(paths) {
44
+ const out = [];
45
+ const seen = new Set();
46
+ for (const candidate of paths ?? []) {
47
+ if (!candidate) continue;
48
+ const normalized = path.resolve(candidate);
49
+ if (seen.has(normalized)) continue;
50
+ seen.add(normalized);
51
+ out.push(normalized);
52
+ }
53
+ return out;
19
54
  }
@@ -84,7 +84,7 @@ const server = http.createServer(async (req, res) => {
84
84
  res.writeHead(200, { "Content-Type": mimeTypes[path.extname(filePath)] ?? "application/octet-stream" });
85
85
  res.end(body);
86
86
  } catch (err) {
87
- // SPA fallback: when a path doesn't map to a real file AND it
87
+ // SPA HTML response: when a path doesn't map to a real file AND it
88
88
  // looks like a client-side route (no extension, not under a
89
89
  // reserved namespace), serve index.html so the reader's URL-based
90
90
  // routing can take over. This lets /cheatsheet / /proposal etc.
@@ -181,9 +181,9 @@ function valueAfter(args, flag) {
181
181
 
182
182
  async function inferWorkspaceRoot(staticRoot) {
183
183
  for (const candidate of [staticRoot, path.dirname(staticRoot), path.dirname(path.dirname(staticRoot))]) {
184
- // 1.0 workspace markers: press/index.tsx (the document entry) or
185
- // package.json with an "openpress" field. Either is sufficient.
186
- if (await fileExists(path.join(candidate, "press", "index.tsx"))) return candidate;
184
+ // Workspace markers: folder-convention Press entries or package.json
185
+ // with an "openpress" field. Either is sufficient.
186
+ if (await hasFolderPressEntries(candidate)) return candidate;
187
187
  if (await hasOpenpressPackageField(candidate)) return candidate;
188
188
  }
189
189
  if (path.basename(path.dirname(staticRoot)) === ".deploy") {
@@ -202,6 +202,20 @@ async function hasOpenpressPackageField(dir) {
202
202
  }
203
203
  }
204
204
 
205
+ async function hasFolderPressEntries(dir) {
206
+ let entries;
207
+ try {
208
+ entries = await fs.readdir(path.join(dir, "press"), { withFileTypes: true });
209
+ } catch {
210
+ return false;
211
+ }
212
+ for (const entry of entries) {
213
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "shared") continue;
214
+ if (await fileExists(path.join(dir, "press", entry.name, "press.tsx"))) return true;
215
+ }
216
+ return false;
217
+ }
218
+
205
219
  async function handleLocalPdfExportRequest(req, res) {
206
220
  if (req.method !== "POST") {
207
221
  writeJson(res, 405, { ok: false, message: "Local PDF export endpoint requires POST." });
@@ -377,14 +391,12 @@ async function handleMediaFileRequest(req, res, url) {
377
391
  writeJson(res, 404, { ok: false, message: "Media file not found." });
378
392
  return;
379
393
  }
380
- const targetPath = path.join(config.paths.mediaDir, fileName);
381
- const resolvedTarget = path.resolve(targetPath);
382
- const mediaRoot = path.resolve(config.paths.mediaDir);
383
- if (!resolvedTarget.startsWith(`${mediaRoot}${path.sep}`) && resolvedTarget !== mediaRoot) {
384
- writeJson(res, 403, { ok: false, message: "Forbidden." });
394
+ const mediaPath = await findMediaFile(fileName);
395
+ if (!mediaPath) {
396
+ writeJson(res, 404, { ok: false, message: "Media file not found." });
385
397
  return;
386
398
  }
387
- const body = await fs.readFile(resolvedTarget);
399
+ const body = await fs.readFile(mediaPath);
388
400
  res.writeHead(200, {
389
401
  "Content-Type": mediaMimeType(fileName),
390
402
  "Cache-Control": "no-store",
@@ -517,11 +529,7 @@ async function isDeploymentDirty(deployedAt) {
517
529
 
518
530
  function getDeploymentSourcePaths() {
519
531
  return [
520
- config.paths.sourceDir,
521
- config.paths.mediaDir,
522
- config.paths.themeDir,
523
- config.paths.designDoc,
524
- config.paths.componentsDir,
532
+ config.paths.documentRoot,
525
533
  path.join(FRAMEWORK_ROOT, "src"),
526
534
  path.join(FRAMEWORK_ROOT, "index.html"),
527
535
  path.join(FRAMEWORK_ROOT, "vite.config.ts"),
@@ -597,6 +605,51 @@ async function uniqueMediaFileName(mediaDir, fileName) {
597
605
  return candidate;
598
606
  }
599
607
 
608
+ async function findMediaFile(fileName) {
609
+ for (const mediaRoot of await collectMediaRoots()) {
610
+ const resolvedRoot = path.resolve(mediaRoot);
611
+ const candidate = path.resolve(mediaRoot, fileName);
612
+ if (!isInsideRoot(candidate, resolvedRoot)) continue;
613
+ if (await fileExists(candidate)) return candidate;
614
+ }
615
+ return null;
616
+ }
617
+
618
+ async function collectMediaRoots() {
619
+ const roots = [
620
+ config.paths.mediaDir,
621
+ path.join(config.paths.publicDir, "media"),
622
+ path.join(root, "openpress", "media"),
623
+ ];
624
+ try {
625
+ const entries = await fs.readdir(config.paths.documentRoot, { withFileTypes: true });
626
+ for (const entry of entries) {
627
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "shared") continue;
628
+ roots.push(path.join(config.paths.documentRoot, entry.name, "media"));
629
+ }
630
+ } catch {
631
+ // Missing press/ is handled by render/validate.
632
+ }
633
+ return uniquePaths(roots);
634
+ }
635
+
636
+ function uniquePaths(paths) {
637
+ const out = [];
638
+ const seen = new Set();
639
+ for (const candidate of paths) {
640
+ const normalized = path.resolve(candidate);
641
+ if (seen.has(normalized)) continue;
642
+ seen.add(normalized);
643
+ out.push(normalized);
644
+ }
645
+ return out;
646
+ }
647
+
648
+ function isInsideRoot(candidate, rootDir) {
649
+ const relative = path.relative(rootDir, candidate);
650
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
651
+ }
652
+
600
653
  function readRequestBuffer(req, maxBytes) {
601
654
  return new Promise((resolve, reject) => {
602
655
  const chunks = [];
@@ -64,8 +64,8 @@ function attrValue(attrs, name) {
64
64
  return attrs.match(pattern)?.[2] ?? "";
65
65
  }
66
66
 
67
- function stringOption(value, fallback) {
68
- return typeof value === "string" && value.trim() ? value.trim() : fallback;
67
+ function stringOption(value, defaultValue) {
68
+ return typeof value === "string" && value.trim() ? value.trim() : defaultValue;
69
69
  }
70
70
 
71
71
  function escapeHtml(value) {
@@ -6,8 +6,7 @@ import { collectSourceTextFiles } from "../runtime/source-text-tools.mjs";
6
6
 
7
7
  // Any `.mdx` or `.tsx` file under `press/` is a legal comment target.
8
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 `press/chapters/<slug>/content/*.mdx`. The boundary
9
+ // `section-files`, `file-list`, custom `root` paths, etc. The boundary
11
10
  // is "inside the workspace's authored `press/` directory" and "looks
12
11
  // like an editable React/MDX source" by extension.
13
12
  const EDITABLE_COMMENT_SOURCE_PATTERNS = [
@@ -1,9 +1,8 @@
1
1
  // Layer 1 — Document entry loader.
2
2
  //
3
- // Loads `press/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`).
3
+ // Discovers `press/*/press.tsx`, generates an internal Workspace entry,
4
+ // and sets up the vite SSR server with `@open-press/core` aliases
5
+ // (including the subpaths `/mdx` and `/manuscript`).
7
6
 
8
7
  import fs from "node:fs/promises";
9
8
  import { createRequire } from "node:module";
@@ -26,11 +25,61 @@ const REACT_PACKAGE_ROOT = path.join(FRAMEWORK_ROOT, "node_modules", "react");
26
25
  const require = createRequire(import.meta.url);
27
26
  const REACT_EXPORT_NAMES = Object.keys(require("react")).filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
28
27
 
29
- // 1.0 contract: the document entry lives at press/index.tsx.
30
28
  async function resolveEntryPath(workspaceRoot) {
31
- const candidate = path.join(workspaceRoot, "press", "index.tsx");
32
- if (await fileExists(candidate)) return candidate;
33
- return null;
29
+ return createDiscoveredPressEntry(workspaceRoot);
30
+ }
31
+
32
+ async function createDiscoveredPressEntry(workspaceRoot) {
33
+ const pressRoot = path.join(workspaceRoot, "press");
34
+ let entries = [];
35
+ try {
36
+ const children = await fs.readdir(pressRoot, { withFileTypes: true });
37
+ for (const child of children) {
38
+ if (!child.isDirectory()) continue;
39
+ if (child.name === "shared" || child.name.startsWith(".")) continue;
40
+ const entryPath = path.join(pressRoot, child.name, "press.tsx");
41
+ if (await fileExists(entryPath)) entries.push({ folder: child.name, entryPath });
42
+ }
43
+ } catch {
44
+ return null;
45
+ }
46
+
47
+ entries = entries.sort((a, b) => a.folder.localeCompare(b.folder));
48
+ if (entries.length === 0) return null;
49
+
50
+ for (const entry of entries) {
51
+ const source = await fs.readFile(entry.entryPath, "utf8");
52
+ assertNoObviousTopLevelSideEffects(source, entry.entryPath);
53
+ }
54
+
55
+ const generatedDir = path.join(workspaceRoot, ".openpress", "react");
56
+ await fs.mkdir(generatedDir, { recursive: true });
57
+ const generatedEntry = path.join(generatedDir, "discovered-press-entry.tsx");
58
+ const imports = entries
59
+ .map((entry, index) => `import Press${index} from "${relativeImportPath(generatedDir, entry.entryPath)}";`)
60
+ .join("\n");
61
+ const children = entries.map((_, index) => ` <Press${index} />`).join("\n");
62
+ const source = `import { Workspace } from "@open-press/core";
63
+ ${imports}
64
+
65
+ export const __openpressPressFolders = ${JSON.stringify(entries.map((entry) => entry.folder))};
66
+
67
+ export default function DiscoveredOpenPressWorkspace() {
68
+ return (
69
+ <Workspace>
70
+ ${children}
71
+ </Workspace>
72
+ );
73
+ }
74
+ `;
75
+ await fs.writeFile(generatedEntry, source, "utf8");
76
+ return generatedEntry;
77
+ }
78
+
79
+ function relativeImportPath(fromDir, toFile) {
80
+ let relative = path.relative(fromDir, toFile).replaceAll(path.sep, "/");
81
+ if (!relative.startsWith(".")) relative = `./${relative}`;
82
+ return relative;
34
83
  }
35
84
 
36
85
  export async function loadReactDocumentEntry(root = ".", { server: externalServer } = {}) {
@@ -82,6 +131,9 @@ export async function loadReactDocumentEntry(root = ".", { server: externalServe
82
131
  workspaceProps: inspection.workspaceProps,
83
132
  pressCount: inspection.presses.length,
84
133
  wrappedInWorkspace: inspection.wrappedInWorkspace,
134
+ pressFolders: Array.isArray(mod.__openpressPressFolders)
135
+ ? mod.__openpressPressFolders.filter((item) => typeof item === "string")
136
+ : [],
85
137
  };
86
138
  } finally {
87
139
  if (!externalServer) await ownServer.close();
@@ -112,7 +164,7 @@ export async function createReactSsrServer(workspaceRoot = ".") {
112
164
  { find: "@open-press/core/manuscript", replacement: MANUSCRIPT_ENTRY },
113
165
  { find: "@open-press/core/numbering", replacement: NUMBERING_ENTRY },
114
166
  { find: "@open-press/core", replacement: CORE_ENTRY },
115
- { find: "@/components", replacement: path.join(resolvedWorkspaceRoot, "press", "components") },
167
+ { find: "@/components", replacement: path.join(resolvedWorkspaceRoot, "press", "shared", "components") },
116
168
  ],
117
169
  },
118
170
  optimizeDeps: {
@@ -159,10 +211,11 @@ function assertNoObviousTopLevelSideEffects(source, entryPath) {
159
211
  }
160
212
 
161
213
  function assertPureImport(statement, entryPath) {
214
+ const moduleName = stringLiteralText(statement.moduleSpecifier);
162
215
  if (!statement.importClause) {
216
+ if (typeof moduleName === "string" && moduleName.endsWith(".css")) return;
163
217
  throw new Error(`OpenPress document entry has an unsupported side-effect import in ${entryPath}`);
164
218
  }
165
- const moduleName = stringLiteralText(statement.moduleSpecifier);
166
219
  if (!statement.importClause.isTypeOnly && isFileSystemModule(moduleName)) {
167
220
  throw new Error(`OpenPress document entry imports filesystem APIs at top level in ${entryPath}`);
168
221
  }
@@ -179,7 +232,7 @@ function assertTopLevelVariableStatement(statement, entryPath) {
179
232
  throw new Error(`OpenPress document entry only allows identifier const declarations at top level in ${entryPath}`);
180
233
  }
181
234
  const name = declaration.name.text;
182
- if (exported && name !== "config" && name !== "sources") {
235
+ if (exported && name !== "config" && name !== "sources" && name !== "__openpressPressFolders") {
183
236
  throw new Error(`OpenPress document entry only allows exported const config and sources in ${entryPath}`);
184
237
  }
185
238
  if (!declaration.initializer) {
@@ -0,0 +1,6 @@
1
+ export function exportReactDocument(
2
+ root?: string,
3
+ options?: {
4
+ syncAssets?: boolean;
5
+ },
6
+ ): Promise<unknown>;