Skip to content

Commit 339ca6f

Browse files
committed
feat: add Umami analytics, improve SEO and build pipeline
- analytics: hardcode Umami script, inject only in production builds - seo: add og:image, twitter:image, per-page canonical link via transformHead - pwa: exclude search indexes from precache, serve via NetworkFirst runtime cache - build: switch docs:build to parallel builds (~40% faster) - rss: add en and ja locale feeds - hljs: register diff, sql, rust, go languages - precompute-meta: strip HTML tags and inline Markdown from description - parallel-build: use MAX_OLD_SPACE_SIZE env var instead of hardcoded 24576
1 parent 0121782 commit 339ca6f

7,075 files changed

Lines changed: 10920 additions & 49 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/.vitepress/config.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defineConfig } from "vitepress";
2+
import { VitePWA } from "vite-plugin-pwa";
23
import { buildRSS } from "./rss";
34
import { zh } from "./locales/zh";
45
import { en } from "./locales/en";
@@ -12,6 +13,10 @@ import { zhHk } from "./locales/zh-hk";
1213
// undefined → all 5 (dev mode only, needs lots of RAM)
1314
const BUILD_MODE = process.env.BUILD_MODE as "main" | "zh-variants" | undefined;
1415

16+
// ── Analytics (Umami) ────────────────────────────────────────────────────────
17+
// Only injected in production builds to avoid polluting analytics with dev traffic.
18+
const IS_PRODUCTION = process.env.NODE_ENV === "production";
19+
1520
const allLocales = {
1621
root: { ...zh },
1722
en: { ...en },
@@ -61,6 +66,13 @@ export default defineConfig({
6166
{ property: "og:url", content: "https://fonghehe.github.io/blog/" },
6267
],
6368
["meta", { property: "og:locale", content: "zh_CN" }],
69+
[
70+
"meta",
71+
{
72+
property: "og:image",
73+
content: "https://fonghehe.github.io/blog/icons/icon.svg",
74+
},
75+
],
6476
["meta", { name: "twitter:card", content: "summary_large_image" }],
6577
["meta", { name: "twitter:title", content: "前端成长记录" }],
6678
[
@@ -71,10 +83,36 @@ export default defineConfig({
7183
"一个前端工程师从 2018 年开始的学习与成长记录。1200+ 篇深度文章,涵盖框架原理、工程化实践与前沿探索。",
7284
},
7385
],
86+
[
87+
"meta",
88+
{
89+
name: "twitter:image",
90+
content: "https://fonghehe.github.io/blog/icons/icon.svg",
91+
},
92+
],
93+
// Umami analytics — only injected in production builds
94+
...(IS_PRODUCTION
95+
? ([
96+
[
97+
"script",
98+
{
99+
defer: "",
100+
src: "https://cloud.umami.is/script.js",
101+
"data-website-id": "4d310e6b-2d69-42aa-aaa2-c788a664d3c5",
102+
},
103+
],
104+
] as [string, Record<string, string>][])
105+
: []),
74106
],
75107
sitemap: {
76108
hostname: "https://fonghehe.github.io/blog/",
77109
},
110+
transformHead({ pageData }) {
111+
const canonical = `https://fonghehe.github.io/blog/${pageData.relativePath}`
112+
.replace(/index\.md$/, "")
113+
.replace(/\.md$/, ".html");
114+
return [["link", { rel: "canonical", href: canonical }]];
115+
},
78116
locales: allLocales,
79117
themeConfig: {
80118
search: {
@@ -88,14 +126,16 @@ export default defineConfig({
88126
markdown: {
89127
// 完全替换 shiki — 用轻量 highlighter 直接输出 <pre><code>
90128
// shiki 是构建最大瓶颈:加载语法文件 + token 化 7000 篇文章的代码块
129+
// 语法高亮由客户端 highlight.js 接管(见 theme/index.ts)
91130
highlight(code, lang) {
92131
const escaped = code
93132
.replace(/&/g, "&amp;")
94133
.replace(/</g, "&lt;")
95134
.replace(/>/g, "&gt;")
96135
.replace(/\{\{/g, "&#123;&#123;")
97136
.replace(/\}\}/g, "&#125;&#125;");
98-
return `<pre class="language-${lang}"><code>${escaped}</code></pre>`;
137+
const cls = lang ? ` class="language-${lang}"` : "";
138+
return `<pre class="language-${lang}"><code${cls}>${escaped}</code></pre>`;
99139
},
100140
config(md) {
101141
// 转义非代码区域的 {{ }} 防止 Vue 模板编译报错
@@ -132,6 +172,62 @@ export default defineConfig({
132172
}
133173
},
134174
vite: {
175+
plugins: [
176+
VitePWA({
177+
// Only register service worker in the main build
178+
disable: BUILD_MODE === "zh-variants",
179+
registerType: "autoUpdate",
180+
outDir: ".vitepress/dist",
181+
injectRegister: "script-defer",
182+
manifest: {
183+
name: "前端成长记录",
184+
short_name: "前端记录",
185+
description:
186+
"一个前端工程师从 2018 年开始的学习与成长记录,1200+ 篇深度文章",
187+
theme_color: "#3c8772",
188+
background_color: "#ffffff",
189+
display: "standalone",
190+
start_url: "/blog/",
191+
scope: "/blog/",
192+
icons: [
193+
{
194+
src: "/blog/icons/icon.svg",
195+
sizes: "any",
196+
type: "image/svg+xml",
197+
purpose: "any maskable",
198+
},
199+
],
200+
},
201+
workbox: {
202+
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
203+
// Exclude local search indexes (5-6 MB each) from precache — served via NetworkFirst at runtime
204+
globIgnores: ["**/chunks/@localSearchIndex*"],
205+
navigateFallback: null,
206+
runtimeCaching: [
207+
{
208+
// Search indexes are large and change on every build; fetch fresh when online
209+
urlPattern: /@localSearchIndex/,
210+
handler: "NetworkFirst",
211+
options: {
212+
cacheName: "search-index",
213+
expiration: { maxEntries: 10 },
214+
},
215+
},
216+
{
217+
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
218+
handler: "CacheFirst",
219+
options: {
220+
cacheName: "google-fonts",
221+
expiration: {
222+
maxEntries: 20,
223+
maxAgeSeconds: 60 * 60 * 24 * 365,
224+
},
225+
},
226+
},
227+
],
228+
},
229+
}),
230+
],
135231
build: {
136232
reportCompressedSize: false,
137233
chunkSizeWarningLimit: 4000,

docs/.vitepress/css.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Allow CSS side-effect imports (e.g. highlight.js styles via Vite)
2+
declare module "*.css" {
3+
const content: Record<string, string>;
4+
export default content;
5+
}
6+
declare module "*.min.css" {
7+
const content: Record<string, string>;
8+
export default content;
9+
}
10+
// highlight.js style side-effect imports
11+
declare module "highlight.js/styles/*";

docs/.vitepress/locales/zh-hk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type LocaleConfig = LocaleSpecificConfig<DefaultTheme.Config> & {
66
};
77

88
export const zhHk: LocaleConfig = {
9-
label: "繁體中文(香港)",
9+
label: "繁體中文()",
1010
lang: "zh-HK",
1111
themeConfig: {
1212
nav: [

docs/.vitepress/locales/zh-tw.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type LocaleConfig = LocaleSpecificConfig<DefaultTheme.Config> & {
66
};
77

88
export const zhTw: LocaleConfig = {
9-
label: "繁體中文(台灣)",
9+
label: "繁體中文()",
1010
lang: "zh-TW",
1111
themeConfig: {
1212
nav: [

docs/.vitepress/rss.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,54 @@ import { writeFileSync, mkdirSync } from "fs";
44
import path from "path";
55

66
const SITE_URL = "https://fonghehe.github.io/blog";
7-
const FEED_TITLE = "前端成长记录";
8-
const FEED_DESCRIPTION =
9-
"一个前端工程师从 2018 年开始的学习与成长记录。1200+ 篇深度文章,涵盖框架原理、工程化实践与前沿探索。";
107

11-
export async function buildRSS(config: SiteConfig) {
8+
interface FeedConfig {
9+
globs: string[];
10+
title: string;
11+
description: string;
12+
language: string;
13+
outFile: string;
14+
}
15+
16+
const FEEDS: FeedConfig[] = [
17+
{
18+
globs: ["posts/**/*.md", "archive/**/*.md"],
19+
title: "前端成长记录",
20+
description:
21+
"一个前端工程师从 2018 年开始的学习与成长记录。1200+ 篇深度文章,涵盖框架原理、工程化实践与前沿探索。",
22+
language: "zh-CN",
23+
outFile: "rss.xml",
24+
},
25+
{
26+
globs: ["en/posts/**/*.md", "en/archive/**/*.md"],
27+
title: "Frontend Growth Blog",
28+
description:
29+
"A frontend engineer's learning journey since 2018. 1200+ in-depth articles on framework internals, engineering practices and cutting-edge exploration.",
30+
language: "en",
31+
outFile: "en/rss.xml",
32+
},
33+
{
34+
globs: ["ja/posts/**/*.md", "ja/archive/**/*.md"],
35+
title: "フロントエンド成長記録",
36+
description:
37+
"2018年から始まるフロントエンドエンジニアの学習・成長記録。フレームワーク原理、エンジニアリング実践、最新技術探索などの1200+記事。",
38+
language: "ja",
39+
outFile: "ja/rss.xml",
40+
},
41+
];
42+
43+
async function buildFeed(cfg: FeedConfig, siteConfig: SiteConfig) {
1244
const feed = new Feed({
13-
title: FEED_TITLE,
14-
description: FEED_DESCRIPTION,
45+
title: cfg.title,
46+
description: cfg.description,
1547
id: SITE_URL,
1648
link: SITE_URL,
17-
language: "zh-CN",
18-
copyright: `MIT Licensed - ${FEED_TITLE} 2018-2026`,
49+
language: cfg.language,
50+
copyright: `MIT Licensed - 前端成长记录 2018-2026`,
1951
updated: new Date(),
2052
});
2153

22-
const posts = await createContentLoader(
23-
["posts/**/*.md", "archive/**/*.md"],
24-
{ render: false },
25-
).load();
54+
const posts = await createContentLoader(cfg.globs, { render: false }).load();
2655

2756
const sorted = posts
2857
.filter((p) => p.frontmatter?.date && !p.url.endsWith("/"))
@@ -53,7 +82,11 @@ export async function buildRSS(config: SiteConfig) {
5382
});
5483
}
5584

56-
const outDir = config.outDir;
57-
mkdirSync(outDir, { recursive: true });
58-
writeFileSync(path.join(outDir, "rss.xml"), feed.rss2());
85+
const outPath = path.join(siteConfig.outDir, cfg.outFile);
86+
mkdirSync(path.dirname(outPath), { recursive: true });
87+
writeFileSync(outPath, feed.rss2());
88+
}
89+
90+
export async function buildRSS(config: SiteConfig) {
91+
await Promise.all(FEEDS.map((cfg) => buildFeed(cfg, config)));
5992
}

docs/.vitepress/theme/ArticleTitle.vue

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ const shouldShow = computed(
1515
// so it matches PostList.vue's post.readingTime
1616
const readingTime = computed(() => Number(frontmatter.value.readingTime) || 1);
1717
18+
const wordCount = computed(() => {
19+
const wc = Number(frontmatter.value.wordCount) || 0;
20+
if (wc === 0) return "";
21+
return wc >= 1000 ? `${(wc / 1000).toFixed(1)}k` : String(wc);
22+
});
23+
1824
const isOutdated = computed(() => {
1925
const dateStr = frontmatter.value.date;
2026
if (!dateStr) return false;
@@ -42,6 +48,10 @@ const articleYear = computed(() => {
4248
}}</time>
4349
<span class="meta-separator">·</span>
4450
<span class="meta-reading-time">{{ readingTime }} min read</span>
51+
<template v-if="wordCount">
52+
<span class="meta-separator">·</span>
53+
<span class="meta-word-count">{{ wordCount }} 字</span>
54+
</template>
4555
<span v-if="frontmatter.tags?.length" class="meta-separator">·</span>
4656
<span
4757
v-for="(tag, i) in (frontmatter.tags || []).slice(0, 3)"
@@ -99,6 +109,10 @@ const articleYear = computed(() => {
99109
color: var(--vp-c-text-3);
100110
}
101111
112+
.meta-word-count {
113+
color: var(--vp-c-text-3);
114+
}
115+
102116
.meta-tag {
103117
padding: 0.1rem 0.45rem;
104118
background: var(--vp-c-default-soft);

docs/.vitepress/theme/NotFound.vue

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<script setup lang="ts">
2+
import { useData } from "vitepress";
3+
4+
const { site } = useData();
5+
const base = site.value.base ?? "/";
6+
</script>
7+
8+
<template>
9+
<div class="not-found">
10+
<div class="not-found-inner">
11+
<div class="error-code">404</div>
12+
<h1 class="error-title">页面不见了</h1>
13+
<p class="error-desc">
14+
这个页面可能已被迁移、删除,或者链接有误。<br />
15+
你可以从下面的入口重新出发:
16+
</p>
17+
<div class="error-actions">
18+
<a :href="base" class="btn btn-primary">← 返回首页</a>
19+
<a :href="base + 'posts/'" class="btn btn-secondary">最新文章</a>
20+
<a :href="base + 'archive/'" class="btn btn-secondary">文章归档</a>
21+
</div>
22+
</div>
23+
</div>
24+
</template>
25+
26+
<style scoped>
27+
.not-found {
28+
display: flex;
29+
justify-content: center;
30+
align-items: center;
31+
min-height: calc(100vh - var(--vp-nav-height));
32+
padding: 2rem;
33+
text-align: center;
34+
}
35+
36+
.not-found-inner {
37+
max-width: 480px;
38+
}
39+
40+
.error-code {
41+
font-size: clamp(4rem, 15vw, 7rem);
42+
font-weight: 800;
43+
line-height: 1;
44+
background: linear-gradient(
45+
135deg,
46+
var(--vp-c-brand-1),
47+
var(--vp-c-brand-2, var(--vp-c-brand-1))
48+
);
49+
-webkit-background-clip: text;
50+
-webkit-text-fill-color: transparent;
51+
background-clip: text;
52+
margin-bottom: 1rem;
53+
letter-spacing: -0.04em;
54+
}
55+
56+
.error-title {
57+
font-size: 1.8rem;
58+
font-weight: 700;
59+
color: var(--vp-c-text-1);
60+
margin-bottom: 0.75rem;
61+
}
62+
63+
.error-desc {
64+
color: var(--vp-c-text-2);
65+
font-size: 0.95rem;
66+
line-height: 1.7;
67+
margin-bottom: 2rem;
68+
}
69+
70+
.error-actions {
71+
display: flex;
72+
gap: 0.75rem;
73+
justify-content: center;
74+
flex-wrap: wrap;
75+
}
76+
77+
.btn {
78+
display: inline-block;
79+
padding: 0.5rem 1.25rem;
80+
border-radius: 8px;
81+
font-size: 0.9rem;
82+
font-weight: 500;
83+
text-decoration: none;
84+
transition: opacity 0.2s, transform 0.15s;
85+
}
86+
87+
.btn:hover {
88+
opacity: 0.85;
89+
transform: translateY(-1px);
90+
}
91+
92+
.btn-primary {
93+
background: var(--vp-c-brand-1);
94+
color: white;
95+
}
96+
97+
.btn-secondary {
98+
background: var(--vp-c-default-soft);
99+
color: var(--vp-c-text-1);
100+
border: 1px solid var(--vp-c-divider);
101+
}
102+
</style>

0 commit comments

Comments
 (0)