feat: 实现网页实时预览
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -1,36 +1,8 @@
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
EXPORT_CONFIG_VERSION,
|
||||
defaultExportConfig,
|
||||
supportedPaperFormats
|
||||
} from "@md-to-pdf/core";
|
||||
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
|
||||
const app = Fastify({
|
||||
logger: true
|
||||
});
|
||||
|
||||
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"
|
||||
],
|
||||
planned: ["html-preview", "pdf-export"]
|
||||
}));
|
||||
const app = buildApp();
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
stat
|
||||
} from "node:fs/promises";
|
||||
import {
|
||||
extname,
|
||||
isAbsolute,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from "node:path";
|
||||
import {
|
||||
themeManifestSchema,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface ThemeRecord {
|
||||
manifest: ThemeManifest;
|
||||
directory: string;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
export interface ThemeRegistryOptions {
|
||||
bundledRoot: string;
|
||||
localRoot: string;
|
||||
}
|
||||
|
||||
const assetContentTypes: Record<string, string> = {
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".ttf": "font/ttf",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2"
|
||||
};
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideDirectory(directory: string, path: string) {
|
||||
const pathFromDirectory = relative(directory, path);
|
||||
return (
|
||||
pathFromDirectory === "" ||
|
||||
(!pathFromDirectory.startsWith(`..${sep}`) &&
|
||||
pathFromDirectory !== ".." &&
|
||||
!isAbsolute(pathFromDirectory))
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveThemeFile(directory: string, relativePath: string) {
|
||||
if (
|
||||
isAbsolute(relativePath) ||
|
||||
relativePath.includes("\0") ||
|
||||
relativePath.split(/[\\/]/).includes("..")
|
||||
) {
|
||||
throw new Error("主题资源路径不安全");
|
||||
}
|
||||
|
||||
const realDirectory = await realpath(directory);
|
||||
const candidate = resolve(realDirectory, relativePath);
|
||||
if (!isInsideDirectory(realDirectory, candidate)) {
|
||||
throw new Error("主题资源路径越界");
|
||||
}
|
||||
|
||||
const realCandidate = await realpath(candidate);
|
||||
if (!isInsideDirectory(realDirectory, realCandidate)) {
|
||||
throw new Error("主题资源符号链接越界");
|
||||
}
|
||||
|
||||
const fileStat = await stat(realCandidate);
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error("主题资源不是文件");
|
||||
}
|
||||
|
||||
return realCandidate;
|
||||
}
|
||||
|
||||
async function readThemeRoot(
|
||||
root: string,
|
||||
source: ThemeRecord["source"]
|
||||
): Promise<ThemeRecord[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error) && source === "local") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const records: ThemeRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const directory = resolve(root, entry.name);
|
||||
try {
|
||||
const manifestPath = await resolveThemeFile(directory, "theme.json");
|
||||
const manifest = themeManifestSchema.parse(
|
||||
JSON.parse(await readFile(manifestPath, "utf8"))
|
||||
);
|
||||
|
||||
if (manifest.id !== entry.name) {
|
||||
throw new Error("主题目录名必须与主题 ID 一致");
|
||||
}
|
||||
if (manifest.bundled !== (source === "bundled")) {
|
||||
throw new Error("主题 bundled 标记与来源不一致");
|
||||
}
|
||||
|
||||
await resolveThemeFile(directory, manifest.entry);
|
||||
if (manifest.print) {
|
||||
await resolveThemeFile(directory, manifest.print);
|
||||
}
|
||||
|
||||
records.push({
|
||||
manifest,
|
||||
directory,
|
||||
source
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`主题 ${entry.name} 无效:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function encodeAssetPath(path: string) {
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function prepareThemeCss(css: string, themeId: string) {
|
||||
const withoutTyporaExportIncludes = css.replace(
|
||||
/^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim,
|
||||
""
|
||||
);
|
||||
|
||||
return withoutTyporaExportIncludes.replace(
|
||||
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
|
||||
(match, _quote: string, rawValue: string) => {
|
||||
const value = rawValue.trim();
|
||||
if (value.startsWith("data:") || value.startsWith("#")) {
|
||||
return match;
|
||||
}
|
||||
if (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
||||
) {
|
||||
throw new Error(`主题包含不允许的外部资源:${value}`);
|
||||
}
|
||||
|
||||
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
if (normalized.split("/").includes("..")) {
|
||||
throw new Error(`主题资源路径不安全:${value}`);
|
||||
}
|
||||
|
||||
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(normalized)}")`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
async function list() {
|
||||
const [bundledThemes, localThemes] = await Promise.all([
|
||||
readThemeRoot(options.bundledRoot, "bundled"),
|
||||
readThemeRoot(options.localRoot, "local")
|
||||
]);
|
||||
|
||||
const themes = [...bundledThemes, ...localThemes];
|
||||
const themeIds = new Set<string>();
|
||||
for (const theme of themes) {
|
||||
if (themeIds.has(theme.manifest.id)) {
|
||||
throw new Error(`主题 ID 重复:${theme.manifest.id}`);
|
||||
}
|
||||
themeIds.add(theme.manifest.id);
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
async function get(themeId: string) {
|
||||
return (await list()).find((theme) => theme.manifest.id === themeId);
|
||||
}
|
||||
|
||||
async function getCss(themeId: string) {
|
||||
const theme = await get(themeId);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cssFiles = [
|
||||
await resolveThemeFile(theme.directory, theme.manifest.entry),
|
||||
...(theme.manifest.print
|
||||
? [await resolveThemeFile(theme.directory, theme.manifest.print)]
|
||||
: [])
|
||||
];
|
||||
const css = (
|
||||
await Promise.all(cssFiles.map((path) => readFile(path, "utf8")))
|
||||
).join("\n");
|
||||
|
||||
return prepareThemeCss(css, themeId);
|
||||
}
|
||||
|
||||
async function getAsset(themeId: string, assetPath: string) {
|
||||
const theme = await get(themeId);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const extension = extname(assetPath).toLowerCase();
|
||||
const contentType = assetContentTypes[extension];
|
||||
if (!contentType) {
|
||||
throw new Error("不支持的主题资源类型");
|
||||
}
|
||||
|
||||
const path = await resolveThemeFile(theme.directory, assetPath);
|
||||
return {
|
||||
contentType,
|
||||
content: await readFile(path)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
getAsset,
|
||||
getCss,
|
||||
list
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user