mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 23:02:27 +08:00
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:
parent
23728ba02b
commit
9397e10aad
@ -11,6 +11,7 @@
|
|||||||
"serve:llrt": "./llrt ./dist_backend/app.cjs",
|
"serve:llrt": "./llrt ./dist_backend/app.cjs",
|
||||||
"build": "vite-ssg build",
|
"build": "vite-ssg build",
|
||||||
"build_backend": "pnpx tsx scripts/build-backend.ts",
|
"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_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",
|
"docker_push": "docker push llej0/web-font:${npm_package_version} && docker push llej0/web-font:latest",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
|||||||
97
scripts/lint-font-config.ts
Normal file
97
scripts/lint-font-config.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -73,6 +73,27 @@ function splitSemicolon(text: string | undefined): string[] {
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -80,7 +101,7 @@ function splitSemicolon(text: string | undefined): string[] {
|
|||||||
<!-- 字体加载:SSG 时含占位符,后端替换后即指向正确字体子集 -->
|
<!-- 字体加载:SSG 时含占位符,后端替换后即指向正确字体子集 -->
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
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,
|
lineHeight: 1.3,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
静心茶舍
|
{{ previewLines.title }}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
|
v-if="previewLines.subtitle"
|
||||||
:style="{
|
:style="{
|
||||||
fontFamily: `'${fontName}', sans-serif`,
|
fontFamily: `'${fontName}', sans-serif`,
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
@ -170,7 +192,7 @@ function splitSemicolon(text: string | undefined): string[] {
|
|||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.1em',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
以茶为媒 · 静心观自在
|
{{ previewLines.subtitle }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -34,8 +34,18 @@ const query = ref("");
|
|||||||
/** 字体元数据缓存(key = 字体名),卡片进入视口后按需加载 */
|
/** 字体元数据缓存(key = 字体名),卡片进入视口后按需加载 */
|
||||||
const metaMap = ref<Map<string, FontMeta>>(new Map());
|
const metaMap = ref<Map<string, FontMeta>>(new Map());
|
||||||
|
|
||||||
/** 预览文字内容(与 loadText 参数一致) */
|
/** 默认预览文字(font-config.json 未配置 previewText 时使用) */
|
||||||
const PREVIEW_TEXT = "静心茶舍 天地无极 ABCDEF";
|
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> */
|
/** 排序方式:default | codePoints | name | coverage:<charsetKey> */
|
||||||
const sortBy = ref<string>("default");
|
const sortBy = ref<string>("default");
|
||||||
@ -102,9 +112,10 @@ onMounted(async () => {
|
|||||||
* 2. 请求字体元数据(覆盖率),后端有磁盘缓存不重复计算
|
* 2. 请求字体元数据(覆盖率),后端有磁盘缓存不重复计算
|
||||||
*/
|
*/
|
||||||
function onCardAppear(fontName: string) {
|
function onCardAppear(fontName: string) {
|
||||||
|
/** 预加载默认预览文字,meta 拿到后如果配了 previewText 再加载一次 */
|
||||||
(globalThis as any).WebFont?.loadText?.({
|
(globalThis as any).WebFont?.loadText?.({
|
||||||
fontName,
|
fontName,
|
||||||
text: PREVIEW_TEXT,
|
text: DEFAULT_PREVIEW_TEXT,
|
||||||
family: fontName,
|
family: fontName,
|
||||||
});
|
});
|
||||||
/** 已加载过则跳过 */
|
/** 已加载过则跳过 */
|
||||||
@ -112,16 +123,19 @@ function onCardAppear(fontName: string) {
|
|||||||
fetchFontMeta(fontName)
|
fetchFontMeta(fontName)
|
||||||
.then((m) => {
|
.then((m) => {
|
||||||
metaMap.value.set(fontName, m);
|
metaMap.value.set(fontName, m);
|
||||||
/** 触发响应式更新 */
|
|
||||||
metaMap.value = new Map(metaMap.value);
|
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(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 点击字体卡片 → 跳转详情页 */
|
|
||||||
function goToDetail(name: string) {
|
|
||||||
router.push(`/fonts/${encodeURIComponent(name)}`);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -239,10 +253,12 @@ function goToDetail(name: string) {
|
|||||||
:key="font.name"
|
:key="font.name"
|
||||||
@appear="onCardAppear(font.name)"
|
@appear="onCardAppear(font.name)"
|
||||||
>
|
>
|
||||||
<div
|
<router-link
|
||||||
|
:to="`/fonts/${encodeURIComponent(font.name)}`"
|
||||||
:data-font="font.name"
|
:data-font="font.name"
|
||||||
@click="goToDetail(font.name)"
|
|
||||||
style="
|
style="
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
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'"
|
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'"
|
onmouseout="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.04)';this.style.borderColor='transparent'"
|
||||||
>
|
>
|
||||||
<!-- 字体名 -->
|
<!-- 字体名(优先显示 displayName) -->
|
||||||
<div style="font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px">
|
<div style="font-size: 15px; font-weight: 600; color: #333; margin-bottom: 4px">
|
||||||
{{ font.name }}
|
{{ displayNameOf(font.name) }}
|
||||||
<span
|
<span
|
||||||
v-if="font.temporary"
|
v-if="font.temporary"
|
||||||
style="
|
style="
|
||||||
@ -270,17 +286,25 @@ function goToDetail(name: string) {
|
|||||||
>
|
>
|
||||||
</div>
|
</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
|
<div
|
||||||
:style="{
|
:style="{
|
||||||
fontFamily: `'${font.name}', serif`,
|
fontFamily: `'${font.name}', serif`,
|
||||||
fontSize: '32px',
|
fontSize: '32px',
|
||||||
color: '#2c2c2c',
|
color: '#2c2c2c',
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginBottom: '8px',
|
marginBottom: '4px',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
静心茶舍
|
{{ previewTextOf(font.name).split(' ')[0] || previewTextOf(font.name) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:style="{
|
:style="{
|
||||||
@ -289,7 +313,26 @@ function goToDetail(name: string) {
|
|||||||
color: '#999',
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- 覆盖率标签 -->
|
<!-- 覆盖率标签 -->
|
||||||
@ -306,9 +349,28 @@ function goToDetail(name: string) {
|
|||||||
}"
|
}"
|
||||||
>{{ c.name.replace(/(.+)/, '') }} {{ c.percent }}%</span>
|
>{{ c.name.replace(/(.+)/, '') }} {{ c.percent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</router-link>
|
||||||
</LazyTrigger>
|
</LazyTrigger>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user