feat: 发布 v0.4.0 桌面端
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@md-to-pdf/application",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
MarkdownDocumentParseError,
|
||||
renderMarkdown
|
||||
} from "@md-to-pdf/renderer";
|
||||
import {
|
||||
createThemeRegistry,
|
||||
type ThemeRegistryOptions
|
||||
} from "./theme-registry.js";
|
||||
|
||||
export const MAXIMUM_MARKDOWN_LENGTH = 1_500_000;
|
||||
|
||||
export interface MarkdownRenderRequest {
|
||||
markdown?: unknown;
|
||||
language?: unknown;
|
||||
}
|
||||
|
||||
export class ApplicationRequestError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: 400 | 404 | 413,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApplicationRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplicationServiceOptions extends ThemeRegistryOptions {}
|
||||
|
||||
export function createApplicationService(
|
||||
options: ApplicationServiceOptions
|
||||
) {
|
||||
const themes = createThemeRegistry(options);
|
||||
|
||||
function render(request: MarkdownRenderRequest) {
|
||||
const { markdown, language } = request;
|
||||
if (typeof markdown !== "string") {
|
||||
throw new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_MARKDOWN",
|
||||
"markdown 必须是字符串"
|
||||
);
|
||||
}
|
||||
if (markdown.length > MAXIMUM_MARKDOWN_LENGTH) {
|
||||
throw new ApplicationRequestError(
|
||||
413,
|
||||
"MARKDOWN_TOO_LARGE",
|
||||
"Markdown 内容不能超过 1.5 MB"
|
||||
);
|
||||
}
|
||||
if (language !== undefined && typeof language !== "string") {
|
||||
throw new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_LANGUAGE",
|
||||
"language 必须是字符串"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return renderMarkdown(markdown, {
|
||||
...(language ? { language } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownDocumentParseError) {
|
||||
throw new ApplicationRequestError(
|
||||
400,
|
||||
error.code,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listThemes() {
|
||||
return {
|
||||
themes: (await themes.list()).map(({ manifest, source }) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
bundled: manifest.bundled,
|
||||
source
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getThemeAsset: themes.getAsset,
|
||||
getThemeCss: themes.getCss,
|
||||
invalidateThemes: themes.invalidate,
|
||||
listThemes,
|
||||
render
|
||||
};
|
||||
}
|
||||
|
||||
export type ApplicationService = ReturnType<
|
||||
typeof createApplicationService
|
||||
>;
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
ApplicationRequestError,
|
||||
MAXIMUM_MARKDOWN_LENGTH,
|
||||
createApplicationService,
|
||||
type ApplicationService,
|
||||
type ApplicationServiceOptions,
|
||||
type MarkdownRenderRequest
|
||||
} from "./application-service.js";
|
||||
export {
|
||||
createThemeRegistry,
|
||||
type ThemeRecord,
|
||||
type ThemeRegistryOptions
|
||||
} from "./theme-registry.js";
|
||||
@@ -0,0 +1,406 @@
|
||||
import {
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
stat
|
||||
} from "node:fs/promises";
|
||||
import {
|
||||
extname,
|
||||
isAbsolute,
|
||||
posix,
|
||||
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;
|
||||
onWarning?: (message: string) => void;
|
||||
cacheTtlMs?: number;
|
||||
createAssetUrl?: (themeId: string, assetPath: string) => 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"
|
||||
};
|
||||
|
||||
const cssImportPattern =
|
||||
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
|
||||
const maximumCssImportDepth = 8;
|
||||
|
||||
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"],
|
||||
onWarning?: (message: string) => void
|
||||
): 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 标记与来源不一致");
|
||||
}
|
||||
|
||||
if (manifest.base) {
|
||||
await resolveThemeFile(directory, manifest.base);
|
||||
}
|
||||
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);
|
||||
if (source === "local") {
|
||||
onWarning?.(`已忽略无效本地主题 ${entry.name}:${message}`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`主题 ${entry.name} 无效:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function encodeAssetPath(path: string) {
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function prepareCssSegment(
|
||||
css: string,
|
||||
themeId: string,
|
||||
cssDirectory: string,
|
||||
createAssetUrl: (themeId: string, assetPath: string) => string
|
||||
) {
|
||||
return css.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}`);
|
||||
}
|
||||
|
||||
const assetPath = posix.normalize(posix.join(cssDirectory, normalized));
|
||||
if (
|
||||
assetPath === ".." ||
|
||||
assetPath.startsWith("../") ||
|
||||
assetPath.startsWith("/")
|
||||
) {
|
||||
throw new Error(`主题资源路径越界:${value}`);
|
||||
}
|
||||
|
||||
return `url("${createAssetUrl(themeId, 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,
|
||||
createAssetUrl: (themeId: string, assetPath: string) => 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,
|
||||
createAssetUrl
|
||||
);
|
||||
|
||||
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,
|
||||
createAssetUrl,
|
||||
[...importStack, normalizedPath]
|
||||
);
|
||||
previousEnd = matchStart + match[0].length;
|
||||
}
|
||||
|
||||
const remainder = css.slice(previousEnd);
|
||||
result += prepareCssSegment(
|
||||
remainder,
|
||||
theme.manifest.id,
|
||||
cssDirectory,
|
||||
createAssetUrl
|
||||
);
|
||||
if (/@import\b/i.test(result)) {
|
||||
throw new Error("主题包含不支持的 CSS @import 语法");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
const createAssetUrl =
|
||||
options.createAssetUrl ??
|
||||
((themeId: string, assetPath: string) =>
|
||||
`/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}`);
|
||||
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
|
||||
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
|
||||
throw new Error("主题缓存有效期必须是非负有限数值");
|
||||
}
|
||||
let cachedThemes:
|
||||
| {
|
||||
expiresAt: number;
|
||||
promise: Promise<ThemeRecord[]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
async function scanThemes() {
|
||||
const [bundledThemes, localThemes] = await Promise.all([
|
||||
readThemeRoot(options.bundledRoot, "bundled"),
|
||||
readThemeRoot(options.localRoot, "local", options.onWarning)
|
||||
]);
|
||||
|
||||
const bundledThemeIds = new Set(
|
||||
bundledThemes.map((theme) => theme.manifest.id)
|
||||
);
|
||||
const uniqueLocalThemes = localThemes.filter((theme) => {
|
||||
if (!bundledThemeIds.has(theme.manifest.id)) {
|
||||
return true;
|
||||
}
|
||||
options.onWarning?.(
|
||||
`已忽略与内置主题同 ID 的本地主题 ${theme.manifest.id}`
|
||||
);
|
||||
return false;
|
||||
});
|
||||
const themes = [...bundledThemes, ...uniqueLocalThemes];
|
||||
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;
|
||||
}
|
||||
|
||||
function list() {
|
||||
const now = Date.now();
|
||||
if (cachedThemes && now < cachedThemes.expiresAt) {
|
||||
return cachedThemes.promise;
|
||||
}
|
||||
|
||||
const promise = scanThemes();
|
||||
cachedThemes = {
|
||||
expiresAt: now + cacheTtlMs,
|
||||
promise
|
||||
};
|
||||
void promise.catch(() => {
|
||||
if (cachedThemes?.promise === promise) {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
|
||||
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 = [
|
||||
...(theme.manifest.base ? [theme.manifest.base] : []),
|
||||
theme.manifest.entry,
|
||||
...(theme.manifest.print
|
||||
? [theme.manifest.print]
|
||||
: [])
|
||||
];
|
||||
return (
|
||||
await Promise.all(
|
||||
cssFiles.map((path) =>
|
||||
loadThemeCssFile(theme, path, createAssetUrl)
|
||||
)
|
||||
)
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
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,
|
||||
invalidate,
|
||||
list
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from "vitest";
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rm,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
createApplicationService
|
||||
} from "../src/index.js";
|
||||
|
||||
let temporaryDirectory: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (temporaryDirectory) {
|
||||
await rm(temporaryDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
});
|
||||
}
|
||||
temporaryDirectory = undefined;
|
||||
});
|
||||
|
||||
async function createThemeFixture() {
|
||||
temporaryDirectory = await mkdtemp(
|
||||
join(tmpdir(), "md-to-pdf-application-")
|
||||
);
|
||||
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||
const localRoot = join(temporaryDirectory, "local");
|
||||
const themeRoot = join(bundledRoot, "test-theme");
|
||||
await mkdir(join(themeRoot, "fonts"), { recursive: true });
|
||||
await mkdir(localRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(themeRoot, "theme.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 1,
|
||||
id: "test-theme",
|
||||
name: "测试主题",
|
||||
version: "1.0.0",
|
||||
description: "共享应用服务测试主题",
|
||||
author: "test",
|
||||
license: "MIT",
|
||||
entry: "theme.css",
|
||||
domPreset: "typora",
|
||||
defaultFontSize: "16px",
|
||||
supportedFeatures: ["code", "table"],
|
||||
bundled: true
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
await writeFile(
|
||||
join(themeRoot, "theme.css"),
|
||||
[
|
||||
"@font-face {",
|
||||
" font-family: Test;",
|
||||
" src: url('./fonts/test.woff2') format('woff2');",
|
||||
"}",
|
||||
"#write { font-family: Test; }"
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
await writeFile(
|
||||
join(themeRoot, "fonts", "test.woff2"),
|
||||
Buffer.from([0, 1, 2, 3])
|
||||
);
|
||||
return { bundledRoot, localRoot };
|
||||
}
|
||||
|
||||
describe("共享应用服务", () => {
|
||||
it("渲染安全 Markdown 文档", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const service = createApplicationService(roots);
|
||||
|
||||
const document = service.render({
|
||||
markdown: "# 文档\n\n<script>alert('xss')</script>",
|
||||
language: "zh-CN"
|
||||
});
|
||||
|
||||
expect(document.articleHtml).toContain('id="write"');
|
||||
expect(document.articleHtml).not.toContain("<script");
|
||||
expect(document.metadata.title).toBe("文档");
|
||||
});
|
||||
|
||||
it("以稳定错误协议拒绝非法请求", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const service = createApplicationService(roots);
|
||||
|
||||
expect(() => service.render({ markdown: 42 })).toThrow(
|
||||
expect.objectContaining<ApplicationRequestError>({
|
||||
statusCode: 400,
|
||||
code: "INVALID_MARKDOWN"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("列出主题并使用调用方提供的资源 URL", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const service = createApplicationService({
|
||||
...roots,
|
||||
createAssetUrl: (themeId, assetPath) =>
|
||||
`mdpdf://theme/${themeId}/${assetPath}`
|
||||
});
|
||||
|
||||
await expect(service.listThemes()).resolves.toEqual({
|
||||
themes: [
|
||||
expect.objectContaining({
|
||||
id: "test-theme",
|
||||
bundled: true,
|
||||
source: "bundled"
|
||||
})
|
||||
]
|
||||
});
|
||||
await expect(
|
||||
service.getThemeCss("test-theme")
|
||||
).resolves.toContain(
|
||||
"mdpdf://theme/test-theme/fonts/test.woff2"
|
||||
);
|
||||
});
|
||||
|
||||
it("安全读取主题二进制资源", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const service = createApplicationService(roots);
|
||||
|
||||
await expect(
|
||||
service.getThemeAsset("test-theme", "fonts/test.woff2")
|
||||
).resolves.toEqual({
|
||||
contentType: "font/woff2",
|
||||
content: Buffer.from([0, 1, 2, 3])
|
||||
});
|
||||
await expect(
|
||||
service.getThemeAsset("test-theme", "../test.woff2")
|
||||
).rejects.toThrow("主题资源路径不安全");
|
||||
});
|
||||
|
||||
it("内置主题优先于同 ID 的旧本地副本", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const localThemeRoot = join(roots.localRoot, "test-theme");
|
||||
await mkdir(localThemeRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(localThemeRoot, "theme.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 1,
|
||||
id: "test-theme",
|
||||
name: "旧本地主题",
|
||||
version: "local",
|
||||
description: "迁移前的本地副本",
|
||||
author: "test",
|
||||
license: "local-only",
|
||||
entry: "theme.css",
|
||||
domPreset: "typora",
|
||||
defaultFontSize: "16px",
|
||||
supportedFeatures: ["code", "table"],
|
||||
bundled: false
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
await writeFile(
|
||||
join(localThemeRoot, "theme.css"),
|
||||
"#write { color: red; }",
|
||||
"utf8"
|
||||
);
|
||||
const onWarning = vi.fn();
|
||||
const service = createApplicationService({
|
||||
...roots,
|
||||
onWarning
|
||||
});
|
||||
|
||||
await expect(service.listThemes()).resolves.toEqual({
|
||||
themes: [
|
||||
expect.objectContaining({
|
||||
id: "test-theme",
|
||||
name: "测试主题",
|
||||
bundled: true
|
||||
})
|
||||
]
|
||||
});
|
||||
expect(onWarning).toHaveBeenCalledWith(
|
||||
"已忽略与内置主题同 ID 的本地主题 test-theme"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user