@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.
- package/README.md +2 -2
- package/engine/cli.mjs +1 -1
- package/engine/commands/_shared.mjs +10 -5
- package/engine/commands/deploy.mjs +19 -4
- package/engine/commands/typecheck.mjs +1 -1
- package/engine/document-export.mjs +1 -1
- package/engine/output/page-block.mjs +11 -2
- package/engine/output/public-assets.mjs +41 -6
- package/engine/output/static-server.mjs +84 -24
- package/engine/react/caption-numbering.mjs +2 -2
- package/engine/react/comment-marker.mjs +1 -2
- package/engine/react/document-entry.mjs +64 -11
- package/engine/react/document-export.d.mts +6 -0
- package/engine/react/document-export.mjs +158 -28
- package/engine/react/mdx-compile.mjs +4 -4
- package/engine/react/measurement-css.mjs +3 -3
- package/engine/react/page-folio.mjs +37 -0
- package/engine/react/pagination/allocator.mjs +4 -4
- package/engine/react/pipeline/frame-measurement.mjs +34 -16
- package/engine/react/press-tree-inspection.mjs +43 -13
- package/engine/react/project-asset-endpoint.mjs +45 -11
- package/engine/react/sources/heading-numbering.mjs +2 -2
- package/engine/react/sources/mdx-resolver.mjs +3 -3
- package/engine/react/style-discovery.mjs +60 -11
- package/engine/react/text-source-transform.mjs +18 -4
- package/engine/runtime/config.mjs +22 -22
- package/engine/runtime/file-utils.mjs +57 -13
- package/engine/runtime/inspection.mjs +40 -15
- package/engine/runtime/page-geometry.mjs +6 -6
- package/engine/runtime/source-text-tools.mjs +28 -4
- package/engine/runtime/source-workspace.mjs +6 -9
- package/engine/runtime/validation.mjs +42 -24
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +10 -16
- package/src/openpress/app/OpenPressRuntime.tsx +29 -4
- package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
- package/src/openpress/core/PageFolio.tsx +115 -0
- package/src/openpress/core/Press.tsx +5 -10
- package/src/openpress/core/Slide.tsx +11 -0
- package/src/openpress/core/index.tsx +4 -0
- package/src/openpress/core/types.ts +21 -13
- package/src/openpress/core/useSource.ts +1 -1
- package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
- package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
- package/src/openpress/reader/SlidePresentationPage.tsx +36 -19
- package/src/openpress/reader/SlidePublicPage.tsx +332 -0
- package/src/openpress/reader/index.ts +1 -0
- package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
- package/src/openpress/reader/usePageViewportScale.ts +9 -5
- package/src/openpress/workbench/Workbench.tsx +46 -164
- package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
- package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
- package/src/openpress/workbench/actions/index.ts +1 -1
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +7 -2
- package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
- package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
- package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
- package/src/styles/openpress/app-shell.css +0 -83
- package/src/styles/openpress/print-route.css +1 -3
- package/src/styles/openpress/project-preview-panel.css +5 -783
- package/src/styles/openpress/public-viewer.css +7 -249
- package/src/styles/openpress/reader-runtime.css +0 -274
- package/src/styles/openpress/slide-presenter.css +150 -0
- package/src/styles/openpress/slide-public-viewer.css +222 -0
- package/src/styles/openpress/workbench-dialog.css +267 -0
- package/src/styles/openpress/workbench-export.css +154 -0
- package/src/styles/openpress/workbench-inline-editor.css +128 -0
- package/src/styles/openpress/workbench-panels.css +0 -88
- package/src/styles/openpress/workbench-search.css +257 -0
- package/src/styles/openpress/workbench-toolbar.css +422 -0
- package/src/styles/openpress/workbench.css +34 -1263
- package/src/styles/openpress/workspace-gallery.css +0 -5
- package/src/styles/openpress.css +7 -1
- package/vite.config.ts +98 -25
- package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
- 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
|
|
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
|
|
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:
|
|
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
|
-
`${
|
|
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 {
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
if (await
|
|
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
|
|
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}/${
|
|
330
|
+
pdf: deployedUrl ? `${deployedUrl}/${pdfFilename}` : deploymentInfo.pdf,
|
|
312
331
|
public_url: publicUrl,
|
|
313
332
|
dirty: false,
|
|
314
|
-
command
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
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(
|
|
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",
|
|
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}/${
|
|
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.
|
|
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,
|
|
68
|
-
return typeof value === "string" && value.trim() ? value.trim() :
|
|
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.
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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) {
|