247 lines
6.1 KiB
TypeScript
247 lines
6.1 KiB
TypeScript
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
|
|
};
|
|
}
|