@open-press/core 1.2.0 → 1.3.1

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 (76) hide show
  1. package/README.md +2 -2
  2. package/engine/cli.mjs +1 -1
  3. package/engine/commands/_shared.mjs +10 -5
  4. package/engine/commands/deploy.mjs +19 -4
  5. package/engine/commands/typecheck.mjs +1 -1
  6. package/engine/document-export.mjs +1 -1
  7. package/engine/output/page-block.mjs +11 -2
  8. package/engine/output/public-assets.mjs +41 -6
  9. package/engine/output/static-server.mjs +84 -24
  10. package/engine/react/caption-numbering.mjs +2 -2
  11. package/engine/react/comment-marker.mjs +1 -2
  12. package/engine/react/document-entry.mjs +64 -11
  13. package/engine/react/document-export.d.mts +6 -0
  14. package/engine/react/document-export.mjs +158 -28
  15. package/engine/react/mdx-compile.mjs +4 -4
  16. package/engine/react/measurement-css.mjs +3 -3
  17. package/engine/react/page-folio.mjs +37 -0
  18. package/engine/react/pagination/allocator.mjs +4 -4
  19. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  20. package/engine/react/press-tree-inspection.mjs +43 -13
  21. package/engine/react/project-asset-endpoint.mjs +45 -11
  22. package/engine/react/sources/heading-numbering.mjs +2 -2
  23. package/engine/react/sources/mdx-resolver.mjs +3 -3
  24. package/engine/react/style-discovery.mjs +60 -11
  25. package/engine/react/text-source-transform.mjs +18 -4
  26. package/engine/runtime/config.mjs +22 -22
  27. package/engine/runtime/file-utils.mjs +57 -13
  28. package/engine/runtime/inspection.mjs +40 -15
  29. package/engine/runtime/page-geometry.mjs +6 -6
  30. package/engine/runtime/source-text-tools.mjs +28 -4
  31. package/engine/runtime/source-workspace.mjs +6 -9
  32. package/engine/runtime/validation.mjs +42 -24
  33. package/package.json +1 -1
  34. package/src/openpress/app/OpenPressApp.tsx +10 -16
  35. package/src/openpress/app/OpenPressRuntime.tsx +29 -4
  36. package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
  37. package/src/openpress/core/PageFolio.tsx +115 -0
  38. package/src/openpress/core/Press.tsx +5 -10
  39. package/src/openpress/core/Slide.tsx +11 -0
  40. package/src/openpress/core/index.tsx +4 -0
  41. package/src/openpress/core/types.ts +21 -13
  42. package/src/openpress/core/useSource.ts +1 -1
  43. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  44. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  45. package/src/openpress/reader/SlidePresentationPage.tsx +36 -19
  46. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  47. package/src/openpress/reader/index.ts +1 -0
  48. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  49. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  50. package/src/openpress/workbench/Workbench.tsx +46 -164
  51. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  52. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  53. package/src/openpress/workbench/actions/index.ts +1 -1
  54. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +7 -2
  55. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  56. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  57. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  58. package/src/styles/openpress/app-shell.css +0 -83
  59. package/src/styles/openpress/print-route.css +1 -3
  60. package/src/styles/openpress/project-preview-panel.css +5 -783
  61. package/src/styles/openpress/public-viewer.css +7 -249
  62. package/src/styles/openpress/reader-runtime.css +0 -274
  63. package/src/styles/openpress/slide-presenter.css +150 -0
  64. package/src/styles/openpress/slide-public-viewer.css +222 -0
  65. package/src/styles/openpress/workbench-dialog.css +267 -0
  66. package/src/styles/openpress/workbench-export.css +154 -0
  67. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  68. package/src/styles/openpress/workbench-panels.css +0 -88
  69. package/src/styles/openpress/workbench-search.css +257 -0
  70. package/src/styles/openpress/workbench-toolbar.css +422 -0
  71. package/src/styles/openpress/workbench.css +34 -1263
  72. package/src/styles/openpress/workspace-gallery.css +0 -5
  73. package/src/styles/openpress.css +7 -1
  74. package/vite.config.ts +98 -25
  75. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  76. package/src/styles/openpress/media-workspace.css +0 -230
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).
package/engine/cli.mjs CHANGED
@@ -87,7 +87,7 @@ Commands:
87
87
  typecheck
88
88
  image [--output <outputDir>] [--press <slug>] [--pages <selector>] [--no-build] [--dry-run]
89
89
  pdf [--output <outputDir>/<pdf.filename>] [--press <slug>] [--no-build] [--dry-run]
90
- deploy --confirm [--dry-run]
90
+ deploy --confirm [--press <slug>] [--dry-run]
91
91
  doctor [--json] [--no-cache] # version + skill staleness check
92
92
  upgrade [--dry-run] [--no-deps] [--no-skills] [--json] # apply updates; agent-driven
93
93
  migrate [--dry-run] [--no-deps] [--no-skills] [--json] # alias for upgrade; reads migration notes
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { captureUrlPagesToPng, printUrlToPdf, stopChildProcess, waitForPrintReady } from "../output/chrome-pdf.mjs";
7
- import { loadConfig, publicPdfHref } from "../runtime/config.mjs";
7
+ import { loadConfig } from "../runtime/config.mjs";
8
8
  import { exportDocument } from "../document-export.mjs";
9
9
  import { optimizePdfMediaForStaticRoot } from "../output/pdf-media.mjs";
10
10
 
@@ -142,7 +142,7 @@ function pressPrintUrl(host, port, slug) {
142
142
  return `http://${host}:${port}/${normalized}?print=1`;
143
143
  }
144
144
 
145
- function pressSuffixedFilename(baseFilename, slug) {
145
+ export function pressSuffixedFilename(baseFilename, slug) {
146
146
  const normalized = (slug ?? "").replace(/^\/+|\/+$/g, "");
147
147
  if (!normalized) return baseFilename;
148
148
  const ext = path.extname(baseFilename);
@@ -150,6 +150,10 @@ function pressSuffixedFilename(baseFilename, slug) {
150
150
  return `${stem}-${normalized}${ext}`;
151
151
  }
152
152
 
153
+ export function publicPdfHrefForFilename(filename) {
154
+ return `/${filename}`;
155
+ }
156
+
153
157
  export async function buildReactPdf({
154
158
  root,
155
159
  config,
@@ -294,18 +298,19 @@ export function startStaticServer(root, config, host, port) {
294
298
  });
295
299
  }
296
300
 
297
- export async function writePdfStageDeployConfig(root, source, config) {
301
+ export async function writePdfStageDeployConfig(root, source, config, { pdfFilename = config.pdf.filename } = {}) {
298
302
  const deployRoot = path.resolve(root, source);
299
303
  const openpressDir = path.join(deployRoot, "openpress");
304
+ const pdfHref = publicPdfHrefForFilename(pdfFilename);
300
305
  await fs.mkdir(openpressDir, { recursive: true });
301
306
  await fs.writeFile(
302
307
  path.join(openpressDir, "deploy.json"),
303
- `${JSON.stringify({ pdf: publicPdfHref(config), deployed_at: new Date().toISOString() }, null, 2)}\n`,
308
+ `${JSON.stringify({ pdf: pdfHref, deployed_at: new Date().toISOString() }, null, 2)}\n`,
304
309
  "utf8",
305
310
  );
306
311
  await fs.writeFile(
307
312
  path.join(deployRoot, "_headers"),
308
- `${publicPdfHref(config)}\n Content-Type: application/pdf\n Content-Disposition: inline; filename="${config.pdf.filename}"\n`,
313
+ `${pdfHref}\n Content-Type: application/pdf\n Content-Disposition: inline; filename="${pdfFilename}"\n`,
309
314
  "utf8",
310
315
  );
311
316
  }
@@ -1,6 +1,12 @@
1
1
  import path from "node:path";
2
2
  import { deploySync } from "../output/deploy-sync.mjs";
3
- import { buildReactPdf, formatOpenPressCommand, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
3
+ import {
4
+ buildReactPdf,
5
+ formatOpenPressCommand,
6
+ pressSuffixedFilename,
7
+ runCommand,
8
+ writePdfStageDeployConfig,
9
+ } from "./_shared.mjs";
4
10
 
5
11
  export async function run({ root, config, options, recurse }) {
6
12
  if (config.deploy.requiresConfirmation === true && !options.confirm) {
@@ -10,11 +16,15 @@ export async function run({ root, config, options, recurse }) {
10
16
  const source = config.deploy.source;
11
17
  const projectName = config.deploy.projectName;
12
18
  const commitDirty = config.deploy.commitDirty;
19
+ const pressSlug = normalizePressSlug(options.press);
20
+ const pdfFilename = pressSuffixedFilename(config.pdf.filename, pressSlug);
21
+ const pdfArgs = ["pdf", ".", "--output", `${source}/${pdfFilename}`];
22
+ if (pressSlug) pdfArgs.push("--press", pressSlug);
13
23
  if (options.dryRun) {
14
24
  console.log("OpenPress deploy dry run");
15
25
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
16
26
  console.log(`Step: deploy-sync (copy ${config.outputDir} → ${source})`);
17
- console.log(`Command: ${formatOpenPressCommand(["pdf", ".", "--output", `${source}/${config.pdf.filename}`])}`);
27
+ console.log(`Command: ${formatOpenPressCommand(pdfArgs)}`);
18
28
  console.log(`Step: write ${source}/openpress/deploy.json with deployment metadata`);
19
29
  console.log(`Command: npx wrangler pages deploy ${source}${projectName ? ` --project-name=${projectName}` : ""}${commitDirty ? " --commit-dirty=true" : ""}`);
20
30
  return 0;
@@ -22,10 +32,15 @@ export async function run({ root, config, options, recurse }) {
22
32
  const renderCode = await recurse("render", [root, "--renderer", "react"]);
23
33
  if (renderCode !== 0) return renderCode;
24
34
  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);
35
+ await buildReactPdf({ root, config, outPath: path.resolve(root, source, pdfFilename), noBuild: true, recurse, pressSlug });
36
+ await writePdfStageDeployConfig(root, source, config, { pdfFilename });
27
37
  const wranglerArgs = ["wrangler", "pages", "deploy", source];
28
38
  if (projectName) wranglerArgs.push(`--project-name=${projectName}`);
29
39
  if (commitDirty) wranglerArgs.push("--commit-dirty=true");
30
40
  return runCommand("npx", wranglerArgs, root);
31
41
  }
42
+
43
+ function normalizePressSlug(value) {
44
+ if (typeof value !== "string") return "";
45
+ return value.trim().replace(/^\/+|\/+$/g, "");
46
+ }
@@ -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." });
@@ -283,6 +297,11 @@ async function handleDeployRequest(req, res) {
283
297
  return;
284
298
  }
285
299
 
300
+ const body = await readJsonBody(req);
301
+ const slug = normalizePressSlug(body?.press);
302
+ const command = slug ? `open-press deploy . --confirm --press ${slug}` : "open-press deploy . --confirm";
303
+ const pdfFilename = pressFilename(config.pdf.filename, slug);
304
+
286
305
  if (!isDeployConfigured()) {
287
306
  writeJson(res, 400, {
288
307
  ok: false,
@@ -292,15 +311,15 @@ async function handleDeployRequest(req, res) {
292
311
  deploy_adapter: config.deploy.adapter,
293
312
  deploy_source: config.deploy.source,
294
313
  deploy_project_name: config.deploy.projectName,
295
- command: "open-press deploy . --confirm",
314
+ command,
296
315
  });
297
316
  return;
298
317
  }
299
318
 
300
- const result = await runDeploy();
319
+ const result = await runDeploy(slug);
301
320
  const deployedUrl = extractDeployUrl(result.stdout);
302
321
  if (result.code === 0 && deployedUrl) {
303
- await writeDeploymentPublicUrl(deployedUrl);
322
+ await writeDeploymentPublicUrl(deployedUrl, pdfFilename);
304
323
  }
305
324
  const deploymentInfo = await readDeploymentInfo();
306
325
  const publicUrl = deployedUrl ?? deploymentInfo.public_url;
@@ -308,10 +327,10 @@ async function handleDeployRequest(req, res) {
308
327
  ok: result.code === 0,
309
328
  code: result.code,
310
329
  deployed_at: deploymentInfo.deployed_at,
311
- pdf: deployedUrl ? `${deployedUrl}/${config.pdf.filename}` : deploymentInfo.pdf,
330
+ pdf: deployedUrl ? `${deployedUrl}/${pdfFilename}` : deploymentInfo.pdf,
312
331
  public_url: publicUrl,
313
332
  dirty: false,
314
- command: "open-press deploy . --confirm",
333
+ command,
315
334
  stdout: result.stdout,
316
335
  stderr: result.stderr,
317
336
  });
@@ -372,14 +391,12 @@ async function handleMediaFileRequest(req, res, url) {
372
391
  writeJson(res, 404, { ok: false, message: "Media file not found." });
373
392
  return;
374
393
  }
375
- const targetPath = path.join(config.paths.mediaDir, fileName);
376
- const resolvedTarget = path.resolve(targetPath);
377
- const mediaRoot = path.resolve(config.paths.mediaDir);
378
- if (!resolvedTarget.startsWith(`${mediaRoot}${path.sep}`) && resolvedTarget !== mediaRoot) {
379
- 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." });
380
397
  return;
381
398
  }
382
- const body = await fs.readFile(resolvedTarget);
399
+ const body = await fs.readFile(mediaPath);
383
400
  res.writeHead(200, {
384
401
  "Content-Type": mediaMimeType(fileName),
385
402
  "Cache-Control": "no-store",
@@ -419,9 +436,11 @@ function runLocalPdfExport(slug = "") {
419
436
  });
420
437
  }
421
438
 
422
- function runDeploy() {
439
+ function runDeploy(slug = "") {
440
+ const cliArgs = [CLI_ENTRY, "deploy", ".", "--confirm"];
441
+ if (slug) cliArgs.push("--press", slug);
423
442
  return new Promise((resolve) => {
424
- const child = spawn("node", [CLI_ENTRY, "deploy", ".", "--confirm"], {
443
+ const child = spawn("node", cliArgs, {
425
444
  cwd: workspace,
426
445
  shell: false,
427
446
  });
@@ -485,7 +504,7 @@ async function readDeploymentInfo() {
485
504
  }
486
505
  }
487
506
 
488
- async function writeDeploymentPublicUrl(publicUrl) {
507
+ async function writeDeploymentPublicUrl(publicUrl, pdfFilename = config.pdf.filename) {
489
508
  let deployConfig = {};
490
509
  try {
491
510
  deployConfig = JSON.parse(await fs.readFile(config.paths.deployMetadata, "utf8"));
@@ -495,7 +514,7 @@ async function writeDeploymentPublicUrl(publicUrl) {
495
514
  await fs.mkdir(path.dirname(config.paths.deployMetadata), { recursive: true });
496
515
  await fs.writeFile(
497
516
  config.paths.deployMetadata,
498
- `${JSON.stringify({ ...deployConfig, pdf: `${publicUrl}/${config.pdf.filename}`, public_url: publicUrl }, null, 2)}\n`,
517
+ `${JSON.stringify({ ...deployConfig, pdf: `${publicUrl}/${pdfFilename}`, public_url: publicUrl }, null, 2)}\n`,
499
518
  "utf8",
500
519
  );
501
520
  }
@@ -510,11 +529,7 @@ async function isDeploymentDirty(deployedAt) {
510
529
 
511
530
  function getDeploymentSourcePaths() {
512
531
  return [
513
- config.paths.sourceDir,
514
- config.paths.mediaDir,
515
- config.paths.themeDir,
516
- config.paths.designDoc,
517
- config.paths.componentsDir,
532
+ config.paths.documentRoot,
518
533
  path.join(FRAMEWORK_ROOT, "src"),
519
534
  path.join(FRAMEWORK_ROOT, "index.html"),
520
535
  path.join(FRAMEWORK_ROOT, "vite.config.ts"),
@@ -590,6 +605,51 @@ async function uniqueMediaFileName(mediaDir, fileName) {
590
605
  return candidate;
591
606
  }
592
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
+
593
653
  function readRequestBuffer(req, maxBytes) {
594
654
  return new Promise((resolve, reject) => {
595
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>;