import { fileURLToPath, URL } from "node:url"; import { createRequire } from "node:module"; import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { loadConfig, publicPdfHref } from "./engine/runtime/config.mjs"; import { normalizeDeployPressSlug } from "./engine/runtime/deploy-target.mjs"; import { pressSuffixedFilename } from "./engine/runtime/press-filename.mjs"; import { searchSourceText } from "./engine/runtime/source-text-tools.mjs"; import { createDeployEndpoints } from "./engine/output/deploy-endpoint.mjs"; import { wordFilenameFromPdfFilename } from "./engine/output/word-docx.mjs"; import { handleCommentRequest } from "./engine/react/comment-endpoint.mjs"; import { handleChangePreviewRequest } from "./engine/react/change-preview-endpoint.mjs"; import { handleProjectAssetRequest } from "./engine/react/project-asset-endpoint.mjs"; import { handleSourceEditRequest } from "./engine/react/source-edit-endpoint.mjs"; import { rejectUntrustedLocalMutationRequest } from "./engine/runtime/local-mutation-guard.mjs"; import { handleWorkspaceSettingsRequest } from "./engine/runtime/workspace-settings-endpoint.mjs"; import { runIsolatedDocumentExport } from "./engine/commands/_shared.mjs"; const frameworkRoot = fileURLToPath(new URL("./", import.meta.url)); const require = createRequire(import.meta.url); const workspaceUiFontRoot = path.dirname(require.resolve("@fontsource-variable/nunito-sans/package.json")); const workspaceRoot = process.env.OPENPRESS_WORKSPACE_ROOT ? path.resolve(process.env.OPENPRESS_WORKSPACE_ROOT) : frameworkRoot; const sourceRoot = path.join(frameworkRoot, "src"); const openpressCliPath = path.join(frameworkRoot, "engine", "cli.mjs"); const openpressCoreEntry = path.join(frameworkRoot, "src", "openpress", "core", "index.tsx"); const openpressMdxEntry = path.join(frameworkRoot, "src", "openpress", "mdx", "index.ts"); const openpressManuscriptEntry = path.join(frameworkRoot, "src", "openpress", "manuscript", "index.tsx"); const openpressNavigationEntry = path.join(frameworkRoot, "src", "openpress", "navigation", "index.ts"); const openpressNumberingEntry = path.join(frameworkRoot, "src", "openpress", "numbering", "index.ts"); const openpressThemeEntry = path.join(frameworkRoot, "src", "openpress", "theme", "index.tsx"); const openpressConfig = await loadConfig(workspaceRoot); const outputDir = openpressConfig.paths.outputDir; const reactDocumentRoot = openpressConfig.paths.documentRoot; const reactDocumentComponentsRoot = openpressConfig.paths.componentsDir; const activeContentDir = reactDocumentRoot; // Workspace directories — Vite resolves these at build time so that // `import.meta.glob("@workspace/content/**")` and friends follow the active // OpenPress authoring source instead of a hardcoded source prefix. const workspaceAliases = { "@workspace/content": activeContentDir, "@workspace/media": openpressConfig.paths.mediaDir, "@workspace/components": openpressConfig.paths.componentsDir, }; // Relative paths displayed back to the user (e.g. "press/report/chapters"). // Resolved at build time so the React app does not hardcode a source root. function relativeFromWorkspace(absolute: string) { const rel = path.relative(workspaceRoot, absolute).replaceAll("\\", "/"); return rel.endsWith("/") ? rel : `${rel}`; } const workspaceDefines = { __OPENPRESS_CONTENT_PATH__: JSON.stringify(relativeFromWorkspace(activeContentDir)), __OPENPRESS_MEDIA_PATH__: JSON.stringify(relativeFromWorkspace(openpressConfig.paths.mediaDir)), __OPENPRESS_COMPONENTS_PATH__: JSON.stringify(relativeFromWorkspace(openpressConfig.paths.componentsDir)), __OPENPRESS_PDF_HREF__: JSON.stringify(publicPdfHref(openpressConfig)), }; export default defineConfig({ root: frameworkRoot, // Export and thumbnail Chrome sessions navigate directly to nested Press // routes (for example /report/preview?print=1). Root-relative assets keep // those direct navigations from resolving ./assets and ./openpress under // the Press slug, where the preview host correctly returns 404. base: "/", cacheDir: path.join(workspaceRoot, ".openpress", "vite-client"), publicDir: path.join(workspaceRoot, "public"), plugins: [openpressTailwindSourcePlugin(), openpressLocalDeployPlugin(), tailwindcss(), react()], define: workspaceDefines, resolve: { dedupe: ["react", "react-dom", "@mdx-js/react"], alias: { // Subpaths must come before the base path so resolution matches longest first. "@open-press/core/mdx": openpressMdxEntry, "@open-press/core/manuscript": openpressManuscriptEntry, "@open-press/core/navigation": openpressNavigationEntry, "@open-press/core/numbering": openpressNumberingEntry, "@open-press/core/theme": openpressThemeEntry, "@open-press/core": openpressCoreEntry, "@/components": reactDocumentComponentsRoot, "@": sourceRoot, ...workspaceAliases, }, }, optimizeDeps: { include: [ "@mdx-js/react", "lucide-react", "react", "react-dom", "react-dom/client", "react/jsx-dev-runtime", "react/jsx-runtime", ], }, build: { outDir: outputDir, emptyOutDir: true, rollupOptions: { output: { entryFileNames: "assets/[name]-[hash]-openpress.js", chunkFileNames: "assets/[name]-[hash]-openpress.js", assetFileNames: "assets/[name]-[hash]-openpress[extname]", }, }, }, server: { host: "127.0.0.1", port: 5173, fs: { allow: Array.from(new Set([frameworkRoot, workspaceRoot, workspaceUiFontRoot])), }, watch: { ignored: [ "**/.openpress/tmp/**", "**/.deploy/**", openpressConfig.paths.outputDir + "/**", openpressConfig.paths.publicDir + "/**", ], }, }, preview: { host: "127.0.0.1", port: 5173, }, }); function openpressTailwindSourcePlugin() { const openpressCssPath = path.join(sourceRoot, "styles", "openpress.css"); const generatedReactHtmlSourceRoot = path.join(frameworkRoot, "engine", "react"); return { name: "openpress-tailwind-source", enforce: "pre" as const, transform(source: string, id: string) { const normalized = id.split("?")[0]; if (normalized !== openpressCssPath) return null; const sources = [reactDocumentRoot, reactDocumentComponentsRoot, generatedReactHtmlSourceRoot, sourceRoot] .filter((sourcePath) => sourcePath && path.isAbsolute(sourcePath)) .map((sourcePath) => cssRelativePath(path.dirname(openpressCssPath), sourcePath)); const directives = Array.from(new Set(sources)) .map((sourcePath) => `@source ${JSON.stringify(sourcePath)};`) .join("\n"); return directives ? `${source}\n\n${directives}\n` : source; }, }; } const deployEndpoints = createDeployEndpoints({ config: openpressConfig, workspaceRoot, frameworkRoot, cliEntry: openpressCliPath, }); function openpressLocalDeployPlugin() { // Suppress auto-reload while a local source mutation is being processed (and for a // brief quiet period after it completes, to absorb late chokidar events that // arrive after the response was sent). Tracking in-flight requests (rather // than a fixed timeout) means slow exports on large workspaces no longer race // the suppression window and trigger an unwanted full-reload that wipes the // inline edit or comment review state. let inFlightSourceMutations = 0; let lastSourceMutationEndedAt = 0; const SOURCE_MUTATION_QUIET_MS = 5000; let debounceTimer: ReturnType | null = null; let exporting = false; const shouldSuppressForSourceMutation = () => { if (inFlightSourceMutations > 0) return true; if (lastSourceMutationEndedAt === 0) return false; return Date.now() - lastSourceMutationEndedAt < SOURCE_MUTATION_QUIET_MS; }; const trackSourceMutation = (req: IncomingMessage, res: ServerResponse, methods: string[]) => { if (!methods.includes(req.method ?? "")) return; inFlightSourceMutations += 1; let released = false; const release = () => { if (released) return; released = true; inFlightSourceMutations = Math.max(0, inFlightSourceMutations - 1); lastSourceMutationEndedAt = Date.now(); }; res.on("close", release); res.on("finish", release); }; type LocalApiMiddlewares = { use: { (path: string, handler: (req: IncomingMessage, res: ServerResponse) => void): void; (handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): void; }; }; const installLocalApiMiddlewares = ( middlewares: LocalApiMiddlewares, { trackMutations, includeReview, staticRoots, }: { trackMutations: boolean; includeReview: boolean; staticRoots: string[] }, ) => { const track = (req: IncomingMessage, res: ServerResponse, methods: string[]) => { if (trackMutations) trackSourceMutation(req, res, methods); }; middlewares.use("/__openpress/local-pdf-export", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; void handleLocalPdfExportRequest(req, res); }); middlewares.use("/__openpress/local-pdf-file", (req, res) => { void handleLocalPdfFileRequest(req, res); }); middlewares.use("/__openpress/local-word-export", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; void handleLocalWordExportRequest(req, res); }); middlewares.use("/__openpress/local-word-file", (req, res) => { void handleLocalWordFileRequest(req, res); }); middlewares.use("/__openpress/status", (req, res) => { void deployEndpoints.handleStatusRequest(req, res); }); middlewares.use("/__openpress/workspace-settings", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; track(req, res, ["PUT"]); void handleWorkspaceSettingsRequest(req, res, { root: workspaceRoot, writable: true, }); }); middlewares.use("/openpress/settings.json", (req, res) => { void handleWorkspaceSettingsRequest(req, res, { root: workspaceRoot, writable: false, publicOnly: true, }); }); middlewares.use("/__openpress/search", (req, res) => { void handleLocalSearchRequest(req, res); }); middlewares.use("/__openpress/source-edit", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; track(req, res, ["POST"]); void handleSourceEditRequest(req, res, { root: workspaceRoot }); }); middlewares.use("/__openpress/deploy", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; void deployEndpoints.handleDeployRequest(req, res); }); if (includeReview) { middlewares.use("/__openpress/comment", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; track(req, res, ["POST", "PATCH", "DELETE"]); void handleCommentRequest(req, res, { root: workspaceRoot }); }); middlewares.use("/__openpress/change-preview", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; void handleChangePreviewRequest(req, res, { root: workspaceRoot }); }); } middlewares.use("/__openpress/media-upload", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; void handleLocalMediaUploadRequest(req, res); }); middlewares.use("/__openpress/project-asset", (req, res) => { if (rejectUntrustedLocalMutationRequest(req, res)) return; void handleProjectAssetRequest(req, res, { root: workspaceRoot }); }); middlewares.use("/openpress/media", (req, res) => { void handleLocalMediaFileRequest(req, res); }); middlewares.use((req, res, next) => { void preserveReservedNamespace404(req, res, next, staticRoots); }); }; async function preserveReservedNamespace404( req: IncomingMessage, res: ServerResponse, next: () => void, staticRoots: string[], ) { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; const isReserved = pathname.startsWith("/openpress/") || pathname.startsWith("/__openpress/") || pathname.startsWith("/assets/"); if (!isReserved) { next(); return; } try { const decodedPathname = decodeURIComponent(pathname); const candidateFiles = staticRoots .map((staticRoot) => { const resolvedRoot = path.resolve(staticRoot); const requestedFile = path.resolve(resolvedRoot, `.${decodedPathname}`); return requestedFile.startsWith(`${resolvedRoot}${path.sep}`) ? requestedFile : null; }) .filter((candidateFile): candidateFile is string => candidateFile !== null); for (const candidateFile of candidateFiles) { try { await fs.access(candidateFile); next(); return; } catch { // Try the next Vite static root. } } throw new Error("Missing reserved preview file"); } catch { res.writeHead(404); res.end("Not found"); } } return { name: "openpress-local-deploy-endpoint", configureServer(server: { middlewares: LocalApiMiddlewares; ws: { send: (payload: unknown) => void }; }) { // Wrap server.ws.send so we can veto any full-reload signal (from Vite // core, the React plugin, MDX/glob invalidation, etc.) that fires while // a local source mutation is in flight. The calling UI already reconciles // client-side via the response; a full reload from another code path // would otherwise blow away in-flight review state. const originalSend = server.ws.send.bind(server.ws); server.ws.send = ((payload: unknown) => { const payloadType = payload && typeof payload === "object" && "type" in payload ? (payload as { type?: unknown }).type : undefined; if (payloadType === "full-reload") { if (shouldSuppressForSourceMutation()) { console.log("[Vite] Suppressing full-reload while a local source mutation is in flight"); return; } console.log("[Vite] ws.send full-reload (NOT suppressed)"); } return originalSend(payload); }) as typeof originalSend; installLocalApiMiddlewares(server.middlewares, { trackMutations: true, includeReview: true, staticRoots: [path.join(workspaceRoot, "public"), openpressConfig.paths.outputDir], }); }, configurePreviewServer(server: { middlewares: LocalApiMiddlewares; }) { installLocalApiMiddlewares(server.middlewares, { trackMutations: false, includeReview: false, staticRoots: [openpressConfig.paths.outputDir], }); }, async handleHotUpdate({ file, server }: { file: string; server: { ws: { send: (payload: unknown) => void } } }) { const inDocumentRoot = file.startsWith(reactDocumentRoot + path.sep) || file === reactDocumentRoot; const inContentDir = file.startsWith(activeContentDir + path.sep) || file === activeContentDir; const inPublicOpenpressDir = file.startsWith(openpressConfig.paths.publicDir + path.sep) || file === openpressConfig.paths.publicDir; const inOutputDir = file.startsWith(openpressConfig.paths.outputDir + path.sep) || file === openpressConfig.paths.outputDir; // Only react to changes inside the press/document directory. if (!inDocumentRoot && !inContentDir) { if (inPublicOpenpressDir || inOutputDir || file.includes("/.deploy/")) { return []; // Suppress Vite's default full-reload for generated document/output files. } console.log(`[Vite] Falling back to Vite default HMR for outside file: ${file}`); return; } // Skip when a local endpoint already reconciles the mutation client-side. if (shouldSuppressForSourceMutation()) { console.log(`[Vite] Suppressing HMR for document file during a local source mutation: ${file}`); return []; } console.log(`[Vite] Triggering HMR full-reload for document file: ${file}`); if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { if (exporting) return; exporting = true; try { const result = await runIsolatedDocumentExport(workspaceRoot); if (result.code !== 0) { console.error(`[OpenPress] HMR export failed:\n${result.stderr || result.stdout}`); } } catch { // Export failure must not crash the dev server. } finally { exporting = false; } server.ws.send({ type: "full-reload" }); }, 300); return []; // Suppress Vite's premature HMR until our export finishes. }, }; } function cssRelativePath(fromDir: string, toPath: string) { const relativePath = path.relative(fromDir, toPath).replaceAll("\\", "/"); if (relativePath.startsWith(".")) return relativePath; return `./${relativePath}`; } async function handleLocalMediaUploadRequest(req: IncomingMessage, res: ServerResponse) { if (req.method !== "POST") { writeJson(res, 405, { ok: false, message: "Media upload endpoint requires POST." }); return; } const rawFileName = headerValue(req.headers["x-openpress-file-name"]); const decodedFileName = rawFileName ? safeDecodeURIComponent(rawFileName) : ""; const fileName = sanitizeMediaFileName(decodedFileName); if (!fileName) { writeJson(res, 400, { ok: false, message: "Media upload requires a valid file name." }); return; } if (!isAllowedMediaFile(fileName)) { writeJson(res, 400, { ok: false, message: "Only png, jpg, jpeg, gif, svg, and webp files can be uploaded." }); return; } try { const body = await readRequestBuffer(req, 30 * 1024 * 1024); if (body.length === 0) { writeJson(res, 400, { ok: false, message: "Uploaded media file is empty." }); return; } await fs.mkdir(openpressConfig.paths.mediaDir, { recursive: true }); const uniqueFileName = await uniqueMediaFileName(openpressConfig.paths.mediaDir, fileName); const targetPath = path.join(openpressConfig.paths.mediaDir, uniqueFileName); await fs.writeFile(targetPath, body); const relativePath = relativeFromWorkspace(targetPath); writeJson(res, 200, { ok: true, asset: { fileName: uniqueFileName, src: `/openpress/media/${encodeURIComponent(uniqueFileName)}`, path: relativePath, mention: `@media/${uniqueFileName}`, }, }); } catch (error) { writeJson(res, 500, { ok: false, message: error instanceof Error ? error.message : String(error) }); } } async function handleLocalMediaFileRequest(req: IncomingMessage, res: ServerResponse) { if (req.method !== "GET" && req.method !== "HEAD") { writeJson(res, 405, { ok: false, message: "Media file endpoint requires GET." }); return; } try { const requestUrl = new URL(req.url ?? "/", "http://localhost"); const fileName = sanitizeMediaFileName(safeDecodeURIComponent(requestUrl.pathname.replace(/^\/openpress\/media\/?/, "").replace(/^\/+/, ""))); if (!fileName) { writeJson(res, 404, { ok: false, message: "Media file not found." }); return; } const mediaPath = await findLocalMediaFile(fileName); if (!mediaPath) { writeJson(res, 404, { ok: false, message: "Media file not found." }); return; } const body = await fs.readFile(mediaPath); res.writeHead(200, { "Content-Type": mediaMimeType(fileName), "Cache-Control": "no-store", }); if (req.method === "HEAD") { res.end(); } else { res.end(body); } } catch { writeJson(res, 404, { ok: false, message: "Media file not found." }); } } async function handleLocalPdfExportRequest(req: IncomingMessage, res: ServerResponse) { if (req.method !== "POST") { writeJson(res, 405, { ok: false, message: "Local PDF export endpoint requires POST." }); return; } const body = await readJsonRequestBody(req); const slug = normalizeDeployPressSlug(body?.press); const pages = parsePageIndexes(body?.pages); const result = await runLocalPdfExport(slug, pages ?? undefined); const pdfPath = pressPdfAbsolutePath(slug); const exists = await fileExists(pdfPath); const cliArgs = buildPdfCliArgs(slug, pages); const pdfUrl = `/__openpress/local-pdf-file?${slug ? `press=${encodeURIComponent(slug)}&` : ""}ts=${Date.now()}`; writeJson(res, result.code === 0 && exists ? 200 : 500, { ok: result.code === 0 && exists, code: result.code, pdf: pdfUrl, command: openpressCliCommand(cliArgs), stdout: result.stdout, stderr: result.stderr, }); } async function handleLocalPdfFileRequest(req: IncomingMessage, res: ServerResponse) { if (req.method !== "GET") { writeJson(res, 405, { ok: false, message: "Local PDF file endpoint requires GET." }); return; } const requestUrl = new URL(req.url ?? "/", "http://localhost"); const slug = normalizeDeployPressSlug(requestUrl.searchParams.get("press")); const pdfPath = pressPdfAbsolutePath(slug); const filename = pressSuffixedFilename(openpressConfig.pdf.filename, slug); try { const body = await fs.readFile(pdfPath); res.writeHead(200, { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${filename}"`, "Cache-Control": "no-store", }); res.end(body); } catch { writeJson(res, 404, { ok: false, message: "Local PDF has not been generated yet." }); } } async function handleLocalWordExportRequest(req: IncomingMessage, res: ServerResponse) { if (req.method !== "POST") { writeJson(res, 405, { ok: false, message: "Local Word export endpoint requires POST." }); return; } const body = await readJsonRequestBody(req); const slug = normalizeDeployPressSlug(body?.press); const mode = normalizeWordMode(body?.mode); const pages = mode === "visual" ? parsePageIndexes(body?.pages) : null; const result = await runLocalWordExport(slug, mode, pages ?? undefined); const wordPath = pressWordAbsolutePath(slug); const exists = await fileExists(wordPath); const cliArgs = buildWordCliArgs(slug, mode, pages); const wordUrl = `/__openpress/local-word-file?${slug ? `press=${encodeURIComponent(slug)}&` : ""}ts=${Date.now()}`; writeJson(res, result.code === 0 && exists ? 200 : 500, { ok: result.code === 0 && exists, code: result.code, word: wordUrl, command: openpressCliCommand(cliArgs), stdout: result.stdout, stderr: result.stderr, }); } async function handleLocalWordFileRequest(req: IncomingMessage, res: ServerResponse) { if (req.method !== "GET") { writeJson(res, 405, { ok: false, message: "Local Word file endpoint requires GET." }); return; } const requestUrl = new URL(req.url ?? "/", "http://localhost"); const slug = normalizeDeployPressSlug(requestUrl.searchParams.get("press")); const wordPath = pressWordAbsolutePath(slug); const filename = pressWordFilename(slug); try { const body = await fs.readFile(wordPath); res.writeHead(200, { "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Content-Disposition": `attachment; filename="${filename}"`, "Cache-Control": "no-store", }); res.end(body); } catch { writeJson(res, 404, { ok: false, message: "Local Word document has not been generated yet." }); } } function normalizeWordMode(value: unknown): "visual" | "semantic" { return value === "semantic" ? "semantic" : "visual"; } function pressPdfAbsolutePath(slug: string): string { return path.join(openpressConfig.outputDir, pressSuffixedFilename(openpressConfig.pdf.filename, slug)); } function pressWordFilename(slug: string): string { return pressSuffixedFilename(wordFilenameFromPdfFilename(openpressConfig.pdf.filename), slug); } function pressWordAbsolutePath(slug: string): string { return path.join(openpressConfig.outputDir, pressWordFilename(slug)); } async function readJsonRequestBody(req: IncomingMessage): Promise<{ press?: unknown; pages?: unknown; mode?: unknown } | null> { try { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)); } if (chunks.length === 0) return null; const text = Buffer.concat(chunks).toString("utf8"); if (!text.trim()) return null; return JSON.parse(text); } catch { return null; } } async function handleLocalSearchRequest(req: IncomingMessage, res: ServerResponse) { if (req.method !== "GET") { writeJson(res, 405, { ok: false, message: "Search endpoint requires GET." }); return; } const requestUrl = new URL(req.url ?? "/", "http://localhost"); const query = (requestUrl.searchParams.get("q") ?? "").trim(); if (!query) { writeJson(res, 400, { ok: false, message: "Search query is required." }); return; } try { const report = await searchSourceText({ config: openpressConfig, query, scope: searchScopeFrom(requestUrl.searchParams), caseSensitive: requestUrl.searchParams.get("caseSensitive") === "true", }); writeJson(res, 200, { ok: true, ...report }); } catch (error) { writeJson(res, 500, { ok: false, message: error instanceof Error ? error.message : String(error) }); } } function openpressCliCommand(args: string[]) { return `open-press ${args.join(" ")}`; } function buildPdfCliArgs(slug: string, pages: number[] | null): string[] { const args = ["pdf", "."]; if (slug) args.push("--press", slug); if (pages && pages.length > 0) args.push("--pages", pages.join(",")); return args; } function buildWordCliArgs(slug: string, mode: "visual" | "semantic", pages: number[] | null): string[] { const args = ["word", "."]; if (mode === "visual") args.push("--visual"); if (slug) args.push("--press", slug); if (mode === "visual" && pages && pages.length > 0) args.push("--pages", pageIndexesToSelector(pages)); return args; } function parsePageIndexes(value: unknown): number[] | null { if (!Array.isArray(value)) return null; const indexes = value.filter((v) => Number.isInteger(v) && v >= 0) as number[]; return indexes.length > 0 ? indexes : null; } function pageIndexesToSelector(indexes: number[]): string { return indexes.map((index) => String(index + 1)).join(","); } function runLocalPdfExport(slug = "", pages?: number[]) { const args = [openpressCliPath, "pdf", "."]; if (slug) args.push("--press", slug); if (pages && pages.length > 0) args.push("--pages", pages.join(",")); return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { const child = spawn("node", args, { cwd: workspaceRoot, shell: false, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (error) => { resolve({ code: 1, stdout, stderr: `${stderr}${error.message}\n` }); }); child.on("close", (code) => { resolve({ code: code ?? 1, stdout, stderr }); }); }); } function runLocalWordExport(slug = "", mode: "visual" | "semantic" = "visual", pages?: number[]) { const args = [openpressCliPath, "word", "."]; if (mode === "visual") args.push("--visual"); if (slug) args.push("--press", slug); if (mode === "visual" && pages && pages.length > 0) args.push("--pages", pageIndexesToSelector(pages)); return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { const child = spawn("node", args, { cwd: workspaceRoot, shell: false, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (error) => { resolve({ code: 1, stdout, stderr: `${stderr}${error.message}\n` }); }); child.on("close", (code) => { resolve({ code: code ?? 1, stdout, stderr }); }); }); } function searchScopeFrom(searchParams: URLSearchParams) { return searchParams.get("scope") === "all" ? "all" : "content"; } async function fileExists(filePath: string) { try { await fs.access(filePath); return true; } catch { return false; } } function headerValue(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } function safeDecodeURIComponent(value: string) { try { return decodeURIComponent(value); } catch { return value; } } function sanitizeMediaFileName(value: string) { const baseName = path.basename(value).trim(); if (!baseName) return ""; const ext = path.extname(baseName); const stem = path.basename(baseName, ext) .replace(/[\\/:*?"<>|#%{}^~[\]`]/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); if (!stem || !ext) return ""; return `${stem}${ext.toLowerCase()}`; } function isAllowedMediaFile(fileName: string) { return /\.(png|jpe?g|gif|svg|webp)$/i.test(fileName); } function mediaMimeType(fileName: string) { const ext = path.extname(fileName).toLowerCase(); if (ext === ".png") return "image/png"; if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; if (ext === ".gif") return "image/gif"; if (ext === ".svg") return "image/svg+xml"; if (ext === ".webp") return "image/webp"; return "application/octet-stream"; } async function uniqueMediaFileName(mediaDir: string, fileName: string) { const ext = path.extname(fileName); const stem = path.basename(fileName, ext); let candidate = fileName; let counter = 2; while (await fileExists(path.join(mediaDir, candidate))) { candidate = `${stem}-${counter}${ext}`; counter += 1; } return candidate; } async function findLocalMediaFile(fileName: string): Promise { for (const mediaRoot of await collectLocalMediaRoots()) { const resolvedRoot = path.resolve(mediaRoot); const candidate = path.resolve(mediaRoot, fileName); if (!isInsideRoot(candidate, resolvedRoot)) continue; if (await fileExists(candidate)) return candidate; } return null; } async function collectLocalMediaRoots(): Promise { const roots = [ openpressConfig.paths.mediaDir, path.join(openpressConfig.paths.publicDir, "media"), ]; try { const entries = await fs.readdir(openpressConfig.paths.documentRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "shared") continue; roots.push(path.join(openpressConfig.paths.documentRoot, entry.name, "media")); } } catch { // Missing press/ is handled by the render/validate commands. } return uniquePaths(roots); } function uniquePaths(paths: string[]): string[] { const out: string[] = []; const seen = new Set(); for (const candidate of paths) { const normalized = path.resolve(candidate); if (seen.has(normalized)) continue; seen.add(normalized); out.push(normalized); } return out; } function isInsideRoot(candidate: string, rootDir: string): boolean { const relative = path.relative(rootDir, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function readRequestBuffer(req: IncomingMessage, maxBytes: number) { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let total = 0; req.on("data", (chunk: Buffer | string) => { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); total += buffer.length; if (total > maxBytes) { reject(new Error("Uploaded media file is too large.")); req.destroy(); return; } chunks.push(buffer); }); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); }); } function writeJson(res: ServerResponse, status: number, body: unknown) { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(`${JSON.stringify(body, null, 2)}\n`); }