mirror of
https://github.com/2234839/web-font.git
synced 2026-04-29 21:00:45 +08:00
重构前端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:
parent
1a611e7e28
commit
7fceccfe4d
255
backend/app.ts
255
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<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
16
backend/config.ts
Normal 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;
|
||||
@ -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
89
backend/multipart.ts
Normal 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[] };
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
98
backend/upload.ts
Normal 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 };
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
374
src/App.tsx
374
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<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
45
src/api.ts
Normal 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();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user