feat: 扩展本地主题兼容能力

This commit is contained in:
SkyJourney
2026-07-26 00:26:43 +08:00
parent fa07472428
commit ec14f7970a
7 changed files with 769 additions and 130 deletions
+107 -14
View File
@@ -7,6 +7,7 @@ import {
import {
extname,
isAbsolute,
posix,
relative,
resolve,
sep
@@ -39,6 +40,10 @@ const assetContentTypes: Record<string, string> = {
".woff2": "font/woff2"
};
const cssImportPattern =
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
const maximumCssImportDepth = 8;
function isMissingFileError(error: unknown) {
return (
error instanceof Error &&
@@ -119,6 +124,9 @@ async function readThemeRoot(
throw new Error("主题 bundled 标记与来源不一致");
}
if (manifest.base) {
await resolveThemeFile(directory, manifest.base);
}
await resolveThemeFile(directory, manifest.entry);
if (manifest.print) {
await resolveThemeFile(directory, manifest.print);
@@ -145,13 +153,12 @@ function encodeAssetPath(path: string) {
.join("/");
}
function prepareThemeCss(css: string, themeId: string) {
const withoutTyporaExportIncludes = css.replace(
/^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim,
""
);
return withoutTyporaExportIncludes.replace(
function prepareCssSegment(
css: string,
themeId: string,
cssDirectory: string
) {
return css.replace(
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
(match, _quote: string, rawValue: string) => {
const value = rawValue.trim();
@@ -171,11 +178,96 @@ function prepareThemeCss(css: string, themeId: string) {
throw new Error(`主题资源路径不安全:${value}`);
}
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(normalized)}")`;
const assetPath = posix.normalize(posix.join(cssDirectory, normalized));
if (
assetPath === ".." ||
assetPath.startsWith("../") ||
assetPath.startsWith("/")
) {
throw new Error(`主题资源路径越界:${value}`);
}
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}")`;
}
);
}
function readImportedPath(match: RegExpMatchArray) {
return match.slice(1).find((value): value is string => value !== undefined);
}
function normalizeCssPath(relativePath: string) {
const normalized = relativePath.replaceAll("\\", "/");
if (
normalized.startsWith("/") ||
normalized.split("/").includes("..") ||
/^[a-z][a-z0-9+.-]*:/i.test(normalized)
) {
throw new Error(`主题 CSS 路径不安全:${relativePath}`);
}
return posix.normalize(normalized.replace(/^\.\//, ""));
}
async function loadThemeCssFile(
theme: ThemeRecord,
relativePath: string,
importStack: string[] = []
): Promise<string> {
const normalizedPath = normalizeCssPath(relativePath);
if (importStack.includes(normalizedPath)) {
throw new Error(
`主题 CSS 存在循环引用:${[...importStack, normalizedPath].join(" -> ")}`
);
}
if (importStack.length >= maximumCssImportDepth) {
throw new Error(`主题 CSS 导入层级超过 ${maximumCssImportDepth}`);
}
const cssPath = await resolveThemeFile(theme.directory, normalizedPath);
const css = (await readFile(cssPath, "utf8")).replace(
/^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim,
""
);
const cssDirectory = posix.dirname(normalizedPath);
const matches = [...css.matchAll(cssImportPattern)];
let result = "";
let previousEnd = 0;
for (const match of matches) {
const matchStart = match.index ?? 0;
result += prepareCssSegment(
css.slice(previousEnd, matchStart),
theme.manifest.id,
cssDirectory
);
const importedPath = readImportedPath(match);
if (!importedPath) {
throw new Error(`无法解析主题 CSS 导入:${match[0]}`);
}
const normalizedImport = normalizeCssPath(importedPath);
const importPath = posix.normalize(posix.join(cssDirectory, normalizedImport));
result += await loadThemeCssFile(theme, importPath, [
...importStack,
normalizedPath
]);
previousEnd = matchStart + match[0].length;
}
const remainder = css.slice(previousEnd);
result += prepareCssSegment(
remainder,
theme.manifest.id,
cssDirectory
);
if (/@import\b/i.test(result)) {
throw new Error("主题包含不支持的 CSS @import 语法");
}
return result;
}
export function createThemeRegistry(options: ThemeRegistryOptions) {
async function list() {
const [bundledThemes, localThemes] = await Promise.all([
@@ -206,16 +298,17 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
}
const cssFiles = [
await resolveThemeFile(theme.directory, theme.manifest.entry),
...(theme.manifest.base ? [theme.manifest.base] : []),
theme.manifest.entry,
...(theme.manifest.print
? [await resolveThemeFile(theme.directory, theme.manifest.print)]
? [theme.manifest.print]
: [])
];
const css = (
await Promise.all(cssFiles.map((path) => readFile(path, "utf8")))
return (
await Promise.all(
cssFiles.map((path) => loadThemeCssFile(theme, path))
)
).join("\n");
return prepareThemeCss(css, themeId);
}
async function getAsset(themeId: string, assetPath: string) {