import { realpathSync } from "fs"; import path from "path"; import { Router, Request, Response } from "express"; import { marked } from "marked"; import { renderMarpDeck } from "@mulmoclaude/markdown-plugin"; import { listMarpThemes } from "../../workspace/marp-themes.js"; import puppeteer from "puppeteer"; import { errorMessage } from "../../utils/errors.js"; import { badRequest, serverError } from "../../utils/httpError.js"; import { WORKSPACE_DIRS } from "../../workspace/paths.js"; import { resolveWithinRoot, readBinarySafeSync } from "../../utils/files/safe.js"; import { resolveWorkspacePath } from "../../utils/files/workspace-io.js"; import { parseFrontmatter } from "@mulmoclaude/markdown-utils/markdown/frontmatter"; import { log } from "../../system/logger/index.js"; import { API_ROUTES } from "../../../src/config/apiRoutes.js"; import { transformResolvableUrlsInHtml } from "@mulmoclaude/markdown-utils/image/htmlSrcAttrs"; const router = Router(); const MARKDOWN_CSS = ` body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, "Noto Sans CJK JP", "Noto Sans JP", sans-serif; font-size: 13px; line-height: 1.6; color: #1f2937; max-width: 800px; margin: 0 auto; padding: 32px 48px; } h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.75rem; color: #111827; } h2 { font-size: 1.25rem; font-weight: 600; margin: 1.5rem 0 0.5rem; color: #1f2937; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; } h3 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #374151; } p { margin: 0 0 0.75rem; } ul, ol { margin: 0 0 0.75rem 1.5rem; } li { margin-bottom: 0.2rem; } ul { list-style-type: disc; } ol { list-style-type: decimal; } code { background: #f3f4f6; padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.85em; font-family: monospace; } pre { background: #f3f4f6; padding: 0.75rem; border-radius: 0.375rem; overflow-x: auto; margin: 0 0 0.75rem; } pre code { background: none; padding: 0; } blockquote { border-left: 3px solid #d1d5db; padding-left: 1rem; color: #6b7280; margin: 0.75rem 0; } hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.25rem 0; } table { border-collapse: collapse; width: 100%; margin: 0 0 0.75rem; font-size: 0.875rem; } th, td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; } th { background: #f9fafb; font-weight: 600; } strong { font-weight: 600; } a { color: #2563eb; } img { max-width: 100%; height: auto; } `; const MIME_BY_EXT: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", }; // Realpath of the workspace, resolved once at module load. Used to // validate that image paths resolved relative to markdowns/ stay // inside the workspace after symlink resolution. const defaultWorkspaceRoot = realpathSync(resolveWorkspacePath("")); export interface InlineImagesOptions { /** Workspace root absolute path. Defaults to the lazily-resolved * realpath of the configured workspace. */ workspaceRoot?: string; /** Workspace-relative directory the markdown source lives in, * used to resolve `../foo.png`-style references. e.g. * `"data/wiki/pages"` for Wiki page PDFs. Defaults to * `WORKSPACE_DIRS.markdowns` for legacy callers. Inputs are * rejected if they're absolute or contain `..` segments — the * workspace boundary is enforced anyway by `resolveWithinRoot`, * but rejecting up-front gives a clearer log line than a * silently-broken image. */ sourceDir?: string | undefined; } // Tag scanning + attribute iteration live in the shared helper // (`src/utils/image/htmlSrcAttrs.ts`) so the Markdown surface // (`rewriteImgSrcAttrsInHtml`) and this PDF surface stay in lockstep. // Adding a tag / attribute (Stage B's `` / `