diff --git a/package.json b/package.json index 65f390a..f3e11da 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "dependencies": { "@unhead/vue": "^2.1.17", "pinyin-pro": "^3.29.1", - "web-streams-polyfill": "^4.3.0" + "web-streams-polyfill": "^4.3.0", + "webfont-sdk": "workspace:*" }, "devDependencies": { "@leafer-ui/node": "2.2.9", diff --git a/packages/leafer-x-webfont/demo/index.html b/packages/leafer-x-webfont/demo/index.html index 498a905..3f98133 100644 --- a/packages/leafer-x-webfont/demo/index.html +++ b/packages/leafer-x-webfont/demo/index.html @@ -54,31 +54,36 @@ ``` +## 服务端 REST API 客户端 + +增量加载之外,SDK 还封装了子集化服务的公开 REST 接口(字体列表 / 元数据 / 上传 / 统计), +独立子路径导出,不进主入口: + +```ts +import { createWebFontApi } from 'webfont-sdk/api' + +const api = createWebFontApi({ baseUrl: 'https://webfont.shenzilong.cn' }) + +/** 字体列表(文件名 + 是否临时字体) */ +const fonts = await api.fonts() + +/** 字体元数据:codepoint 区间 / 各字符集覆盖率 / name 表信息 / 站长配置 */ +const meta = await api.fontMeta('霞鹜文楷.ttf') + +/** 上传临时字体(默认到期自动清理;admin 模式需 apiKey) */ +const result = await api.upload({ data: file, filename: '我的字体.ttf' }) +if (!result.success) console.error(result.error) + +/** 服务配置 / 运行统计 */ +const config = await api.config() +const stats = await api.stats() +``` + ## 文档 完整 API 文档: diff --git a/packages/webfont-sdk/package.json b/packages/webfont-sdk/package.json index 922c50f..110f6df 100644 --- a/packages/webfont-sdk/package.json +++ b/packages/webfont-sdk/package.json @@ -1,7 +1,7 @@ { "name": "webfont-sdk", - "version": "0.1.0", - "description": "Web 字体按需加载 SDK —— 只加载实际用到的字符,增量去重、无闪烁,支持 DOM(CSS) 与 Canvas(FontFace) 两种模式", + "version": "0.2.0", + "description": "Web 字体按需加载 SDK —— 只加载实际用到的字符,增量去重、无闪烁,支持 DOM(CSS) 与 Canvas(FontFace) 两种模式,内置服务端 REST API 客户端", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -11,6 +11,11 @@ "types": "./dist/index.d.ts", "development": "./src/index.ts", "import": "./dist/index.js" + }, + "./api": { + "types": "./dist/api.d.ts", + "development": "./src/api.ts", + "import": "./dist/api.js" } }, "files": [ diff --git a/packages/webfont-sdk/src/api.ts b/packages/webfont-sdk/src/api.ts new file mode 100644 index 0000000..803086c --- /dev/null +++ b/packages/webfont-sdk/src/api.ts @@ -0,0 +1,228 @@ +/** + * 服务端 REST API 客户端 —— webfont 子集化服务公开接口的封装 + * + * 与增量加载引擎(主入口 index.ts)完全独立,按需从子路径导入: + * import { createWebFontApi } from 'webfont-sdk/api' + * + * 覆盖的接口(与后端 routes 一一对应): + * - GET /api/fonts → api.fonts() 字体列表 + * - GET /api/font-meta → api.fontMeta(name) 字体元数据(覆盖率 / codepoint 区间 / name 表) + * - GET /api/config → api.config() 服务公开配置 + * - GET /api/stats → api.stats() 运行统计 + * - POST /api/upload → api.upload({...}) 上传字体(临时 / 管理员) + * - POST /api/stats/event → api.reportEvent(...) 离线裁剪匿名事件上报 + */ + +/** 字体列表项(GET /api/fonts) */ +export interface IApiFontInfo { + /** 字体文件名(子集 API 查询用,如 '令东齐伋复刻体.ttf') */ + name: string + /** 是否为临时上传字体(到期自动清理,列表展示时可过滤) */ + temporary: boolean +} + +/** 字符集覆盖率项(fontMeta 返回) */ +export interface IApiCharsetCoverage { + /** 字符集标识,如 'ascii'、'cjkBasic' */ + key: string + /** 字符集名称(中文) */ + name: string + /** 该字符集总字符数 */ + total: number + /** 字体覆盖的字符数 */ + covered: number + /** 覆盖率百分比(0~100,保留一位小数) */ + percent: number +} + +/** OpenType name 表信息(版权 / 作者 / 许可等,fontMeta 返回) */ +export interface IApiFontNameInfo { + copyright?: string + family?: string + subfamily?: string + uniqueId?: string + fullName?: string + version?: string + postScript?: string + trademark?: string + manufacturer?: string + designer?: string + description?: string + vendorUrl?: string + designerUrl?: string + license?: string + licenseUrl?: string +} + +/** 人工配置项(服务端 font-config.json,由站长维护,fontMeta 返回) */ +export interface IApiFontUserConfig { + /** 显示名称(优先于文件名) */ + displayName?: string + /** 描述 / 简介 */ + description?: string + /** 标签列表 */ + tags?: string[] + /** 开源仓库地址 */ + homepage?: string + /** 默认预览文字 */ + previewText?: string + /** 详情页正文标题 */ + bodyTitle?: string + /** 详情页正文段落 */ + bodyText?: string + /** 详情页字符预览行 */ + charsetPreview?: string +} + +/** 字体元数据(GET /api/font-meta) */ +export interface IApiFontMeta { + /** 元数据版本指纹(算法变更后 bump) */ + metaVersion: number + /** 字体支持的 codepoint 总数 */ + totalCodePoints: number + /** 各标准字符集覆盖率 */ + coverage: IApiCharsetCoverage[] + /** 支持的 codepoint 区间(紧凑表示,如 [[0x20, 0x7e], [0x4e00, 0x9fff]]) */ + ranges: Array<[number, number]> + /** name 表基本信息 */ + info: IApiFontNameInfo + /** 人工配置(服务端配置了才有) */ + config?: IApiFontUserConfig +} + +/** 服务公开配置(GET /api/config) */ +export interface IApiServerConfig { + /** 是否启用临时字体上传 */ + enableTempUpload: boolean + /** 是否启用管理员上传(服务端配置了 API Key) */ + adminUploadEnabled: boolean + /** 支持的子集输出格式 */ + supportedOutTypes: Array<'woff2' | 'ttf'> + /** 临时字体保留时限(秒) */ + tempRetentionSeconds: number + /** 字体子集化最大并发数 */ + subsetConcurrency: number + /** 队列等待超时(秒) */ + subsetQueueTimeoutSeconds: number +} + +/** 运行统计(GET /api/stats) */ +export interface IApiServerStats { + /** 服务运行秒数 */ + uptime: number + /** 总请求数 */ + totalRequests: number + /** 子集化请求数 */ + subsetRequests: number + /** 子集缓存命中数 */ + subsetCacheHits: number + /** 累计裁剪字符数 */ + totalChars: number + /** 临时字体上传次数 */ + tempUploads: number + /** 离线裁剪完成次数 */ + offlineSubsets: number + /** 离线裁剪字体下载次数 */ + offlineDownloads: number + /** 子集结果缓存条目数 */ + subsetCacheEntries: number + /** 字体二进制缓存条目数 */ + fontBufferCacheEntries: number +} + +/** 上传结果(POST /api/upload;失败是业务预期,不抛错由调用方判断 success) */ +export interface IApiUploadResult { + success: boolean + error?: string +} + +/** 客户端配置 */ +export interface IWebFontApiOptions { + /** 服务基地址,默认官方在线服务 */ + baseUrl?: string + /** 自定义 fetch 实现(老版本 Node / 测试注入用,默认全局 fetch) */ + fetchImpl?: typeof fetch +} + +/** 上传参数 */ +export interface IApiUploadInput { + /** 字体二进制内容 */ + data: Blob + /** 保存到服务端的文件名(如 '我的字体.ttf') */ + filename: string + /** 上传模式:temp 临时字体(默认,到期自动清理)/ admin 管理员字体(需 apiKey) */ + mode?: 'temp' | 'admin' + /** admin 模式的 API Key */ + apiKey?: string +} + +/** API 客户端实例 */ +export interface IWebFontApi { + /** 字体列表 */ + fonts(): Promise + /** 字体元数据(覆盖率 / codepoint 区间 / name 表 / 人工配置) */ + fontMeta(fontName: string): Promise + /** 服务公开配置 */ + config(): Promise + /** 运行统计 */ + stats(): Promise + /** 上传字体(失败返回 { success: false, error },不抛错) */ + upload(input: IApiUploadInput): Promise + /** 离线裁剪匿名事件上报(只发事件类型,不含任何内容数据;页面卸载也能送达) */ + reportEvent(event: 'offline_subset' | 'offline_download'): void +} + +/** + * 创建服务端 API 客户端 + * + * GET 类接口对非 2xx 响应直接抛 Error(fail fast,错误信息含状态码与响应体); + * upload 例外——服务端用 4xx + { success: false, error } 表达业务失败,原样返回。 + */ +export function createWebFontApi(options: IWebFontApiOptions = {}): IWebFontApi { + /** 去掉末尾斜杠,避免拼出双斜杠 */ + const baseUrl = (options.baseUrl ?? 'https://webfont.shenzilong.cn').replace(/\/+$/, '') + const fetchImpl = options.fetchImpl ?? fetch + + async function getJson(path: string): Promise { + const res = await fetchImpl(baseUrl + path) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`webfont-sdk/api GET ${path} → HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`) + } + return res.json() as Promise + } + + return { + fonts: () => getJson('/api/fonts'), + fontMeta: (fontName) => getJson(`/api/font-meta?font=${encodeURIComponent(fontName)}`), + config: () => getJson('/api/config'), + stats: () => getJson('/api/stats'), + + upload: async ({ data, filename, mode = 'temp', apiKey }: IApiUploadInput) => { + const form = new FormData() + form.append('font', data, filename) + /** 仅 admin 模式需要鉴权头 */ + const headers: Record = {} + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + const res = await fetchImpl(`${baseUrl}/api/upload?mode=${mode}`, { + method: 'POST', + body: form, + headers, + }) + return res.json() as Promise + }, + + reportEvent: (event) => { + const body = JSON.stringify({ event }) + /** sendBeacon 优先:页面卸载时也能送达;失败静默降级 fetch keepalive——统计不干扰主流程 */ + const nav = (globalThis as { navigator?: Navigator }).navigator + if (nav?.sendBeacon?.(`${baseUrl}/api/stats/event`, new Blob([body], { type: 'application/json' }))) return + fetchImpl(`${baseUrl}/api/stats/event`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => {}) + }, + } +} diff --git a/packages/webfont-sdk/tsdown.config.ts b/packages/webfont-sdk/tsdown.config.ts index ff5a240..c4a98b1 100644 --- a/packages/webfont-sdk/tsdown.config.ts +++ b/packages/webfont-sdk/tsdown.config.ts @@ -1,9 +1,9 @@ import { defineConfig } from 'tsdown' export default [ - /** ESM + d.ts —— npm 包主产物(leafer 插件 / bundler 用户) */ + /** ESM + d.ts —— npm 包主产物(leafer 插件 / bundler 用户);api 为服务端 REST 客户端子路径 */ defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/api.ts'], format: 'esm', dts: true, clean: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f689379..240f4c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: web-streams-polyfill: specifier: ^4.3.0 version: 4.3.0 + webfont-sdk: + specifier: workspace:* + version: link:packages/webfont-sdk devDependencies: '@leafer-ui/node': specifier: 2.2.9 diff --git a/src/api.ts b/src/api.ts index 02e9cc2..472c030 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,157 +1,80 @@ -export interface FontInfo { - name: string; +/** + * 主站 API 适配层 —— 基于官方 SDK 客户端(webfont-sdk/api)的薄封装 + * + * 所有类型与请求逻辑统一走 webfont-sdk:接口变更只改 SDK 一处, + * 主站(dogfooding)、leafer 插件 demo、npm 用户共享同一实现。 + * + * 保留原有的函数签名与旧类型别名(FontInfo / FontMeta / ...), + * 组件层零改动即可切换;新代码建议直接 import SDK 类型。 + * + * baseUrl 说明:主站与后端同源(dev 走 vite proxy /api → 8087), + * 传空串 baseUrl 后客户端拼出相对路径 "/api/...",同源与代理都正确。 + */ +import { + createWebFontApi, + type IApiFontInfo, + type IApiFontMeta, + type IApiServerConfig, + type IApiServerStats, + type IApiUploadResult, +} from "webfont-sdk/api"; + +const api = createWebFontApi({ baseUrl: "" }); + +/** 字体列表项(旧名兼容;新代码用 IApiFontInfo) */ +export type FontInfo = IApiFontInfo & { + /** 旧字段(后端实际不返回,恒 undefined),dev 预览页模板引用,保留可选 */ dir?: string; - /** 是否为临时上传的字体 */ - temporary?: boolean; -} +}; -export interface ServerConfig { - enableTempUpload: boolean; - adminUploadEnabled: boolean; - supportedOutTypes: ("woff2" | "ttf")[]; - /** 临时字体保留时限(秒) */ - tempRetentionSeconds?: number; - /** 字体子集化最大并发数 */ - subsetConcurrency?: number; - /** 队列等待超时(秒) */ - subsetQueueTimeoutSeconds?: number; -} +/** 字体元数据(旧名兼容;新代码用 IApiFontMeta) */ +export type FontMeta = IApiFontMeta; -export interface UploadResult { - success: boolean; - error?: string; -} +/** 服务公开配置(旧名兼容;新代码用 IApiServerConfig) */ +export type ServerConfig = IApiServerConfig; -export interface ServerStats { - uptime: number; - totalRequests: number; - subsetRequests: number; - subsetCacheHits: number; - totalChars: number; - subsetCacheEntries: number; - fontBufferCacheEntries: number; - /** 临时文件上传次数 */ - tempUploads?: number; - /** 离线裁剪完成次数 */ - offlineSubsets?: number; - /** 离线裁剪字体下载次数 */ - offlineDownloads?: number; -} +/** 上传结果(旧名兼容;新代码用 IApiUploadResult) */ +export type UploadResult = IApiUploadResult; -/** 字符集覆盖率 */ -export interface CharsetCoverage { - /** 字符集标识,如 "ascii"、"cjkBasic" */ - key: string; - name: string; - total: number; - covered: number; - percent: number; -} +/** 运行统计(旧名兼容;新代码用 IApiServerStats) */ +export type ServerStats = IApiServerStats; -/** 字体基本信息(来自 OpenType name 表) */ -export interface FontInfo { - copyright?: string; - family?: string; - subfamily?: string; - uniqueId?: string; - fullName?: string; - version?: string; - postScript?: string; - trademark?: string; - manufacturer?: string; - designer?: string; - description?: string; - vendorUrl?: string; - designerUrl?: string; - license?: string; - licenseUrl?: string; -} - -/** 人工配置项(来自 font-config.json,由用户维护) */ -export interface FontUserConfig { - /** 显示名称(优先于文件名) */ - displayName?: string; - /** 描述/简介 */ - description?: string; - /** 标签列表 */ - tags?: string[]; - /** 开源仓库地址(如 GitHub URL) */ - homepage?: string; - /** 默认预览文字 */ - previewText?: string; - /** 详情页正文标题 */ - bodyTitle?: string; - /** 详情页正文段落 */ - bodyText?: string; - /** 详情页字符预览行 */ - charsetPreview?: string; -} - -/** 字体元数据 */ -export interface FontMeta { - totalCodePoints: number; - coverage: CharsetCoverage[]; - ranges: Array<[number, number]>; - /** 字体基本信息(版权、作者等) */ - info: FontInfo; - /** 人工配置(来自 font-config.json) */ - config?: FontUserConfig; -} +export { type IApiCharsetCoverage as CharsetCoverage, type IApiFontUserConfig as FontUserConfig } from "webfont-sdk/api"; export async function fetchFonts(): Promise { - const res = await fetch("/api/fonts"); - return res.json(); + return api.fonts(); } export async function fetchFontMeta(fontName: string): Promise { - const res = await fetch(`/api/font-meta?font=${encodeURIComponent(fontName)}`); - return res.json(); + return api.fontMeta(fontName); } export async function fetchConfig(): Promise { - const res = await fetch("/api/config"); - return res.json(); + return api.config(); } +export async function fetchStats(): Promise { + return api.stats(); +} + +/** + * 上传字体(临时 / 管理员) + * + * SDK 客户端用 FormData 上传,主站调用方传的是 File —— 直接透传, + * FormData.append(name, File) 会自动带上 filename。 + */ export async function uploadFont( file: File, mode: "temp" | "admin", apiKey?: string, ): Promise { - const formData = new FormData(); - formData.append("font", file); - - const headers: Record = {}; - if (apiKey) { - headers["Authorization"] = `Bearer ${apiKey}`; - } - - const res = await fetch(`/api/upload?mode=${mode}`, { - method: "POST", - body: formData, - headers, - }); - return res.json(); -} - -export async function fetchStats(): Promise { - const res = await fetch("/api/stats"); - return res.json(); + return api.upload({ data: file, filename: file.name, mode, apiKey }); } /** - * 离线裁剪匿名事件上报 - * - * 只发送事件类型(裁剪完成 / 下载),不包含字体、文字等任何内容数据。 - * 用 sendBeacon 优先(页面卸载时也能送达),失败静默——统计不干扰主流程。 + * 离线裁剪匿名事件上报 —— 透传 SDK 实现 + * (sendBeacon 优先,页面卸载也能送达;只发事件类型,无内容数据) */ export function reportOfflineEvent(event: "offline_subset" | "offline_download"): void { - const body = JSON.stringify({ event }); - if (navigator.sendBeacon?.("/api/stats/event", new Blob([body], { type: "application/json" }))) return; - fetch("/api/stats/event", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - keepalive: true, - }).catch(() => {}); + api.reportEvent(event); } diff --git a/src/i18n.ts b/src/i18n.ts index 722febb..711c038 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -57,6 +57,12 @@ const messages = { sdkModes: "还支持", observeFont: "(MutationObserver 事件驱动)和", loadText: "(手动传文本)两种方式,多种方式可同时使用,SDK 内部自动按字体去重增量加载。", + /** npm SDK 章节 */ + npmSdkTitle: "npm 包(推荐,工程化项目):", + npmSdkText: "安装 ", + npmSdkText2: " 后可在任意前端工程 / Node 项目中使用,支持 DOM(CSS 模式)与 Canvas(FontFace 模式):", + npmSdkApiTitle: "服务端 API 客户端:", + npmSdkApiText: "字体列表 / 元数据 / 上传 / 统计等 REST 接口封装在独立子路径,供字体选择器等场景使用:", thanks: "感谢", thanksText: "收录本项目", buyCoffee: "觉得好用?", @@ -161,6 +167,12 @@ const messages = { sdkModes: "Also supports ", observeFont: " (MutationObserver-driven) and ", loadText: " (manual text). Multiple modes can be used simultaneously with automatic deduplication.", + /** npm SDK section */ + npmSdkTitle: "npm package (recommended for projects):", + npmSdkText: "Install ", + npmSdkText2: " to use in any frontend / Node project. Supports DOM (CSS mode) and Canvas (FontFace mode):", + npmSdkApiTitle: "Server API client:", + npmSdkApiText: "REST endpoints (font list / metadata / upload / stats) live in a separate subpath export:", thanks: "Thanks to ", thanksText: " for featuring this project", buyCoffee: "Find it useful? ", diff --git a/src/pages/Docs.vue b/src/pages/Docs.vue index 8f8258d..1b0d8dc 100644 --- a/src/pages/Docs.vue +++ b/src/pages/Docs.vue @@ -40,6 +40,41 @@ const basicUsageCode = computed(() => { const jsSdkCode = computed(() => { return '