feat: 实现网页实时预览

This commit is contained in:
SkyJourney
2026-07-25 21:52:45 +08:00
parent 7224dfdf60
commit fa07472428
16 changed files with 2860 additions and 218 deletions
+149
View File
@@ -0,0 +1,149 @@
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import Fastify from "fastify";
import {
EXPORT_CONFIG_VERSION,
defaultExportConfig,
supportedPaperFormats
} from "@md-to-pdf/core";
import { RENDERER_VERSION, renderMarkdown } from "@md-to-pdf/renderer";
import { createThemeRegistry } from "./theme-registry.js";
interface RenderRequestBody {
markdown?: unknown;
language?: unknown;
}
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
export interface BuildAppOptions {
localThemeRoot?: string;
logger?: boolean;
}
export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
logger: options.logger ?? true,
bodyLimit: 2 * 1024 * 1024
});
const themes = createThemeRegistry({
bundledRoot: resolve(projectRoot, "themes"),
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes")
});
app.get("/api/health", async () => ({
status: "ok",
service: "md-to-pdf",
configVersion: EXPORT_CONFIG_VERSION,
rendererVersion: RENDERER_VERSION
}));
app.get("/api/capabilities", async () => ({
defaultExportConfig,
supportedPaperFormats,
implemented: [
"project-skeleton",
"export-config",
"theme-manifest",
"markdown-render",
"html-preview"
],
planned: ["pdf-export"]
}));
app.get("/api/themes", async () => ({
themes: (await themes.list()).map(({ manifest, source }) => ({
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
bundled: manifest.bundled,
source
}))
}));
app.get<{ Params: { themeId: string } }>(
"/api/themes/:themeId/css",
async (request, reply) => {
const { themeId } = request.params;
const css = await themes.getCss(themeId);
if (css === undefined) {
return reply.code(404).send({
error: "THEME_NOT_FOUND",
message: "未找到指定主题"
});
}
return reply
.header("content-type", "text/css; charset=utf-8")
.header("cache-control", "public, max-age=300")
.send(css);
}
);
app.get<{ Params: { themeId: string; "*": string } }>(
"/api/themes/:themeId/assets/*",
async (request, reply) => {
try {
const asset = await themes.getAsset(
request.params.themeId,
request.params["*"]
);
if (!asset) {
return reply.code(404).send({
error: "THEME_NOT_FOUND",
message: "未找到指定主题"
});
}
return reply
.header("content-type", asset.contentType)
.header("cache-control", "public, max-age=300")
.header("content-security-policy", "default-src 'none'; sandbox")
.header("x-content-type-options", "nosniff")
.send(asset.content);
} catch {
return reply.code(404).send({
error: "THEME_ASSET_NOT_FOUND",
message: "未找到指定主题资源"
});
}
}
);
app.post<{ Body: RenderRequestBody }>(
"/api/render",
async (request, reply) => {
const { markdown, language } = request.body ?? {};
if (typeof markdown !== "string") {
return reply.code(400).send({
error: "INVALID_MARKDOWN",
message: "markdown 必须是字符串"
});
}
if (markdown.length > 1_500_000) {
return reply.code(413).send({
error: "MARKDOWN_TOO_LARGE",
message: "Markdown 内容不能超过 1.5 MB"
});
}
if (language !== undefined && typeof language !== "string") {
return reply.code(400).send({
error: "INVALID_LANGUAGE",
message: "language 必须是字符串"
});
}
return renderMarkdown(markdown, {
...(typeof language === "string" ? { language } : {})
});
}
);
return app;
}