diff --git a/backend/app.ts b/backend/app.ts index fac7915..426664b 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -2,25 +2,101 @@ import { fontSubset } from "./font_util/font"; import { mimeTypes } from "./server/mime_type"; import type { cMiddleware } from "./server/req_res"; import { SimpleHttpServer } from "./server/server"; -import { path_join, readFile, stat } from "./interface"; +import { path_join, readFile, stat, readdir, mkdir } from "./interface"; +import { enableTempUpload, adminApiKey, fontDirs } from "./config"; +import { parseMultipart } from "./multipart"; +import { handleTempUpload, handleAdminUpload } from "./upload"; + let release_name = global.tjs ? "tjs" : globalThis?.process?.release?.name; +let runtimeReady: Promise; if (release_name === "tjs") { - import("./server/tjs"); + runtimeReady = import("./server/tjs").then(() => {}); } else if (release_name === "node" || release_name === "llrt") { - import("./server/node"); + runtimeReady = import("./server/node").then(() => {}); +} else { + runtimeReady = Promise.resolve(); } if (release_name === "llrt") { - import("./server/llrt"); + runtimeReady = runtimeReady.then(() => import("./server/llrt").then(() => {})); +} +const ROOT_DIR = "dist"; + +/** 启动时确保必要目录存在 */ +async function ensureDirectories() { + for (const dir of ["font/temp", "font/admin"]) { + try { + await stat(dir); + } catch { + await mkdir(dir); + } + } +} + +/** + * 在所有字体目录中查找字体文件 + * 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配 + * @returns 找到的字体完整路径,未找到则返回 null + */ +async function findFontPath(filename: string): Promise { + // 先尝试精确匹配 + for (const dir of fontDirs) { + const filePath = path_join(dir, filename); + try { + const s = await stat(filePath); + if (s.isFile()) return filePath; + } catch { + // 继续搜索 + } + } + + // 收集所有字体文件名(不含扩展名)和完整路径 + const allFonts: Array<{ basename: string; path: string }> = []; + for (const dir of fontDirs) { + try { + const entries = await readdir(dir); + for (const entry of entries) { + if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) { + allFonts.push({ + basename: entry.name.replace(/\.[^.]+$/, ""), + path: path_join(dir, entry.name), + }); + } + } + } catch { + // 目录不存在,跳过 + } + } + + const query = filename.replace(/\.[^.]+$/, "").toLowerCase(); + + // 前缀匹配 + for (const f of allFonts) { + if (f.basename.toLowerCase().startsWith(query)) return f.path; + } + + // 包含匹配 + for (const f of allFonts) { + if (f.basename.toLowerCase().includes(query)) return f.path; + } + + return null; +} + +/** JSON 响应工具 */ +function jsonResponse(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); } -const ROOT_DIR = "dist"; // 静态文件目录 const logMiddleware: cMiddleware = async (req, res, next) => { const t1 = Date.now(); const r = await next(req, res); const t2 = Date.now(); const url = new URL(req.url); - console.log(`[${t2 - t1}ms] ${url.pathname}`); + console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`); return r; }; @@ -43,7 +119,6 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) { }, }); } else { - // 文件不存在 newRes = new Response("404 Not Found", { status: 404, headers: { @@ -72,9 +147,7 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) { return next(req, newRes); }; const corsMiddleware: cMiddleware = async (req, res, next) => { - // 如果是 OPTIONS 请求(预检请求),直接返回成功响应 if (req.method === "OPTIONS") { - // 直接结束请求,不继续传递到下一个中间件 return { req, res: new Response("", { @@ -86,33 +159,94 @@ const corsMiddleware: cMiddleware = async (req, res, next) => { }; } else { const newRes = await next(req, res); - // 允许所有域跨域请求 newRes.res.headers.append("Access-Control-Allow-Origin", "*"); - // 如果你只想允许特定域名: - // res.headers["Access-Control-Allow-Origin"] = "https://example.com"; - // 允许常见的 HTTP 方法 newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); - // 允许的请求头 newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization"); return newRes; } }; -const fontApiMiddleware: cMiddleware = async (req, res, next) => { - // 创建一个新的 URL 对象(需要一个完整的 URL,必须包含协议和主机) + +/** GET /api/fonts — 列出所有可用字体 */ +async function handleListFonts(req: Request, res: Response) { + const allFonts: Array<{ name: string; dir: string }> = []; + + for (const dir of fontDirs) { + try { + const entries = await readdir(dir); + for (const entry of entries) { + if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) { + allFonts.push({ name: entry.name, dir }); + } + } + } catch { + // 目录不存在,跳过 + } + } + + return { req, res: jsonResponse(allFonts) }; +} + +/** GET /api/config — 返回公开配置 */ +async function handleGetConfig(req: Request, res: Response) { + return { + req, + res: jsonResponse({ + enableTempUpload, + adminUploadEnabled: !!adminApiKey, + }), + }; +} + +/** POST /api/upload?mode=temp|admin — 上传字体 */ +async function handleUpload(req: Request, res: Response) { + const url = new URL(req.url, "https://webfont.shenzilong.com"); + const mode = url.searchParams.get("mode") ?? "temp"; + + const contentType = req.headers.get("Content-Type") ?? ""; + let body: ArrayBuffer; + try { + body = await req.arrayBuffer(); + } catch { + return { req, res: jsonResponse({ success: false, error: "读取请求体失败" }, 400) }; + } + + let parsed; + try { + parsed = parseMultipart(contentType, body); + } catch { + return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) }; + } + + if (!parsed.files || parsed.files.length === 0) { + return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) }; + } + + const file = parsed.files[0]; + + if (mode === "admin") { + const authHeader = req.headers.get("Authorization") ?? ""; + const apiKey = authHeader.replace("Bearer ", ""); + const result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey); + return { req, res: jsonResponse(result, result.success ? 200 : 403) }; + } + + // 默认:临时上传 + const result = await handleTempUpload({ data: file.data, filename: file.filename }); + return { req, res: jsonResponse(result, result.success ? 200 : 400) }; +} + +/** GET /api?font=...&text=... — 字体裁剪 */ +async function handleFontSubset(req: Request, res: Response) { const url = new URL(req.url, "https://webfont.shenzilong.com"); - if (!url.pathname.startsWith("/api")) return next(req, res); const params = new URLSearchParams(url.search); const font = params.get("font") || ""; const text = params.get("text") || ""; if (text.length === 0) { return { req, res }; } - const path = `font/${font}`; - const fontType = path.split(".").pop() as "ttf"; - let oldFontBuffer: ArrayBuffer; - try { - oldFontBuffer = new Uint8Array(await readFile(path)).buffer; - } catch { + + const fontPath = await findFontPath(font); + if (!fontPath) { return { req, res: new Response(`Font not found: ${font}`, { @@ -122,6 +256,20 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => { }; } + const fontType = fontPath.split(".").pop() as "ttf"; + let oldFontBuffer: ArrayBuffer; + try { + oldFontBuffer = new Uint8Array(await readFile(fontPath)).buffer; + } catch { + return { + req, + res: new Response(`Font read error: ${font}`, { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }), + }; + } + const outType = "ttf"; const newFont = await fontSubset(oldFontBuffer, text, { outType: outType, @@ -137,12 +285,59 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => { }, }), }; +} + +/** 统一的 API 路由中间件 */ +const fontApiMiddleware: cMiddleware = async (req, res, next) => { + const url = new URL(req.url, "https://webfont.shenzilong.com"); + if (!url.pathname.startsWith("/api")) return next(req, res); + + if (url.pathname === "/api/fonts" && req.method === "GET") { + return handleListFonts(req, res); + } + if (url.pathname === "/api/config" && req.method === "GET") { + return handleGetConfig(req, res); + } + if (url.pathname === "/api/upload" && req.method === "POST") { + return handleUpload(req, res); + } + if (url.pathname === "/api" && req.method === "GET") { + return handleFontSubset(req, res); + } + + return next(req, res); }; -const server = new SimpleHttpServer({ port: 8087 }); -server.use( - logMiddleware, - corsMiddleware, - fontApiMiddleware, - staticFileMiddleware, -); +/** 上传文件大小限制 50MB */ +const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; + +const uploadSizeMiddleware: cMiddleware = async (req, res, next) => { + if (req.method === "POST" && new URL(req.url).pathname === "/api/upload") { + const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10); + if (contentLength > MAX_UPLOAD_SIZE) { + return { + req, + res: jsonResponse({ success: false, error: "文件过大,最大 50MB" }, 413), + }; + } + } + return next(req, res); +}; + +async function main() { + await runtimeReady; + await ensureDirectories(); + + const server = new SimpleHttpServer({ port: 8087 }); + server.use( + logMiddleware, + corsMiddleware, + uploadSizeMiddleware, + fontApiMiddleware, + staticFileMiddleware, + ); + console.log("[config] temp upload:", enableTempUpload); + console.log("[config] admin upload:", !!adminApiKey); +} + +main(); diff --git a/backend/config.ts b/backend/config.ts new file mode 100644 index 0000000..f2386cd --- /dev/null +++ b/backend/config.ts @@ -0,0 +1,16 @@ +/** + * 从环境变量读取服务配置,启动时一次性加载 + */ +const env = globalThis.process?.env ?? {}; + +/** 临时上传开关 */ +export const enableTempUpload = env.ENABLE_TEMP_UPLOAD === "true"; + +/** 管理员 API Key,为空则管理员上传不可用 */ +export const adminApiKey: string = env.ADMIN_API_KEY ?? ""; + +/** 临时上传目录最大文件数 */ +export const tempMaxFiles = 10; + +/** 字体搜索目录(按优先级排序) */ +export const fontDirs = ["font", "font/temp", "font/admin"] as const; diff --git a/backend/interface.ts b/backend/interface.ts index adfb18d..3a2447d 100644 --- a/backend/interface.ts +++ b/backend/interface.ts @@ -5,9 +5,31 @@ export let stat: (path: string) => Promise<{ export let readFile: (path: string) => Promise; -export const implInterface = (options: { stat: typeof stat; readFile: typeof readFile }) => { +export let writeFile: (path: string, data: Uint8Array) => Promise; + +export let readdir: (path: string) => Promise<{ + isFile: () => boolean; + name: string; +}[]>; + +export let mkdir: (path: string) => Promise; + +export let unlink: (path: string) => Promise; + +export const implInterface = (options: { + stat: typeof stat; + readFile: typeof readFile; + writeFile: typeof writeFile; + readdir: typeof readdir; + mkdir: typeof mkdir; + unlink: typeof unlink; +}) => { stat = options.stat; readFile = options.readFile; + writeFile = options.writeFile; + readdir = options.readdir; + mkdir = options.mkdir; + unlink = options.unlink; }; export function path_join(...paths: string[]) { diff --git a/backend/multipart.ts b/backend/multipart.ts new file mode 100644 index 0000000..d17e540 --- /dev/null +++ b/backend/multipart.ts @@ -0,0 +1,89 @@ +/** + * 轻量 multipart/form-data 解析器,不依赖外部库 + */ +export interface MultipartFile { + /** 表单字段名 */ + name: string; + /** 原始文件名 */ + filename: string; + /** 文件二进制数据 */ + data: Uint8Array; +} + +export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartFile { + const boundaryMatch = contentType.match(/boundary=([^\s;]+)/); + if (!boundaryMatch) throw new Error("No boundary found"); + const boundary = boundaryMatch[1].replace(/^"(.*)"$/, "$1"); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder("utf-8"); + const bodyBytes = new Uint8Array(body); + const delimiter = encoder.encode("\r\n--" + boundary); + const endDelimiter = encoder.encode("--" + boundary + "--"); + + /** 在字节数组中查找子串位置 */ + function findBytes(haystack: Uint8Array, needle: Uint8Array, offset: number): number { + for (let i = offset; i <= haystack.length - needle.length; i++) { + let match = true; + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; + } + + const files: MultipartFile[] = []; + + // 跳过起始边界 "--boundary\r\n" + const startBoundary = encoder.encode("--" + boundary + "\r\n"); + let pos = 0; + const sbPos = findBytes(bodyBytes, startBoundary, pos); + if (sbPos === -1) throw new Error("Invalid multipart: no start boundary"); + pos = sbPos + startBoundary.length; + + while (pos < bodyBytes.length) { + // 检查是否到达结束边界 + if (findBytes(bodyBytes, endDelimiter, pos - 2) !== -1) break; + + // 查找 headers 和 body 的分界 "\r\n\r\n" + const headerEnd = findBytes(bodyBytes, encoder.encode("\r\n\r\n"), pos); + if (headerEnd === -1) break; + + const headerText = decoder.decode(bodyBytes.slice(pos, headerEnd)); + const bodyStart = headerEnd + 4; + + // 查找下一个 boundary + const nextBoundary = findBytes(bodyBytes, delimiter, bodyStart); + if (nextBoundary === -1) break; + + // part body 去掉末尾的 "\r\n" + const partBody = bodyBytes.slice(bodyStart, nextBoundary); + const actualBody = partBody.length >= 2 && partBody[partBody.length - 1] === 10 && partBody[partBody.length - 2] === 13 + ? partBody.slice(0, partBody.length - 2) + : partBody; + + // 从 Content-Disposition 中解析字段名和文件名 + const nameMatch = headerText.match(/name="([^"]*)"/); + const filenameMatch = headerText.match(/filename="([^"]*)"/); + + if (nameMatch) { + files.push({ + name: nameMatch[1], + filename: filenameMatch?.[1] ?? "", + data: actualBody, + }); + } + + pos = nextBoundary + delimiter.length; + // 跳过 boundary 后的 "\r\n" + if (pos < bodyBytes.length && bodyBytes[pos] === 13 && bodyBytes[pos + 1] === 10) { + pos += 2; + } + } + + return { ...files[0], files } as MultipartFile & { files: MultipartFile[] }; +} diff --git a/backend/server/node.ts b/backend/server/node.ts index ee70731..1a867ee 100644 --- a/backend/server/node.ts +++ b/backend/server/node.ts @@ -1,5 +1,5 @@ import { implInterface } from "../interface"; -import { stat, readFile } from "fs/promises"; +import { stat, readFile, writeFile, readdir, mkdir, unlink } from "fs/promises"; implInterface({ async stat(path) { const r = await stat(path); @@ -8,4 +8,20 @@ implInterface({ readFile(path) { return readFile(path); }, + writeFile(path, data) { + return writeFile(path, data); + }, + async readdir(path) { + const entries = await readdir(path, { withFileTypes: true }); + return entries.map((entry) => ({ + isFile: () => entry.isFile(), + name: entry.name, + })); + }, + async mkdir(path) { + await mkdir(path, { recursive: true }); + }, + unlink(path) { + return unlink(path); + }, }); diff --git a/backend/server/server.ts b/backend/server/server.ts index 63b5077..e9340a4 100644 --- a/backend/server/server.ts +++ b/backend/server/server.ts @@ -99,7 +99,6 @@ async function connectionHandle( // node 运行时 // 释放写入器的锁定 resWriter.releaseLock(); - console.log("[connection.writable.locked]", connection.writable.locked); // https://github.com/saghul/txiki.js/issues/646 await res.body?.pipeTo(connection.writable); } else { diff --git a/backend/server/tjs.ts b/backend/server/tjs.ts index 3663bb9..f9fec76 100644 --- a/backend/server/tjs.ts +++ b/backend/server/tjs.ts @@ -2,13 +2,36 @@ import { implInterface } from "../interface"; implInterface({ async stat(path) { - const r = await global.tjs.stat(path); + const r = await tjs.stat(path); return { isFile: () => r.isFile, size: r.size, }; }, readFile(path) { - return global.tjs.readFile(path); + return tjs.readFile(path); + }, + writeFile(path, data) { + return tjs.writeFile(path, data); + }, + async readdir(path) { + const entries: { isFile: () => boolean; name: string }[] = []; + const dir = await tjs.readDir(path); + for await (const entry of dir) { + entries.push({ + isFile: () => entry.isFile, + name: entry.name, + }); + } + await dir.close(); + return entries; + }, + /** TJS 没有 mkdir,通过写入占位文件来确保目录存在 */ + async mkdir(path) { + const placeholder = path + "/.keep"; + await tjs.writeFile(placeholder, new Uint8Array(0)); + }, + unlink(path) { + return tjs.remove(path); }, }); diff --git a/backend/upload.ts b/backend/upload.ts new file mode 100644 index 0000000..fdf8012 --- /dev/null +++ b/backend/upload.ts @@ -0,0 +1,98 @@ +import { writeFile, unlink, readdir, path_join } from "./interface"; +import { enableTempUpload, adminApiKey, tempMaxFiles } from "./config"; + +/** 允许的字体文件扩展名 */ +const ALLOWED_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"]; + +function isAllowedFontFile(filename: string): boolean { + const lower = filename.toLowerCase(); + return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +/** 清理文件名,移除路径分隔符和危险字符 */ +function sanitizeFilename(filename: string): string { + return filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, ""); +} + +/** 生成带时间戳前缀的文件名,用于 FIFO 排序 */ +function generateTempFilename(originalName: string): string { + const ts = Date.now().toString(36); + const ext = originalName.includes(".") ? "." + originalName.split(".").pop() : ".ttf"; + const base = originalName.replace(/\.[^.]+$/, ""); + return `${ts}_${base}${ext}`; +} + +/** 获取临时目录中的字体文件列表 */ +async function getTempFiles(): Promise { + const entries = await readdir("font/temp"); + return entries + .filter((e) => e.isFile() && isAllowedFontFile(e.name)) + .map((e) => e.name) + .sort(); +} + +/** 确保目录存在,不存在则创建 */ +async function ensureDir(dir: string) { + const { stat, mkdir } = await import("./interface"); + try { + await stat(dir); + } catch { + await mkdir(dir); + } +} + +export interface UploadResult { + success: boolean; + error?: string; +} + +export async function handleTempUpload(fileData: { data: Uint8Array; filename: string }): Promise { + if (!enableTempUpload) { + return { success: false, error: "临时上传功能未启用" }; + } + + if (!isAllowedFontFile(fileData.filename)) { + return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" }; + } + + await ensureDir("font/temp"); + + const existingFiles = await getTempFiles(); + + // FIFO: 超出上限时删除最早的文件 + if (existingFiles.length >= tempMaxFiles) { + const toDelete = existingFiles[0]; + try { + await unlink(path_join("font/temp", toDelete)); + } catch { + // 删除失败不影响上传 + } + } + + const filename = generateTempFilename(sanitizeFilename(fileData.filename)); + await writeFile(path_join("font/temp", filename), fileData.data); + return { success: true }; +} + +export async function handleAdminUpload( + fileData: { data: Uint8Array; filename: string }, + apiKey: string, +): Promise { + if (!adminApiKey) { + return { success: false, error: "管理员上传功能未启用" }; + } + + if (apiKey !== adminApiKey) { + return { success: false, error: "API Key 无效" }; + } + + if (!isAllowedFontFile(fileData.filename)) { + return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" }; + } + + await ensureDir("font/admin"); + + const filename = sanitizeFilename(fileData.filename); + await writeFile(path_join("font/admin", filename), fileData.data); + return { success: true }; +} diff --git a/index.html b/index.html index 7021737..64708e0 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,10 @@ - + - Vite + Solid + TS + Web Font
diff --git a/scripts/dev-all.ts b/scripts/dev-all.ts index 931a955..1f8145a 100644 --- a/scripts/dev-all.ts +++ b/scripts/dev-all.ts @@ -30,7 +30,7 @@ process.on("SIGTERM", () => { console.log("Starting frontend and backend dev servers...\n"); -const backend = spawn("pnpx", ["tsx", "backend/app.ts"], { +const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], { cwd: ROOT_DIR, stdio: "inherit", shell: true, diff --git a/src/App.tsx b/src/App.tsx index fc84545..6ef3d6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,52 +1,366 @@ -import { createMemo, createSignal, type Accessor } from "solid-js"; +import { createMemo, createSignal, onMount, Show, For, type Accessor } from "solid-js"; +import { fetchFonts, fetchConfig, uploadFont, type FontInfo, type ServerConfig } from "./api"; + +const s = { + wrap: { + "max-width": "720px", + margin: "0 auto", + padding: "48px 24px", + "font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + color: "#1a1a1a", + "line-height": "1.6", + } as const, + h1: { + "font-size": "22px", + "font-weight": 600, + margin: "0 0 4px 0", + } as const, + desc: { + "font-size": "14px", + color: "#888", + margin: "0 0 36px 0", + } as const, + label: { + display: "block", + "font-size": "13px", + color: "#555", + "margin-bottom": "6px", + } as const, + select: { + width: "100%", + padding: "8px 12px", + "font-size": "15px", + border: "1px solid #d9d9d9", + "border-radius": "6px", + outline: "none", + "box-sizing": "border-box", + } as const, + textarea: { + width: "100%", + height: "72px", + padding: "8px 12px", + "font-size": "18px", + border: "1px solid #d9d9d9", + "border-radius": "6px", + resize: "vertical", + "box-sizing": "border-box", + outline: "none", + color: "#e74c3c", + } as const, + pre: { + background: "#f7f7f8", + padding: "16px", + "border-radius": "6px", + "font-size": "13px", + "font-family": "'SF Mono', Menlo, Consolas, monospace", + overflow: "auto", + "white-space": "pre-wrap", + "word-break": "break-all", + "line-height": "1.5", + margin: "0", + } as const, + section: { + "margin-bottom": "28px", + } as const, + card: { + padding: "16px", + border: "1px solid #e8e8e8", + "border-radius": "8px", + "margin-bottom": "16px", + } as const, + btn: { + padding: "6px 20px", + "font-size": "14px", + border: "1px solid #d9d9d9", + "border-radius": "6px", + cursor: "pointer", + background: "#fff", + color: "#333", + } as const, + input: { + padding: "6px 12px", + "font-size": "14px", + border: "1px solid #d9d9d9", + "border-radius": "6px", + outline: "none", + "box-sizing": "border-box", + } as const, +}; function App() { const [text, set_text] = createSignal("天地无极,乾坤借法"); + const [fonts, set_fonts] = createSignal([]); + const [selectedFont, set_selectedFont] = createSignal(""); + const [serverConfig, set_serverConfig] = createSignal({ + enableTempUpload: false, + adminUploadEnabled: false, + }); - // const serverPath = import.meta.env.DEV ? "/" : "https://webfont.shenzilong.cn/"; - const serverPath = "/"; - const style = createMemo( - () => ` - @font-face { - font-family: "CustomFont"; - src: url("${serverPath}api?font=令东齐伋复刻体.ttf&text=${text()}") format("truetype"); + onMount(async () => { + const [fontList, config] = await Promise.all([fetchFonts().catch(() => []), fetchConfig().catch(() => ({ enableTempUpload: false, adminUploadEnabled: false }))]); + set_fonts(fontList); + set_serverConfig(config); + if (fontList.length > 0) { + set_selectedFont(fontList[0].name); + } + }); + + const cssStyle = createMemo(() => { + const font = selectedFont(); + if (!font) return ""; + return `@font-face { + font-family: "CustomFont"; + src: url("/api?font=${font}&text=${encodeURIComponent(text())}") format("truetype"); +} +.custom-font { + color: red; + font-family: "CustomFont"; +}`; + }); + + const throttledCss = useThrottledMemo(() => cssStyle(), 1000, text); + + async function refreshFonts() { + const fontList = await fetchFonts(); + set_fonts(fontList); + if (fontList.length > 0 && !selectedFont()) { + set_selectedFont(fontList[0].name); + } } - input { - color: red; - font-family: "CustomFont"; - } -`, - ); - const throttledSetMemo = useThrottledMemo(() => style(), 1000); + return ( -
-

- web font{" "} -

-
-
在下面输入文本查看效果
- +
+

Web Font

+ + + Star on GitHub + +
+

输入文本,获取仅包含所需字符的子集字体 CSS

+ +
+ + +
+ +
+ +