feat: 字体列表/详情页接入 font-config 配置 + lint 脚本

前端:
- FontList.vue: 卡片使用 router-link 跳转;
  展示 displayName/description/previewText/tags;
  底部添加字体投稿联系文案
- FontDetail.vue: 预览文字使用 config.previewText 替代写死文案

工具:
- scripts/lint-font-config.ts: 检查 previewText 字符是否被字体支持
- package.json: 添加 lint:font-config 脚本
This commit is contained in:
崮生(子虚) 2026-07-30 23:23:30 +08:00
parent 23728ba02b
commit 9397e10aad
4 changed files with 204 additions and 22 deletions

View File

@ -11,6 +11,7 @@
"serve:llrt": "./llrt ./dist_backend/app.cjs",
"build": "vite-ssg build",
"build_backend": "pnpx tsx scripts/build-backend.ts",
"lint:font-config": "pnpx tsx scripts/lint-font-config.ts",
"docker_build": "docker build -t llej0/web-font:${npm_package_version} -t llej0/web-font:latest .",
"docker_push": "docker push llej0/web-font:${npm_package_version} && docker push llej0/web-font:latest",
"preview": "vite preview",

View File

@ -0,0 +1,97 @@
/**
* lint-font-config.ts
*
* font/font-config.json previewText
* previewText
*
* pnpm tsx scripts/lint-font-config.ts
*
* font-config.json previewText
*/
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { extractCodePoints } from "../backend/font_util/font_meta.ts";
interface FontUserConfig {
displayName?: string;
previewText?: string;
}
type FontConfig = Record<string, FontUserConfig>;
const FONT_DIR = "font";
const CONFIG_PATH = "font/font-config.json";
async function main() {
/** 读取配置 */
const configRaw = await readFile(CONFIG_PATH, "utf-8");
const config = JSON.parse(configRaw) as FontConfig;
/** 扫描字体目录,建立 文件名→路径 映射 */
const fontFiles = await readdir(FONT_DIR);
const fontPathMap = new Map<string, string>();
for (const f of fontFiles) {
if (/\.(ttf|otf|TTF|OTF)$/i.test(f)) {
fontPathMap.set(f, join(FONT_DIR, f));
}
}
let hasError = false;
for (const [fileName, cfg] of Object.entries(config)) {
const previewText = cfg.previewText;
if (!previewText) {
/** 没配 previewText跳过用默认值无法检查 */
continue;
}
const fontPath = fontPathMap.get(fileName);
if (!fontPath) {
console.warn(`⚠️ [${fileName}] 字体文件不存在,跳过`);
continue;
}
/** 读取字体并提取 codepoints */
const raw = await readFile(fontPath);
const fontBuffer = raw.buffer.slice(
raw.byteOffset,
raw.byteOffset + raw.byteLength,
);
const supportedCps = extractCodePoints(fontBuffer);
/** 逐字符检查 previewText */
const chars = [...previewText];
/** 空格/换行等空白字符不检查(字体通常都有,但即使没有也不影响预览) */
const missing: string[] = [];
for (const ch of chars) {
if (/\s/.test(ch)) continue;
const cp = ch.codePointAt(0)!;
if (!supportedCps.has(cp)) {
missing.push(ch);
}
}
const displayName = cfg.displayName ?? fileName;
if (missing.length > 0) {
hasError = true;
console.error(
`❌ [${displayName}] (${fileName}) previewText 含 ${missing.length} 个不支持的字符:${missing.join(" ")}`,
);
console.error(` previewText: "${previewText}"`);
} else {
console.log(`✅ [${displayName}] (${fileName}) previewText 检查通过`);
}
}
if (hasError) {
console.error("\n💔 存在不支持的字符,请修正 previewText");
process.exit(1);
} else {
console.log("\n🎉 所有 previewText 检查通过");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -73,6 +73,27 @@ function splitSemicolon(text: string | undefined): string[] {
.map((s) => s.trim())
.filter(Boolean);
}
/** 默认预览文案font-config.json 未配 previewText 时使用) */
const DEFAULT_PREVIEW = "静心茶舍以茶为媒观自在一叶知秋山间晨露未晞茶人已入林深处指尖轻捻择其嫩芽天地无极乾坤借法";
/** 当前字体的预览文案:优先用 config.previewText */
const previewContent = computed(() => meta.value?.config?.previewText ?? DEFAULT_PREVIEW);
/** 预览文字拆分:第一段做大标题,其余做副文本 */
const previewLines = computed(() => {
const text = previewContent.value;
/** 按空格拆分,第一段做大字标题 */
const parts = text.split(/\s+/).filter(Boolean);
return {
title: parts[0] ?? text,
subtitle: parts.slice(1).join(" ") || "",
full: text,
};
});
/** 显示名:优先 config.displayName */
const displayName = computed(() => meta.value?.config?.displayName ?? fontName.value);
</script>
<template>
@ -80,7 +101,7 @@ function splitSemicolon(text: string | undefined): string[] {
<!-- 字体加载SSG 时含占位符后端替换后即指向正确字体子集 -->
<link
rel="stylesheet"
:href="`${origin}/api?font=${fontName}&text=静心茶舍以茶为媒观自在一叶知秋山间晨露未晞茶人已入林深处指尖轻捻择其嫩芽天地无极乾坤借法&outType=woff2`"
:href="`${origin}/api?font=${fontName}&text=${encodeURIComponent(previewContent)}&outType=woff2`"
/>
<!-- 顶部导航 -->
@ -159,9 +180,10 @@ function splitSemicolon(text: string | undefined): string[] {
lineHeight: 1.3,
}"
>
静心茶舍
{{ previewLines.title }}
</div>
<p
v-if="previewLines.subtitle"
:style="{
fontFamily: `'${fontName}', sans-serif`,
fontSize: '16px',
@ -170,7 +192,7 @@ function splitSemicolon(text: string | undefined): string[] {
letterSpacing: '0.1em',
}"
>
以茶为媒 · 静心观自在
{{ previewLines.subtitle }}
</p>
</div>

View File

@ -34,8 +34,18 @@ const query = ref("");
/** 字体元数据缓存key = 字体名),卡片进入视口后按需加载 */
const metaMap = ref<Map<string, FontMeta>>(new Map());
/** 预览文字内容(与 loadText 参数一致) */
const PREVIEW_TEXT = "静心茶舍 天地无极 ABCDEF";
/** 默认预览文字font-config.json 未配置 previewText 时使用) */
const DEFAULT_PREVIEW_TEXT = "静心茶舍 天地无极 ABCDEF";
/** 取某个字体的预览文字:优先用 font-config.json 配置的 previewText */
function previewTextOf(fontName: string): string {
return metaMap.value.get(fontName)?.config?.previewText ?? DEFAULT_PREVIEW_TEXT;
}
/** 取显示名:优先用 config.displayName否则用文件名 */
function displayNameOf(fontName: string): string {
return metaMap.value.get(fontName)?.config?.displayName ?? fontName;
}
/** 排序方式default | codePoints | name | coverage:<charsetKey> */
const sortBy = ref<string>("default");
@ -102,9 +112,10 @@ onMounted(async () => {
* 2. 请求字体元数据覆盖率后端有磁盘缓存不重复计算
*/
function onCardAppear(fontName: string) {
/** 预加载默认预览文字meta 拿到后如果配了 previewText 再加载一次 */
(globalThis as any).WebFont?.loadText?.({
fontName,
text: PREVIEW_TEXT,
text: DEFAULT_PREVIEW_TEXT,
family: fontName,
});
/** 已加载过则跳过 */
@ -112,16 +123,19 @@ function onCardAppear(fontName: string) {
fetchFontMeta(fontName)
.then((m) => {
metaMap.value.set(fontName, m);
/** 触发响应式更新 */
metaMap.value = new Map(metaMap.value);
/** 如果配了专属 previewText用配置文字再加载一次字体子集 */
const customText = m.config?.previewText;
if (customText && customText !== DEFAULT_PREVIEW_TEXT) {
(globalThis as any).WebFont?.loadText?.({
fontName,
text: customText,
family: fontName,
});
}
})
.catch(() => {});
}
/** 点击字体卡片 → 跳转详情页 */
function goToDetail(name: string) {
router.push(`/fonts/${encodeURIComponent(name)}`);
}
</script>
<template>
@ -239,10 +253,12 @@ function goToDetail(name: string) {
:key="font.name"
@appear="onCardAppear(font.name)"
>
<div
<router-link
:to="`/fonts/${encodeURIComponent(font.name)}`"
:data-font="font.name"
@click="goToDetail(font.name)"
style="
display: block;
text-decoration: none;
background: #fff;
border-radius: 12px;
padding: 24px;
@ -253,9 +269,9 @@ function goToDetail(name: string) {
onmouseover="this.style.boxShadow='0 4px 16px rgba(0,0,0,0.08)';this.style.borderColor='#1677ff'"
onmouseout="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.04)';this.style.borderColor='transparent'"
>
<!-- 字体名 -->
<div style="font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px">
{{ font.name }}
<!-- 字体名优先显示 displayName -->
<div style="font-size: 15px; font-weight: 600; color: #333; margin-bottom: 4px">
{{ displayNameOf(font.name) }}
<span
v-if="font.temporary"
style="
@ -270,17 +286,25 @@ function goToDetail(name: string) {
>
</div>
<!-- 字体预览 -->
<!-- 描述来自 font-config.json -->
<div
v-if="metaMap.get(font.name)?.config?.description"
style="font-size: 12px; color: #888; margin-bottom: 10px; line-height: 1.4"
>
{{ metaMap.get(font.name)?.config?.description }}
</div>
<!-- 字体预览优先使用配置的 previewText -->
<div
:style="{
fontFamily: `'${font.name}', serif`,
fontSize: '32px',
color: '#2c2c2c',
lineHeight: 1.4,
marginBottom: '8px',
marginBottom: '4px',
}"
>
静心茶舍
{{ previewTextOf(font.name).split(' ')[0] || previewTextOf(font.name) }}
</div>
<div
:style="{
@ -289,7 +313,26 @@ function goToDetail(name: string) {
color: '#999',
}"
>
天地无极 ABCDEF
{{ previewTextOf(font.name).split(' ').slice(1).join(' ') || 'ABCDEF abcdef 0123' }}
</div>
<!-- 标签来自 font-config.json -->
<div
v-if="metaMap.get(font.name)?.config?.tags?.length"
style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px"
>
<span
v-for="tag in metaMap.get(font.name)?.config?.tags ?? []"
:key="tag"
style="
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #f5f0e8;
color: #8b7355;
"
>{{ tag }}</span
>
</div>
<!-- 覆盖率标签 -->
@ -306,9 +349,28 @@ function goToDetail(name: string) {
}"
>{{ c.name.replace(/.+/, '') }} {{ c.percent }}%</span>
</div>
</div>
</router-link>
</LazyTrigger>
</div>
<!-- 投稿提示 -->
<div
style="
margin-top: 32px;
padding: 20px 24px;
background: #faf8f5;
border-radius: 12px;
border: 1px solid #f0ebe3;
text-align: center;
font-size: 14px;
color: #8b7355;
line-height: 1.8;
"
>
想要永久上传自己的商用免费字体
<a href="mailto:admin@shenzilong.cn" style="color: #8b7355; text-decoration: underline">联系崮生</a>
· 邮箱 admin@shenzilong.cn
</div>
</div>
</div>
</template>