重构前端UI,新增字体上传/下载/模糊匹配功能

- 前端UI重构:字体选择器、文本预览、CSS输出+复制、下载字体、上传区域
- 后端新增API:/api/fonts(列出字体)、/api/config(公开配置)、/api/upload(上传字体)
- 字体查找支持模糊匹配(精确 > 前缀 > 包含)
- 上传功能分两种模式:临时上传(ENV开关,FIFO上限10个)和管理员上传(API Key认证)
- 新增multipart解析器、配置模块、文件系统抽象(writeFile/readdir/mkdir/unlink)
- 后端启动时等待运行时初始化完成,确保接口可用
- dev-all.ts后端启用tsx watch模式自动重载

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-08 16:18:46 +08:00
parent 1a611e7e28
commit 7fceccfe4d
12 changed files with 886 additions and 67 deletions

View File

@ -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<void>;
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<string | null> {
// 先尝试精确匹配
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();

16
backend/config.ts Normal file
View File

@ -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;

View File

@ -5,9 +5,31 @@ export let stat: (path: string) => Promise<{
export let readFile: (path: string) => Promise<Uint8Array>;
export const implInterface = (options: { stat: typeof stat; readFile: typeof readFile }) => {
export let writeFile: (path: string, data: Uint8Array) => Promise<void>;
export let readdir: (path: string) => Promise<{
isFile: () => boolean;
name: string;
}[]>;
export let mkdir: (path: string) => Promise<void>;
export let unlink: (path: string) => Promise<void>;
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[]) {

89
backend/multipart.ts Normal file
View File

@ -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[] };
}

View File

@ -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);
},
});

View File

@ -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 {

View File

@ -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);
},
});

98
backend/upload.ts Normal file
View File

@ -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<string[]> {
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<UploadResult> {
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<UploadResult> {
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 };
}

View File

@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Solid + TS</title>
<title>Web Font</title>
</head>
<body>
<div id="root"></div>

View File

@ -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,

View File

@ -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<FontInfo[]>([]);
const [selectedFont, set_selectedFont] = createSignal("");
const [serverConfig, set_serverConfig] = createSignal<ServerConfig>({
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 (
<div>
<h1>
<a href="https://github.com/2234839/web-font">web font</a>{" "}
</h1>
<div>
<div></div>
<input
style={{ "font-size": "46px", "margin-top": "3px" }}
<div style={s.wrap}>
<div style={{ display: "flex", "align-items": "center", "justify-content": "space-between" }}>
<h1 style={s.h1}>Web Font</h1>
<a
href="https://github.com/2234839/web-font"
target="_blank"
rel="noopener noreferrer"
style={{ display: "inline-flex", "align-items": "center", "gap": "4px", "font-size": "13px", color: "#888", "text-decoration": "none", border: "1px solid #d9d9d9", "border-radius": "6px", padding: "4px 10px" }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Star on GitHub
</a>
</div>
<p style={s.desc}> CSS</p>
<section style={s.section}>
<label style={s.label}></label>
<select
style={s.select}
value={selectedFont()}
onChange={(e) => set_selectedFont(e.target.value)}
>
<option value="">-- --</option>
<For each={fonts()}>
{(f) => (
<option value={f.name}>
{f.name} ({f.dir})
</option>
)}
</For>
</select>
</section>
<section style={s.section}>
<label style={s.label}></label>
<textarea
style={{
...s.textarea,
"font-family": selectedFont() ? '"CustomFont", sans-serif' : "inherit",
}}
value={text()}
onInput={(e) => set_text(e.target.value)}
placeholder="在此输入文本..."
/>
<div>{text()}</div>
</div>
</section>
<pre>{"<style>" + style() + "</style>"}</pre>
<style>{throttledSetMemo()}</style>
<Show when={selectedFont() && text()}>
<section style={s.section}>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", "margin-bottom": "6px" }}>
<label style={{ ...s.label, margin: "0" }}>CSS </label>
<div style={{ display: "flex", gap: "6px" }}>
<button
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
onClick={() => {
const a = document.createElement("a");
a.href = `/api?font=${selectedFont()}&text=${encodeURIComponent(text())}`;
a.download = selectedFont().replace(/\.[^.]+$/, "") + "_subset.ttf";
a.click();
}}
>
</button>
<button
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
onClick={() => navigator.clipboard.writeText(cssStyle())}
>
CSS
</button>
</div>
</div>
<pre style={s.pre}>{cssStyle()}</pre>
</section>
</Show>
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
<style>{throttledCss()}</style>
</div>
);
}
export default App;
function UploadSection(props: { config: ServerConfig; onUploaded: () => void }) {
const [tempFile, set_tempFile] = createSignal<File | null>(null);
const [adminFile, set_adminFile] = createSignal<File | null>(null);
const [adminApiKey, set_adminApiKey] = createSignal("");
const [uploading, set_uploading] = createSignal(false);
const [msg, set_msg] = createSignal<{ ok: boolean; text: string } | null>(null);
function useThrottledMemo<T>(fn: () => T, delay: number): Accessor<T> {
function showMsg(ok: boolean, text: string) {
set_msg({ ok, text });
setTimeout(() => set_msg(null), 3000);
}
async function handleTempUpload() {
const file = tempFile();
if (!file) return;
set_uploading(true);
const result = await uploadFont(file, "temp");
set_uploading(false);
if (result.success) {
showMsg(true, "上传成功");
set_tempFile(null);
props.onUploaded();
} else {
showMsg(false, result.error ?? "上传失败");
}
}
async function handleAdminUpload() {
const file = adminFile();
if (!file) return;
set_uploading(true);
const result = await uploadFont(file, "admin", adminApiKey());
set_uploading(false);
if (result.success) {
showMsg(true, "上传成功");
set_adminFile(null);
props.onUploaded();
} else {
showMsg(false, result.error ?? "上传失败");
}
}
const canUpload = () => props.config.enableTempUpload || props.config.adminUploadEnabled;
return (
<Show when={canUpload()}>
<section style={s.section}>
<label style={{ ...s.label, "font-size": "14px", "font-weight": 500, "margin-bottom": "12px" }}></label>
<Show when={msg()}>
{(m) => (
<div
style={{
padding: "8px 12px",
"margin-bottom": "12px",
"border-radius": "6px",
"font-size": "13px",
background: m().ok ? "#f0faf0" : "#fef2f2",
color: m().ok ? "#166534" : "#b91c1c",
border: `1px solid ${m().ok ? "#bbf7d0" : "#fecaca"}`,
}}
>
{m().text}
</div>
)}
</Show>
<Show when={props.config.enableTempUpload}>
<div style={s.card}>
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}></div>
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
10
</div>
<div style={{ display: "flex", "gap": "8px", "align-items": "center" }}>
<label style={{ ...s.btn, display: "inline-flex", "align-items": "center", cursor: "pointer" }}>
<input
type="file"
accept=".ttf,.otf,.woff,.woff2"
style={{ display: "none" }}
onChange={(e) => set_tempFile(e.target.files?.[0] ?? null)}
/>
</label>
<span style={{ "font-size": "13px", color: "#666" }}>
{tempFile()?.name ?? "未选择文件"}
</span>
<button
style={{ ...s.btn, opacity: tempFile() && !uploading() ? 1 : 0.5, cursor: tempFile() && !uploading() ? "pointer" : "not-allowed" }}
disabled={!tempFile() || uploading()}
onClick={handleTempUpload}
>
{uploading() ? "..." : "上传"}
</button>
</div>
</div>
</Show>
<Show when={props.config.adminUploadEnabled}>
<div style={s.card}>
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}></div>
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
API Key
</div>
<input
type="password"
style={{ ...s.input, width: "100%", "margin-bottom": "10px" }}
value={adminApiKey()}
onInput={(e) => set_adminApiKey(e.target.value)}
placeholder="API Key"
/>
<div style={{ display: "flex", "gap": "8px", "align-items": "center" }}>
<label style={{ ...s.btn, display: "inline-flex", "align-items": "center", cursor: "pointer" }}>
<input
type="file"
accept=".ttf,.otf,.woff,.woff2"
style={{ display: "none" }}
onChange={(e) => set_adminFile(e.target.files?.[0] ?? null)}
/>
</label>
<span style={{ "font-size": "13px", color: "#666" }}>
{adminFile()?.name ?? "未选择文件"}
</span>
<button
style={{ ...s.btn, opacity: adminFile() && adminApiKey() && !uploading() ? 1 : 0.5, cursor: adminFile() && adminApiKey() && !uploading() ? "pointer" : "not-allowed" }}
disabled={!adminFile() || !adminApiKey() || uploading()}
onClick={handleAdminUpload}
>
{uploading() ? "..." : "上传"}
</button>
</div>
</div>
</Show>
</section>
</Show>
);
}
function useThrottledMemo<T>(fn: () => T, delay: number, trigger?: Accessor<unknown>): Accessor<T> {
const [throttledValue, setThrottledValue] = createSignal<T>(fn());
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isFirst = true;
createMemo(() => {
if (trigger) trigger();
const value = fn();
if (isFirst) {
isFirst = false;
// @ts-expect-error
setThrottledValue(value);
return;
}
if (timeoutId === null) {
// @ts-expect-error
setThrottledValue(value);
@ -60,3 +374,5 @@ function useThrottledMemo<T>(fn: () => T, delay: number): Accessor<T> {
return throttledValue;
}
export default App;

45
src/api.ts Normal file
View File

@ -0,0 +1,45 @@
export interface FontInfo {
name: string;
dir: string;
}
export interface ServerConfig {
enableTempUpload: boolean;
adminUploadEnabled: boolean;
}
export interface UploadResult {
success: boolean;
error?: string;
}
export async function fetchFonts(): Promise<FontInfo[]> {
const res = await fetch("/api/fonts");
return res.json();
}
export async function fetchConfig(): Promise<ServerConfig> {
const res = await fetch("/api/config");
return res.json();
}
export async function uploadFont(
file: File,
mode: "temp" | "admin",
apiKey?: string,
): Promise<UploadResult> {
const formData = new FormData();
formData.append("font", file);
const headers: Record<string, string> = {};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const res = await fetch(`/api/upload?mode=${mode}`, {
method: "POST",
body: formData,
headers,
});
return res.json();
}