Commit 29b9101

HPCesia <me@hpcesia.com>
2025-05-20 10:50:15
feat(i18n): switch web i18n to typesafe-i18n
1 parent 9e4647b
i18n/en/web/index.ts
@@ -0,0 +1,79 @@
+import type { BaseTranslation } from '../../i18n-types.js';
+
+const en_web = {
+  common: {
+    open: 'Open',
+    close: 'Close',
+  },
+  navigation: {
+    home: 'Home',
+    about: 'About',
+    archive: {
+      title: 'Archive',
+      time: 'Time',
+      tags: 'Tags',
+      categories: 'Categories',
+    },
+    friendLinks: 'Links',
+    menu: 'Menu',
+    prevPage: 'Previous Page',
+    nextPage: 'Next Page',
+    recentPosts: 'Recent Posts',
+  },
+  status: {
+    totalPosts: 'Total Posts',
+    totalWords: 'Total Words',
+    lastUpdated: 'Last Updated',
+    runTime: 'Run Time',
+    wordsCount: '{0} word{{s}}',
+    readTime: '{0} minute{{s}}',
+    postsCount: '{0} post{{s}}',
+    tagsCount: '{0} tag{{s}}',
+    categoriesCount: '{0} category{{s}}',
+  },
+  button: {
+    search: 'Search',
+    subscribe: 'Subscribe',
+    more: 'More',
+    themeToggle: {
+      title: 'Toggle Theme',
+      lightMode: 'Light',
+      darkMode: 'Dark',
+      systemMode: 'System',
+    },
+  },
+  meta: {
+    author: 'Author',
+    publishedAt: 'Published At',
+    license: 'License',
+    tags: 'Tags',
+    category: 'Category',
+    unTagged: 'No Tags',
+    unCategorized: 'Uncategorized',
+  },
+  info: {
+    toc: 'Table of Content',
+    toolBar: 'Tool Bar',
+    readingPercentage: 'Reading Percentage',
+    comments: 'Comments',
+    recentComments: 'Recent Comments',
+    commentAbbrs: {
+      image: '[Image]',
+      link: '[Link]',
+      code: '[Code]',
+    },
+    backLinks: 'Back Links',
+    devNote:
+      'This is a draft and will only be displayed in <code>DEV</code> mode. To disable draft preview, please modify <code>{configKey:string}</code> to <code>{configValue:boolean}</code> in <code>{configFilePath:string}</code>.',
+    openMenu: 'Open Menu',
+    closeMenu: 'Close Menu',
+  },
+  search: {
+    title: 'Search',
+    placeholder: 'Search Anything...',
+    searchResults: 'Search Results',
+    noSearchResults: 'No Results Found',
+  },
+} satisfies BaseTranslation;
+
+export default en_web;
i18n/zh-CN/web/index.ts
@@ -0,0 +1,79 @@
+import type { NamespaceWebTranslation } from '../../i18n-types.js';
+
+const zh_CN_web = {
+  common: {
+    open: '打开',
+    close: '关闭',
+  },
+  navigation: {
+    home: '首页',
+    about: '关于',
+    archive: {
+      title: '归档',
+      time: '时间',
+      tags: '标签',
+      categories: '分类',
+    },
+    friendLinks: '友链',
+    menu: '菜单',
+    prevPage: '上一页',
+    nextPage: '下一页',
+    recentPosts: '最近文章',
+  },
+  status: {
+    totalPosts: '文章总数',
+    totalWords: '字数总计',
+    lastUpdated: '最后更新',
+    runTime: '运行时间',
+    wordsCount: '{0} 字',
+    readTime: '{0} 分钟',
+    postsCount: '{0} 篇文章',
+    tagsCount: '{0} 个标签',
+    categoriesCount: '{0} 个分类',
+  },
+  button: {
+    search: '搜索',
+    subscribe: '订阅',
+    more: '更多',
+    themeToggle: {
+      title: '主题切换',
+      lightMode: '亮色',
+      darkMode: '暗色',
+      systemMode: '跟随系统',
+    },
+  },
+  meta: {
+    author: '作者',
+    publishedAt: '发布时间',
+    license: '许可协议',
+    tags: '标签',
+    category: '分类',
+    unTagged: '无标签',
+    unCategorized: '未分类',
+  },
+  info: {
+    toc: '目录',
+    toolBar: '工具栏',
+    readingPercentage: '阅读进度',
+    comments: '评论',
+    recentComments: '最新评论',
+    commentAbbrs: {
+      image: '[图片]',
+      link: '[链接]',
+      code: '[代码]',
+    },
+    backLinks: '反向链接',
+    devNote:
+      '这是一个草稿,只会在 <code>DEV</code> 模式下显示。要禁用草稿预览,请在 <code>{configFilePath}</code> 中将 <code>{configKey}</code> 修改为 <code>{configValue}</code>。',
+    openMenu: '打开菜单',
+    closeMenu: '关闭菜单',
+  },
+  search: {
+    title: '搜索',
+    placeholder: '搜索任何内容...',
+    searchResults: '搜索结果',
+    noSearchResults: '没有找到结果',
+  },
+} satisfies NamespaceWebTranslation;
+
+export default zh_CN_web;
i18n/zh-TW/web/index.ts
@@ -0,0 +1,79 @@
+import type { NamespaceWebTranslation } from '../../i18n-types.js';
+
+const zh_TW_web = {
+  common: {
+    open: '打開',
+    close: '關閉',
+  },
+  navigation: {
+    home: '首頁',
+    about: '關於',
+    archive: {
+      title: '歸檔',
+      time: '時間',
+      tags: '標籤',
+      categories: '分類',
+    },
+    friendLinks: '友鏈',
+    menu: '選單',
+    prevPage: '上一頁',
+    nextPage: '下一頁',
+    recentPosts: '最近文章',
+  },
+  status: {
+    totalPosts: '文章總數',
+    totalWords: '字數總計',
+    lastUpdated: '最後更新',
+    runTime: '運行時間',
+    wordsCount: '{0} 字',
+    readTime: '{0} 分鐘',
+    postsCount: '{0} 篇文章',
+    tagsCount: '{0} 個標籤',
+    categoriesCount: '{0} 個分類',
+  },
+  button: {
+    search: '搜尋',
+    subscribe: '訂閱',
+    more: '更多',
+    themeToggle: {
+      title: '主題切換',
+      lightMode: '亮色',
+      darkMode: '暗色',
+      systemMode: '跟隨系統',
+    },
+  },
+  meta: {
+    author: '作者',
+    publishedAt: '發佈時間',
+    license: '許可協議',
+    tags: '標籤',
+    category: '分類',
+    unTagged: '無標籤',
+    unCategorized: '未分類',
+  },
+  info: {
+    toc: '目錄',
+    toolBar: '工具欄',
+    readingPercentage: '閱讀進度',
+    comments: '評論',
+    recentComments: '最新評論',
+    commentAbbrs: {
+      image: '[圖片]',
+      link: '[連結]',
+      code: '[程式碼]',
+    },
+    backLinks: '反向連結',
+    devNote:
+      '這是一個草稿,只會在 <code>DEV</code> 模式下顯示。要禁用草稿預覽,請在 <code>{configFilePath}</code> 中將 <code>{configKey}</code> 修改為 <code>{configValue}</code>。',
+    openMenu: '打開選單',
+    closeMenu: '關閉選單',
+  },
+  search: {
+    title: '搜尋',
+    placeholder: '搜尋任何內容...',
+    searchResults: '搜尋結果',
+    noSearchResults: '沒有找到結果',
+  },
+} satisfies NamespaceWebTranslation;
+
+export default zh_TW_web;
i18n/custom-types.ts
@@ -0,0 +1,1 @@
+// use this file to export your custom types; these types will be imported by './i18n-types.ts'
\ No newline at end of file
i18n/i18n-types.ts
@@ -14,7 +14,8 @@ export type Translation = RootTranslation & DisallowNamespaces
 
 export type Translations = RootTranslation &
 {
-	cli: NamespaceCliTranslation
+	cli: NamespaceCliTranslation,
+	web: NamespaceWebTranslation
 }
 
 type RootTranslation = {}
@@ -162,8 +163,248 @@ export type NamespaceCliTranslation = {
 	}
 }
 
+export type NamespaceWebTranslation = {
+	common: {
+		/**
+		 * O​p​e​n
+		 */
+		open: string
+		/**
+		 * C​l​o​s​e
+		 */
+		close: string
+	}
+	navigation: {
+		/**
+		 * H​o​m​e
+		 */
+		home: string
+		/**
+		 * A​b​o​u​t
+		 */
+		about: string
+		archive: {
+			/**
+			 * A​r​c​h​i​v​e
+			 */
+			title: string
+			/**
+			 * T​i​m​e
+			 */
+			time: string
+			/**
+			 * T​a​g​s
+			 */
+			tags: string
+			/**
+			 * C​a​t​e​g​o​r​i​e​s
+			 */
+			categories: string
+		}
+		/**
+		 * L​i​n​k​s
+		 */
+		friendLinks: string
+		/**
+		 * M​e​n​u
+		 */
+		menu: string
+		/**
+		 * P​r​e​v​i​o​u​s​ ​P​a​g​e
+		 */
+		prevPage: string
+		/**
+		 * N​e​x​t​ ​P​a​g​e
+		 */
+		nextPage: string
+		/**
+		 * R​e​c​e​n​t​ ​P​o​s​t​s
+		 */
+		recentPosts: string
+	}
+	status: {
+		/**
+		 * T​o​t​a​l​ ​P​o​s​t​s
+		 */
+		totalPosts: string
+		/**
+		 * T​o​t​a​l​ ​W​o​r​d​s
+		 */
+		totalWords: string
+		/**
+		 * L​a​s​t​ ​U​p​d​a​t​e​d
+		 */
+		lastUpdated: string
+		/**
+		 * R​u​n​ ​T​i​m​e
+		 */
+		runTime: string
+		/**
+		 * {​0​}​ ​w​o​r​d​{​{​s​}​}
+		 * @param {string | number | boolean} 0
+		 */
+		wordsCount: RequiredParams<'0'>
+		/**
+		 * {​0​}​ ​m​i​n​u​t​e​{​{​s​}​}
+		 * @param {string | number | boolean} 0
+		 */
+		readTime: RequiredParams<'0'>
+		/**
+		 * {​0​}​ ​p​o​s​t​{​{​s​}​}
+		 * @param {string | number | boolean} 0
+		 */
+		postsCount: RequiredParams<'0'>
+		/**
+		 * {​0​}​ ​t​a​g​{​{​s​}​}
+		 * @param {string | number | boolean} 0
+		 */
+		tagsCount: RequiredParams<'0'>
+		/**
+		 * {​0​}​ ​c​a​t​e​g​o​r​y​{​{​s​}​}
+		 * @param {string | number | boolean} 0
+		 */
+		categoriesCount: RequiredParams<'0'>
+	}
+	button: {
+		/**
+		 * S​e​a​r​c​h
+		 */
+		search: string
+		/**
+		 * S​u​b​s​c​r​i​b​e
+		 */
+		subscribe: string
+		/**
+		 * M​o​r​e
+		 */
+		more: string
+		themeToggle: {
+			/**
+			 * T​o​g​g​l​e​ ​T​h​e​m​e
+			 */
+			title: string
+			/**
+			 * L​i​g​h​t
+			 */
+			lightMode: string
+			/**
+			 * D​a​r​k
+			 */
+			darkMode: string
+			/**
+			 * S​y​s​t​e​m
+			 */
+			systemMode: string
+		}
+	}
+	meta: {
+		/**
+		 * A​u​t​h​o​r
+		 */
+		author: string
+		/**
+		 * P​u​b​l​i​s​h​e​d​ ​A​t
+		 */
+		publishedAt: string
+		/**
+		 * L​i​c​e​n​s​e
+		 */
+		license: string
+		/**
+		 * T​a​g​s
+		 */
+		tags: string
+		/**
+		 * C​a​t​e​g​o​r​y
+		 */
+		category: string
+		/**
+		 * N​o​ ​T​a​g​s
+		 */
+		unTagged: string
+		/**
+		 * U​n​c​a​t​e​g​o​r​i​z​e​d
+		 */
+		unCategorized: string
+	}
+	info: {
+		/**
+		 * T​a​b​l​e​ ​o​f​ ​C​o​n​t​e​n​t
+		 */
+		toc: string
+		/**
+		 * T​o​o​l​ ​B​a​r
+		 */
+		toolBar: string
+		/**
+		 * R​e​a​d​i​n​g​ ​P​e​r​c​e​n​t​a​g​e
+		 */
+		readingPercentage: string
+		/**
+		 * C​o​m​m​e​n​t​s
+		 */
+		comments: string
+		/**
+		 * R​e​c​e​n​t​ ​C​o​m​m​e​n​t​s
+		 */
+		recentComments: string
+		commentAbbrs: {
+			/**
+			 * [​I​m​a​g​e​]
+			 */
+			image: string
+			/**
+			 * [​L​i​n​k​]
+			 */
+			link: string
+			/**
+			 * [​C​o​d​e​]
+			 */
+			code: string
+		}
+		/**
+		 * B​a​c​k​ ​L​i​n​k​s
+		 */
+		backLinks: string
+		/**
+		 * T​h​i​s​ ​i​s​ ​a​ ​d​r​a​f​t​ ​a​n​d​ ​w​i​l​l​ ​o​n​l​y​ ​b​e​ ​d​i​s​p​l​a​y​e​d​ ​i​n​ ​<​c​o​d​e​>​D​E​V​<​/​c​o​d​e​>​ ​m​o​d​e​.​ ​T​o​ ​d​i​s​a​b​l​e​ ​d​r​a​f​t​ ​p​r​e​v​i​e​w​,​ ​p​l​e​a​s​e​ ​m​o​d​i​f​y​ ​<​c​o​d​e​>​{​c​o​n​f​i​g​K​e​y​}​<​/​c​o​d​e​>​ ​t​o​ ​<​c​o​d​e​>​{​c​o​n​f​i​g​V​a​l​u​e​}​<​/​c​o​d​e​>​ ​i​n​ ​<​c​o​d​e​>​{​c​o​n​f​i​g​F​i​l​e​P​a​t​h​}​<​/​c​o​d​e​>​.
+		 * @param {string} configFilePath
+		 * @param {string} configKey
+		 * @param {boolean} configValue
+		 */
+		devNote: RequiredParams<'configFilePath' | 'configKey' | 'configValue'>
+		/**
+		 * O​p​e​n​ ​M​e​n​u
+		 */
+		openMenu: string
+		/**
+		 * C​l​o​s​e​ ​M​e​n​u
+		 */
+		closeMenu: string
+	}
+	search: {
+		/**
+		 * S​e​a​r​c​h
+		 */
+		title: string
+		/**
+		 * S​e​a​r​c​h​ ​A​n​y​t​h​i​n​g​.​.​.
+		 */
+		placeholder: string
+		/**
+		 * S​e​a​r​c​h​ ​R​e​s​u​l​t​s
+		 */
+		searchResults: string
+		/**
+		 * N​o​ ​R​e​s​u​l​t​s​ ​F​o​u​n​d
+		 */
+		noSearchResults: string
+	}
+}
+
 export type Namespaces =
 	| 'cli'
+	| 'web'
 
 type DisallowNamespaces = {
 	/**
@@ -171,6 +412,12 @@ type DisallowNamespaces = {
 	 * you need to use the `./cli/index.ts` file instead
 	 */
 	cli?: "[typesafe-i18n] reserved for 'cli'-namespace. You need to use the `./cli/index.ts` file instead."
+
+	/**
+	 * reserved for 'web'-namespace\
+	 * you need to use the `./web/index.ts` file instead
+	 */
+	web?: "[typesafe-i18n] reserved for 'web'-namespace. You need to use the `./web/index.ts` file instead."
 }
 
 export type TranslationFunctions = {
@@ -304,6 +551,236 @@ export type TranslationFunctions = {
 			rename_to_original_conflict: (arg: { fileName: string }) => LocalizedString
 		}
 	}
+	web: {
+		common: {
+			/**
+			 * Open
+			 */
+			open: () => LocalizedString
+			/**
+			 * Close
+			 */
+			close: () => LocalizedString
+		}
+		navigation: {
+			/**
+			 * Home
+			 */
+			home: () => LocalizedString
+			/**
+			 * About
+			 */
+			about: () => LocalizedString
+			archive: {
+				/**
+				 * Archive
+				 */
+				title: () => LocalizedString
+				/**
+				 * Time
+				 */
+				time: () => LocalizedString
+				/**
+				 * Tags
+				 */
+				tags: () => LocalizedString
+				/**
+				 * Categories
+				 */
+				categories: () => LocalizedString
+			}
+			/**
+			 * Links
+			 */
+			friendLinks: () => LocalizedString
+			/**
+			 * Menu
+			 */
+			menu: () => LocalizedString
+			/**
+			 * Previous Page
+			 */
+			prevPage: () => LocalizedString
+			/**
+			 * Next Page
+			 */
+			nextPage: () => LocalizedString
+			/**
+			 * Recent Posts
+			 */
+			recentPosts: () => LocalizedString
+		}
+		status: {
+			/**
+			 * Total Posts
+			 */
+			totalPosts: () => LocalizedString
+			/**
+			 * Total Words
+			 */
+			totalWords: () => LocalizedString
+			/**
+			 * Last Updated
+			 */
+			lastUpdated: () => LocalizedString
+			/**
+			 * Run Time
+			 */
+			runTime: () => LocalizedString
+			/**
+			 * {0} word{{s}}
+			 */
+			wordsCount: (arg0: string | number | boolean) => LocalizedString
+			/**
+			 * {0} minute{{s}}
+			 */
+			readTime: (arg0: string | number | boolean) => LocalizedString
+			/**
+			 * {0} post{{s}}
+			 */
+			postsCount: (arg0: string | number | boolean) => LocalizedString
+			/**
+			 * {0} tag{{s}}
+			 */
+			tagsCount: (arg0: string | number | boolean) => LocalizedString
+			/**
+			 * {0} category{{s}}
+			 */
+			categoriesCount: (arg0: string | number | boolean) => LocalizedString
+		}
+		button: {
+			/**
+			 * Search
+			 */
+			search: () => LocalizedString
+			/**
+			 * Subscribe
+			 */
+			subscribe: () => LocalizedString
+			/**
+			 * More
+			 */
+			more: () => LocalizedString
+			themeToggle: {
+				/**
+				 * Toggle Theme
+				 */
+				title: () => LocalizedString
+				/**
+				 * Light
+				 */
+				lightMode: () => LocalizedString
+				/**
+				 * Dark
+				 */
+				darkMode: () => LocalizedString
+				/**
+				 * System
+				 */
+				systemMode: () => LocalizedString
+			}
+		}
+		meta: {
+			/**
+			 * Author
+			 */
+			author: () => LocalizedString
+			/**
+			 * Published At
+			 */
+			publishedAt: () => LocalizedString
+			/**
+			 * License
+			 */
+			license: () => LocalizedString
+			/**
+			 * Tags
+			 */
+			tags: () => LocalizedString
+			/**
+			 * Category
+			 */
+			category: () => LocalizedString
+			/**
+			 * No Tags
+			 */
+			unTagged: () => LocalizedString
+			/**
+			 * Uncategorized
+			 */
+			unCategorized: () => LocalizedString
+		}
+		info: {
+			/**
+			 * Table of Content
+			 */
+			toc: () => LocalizedString
+			/**
+			 * Tool Bar
+			 */
+			toolBar: () => LocalizedString
+			/**
+			 * Reading Percentage
+			 */
+			readingPercentage: () => LocalizedString
+			/**
+			 * Comments
+			 */
+			comments: () => LocalizedString
+			/**
+			 * Recent Comments
+			 */
+			recentComments: () => LocalizedString
+			commentAbbrs: {
+				/**
+				 * [Image]
+				 */
+				image: () => LocalizedString
+				/**
+				 * [Link]
+				 */
+				link: () => LocalizedString
+				/**
+				 * [Code]
+				 */
+				code: () => LocalizedString
+			}
+			/**
+			 * Back Links
+			 */
+			backLinks: () => LocalizedString
+			/**
+			 * This is a draft and will only be displayed in <code>DEV</code> mode. To disable draft preview, please modify <code>{configKey}</code> to <code>{configValue}</code> in <code>{configFilePath}</code>.
+			 */
+			devNote: (arg: { configFilePath: string, configKey: string, configValue: boolean }) => LocalizedString
+			/**
+			 * Open Menu
+			 */
+			openMenu: () => LocalizedString
+			/**
+			 * Close Menu
+			 */
+			closeMenu: () => LocalizedString
+		}
+		search: {
+			/**
+			 * Search
+			 */
+			title: () => LocalizedString
+			/**
+			 * Search Anything...
+			 */
+			placeholder: () => LocalizedString
+			/**
+			 * Search Results
+			 */
+			searchResults: () => LocalizedString
+			/**
+			 * No Results Found
+			 */
+			noSearchResults: () => LocalizedString
+		}
+	}
 }
 
 export type Formatters = {}
i18n/i18n-util.async.ts
@@ -13,13 +13,16 @@ const localeTranslationLoaders = {
 
 const localeNamespaceLoaders = {
 	en: {
-		cli: () => import('./en/cli/index.js')
+		cli: () => import('./en/cli/index.js'),
+		web: () => import('./en/web/index.js')
 	},
 	'zh-CN': {
-		cli: () => import('./zh-CN/cli/index.js')
+		cli: () => import('./zh-CN/cli/index.js'),
+		web: () => import('./zh-CN/web/index.js')
 	},
 	'zh-TW': {
-		cli: () => import('./zh-TW/cli/index.js')
+		cli: () => import('./zh-TW/cli/index.js'),
+		web: () => import('./zh-TW/web/index.js')
 	}
 }
 
i18n/i18n-util.sync.ts
@@ -10,21 +10,27 @@ import zh_CN from './zh-CN/index.js'
 import zh_TW from './zh-TW/index.js'
 
 import en_cli from './en/cli/index.js'
+import en_web from './en/web/index.js'
 import zh_CN_cli from './zh-CN/cli/index.js'
+import zh_CN_web from './zh-CN/web/index.js'
 import zh_TW_cli from './zh-TW/cli/index.js'
+import zh_TW_web from './zh-TW/web/index.js'
 
 const localeTranslations = {
 	en: {
 		...en,
-		cli: en_cli
+		cli: en_cli,
+		web: en_web
 	},
 	'zh-CN': {
 		...zh_CN,
-		cli: zh_CN_cli
+		cli: zh_CN_cli,
+		web: zh_CN_web
 	},
 	'zh-TW': {
 		...zh_TW,
-		cli: zh_TW_cli
+		cli: zh_TW_cli,
+		web: zh_TW_web
 	},
 }
 
i18n/i18n-util.ts
@@ -17,7 +17,8 @@ export const locales: Locales[] = [
 ]
 
 export const namespaces: Namespaces[] = [
-	'cli'
+	'cli',
+	'web'
 ]
 
 export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales)
src/components/aside/recent-comments/utils.ts
@@ -1,13 +1,12 @@
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 
 export function cleanCommentHtml(htmlString: string) {
   return htmlString
-    .replaceAll(/<img.*?src="(.*?)"?[^>]+>/gi, i18n(I18nKey.commentReplaceImage)!)
+    .replaceAll(/<img.*?src="(.*?)"?[^>]+>/gi, t.info.commentAbbrs.image())
     .replaceAll(
       /<a[^>]+?href=["']?([^"']+)["']?[^>]*>([^<]+)<\/a>/gi,
-      i18n(I18nKey.commentReplaceLink)!
+      t.info.commentAbbrs.link()
     )
-    .replaceAll(/<pre><code[^>]+?>.*?<\/pre>/gis, i18n(I18nKey.commentReplaceCode)!)
+    .replaceAll(/<pre><code[^>]+?>.*?<\/pre>/gis, t.info.commentAbbrs.code())
     .replaceAll(/<[^>]+>/g, '');
 }
src/components/aside/siteinfo/Stats.astro
@@ -1,8 +1,7 @@
 ---
 import { asideConfig, siteConfig } from '@/config';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import { getPosts, getPostsCount } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import { render } from 'astro:content';
 ---
@@ -12,14 +11,14 @@ import { render } from 'astro:content';
     asideConfig.siteInfo.stats.map(async (entry) => {
       switch (entry) {
         case 'post-count': {
-          const postCount = await getPostsCount();
+          const postsCount = await getPostsCount();
           return (
             <div class="stat">
               <div class="stat-title flex flex-row items-center gap-1">
                 <Icon name="material-symbols:folder-open-rounded" />
-                <span>{i18n(I18nKey.totalPosts)}</span>
+                <span>{t.status.totalPosts}</span>
               </div>
-              <div class="stat-value text-base">{`${postCount} ${postCount > 1 ? i18n(I18nKey.postsCount) : i18n(I18nKey.postCount)}`}</div>
+              <div class="stat-value text-base">{t.status.postsCount(postsCount)}</div>
             </div>
           );
         }
@@ -28,7 +27,7 @@ import { render } from 'astro:content';
             <div class="stat">
               <div class="stat-title flex flex-row items-center gap-1">
                 <Icon name="material-symbols:refresh-rounded" />
-                <span>{i18n(I18nKey.lastUpdated)}</span>
+                <span>{t.status.lastUpdated()}</span>
               </div>
               <div class="stat-value text-base">
                 <time datetime={new Date().toISOString()}>
@@ -42,7 +41,7 @@ import { render } from 'astro:content';
             <div class="stat">
               <div class="stat-title flex flex-row items-center gap-1">
                 <Icon name="material-symbols:docs-rounded" />
-                <span>{i18n(I18nKey.totalWords)}</span>
+                <span>{t.status.totalWords()}</span>
               </div>
               <div class="stat-value text-base">
                 {(async () => {
@@ -54,7 +53,7 @@ import { render } from 'astro:content';
                     })
                   );
                   const total = words.reduce((acc, cur) => acc + cur, 0);
-                  return `${total} ${total > 1 ? i18n(I18nKey.wordsCount) : i18n(I18nKey.wordCount)}`;
+                  return t.status.wordsCount(total);
                 })()}
               </div>
             </div>
@@ -64,7 +63,7 @@ import { render } from 'astro:content';
             <div class="stat">
               <div class="stat-title flex flex-row items-center gap-1">
                 <Icon name="material-symbols:calendar-clock-rounded" />
-                <span>{i18n(I18nKey.runTime)}</span>
+                <span>{t.status.runTime()}</span>
               </div>
               <div class="stat-value text-base">
                 <time
src/components/aside/siteinfo/Tags.astro
@@ -1,8 +1,7 @@
 ---
 import Button from '@components/widgets/Button.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import { getTags, getTagUrl } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 const tagsMap = await getTags();
 ---
 
@@ -20,7 +19,7 @@ const tagsMap = await getTags();
     }
   </div>
   <div class="btn bg-base-200/50 btn-wide hidden rounded-sm hover:brightness-125">
-    {i18n(I18nKey.more)}
+    {t.button.more()}
   </div>
 </div>
 
src/components/aside/RecentCommentsCard.vue
@@ -4,8 +4,7 @@ import { TwikooProvider } from './recent-comments/Twikoo';
 import { WalineProvider } from './recent-comments/Waline';
 import type { CommentData } from './recent-comments/types';
 import { asideConfig, commentConfig, siteConfig } from '@/config';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { onMounted, ref } from 'vue';
 
 const comments = ref<CommentData[]>([]);
@@ -47,7 +46,7 @@ onMounted(() => {
   <div id="recent-comments-card" class="card border-base-300 bg-base-200/25 border">
     <div class="card-body px-4 py-2">
       <div class="card-title">
-        {{ i18n(I18nKey.recentComments) }}
+        {{ t.info.recentComments() }}
       </div>
       <ul class="list">
         <template v-if="!loading">
src/components/misc/BackLinks.astro
@@ -1,6 +1,5 @@
 ---
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 
 interface Props {
   backLinks: {
@@ -20,7 +19,7 @@ const { backLinks } = Astro.props;
 
 <div class="bg-base-100 border-base-content/25 collapse-plus collapse my-4 border">
   <input type="checkbox" />
-  <div class="collapse-title text-lg font-semibold">{i18n(I18nKey.backLinks)}</div>
+  <div class="collapse-title text-lg font-semibold">{t.info.backLinks()}</div>
   <div class="collapse-content">
     <ul class="list">
       {
src/components/misc/CategoryBar.astro
@@ -1,7 +1,6 @@
 ---
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import { getCategoryUrl } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 
 interface Props {
   categories: string[];
@@ -17,8 +16,8 @@ const { categories, currentCategory } = Astro.props;
 >
   <nav
     class="card-body flex flex-row items-center gap-2 overflow-auto px-2 py-3"
-    title={i18n(I18nKey.categories)}
-    aria-label={i18n(I18nKey.categories)}
+    title={t.navigation.archive.categories()}
+    aria-label={t.navigation.archive.categories()}
     role="navigation"
   >
     <a
@@ -28,7 +27,7 @@ const { categories, currentCategory } = Astro.props;
         currentCategory ? '' : 'btn-active',
       ]}
     >
-      {i18n(I18nKey.recentPosts)}
+      {t.navigation.recentPosts()}
     </a>
     {
       categories.map((category) => (
@@ -39,7 +38,7 @@ const { categories, currentCategory } = Astro.props;
             currentCategory === category ? 'btn-active' : '',
           ]}
         >
-          {i18n(category)}
+          {category}
         </a>
       ))
     }
src/components/misc/License.astro
@@ -1,7 +1,6 @@
 ---
 import { licenseConfig, profileConfig, siteConfig } from '@/config';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 
 interface Props {
   time: Date;
@@ -14,17 +13,17 @@ const { time, license, licenseUrl, lang } = Astro.props;
 
 const infomations = [
   {
-    key: i18n(I18nKey.publishedAt),
+    key: t.meta.publishedAt(),
     value: time.toLocaleDateString(lang || siteConfig.lang.replace('_', '-')),
     time: time,
   },
   {
-    key: i18n(I18nKey.author),
+    key: t.meta.author(),
     value: profileConfig.name,
     link: '/about/',
   },
   {
-    key: i18n(I18nKey.license),
+    key: t.meta.license(),
     value: license || licenseConfig.name,
     link: licenseUrl || licenseConfig.url,
   },
src/components/misc/PostInfo.astro
@@ -1,9 +1,8 @@
 ---
 import { articleConfig, siteConfig } from '@/config';
 import MetaIcon from '@components/widgets/MetaIcon.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import { getCategoryUrl, getTagUrl } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 
 interface Props {
   title: string;
@@ -36,13 +35,13 @@ const metas: ({ icon: string; text: string; link?: string; time?: Date } | undef
   articleConfig.wordCount && typeof wordCount === 'number'
     ? {
         icon: 'material-symbols:docs-rounded',
-        text: `${wordCount} ${wordCount === 1 ? i18n(I18nKey.wordCount) : i18n(I18nKey.wordsCount)}`,
+        text: t.status.wordsCount(wordCount),
       }
     : undefined,
   articleConfig.readingTime && typeof readingTime === 'number'
     ? {
         icon: 'material-symbols:nest-clock-farsight-analog-rounded',
-        text: `${readingTime} ${readingTime === 1 ? i18n(I18nKey.minuteCount) : i18n(I18nKey.minutesCount)}`,
+        text: t.status.readTime(readingTime),
       }
     : undefined,
   category
src/components/search/Pagefind.vue
@@ -3,8 +3,7 @@ import type {
   PagefindSearchResult,
   PagefindSearchResults,
 } from '@/types/PagefindSearchAPI.d.ts';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { type Ref, onMounted, ref } from 'vue';
 
 const isLoading = ref(false);
@@ -109,7 +108,7 @@ defineExpose({
     <slot></slot>
     <div
       class="search-result mt-4 flex h-fit max-h-[calc(60vh-8rem)] flex-col items-center gap-2 overflow-y-auto text-center"
-      :aria-label="i18n(I18nKey.searchResults)"
+      :aria-label="t.search.searchResults()"
       tabindex="-1"
     >
       <template v-if="isLoading">
@@ -121,7 +120,7 @@ defineExpose({
           <div class="skeleton mt-1 h-4 w-3/4"></div>
         </div>
       </template>
-      <template v-else-if="noResults">{{ i18n(I18nKey.noSearchResults) }}</template>
+      <template v-else-if="noResults">{{ t.search.noSearchResults() }}</template>
       <template v-else>
         <a
           v-for="result in searchResults"
src/components/widgets/SideToolBar/TocButton.vue
@@ -1,6 +1,5 @@
 <script setup lang="ts">
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { onMounted, onUnmounted, ref } from 'vue';
 
 const isOpen = ref(false);
@@ -65,8 +64,8 @@ onUnmounted(() => {
       ref="buttonRef"
       class="btn btn-circle btn-secondary btn-sm"
       @click="isOpen = !isOpen"
-      :title="i18n(I18nKey.toc)"
-      :aria-label="i18n(I18nKey.toc)"
+      :title="t.info.toc()"
+      :aria-label="t.info.toc()"
       :aria-expanded="isOpen"
       :aria-controls="'stb-toc-wrapper'"
     >
src/components/widgets/DarkModeButton.astro
@@ -1,6 +1,5 @@
 ---
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import type { HTMLAttributes } from 'astro/types';
 import Button from './Button.astro';
@@ -17,11 +16,11 @@ const { class: className, showText, useDefaultBtnClass, ...rest } = Astro.props;
   class:list={['darkmode-btn swap swap-rotate', className]}
   useDefaultClass={useDefaultBtnClass}
   {...rest}
-  data-text-light={i18n(I18nKey.lightMode)}
-  data-text-dark={i18n(I18nKey.darkMode)}
-  data-text-auto={i18n(I18nKey.systemMode)}
+  data-text-light={t.button.themeToggle.lightMode()}
+  data-text-dark={t.button.themeToggle.darkMode()}
+  data-text-auto={t.button.themeToggle.systemMode()}
   role="switch"
-  aria-label={i18n(I18nKey.themeToggle)}
+  aria-label={t.button.themeToggle.title()}
 >
   <input type="checkbox" inert />
   <Icon
src/components/widgets/Pagination.astro
@@ -1,6 +1,5 @@
 ---
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import Button from './Button.astro';
 
@@ -55,12 +54,12 @@ else {
           current < total ? 'max-xs:w-[calc(50%-1rem)]' : 'max-xs:w-[calc(100%-1rem)]',
         ]}
         href={getPageUrl(current - 1)}
-        title={i18n(I18nKey.prevPage)}
-        aria-label={i18n(I18nKey.prevPage)}
+        title={t.navigation.prevPage()}
+        aria-label={t.navigation.prevPage()}
         rel="prev"
       >
         <Icon name="material-symbols:chevron-left-rounded" class="my-1 text-2xl" />
-        <span class="xs:hidden">{i18n(I18nKey.prevPage)}</span>
+        <span class="xs:hidden">{t.navigation.prevPage()}</span>
       </Button>
     )
   }
@@ -118,11 +117,11 @@ else {
           current > 1 ? 'max-xs:w-[calc(50%-1rem)]' : 'max-xs:w-[calc(100%-1rem)]',
         ]}
         href={getPageUrl(current + 1)}
-        title={i18n(I18nKey.nextPage)}
-        aria-label={i18n(I18nKey.nextPage)}
+        title={t.navigation.nextPage()}
+        aria-label={t.navigation.nextPage()}
         rel="next"
       >
-        <span class="xs:hidden">{i18n(I18nKey.nextPage)}</span>
+        <span class="xs:hidden">{t.navigation.nextPage()}</span>
         <Icon name="material-symbols:chevron-right-rounded" class="my-1 text-2xl" />
       </Button>
     )
src/components/widgets/ReadMoreButton.astro
@@ -1,6 +1,5 @@
 ---
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import type { ComponentProps } from 'astro/types';
 
@@ -11,7 +10,7 @@ const { href, title, ...rest } = Astro.props;
 
 <a
   href={href}
-  title={title || i18n(I18nKey.more)}
+  title={title || t.button.more()}
   class="duration-150 hover:brightness-125 active:brightness-75 max-md:hidden"
 >
   <Icon
src/components/Comment.astro
@@ -4,11 +4,10 @@ import Artalk from '@components/comment/Artalk.astro';
 import Giscus from '@components/comment/Giscus.astro';
 import Twikoo from '@components/comment/Twikoo.astro';
 import Waline from '@components/comment/Waline.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 ---
 
-<div id="page-comment" title={i18n(I18nKey.comments)} aria-label={i18n(I18nKey.comments)}>
+<div id="page-comment" title={t.info.comments()} aria-label={t.info.comments()}>
   {
     (() => {
       switch (commentConfig.provider) {
src/components/Navbar.astro
@@ -1,7 +1,6 @@
 ---
 import { navbarConfig, siteConfig } from '@/config';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import Button from './widgets/Button.astro';
 import DarkModeButton from './widgets/DarkModeButton.astro';
@@ -37,8 +36,8 @@ if (!title) title = 'Astral Halo';
             if ('items' in item) {
               return (
                 <div class="group flex flex-col gap-0">
-                  <Button title={i18n(item.title)} class="btn-ghost join-item btn-primary">
-                    <span class="text-xl tracking-wide">{i18n(item.title)}</span>
+                  <Button title={item.title} class="btn-ghost join-item btn-primary">
+                    <span class="text-xl tracking-wide">{item.title}</span>
                   </Button>
                   <div class="hidden h-0 w-0 self-center group-hover:block">
                     <ul class="menu bg-base-200/50 absolute -translate-x-[50%] rounded-xl shadow backdrop-blur-md">
@@ -54,11 +53,11 @@ if (!title) title = 'Astral Halo';
                             (typeof subItem.onclick === 'string'
                               ? { onclick: subItem.onclick }
                               : { id: 'side-' + subItem.onclick.id }))}
-                          title={i18n(subItem.text)}
-                          aria-label={i18n(subItem.text)}
+                          title={subItem.text}
+                          aria-label={subItem.text}
                           class="btn-ghost btn-primary rounded-field"
                         >
-                          <span class="text-xl tracking-wide">{i18n(subItem.text)}</span>
+                          <span class="text-xl tracking-wide">{subItem.text}</span>
                         </Button>
                       ))}
                     </ul>
@@ -78,11 +77,11 @@ if (!title) title = 'Astral Halo';
                     (typeof item.onclick === 'string'
                       ? { onclick: item.onclick }
                       : { id: 'side-' + item.onclick.id }))}
-                  title={i18n(item.text)}
-                  aria-label={i18n(item.text)}
+                  title={item.text}
+                  aria-label={item.text}
                   class="btn-ghost join-item btn-primary"
                 >
-                  <span class="text-xl tracking-wide">{i18n(item.text)}</span>
+                  <span class="text-xl tracking-wide">{item.text}</span>
                 </Button>
               );
             }
@@ -97,8 +96,8 @@ if (!title) title = 'Astral Halo';
                 class="nav-menu-item btn-circle btn-ghost btn-primary"
                 {...('href' in item &&
                   item.href && { href: item.href, target: item.blank ? '_blank' : undefined })}
-                title={i18n(item.text)}
-                aria-label={i18n(item.text)}
+                title={item.text}
+                aria-label={item.text}
                 {...('onclick' in item &&
                   item.onclick &&
                   (typeof item.onclick === 'string'
@@ -117,8 +116,8 @@ if (!title) title = 'Astral Halo';
                 class="nav-menu-item btn-circle btn-ghost btn-primary"
                 {...('href' in item &&
                   item.href && { href: item.href, target: item.blank ? '_blank' : undefined })}
-                title={i18n(item.text)}
-                aria-label={i18n(item.text)}
+                title={item.text}
+                aria-label={item.text}
                 {...('onclick' in item &&
                   item.onclick &&
                   (typeof item.onclick === 'string'
@@ -135,8 +134,8 @@ if (!title) title = 'Astral Halo';
             for="sidebar-drawer"
             class="btn btn-circle btn-ghost btn-primary"
             tabindex="0"
-            title={`${i18n(I18nKey.open)} ${i18n(I18nKey.menu)}`}
-            aria-label={`${i18n(I18nKey.open)} ${i18n(I18nKey.menu)}`}
+            title={t.info.openMenu()}
+            aria-label={t.info.openMenu()}
           >
             <Icon name="material-symbols:menu-rounded" height="1.5rem" width="1.5rem" />
           </label>
@@ -152,8 +151,8 @@ if (!title) title = 'Astral Halo';
     <label
       for="sidebar-drawer"
       class="drawer-overlay"
-      title={`${i18n(I18nKey.close)} ${i18n(I18nKey.menu)}`}
-      aria-label={`${i18n(I18nKey.close)} ${i18n(I18nKey.menu)}`}></label>
+      title={t.info.closeMenu()}
+      aria-label={t.info.closeMenu()}></label>
     <ul
       role="navigation"
       class="menu from-base-100 to-base-300 dark:from-base-300 dark:to-base-100 min-h-full w-[min(calc(100%-3rem),20rem)] bg-linear-150 p-4"
@@ -161,14 +160,14 @@ if (!title) title = 'Astral Halo';
       <li>
         <DarkModeButton class="text-xl" showText={true} useDefaultBtnClass={false} />
       </li>
-      <div class="divider text-lg font-bold">{i18n(I18nKey.menu)}</div>
+      <div class="divider text-lg font-bold">{t.navigation.menu()}</div>
       {
         navbarConfig.navbarCenterItems.map((item) => {
           if ('items' in item) {
             return (
               <li>
                 <details>
-                  <summary class="text-xl">{i18n(item.title)}</summary>
+                  <summary class="text-xl">{item.title}</summary>
                   <ul>
                     {item.items.map((subItem) => (
                       <li>
@@ -183,11 +182,11 @@ if (!title) title = 'Astral Halo';
                             (typeof subItem.onclick === 'string'
                               ? { onclick: subItem.onclick }
                               : { id: 'side-' + subItem.onclick.id }))}
-                          title={i18n(subItem.text)}
-                          aria-label={i18n(subItem.text)}
+                          title={subItem.text}
+                          aria-label={subItem.text}
                           useDefaultClass={false}
                         >
-                          <span class="text-xl">{i18n(subItem.text)}</span>
+                          <span class="text-xl">{subItem.text}</span>
                         </Button>
                       </li>
                     ))}
@@ -209,11 +208,11 @@ if (!title) title = 'Astral Halo';
                     (typeof item.onclick === 'string'
                       ? { onclick: item.onclick }
                       : { id: 'side-' + item.onclick.id }))}
-                  title={i18n(item.text)}
-                  aria-label={i18n(item.text)}
+                  title={item.text}
+                  aria-label={item.text}
                   useDefaultClass={false}
                 >
-                  <span class="text-xl">{i18n(item.text)}</span>
+                  <span class="text-xl">{item.text}</span>
                 </Button>
               </li>
             );
@@ -226,8 +225,8 @@ if (!title) title = 'Astral Halo';
             <Button
               {...('href' in item &&
                 item.href && { href: item.href, target: item.blank ? '_blank' : undefined })}
-              title={i18n(item.text)}
-              aria-label={i18n(item.text)}
+              title={item.text}
+              aria-label={item.text}
               {...('onclick' in item &&
                 item.onclick &&
                 (typeof item.onclick === 'string'
@@ -236,7 +235,7 @@ if (!title) title = 'Astral Halo';
               useDefaultClass={false}
             >
               <Icon name={item.icon} height="1.5rem" width="1.5rem" />
-              <span class="text-xl">{i18n(item.text)}</span>
+              <span class="text-xl">{item.text}</span>
             </Button>
           </li>
         ))
src/components/Search.astro
@@ -1,7 +1,6 @@
 ---
 import { searchConfig } from '@/config';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import Pagefind from './search/Pagefind.vue';
 ---
@@ -11,8 +10,8 @@ import Pagefind from './search/Pagefind.vue';
     <form method="dialog">
       <button
         class="btn btn-circle btn-ghost btn-sm absolute top-2 right-2"
-        title={i18n(I18nKey.close)}
-        aria-label={i18n(I18nKey.close)}>✕</button
+        title={t.common.close()}
+        aria-label={t.common.close()}>✕</button
       >
     </form>
     <div class="w-full p-4">
@@ -31,7 +30,7 @@ import Pagefind from './search/Pagefind.vue';
                       autocomplete="off"
                       autocapitalize="off"
                       class="grow"
-                      placeholder={i18n(I18nKey.search)}
+                      placeholder={t.button.search()}
                     />
                     <Icon
                       name="material-symbols:search-rounded"
@@ -68,6 +67,6 @@ import Pagefind from './search/Pagefind.vue';
     </div>
   </div>
   <form method="dialog" class="modal-backdrop">
-    <button>{i18n(I18nKey.close)}</button>
+    <button>{t.common.close()}</button>
   </form>
 </dialog>
src/components/SideToolBar.astro
@@ -1,7 +1,6 @@
 ---
 import { articleConfig, toolBarConfig } from '@/config';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import Button from './widgets/Button.astro';
 import DarkModeButton from './widgets/DarkModeButton.astro';
@@ -11,7 +10,7 @@ import TocButton from './widgets/SideToolBar/TocButton.vue';
 <div
   id="side-toolbar"
   class="fixed right-0 bottom-10 z-30 grid grid-cols-1 gap-2"
-  aria-label={i18n(I18nKey.toolBar)}
+  aria-label={t.info.toolBar()}
 >
   <div
     id="stb-hide"
@@ -62,7 +61,7 @@ import TocButton from './widgets/SideToolBar/TocButton.vue';
       id="stb-show-more"
       class="btn-circle btn-secondary btn-sm"
       aria-expanded="false"
-      aria-label={i18n(I18nKey.more)}
+      aria-label={t.button.more()}
       aria-controls="stb-hide"
     >
       <Icon name="material-symbols:settings-rounded" class="animate-spin" />
@@ -77,7 +76,7 @@ import TocButton from './widgets/SideToolBar/TocButton.vue';
     <Button id="stb-back-to-top" class="group btn-circle btn-secondary btn-sm">
       <span
         id="stb-read-percentage"
-        aria-label={i18n(I18nKey.toolBarReadingPercentage)}
+        aria-label={t.info.readingPercentage()}
         class="absolute text-sm opacity-0 duration-300 group-hover:opacity-0">0</span
       >
       <Icon
src/i18n/langs/en.ts
@@ -1,73 +0,0 @@
-import Key from '../I18nKey';
-import type { Translation } from '../translation';
-
-export const en: Translation = {
-  [Key.home]: 'Home',
-  [Key.about]: 'About',
-  [Key.archive]: 'Archive',
-  [Key.search]: 'Search',
-  [Key.links]: 'Links',
-  [Key.time]: 'Time',
-  [Key.menu]: 'Menu',
-  [Key.close]: 'Close',
-  [Key.open]: 'Open',
-
-  [Key.prevPage]: 'Previous Page',
-  [Key.nextPage]: 'Next Page',
-
-  [Key.tags]: 'Tags',
-  [Key.categories]: 'Categories',
-  [Key.recentPosts]: 'Recent Posts',
-  [Key.randomPost]: 'Random Post',
-
-  [Key.comments]: 'Comments',
-  [Key.recentComments]: 'Recent Comments',
-  [Key.subscribe]: 'Subscribe',
-  [Key.backLinks]: 'Back Links',
-
-  [Key.untitled]: 'Untitled',
-  [Key.uncategorized]: 'Uncategorized',
-  [Key.untagged]: 'No Tags',
-
-  [Key.totalPosts]: 'Total Posts',
-  [Key.totalWords]: 'Total Words',
-  [Key.lastUpdated]: 'Last Updated',
-  [Key.runTime]: 'Run Time',
-
-  [Key.wordCount]: 'word',
-  [Key.wordsCount]: 'words',
-  [Key.minuteCount]: 'minute',
-  [Key.minutesCount]: 'minutes',
-  [Key.postCount]: 'post',
-  [Key.postsCount]: 'posts',
-  [Key.tagCount]: 'tag',
-  [Key.tagsCount]: 'tags',
-  [Key.categoryCount]: 'category',
-  [Key.categoriesCount]: 'categories',
-
-  [Key.searchResults]: 'Search Results',
-  [Key.noSearchResults]: 'No Results Found',
-
-  [Key.toc]: 'Table of Content',
-
-  [Key.toolBar]: 'Tool Bar',
-  [Key.toolBarReadingPercentage]: 'Reading Percentage',
-
-  [Key.themeToggle]: 'Toggle Theme',
-  [Key.lightMode]: 'Light',
-  [Key.darkMode]: 'Dark',
-  [Key.systemMode]: 'System',
-
-  [Key.more]: 'More',
-
-  [Key.author]: 'Author',
-  [Key.publishedAt]: 'Published at',
-  [Key.license]: 'License',
-
-  [Key.commentReplaceLink]: '[Link]',
-  [Key.commentReplaceImage]: '[Image]',
-  [Key.commentReplaceCode]: '[Code]',
-
-  [Key.draftDevNote]:
-    'This is a draft and will only be displayed in `DEV` mode. To disable draft preview, please modify `buildConfig.showDraftsOnDev` to `false` in `src/config.ts`.',
-};
src/i18n/langs/zh_CN.ts
@@ -1,73 +0,0 @@
-import Key from '../I18nKey';
-import type { Translation } from '../translation';
-
-export const zh_CN: Translation = {
-  [Key.home]: '主页',
-  [Key.about]: '关于',
-  [Key.archive]: '归档',
-  [Key.search]: '搜索',
-  [Key.links]: '友链',
-  [Key.time]: '时间',
-  [Key.menu]: '菜单',
-  [Key.close]: '关闭',
-  [Key.open]: '打开',
-
-  [Key.prevPage]: '上一页',
-  [Key.nextPage]: '下一页',
-
-  [Key.tags]: '标签',
-  [Key.categories]: '分类',
-  [Key.recentPosts]: '最新文章',
-  [Key.randomPost]: '随机文章',
-
-  [Key.comments]: '评论',
-  [Key.recentComments]: '最新评论',
-  [Key.subscribe]: '订阅',
-  [Key.backLinks]: '反向链接',
-
-  [Key.untitled]: '无标题',
-  [Key.uncategorized]: '未分类',
-  [Key.untagged]: '无标签',
-
-  [Key.totalPosts]: '文章总数',
-  [Key.totalWords]: '字数总计',
-  [Key.lastUpdated]: '最后更新',
-  [Key.runTime]: '运行时间',
-
-  [Key.wordCount]: '字',
-  [Key.wordsCount]: '字',
-  [Key.minuteCount]: '分钟',
-  [Key.minutesCount]: '分钟',
-  [Key.postCount]: '篇文章',
-  [Key.postsCount]: '篇文章',
-  [Key.tagCount]: '个标签',
-  [Key.tagsCount]: '个标签',
-  [Key.categoryCount]: '个分类',
-  [Key.categoriesCount]: '个分类',
-
-  [Key.searchResults]: '搜索结果',
-  [Key.noSearchResults]: '没有找到结果',
-
-  [Key.toc]: '目录',
-
-  [Key.toolBar]: '工具栏',
-  [Key.toolBarReadingPercentage]: '阅读进度',
-
-  [Key.themeToggle]: '主题切换',
-  [Key.lightMode]: '亮色',
-  [Key.darkMode]: '暗色',
-  [Key.systemMode]: '跟随系统',
-
-  [Key.more]: '更多',
-
-  [Key.author]: '作者',
-  [Key.publishedAt]: '发布于',
-  [Key.license]: '许可协议',
-
-  [Key.commentReplaceLink]: '[链接]',
-  [Key.commentReplaceImage]: '[图片]',
-  [Key.commentReplaceCode]: '[代码]',
-
-  [Key.draftDevNote]:
-    '这是一篇草稿,只会在 `DEV` 模式下显示。关闭草稿预览,请修改 `src/config.ts` 中的 `buildConfig.showDraftsOnDev` 为 `false`。',
-};
src/i18n/langs/zh_TW.ts
@@ -1,73 +0,0 @@
-import Key from '../I18nKey';
-import type { Translation } from '../translation';
-
-export const zh_TW: Translation = {
-  [Key.home]: '首頁',
-  [Key.about]: '關於',
-  [Key.archive]: '彙整',
-  [Key.search]: '搜尋',
-  [Key.links]: '連結',
-  [Key.time]: '時間',
-  [Key.menu]: '選單',
-  [Key.close]: '關閉',
-  [Key.open]: '開啟',
-
-  [Key.prevPage]: '上一頁',
-  [Key.nextPage]: '下一頁',
-
-  [Key.tags]: '標籤',
-  [Key.categories]: '分類',
-  [Key.recentPosts]: '最新文章',
-  [Key.randomPost]: '隨機文章',
-
-  [Key.comments]: '評論',
-  [Key.recentComments]: '最新評論',
-  [Key.subscribe]: '訂閱',
-  [Key.backLinks]: '反向連結',
-
-  [Key.untitled]: '無標題',
-  [Key.uncategorized]: '未分類',
-  [Key.untagged]: '無標籤',
-
-  [Key.totalPosts]: '文章總數',
-  [Key.totalWords]: '字數總計',
-  [Key.lastUpdated]: '最後更新',
-  [Key.runTime]: '運行時間',
-
-  [Key.wordCount]: '字',
-  [Key.wordsCount]: '字',
-  [Key.minuteCount]: '分鐘',
-  [Key.minutesCount]: '分鐘',
-  [Key.postCount]: '篇文章',
-  [Key.postsCount]: '篇文章',
-  [Key.tagCount]: '個標籤',
-  [Key.tagsCount]: '個標籤',
-  [Key.categoryCount]: '個分類',
-  [Key.categoriesCount]: '個分類',
-
-  [Key.searchResults]: '搜尋結果',
-  [Key.noSearchResults]: '沒有找到結果',
-
-  [Key.toc]: '目錄',
-
-  [Key.toolBar]: '工具列',
-  [Key.toolBarReadingPercentage]: '閱讀進度',
-
-  [Key.themeToggle]: '主題切換',
-  [Key.lightMode]: '亮色',
-  [Key.darkMode]: '暗色',
-  [Key.systemMode]: '跟隨系統',
-
-  [Key.more]: '更多',
-
-  [Key.author]: '作者',
-  [Key.publishedAt]: '發佈於',
-  [Key.license]: '許可協議',
-
-  [Key.commentReplaceLink]: '[連結]',
-  [Key.commentReplaceImage]: '[圖片]',
-  [Key.commentReplaceCode]: '[程式碼]',
-
-  [Key.draftDevNote]:
-    '這是一篇草稿,只會在 `DEV` 模式下顯示。關閉草稿預覽,請修改 `src/config.ts` 中的 `buildConfig.showDraftsOnDev` 為 `false`。',
-};
src/i18n/I18nKey.ts
@@ -1,73 +0,0 @@
-enum I18nKey {
-  home = 'home',
-  about = 'about',
-  archive = 'archive',
-  search = 'search',
-  links = 'links',
-  time = 'time',
-  menu = 'menu',
-  close = 'close',
-  open = 'open',
-
-  prevPage = 'prevPage',
-  nextPage = 'nextPage',
-
-  tags = 'tags',
-  categories = 'categories',
-  recentPosts = 'recentPosts',
-  randomPost = 'randomPost',
-
-  comments = 'comments',
-  recentComments = 'recentComments',
-  subscribe = 'subscribe',
-  backLinks = 'backLinks',
-
-  untitled = 'untitled',
-  uncategorized = 'uncategorized',
-  untagged = 'untagged',
-
-  totalPosts = 'totalPosts',
-  totalWords = 'totalWords',
-  lastUpdated = 'lastUpdated',
-  runTime = 'runTime',
-
-  wordCount = 'wordCount',
-  wordsCount = 'wordsCount',
-  minuteCount = 'minuteCount',
-  minutesCount = 'minutesCount',
-  postCount = 'postCount',
-  postsCount = 'postsCount',
-  tagCount = 'tagCount',
-  tagsCount = 'tagsCount',
-  categoryCount = 'categoryCount',
-  categoriesCount = 'categoriesCount',
-
-  searchResults = 'searchResults',
-  noSearchResults = 'noSearchResults',
-
-  toc = 'toc',
-
-  toolBar = 'toolBar',
-  toolBarReadingPercentage = 'toolBarReadingPercentage',
-
-  themeToggle = 'themeToggle',
-  lightMode = 'lightMode',
-  darkMode = 'darkMode',
-  systemMode = 'systemMode',
-
-  more = 'more',
-
-  author = 'author',
-  publishedAt = 'publishedAt',
-  license = 'license',
-
-  /** The replace text for the comment content, used in Recent Comments. */
-  commentReplaceLink = 'commentReplaceLink',
-  commentReplaceImage = 'commentReplaceImage',
-  commentReplaceCode = 'commentReplaceCode',
-
-  /** Note in the top of drafts content in dev mode. This key supports markdown syntax, using `markdown-it`. */
-  draftDevNote = 'draftDevNote',
-}
-
-export default I18nKey;
src/i18n/translation.ts
@@ -1,31 +0,0 @@
-import I18nKey from './I18nKey';
-import { en } from './langs/en';
-import { zh_CN } from './langs/zh_CN';
-import { zh_TW } from './langs/zh_TW';
-import { siteConfig } from '@/config';
-
-export type Translation = {
-  [K in I18nKey]: string;
-};
-
-const defaultTranslation = en;
-
-const map: { [key: string]: Translation } = {
-  en: en,
-  en_us: en,
-  en_gb: en,
-  en_au: en,
-  zh_cn: zh_CN,
-  zh_tw: zh_TW,
-};
-
-export function getTranslation(lang: string): Translation {
-  return map[lang.toLowerCase()] || defaultTranslation;
-}
-
-export function i18n(key: I18nKey | string | undefined): string | undefined {
-  if (typeof key === 'undefined') return undefined;
-  const lang = siteConfig.lang || 'en';
-  const translate = getTranslation(lang);
-  return key in I18nKey ? translate[key as I18nKey] : key;
-}
src/pages/archives/categories/[category]/[page].astro
@@ -5,14 +5,16 @@ import RecentCommentsCard from '@components/aside/RecentCommentsCard.vue';
 import SiteInfoCard from '@components/aside/SiteInfoCard.astro';
 import CategoryBar from '@components/misc/CategoryBar.astro';
 import PostsPage from '@components/PostsPage.astro';
-import I18nKey from '@i18n/I18nKey';
 import MainLayout from '@layouts/MainLayout.astro';
 import { getCategories, getSortedPosts } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 
 export async function getStaticPaths() {
   const posts = await getSortedPosts();
   const categories = [
-    ...new Set(posts.map((post) => post.data.category || I18nKey.uncategorized)),
+    ...new Set(
+      posts.map((post) => post.data.category || t?.meta.unCategorized() || 'uncategorized')
+    ),
   ];
   return categories
     .map((category) => {
src/pages/archives/categories/index.astro
@@ -10,9 +10,8 @@ import ProfileCard from '@components/aside/ProfileCard.astro';
 import TOC from '@components/aside/TOC.astro';
 import Button from '@components/widgets/Button.astro';
 import MetaIcon from '@components/widgets/MetaIcon.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import MainLayout from '@layouts/MainLayout.astro';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 
 const categoriesMap = await getCategories();
@@ -25,29 +24,21 @@ for (const [category] of categoriesMap) {
 }
 const uncategorizedPosts = allPosts.filter((post) => !post.data.category).slice(0, 3);
 if (uncategorizedPosts.length > 0)
-  categoryPosts.set(i18n(I18nKey.uncategorized) as string, uncategorizedPosts);
+  categoryPosts.set(t.meta.unCategorized(), uncategorizedPosts);
 ---
 
-<MainLayout title={i18n(I18nKey.categories)}>
+<MainLayout title={t.navigation.archive.categories()}>
   <div
     class="card bg-base-200/25 border-base-300 swup-transition-fade mx-auto flex flex-col items-center border px-6 py-4"
   >
     <div class="tooltip md:tooltip-right tooltip-bottom mx-auto w-fit">
-      <h1 class="text-center text-3xl font-bold">{i18n(I18nKey.categories)}</h1>
+      <h1 class="text-center text-3xl font-bold">{t.navigation.archive.categories()}</h1>
       <div class="tooltip-content flex flex-col">
         <div>
-          {
-            `${allPosts.length} ${i18n(
-              allPosts.length > 1 ? I18nKey.postsCount : I18nKey.postCount
-            )}, `
-          }
+          {t.status.postsCount(allPosts.length) + ', '}
         </div>
         <div>
-          {
-            `${categoriesMap.size} ${i18n(
-              categoriesMap.size > 1 ? I18nKey.categoriesCount : I18nKey.categoryCount
-            )}`
-          }
+          {t.status.categoriesCount(categoriesMap.size)}
         </div>
       </div>
     </div>
@@ -79,7 +70,7 @@ if (uncategorizedPosts.length > 0)
                   </span>
                 </h2>
                 <Button href={getCategoryUrl(category)} title={category} class="pl-3">
-                  {i18n(I18nKey.more)}
+                  {t.button.more()}
                   <Icon
                     name="material-symbols:chevron-right-rounded"
                     height="1.5rem"
@@ -102,7 +93,7 @@ if (uncategorizedPosts.length > 0)
                         {[
                           {
                             icon: 'material-symbols:category-outline-rounded',
-                            text: data.category || i18n(I18nKey.uncategorized),
+                            text: data.category || t.meta.unCategorized(),
                             link: getCategoryUrl(data.category),
                           },
                           ...(data.tags?.map((tag) => {
src/pages/archives/tags/[tag]/[page].astro
@@ -2,23 +2,23 @@
 import { siteConfig } from '@/config';
 import PostsPage from '@components/PostsPage.astro';
 import ProfileCard from '@components/aside/ProfileCard.astro';
-import I18nKey from '@i18n/I18nKey';
 import MainLayout from '@layouts/MainLayout.astro';
 import { getSortedPosts } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 
 export async function getStaticPaths() {
   const posts = await getSortedPosts();
   const tags = [
     ...new Set(
       posts
-        .map((post) => (post.data.tags.length > 0 ? post.data.tags : [I18nKey.untagged]))
+        .map((post) => (post.data.tags.length > 0 ? post.data.tags : [t.meta.unTagged()]))
         .flat()
     ),
   ];
   return tags
     .map((tag) => {
       const tagPosts =
-        tag === I18nKey.untagged
+        tag === t.meta.unTagged()
           ? posts.filter((post) => post.data.tags.length === 0)
           : posts.filter((post) => post.data.tags.includes(tag));
       const pageNum = Math.ceil(tagPosts.length / siteConfig.postsPerPage);
src/pages/archives/tags/index.astro
@@ -5,9 +5,8 @@ import ProfileCard from '@components/aside/ProfileCard.astro';
 import TOC from '@components/aside/TOC.astro';
 import Button from '@components/widgets/Button.astro';
 import MetaIcon from '@components/widgets/MetaIcon.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import MainLayout from '@layouts/MainLayout.astro';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 
 const tagsMap = await getTags();
@@ -21,26 +20,22 @@ for (const [tag] of tagsMap) {
 const untaggedPosts = allPosts
   .filter((post) => !post.data.tags || post.data.tags.length === 0)
   .slice(0, 3);
-if (untaggedPosts.length > 0) tagPosts.set(i18n(I18nKey.untagged) as string, untaggedPosts);
+if (untaggedPosts.length > 0) tagPosts.set(t.meta.unTagged(), untaggedPosts);
 ---
 
-<MainLayout title={i18n(I18nKey.tags)}>
+<MainLayout title={t.navigation.archive.tags()}>
   <div
     class="card bg-base-200/25 border-base-300 swup-transition-fade mx-auto flex flex-col items-center border px-6 py-4"
   >
     <div class="tooltip md:tooltip-right tooltip-bottom mx-auto w-fit">
-      <h1 class="text-center text-3xl font-bold">{i18n(I18nKey.tags)}</h1><div
+      <h1 class="text-center text-3xl font-bold">{t.navigation.archive.tags()}</h1><div
         class="tooltip-content flex flex-col"
       >
         <div>
-          {
-            `${allPosts.length} ${i18n(
-              allPosts.length > 1 ? I18nKey.postsCount : I18nKey.postCount
-            )}, `
-          }
+          {t.status.postsCount(allPosts.length) + ', '}
         </div>
         <div>
-          {`${tagsMap.size} ${i18n(tagsMap.size > 1 ? I18nKey.tagsCount : I18nKey.tagCount)}`}
+          {t.status.tagsCount(tagsMap.size)}
         </div>
       </div>
     </div>
@@ -72,7 +67,7 @@ if (untaggedPosts.length > 0) tagPosts.set(i18n(I18nKey.untagged) as string, unt
                   </span>
                 </h2>
                 <Button href={getTagUrl(tag)} title={tag} class="pl-3">
-                  {i18n(I18nKey.more)}
+                  {t.button.more()}
                   <Icon
                     name="material-symbols:chevron-right-rounded"
                     height="1.5rem"
@@ -95,7 +90,7 @@ if (untaggedPosts.length > 0) tagPosts.set(i18n(I18nKey.untagged) as string, unt
                         {[
                           {
                             icon: 'material-symbols:category-outline-rounded',
-                            text: data.category || i18n(I18nKey.uncategorized),
+                            text: data.category || t.meta.unCategorized(),
                             link: getCategoryUrl(data.category),
                           },
                           ...data.tags.map((tag) => {
src/pages/archives/[...time].astro
@@ -4,10 +4,9 @@ import type { BlogPostData } from '@/types/data';
 import ProfileCard from '@components/aside/ProfileCard.astro';
 import TOC from '@components/aside/TOC.astro';
 import MetaIcon from '@components/widgets/MetaIcon.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import MainLayout from '@layouts/MainLayout.astro';
 import { getCategoryUrl, getPostsCount, getSortedPosts, getTagUrl } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import dayjs from 'dayjs';
 
@@ -66,9 +65,9 @@ const postCount = await getPostsCount();
 <MainLayout>
   <div class="card border-base-300 bg-base-200/25 swup-transition-fade border px-6 py-4">
     <div class="tooltip md:tooltip-right tooltip-bottom mx-auto w-fit">
-      <h1 class="text-center text-3xl font-bold">{i18n(I18nKey.archive)}</h1>
+      <h1 class="text-center text-3xl font-bold">{t.navigation.archive.title()}</h1>
       <div class="tooltip-content">
-        {`${postCount} ${i18n(postCount > 1 ? I18nKey.postsCount : I18nKey.postCount)}`}
+        {t.status.postsCount(postCount)}
       </div>
     </div>
     {
@@ -97,7 +96,7 @@ const postCount = await getPostsCount();
                       {[
                         {
                           icon: 'material-symbols:category-outline-rounded',
-                          text: data.category || i18n(I18nKey.uncategorized),
+                          text: data.category || t.meta.unCategorized(),
                           link: getCategoryUrl(data.category),
                         },
                         ...data.tags.map((tag) => {
src/pages/posts/[article].astro
@@ -5,13 +5,11 @@ import License from '@components/misc/License.astro';
 import PostInfo from '@components/misc/PostInfo.astro';
 import ImageWrapper from '@components/utils/ImageWrapper.astro';
 import Markdown from '@components/utils/Markdown.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
 import { getAllReferences, getPosts } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 import { Icon } from 'astro-icon/components';
 import { render } from 'astro:content';
-import MarkdownIt from 'markdown-it';
 
 export async function getStaticPaths() {
   const posts = await getPosts();
@@ -88,7 +86,13 @@ const backLinks: {
             <Icon name="material-symbols:info-outline-rounded" />
             NOTE
           </p>
-          <Fragment set:html={new MarkdownIt().render(i18n(I18nKey.draftDevNote)!)} />
+          <Fragment
+            set:html={t.info.devNote({
+              configKey: 'buildConfig.showDraftsOnDev',
+              configValue: false,
+              configFilePath: 'src/config.ts',
+            })}
+          />
         </div>
       )
     }
src/pages/about.astro
@@ -1,10 +1,9 @@
 ---
 import PostInfo from '@components/misc/PostInfo.astro';
 import Markdown from '@components/utils/Markdown.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
 import { getAllReferences } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 import { getEntry, render } from 'astro:content';
 
 const md = await getEntry('spec', 'about');
@@ -31,12 +30,12 @@ if (md) {
 ---
 
 <PostPageLayout
-  title={md?.data.title || (i18n(I18nKey.about) as string)}
+  title={md?.data.title || t.navigation.about()}
   headings={headings}
   comment={md?.data.comment}
 >
   <Fragment slot="header-content">
-    <PostInfo title={md?.data.title || (i18n(I18nKey.about) as string)} />
+    <PostInfo title={md?.data.title || t.navigation.about()} />
   </Fragment>
   <Markdown
     bidirectional-references={md
src/pages/links.astro
@@ -3,10 +3,9 @@ import { linksConfig } from '@/config';
 import PostInfo from '@components/misc/PostInfo.astro';
 import ImageWrapper from '@components/utils/ImageWrapper.astro';
 import Markdown from '@components/utils/Markdown.astro';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
 import { getAllReferences } from '@utils/content-utils';
+import { t } from '@utils/i18n';
 import { getEntry, render } from 'astro:content';
 
 const md = await getEntry('spec', 'links');
@@ -39,12 +38,12 @@ const groupHeadings = linksConfig.items.map((item) => ({
 ---
 
 <PostPageLayout
-  title={i18n(I18nKey.links) as string}
+  title={t.navigation.friendLinks()}
   comment={md?.data.comment}
   headings={[...groupHeadings, ...headings]}
 >
   <Fragment slot="header-content">
-    <PostInfo title={i18n(I18nKey.links) as string} />
+    <PostInfo title={t.navigation.friendLinks()} />
   </Fragment>
   {
     linksConfig.items.map((item) => (
src/types/config.d.ts
@@ -1,4 +1,4 @@
-import type I18nKey from '@i18n/I18nKey';
+import type { Locales } from 'i18n/i18n-types';
 
 // ============================================================================
 export type Favicon = {
@@ -23,7 +23,7 @@ export type ButtonSubConfig<T extends string> = T extends 'text'
        *
        * 按钮的文本。
        */
-      text: string | I18nKey;
+      text: string;
     } & (
       | {
           /**
@@ -68,7 +68,7 @@ export type ButtonSubConfig<T extends string> = T extends 'text'
          *
          * 按钮的文本。
          */
-        text?: string | I18nKey;
+        text?: string;
       } & (
         | {
             /**
@@ -122,7 +122,7 @@ export type SiteConfig = {
    *
    * 站点的语言。
    */
-  lang: string;
+  lang: Locales;
   /**
    * The time when the site was created.
    *
@@ -345,7 +345,7 @@ export type NavbarConfig = {
          *
          * 组的标题。
          */
-        title: string | I18nKey;
+        title: string;
         /**
          * The items displayed in the group.
          *
src/utils/content-utils.ts
@@ -1,8 +1,7 @@
+import { t } from './i18n';
 import { buildConfig } from '@/config';
 import type { BlogPostData } from '@/types/data';
 import type { BlogPost } from '@/types/data';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
 import { type CollectionEntry, getCollection, render } from 'astro:content';
 import dayjs from 'dayjs';
 
@@ -48,7 +47,7 @@ export async function getCategories(): Promise<Map<string, number>> {
   const categoryMap = new Map<string, number>();
 
   allBlogPosts.forEach((post) => {
-    const category = post.data.category || (i18n(I18nKey.uncategorized) as string);
+    const category = post.data.category || t.meta.unCategorized();
     categoryMap.set(category, (categoryMap.get(category) || 0) + 1);
   });
 
@@ -72,12 +71,12 @@ export async function getTags(): Promise<Map<string, number>> {
 export function getCategoryUrl(category: string | undefined) {
   return category
     ? `/archives/categories/${category.replaceAll(/[\\/]/g, '-')}/1/`
-    : `/archives/categories/${I18nKey.uncategorized}/1/`;
+    : `/archives/categories/uncategorized/1/`;
 }
 
 export function getTagUrl(tag: string) {
-  return tag === i18n(I18nKey.untagged)
-    ? `/archives/tags/${I18nKey.untagged}/1`
+  return tag === t.meta.unTagged()
+    ? `/archives/tags/untagged/1`
     : `/archives/tags/${tag.replaceAll(/[\\/]/g, '-')}/1`;
 }
 
src/utils/i18n.ts
@@ -0,0 +1,4 @@
+import originalL from '../../i18n/i18n-node';
+import { siteConfig } from '../config';
+
+export const t = originalL[siteConfig.lang].web;
src/config.ts
@@ -1,8 +1,7 @@
 // WARNING: This file will be bundled into the build product.
 // DO NOT add any sensitive information here.
 // 警告: 该文件会被打包到构建产物中, 不要在此添加任何敏感信息
-import I18nKey from './i18n/I18nKey';
-import { getRandomPost } from './scripts/utils';
+import L from '../i18n/i18n-node';
 import type {
   ArticleConfig,
   AsideConfig,
@@ -21,7 +20,7 @@ import type {
 export const siteConfig: SiteConfig = {
   title: 'Astral Halo',
   subtitle: 'A static blog template powered by Astro',
-  lang: 'en', // "en" | "zh_CN" | "zh_TW"
+  lang: 'en',
   createAt: new Date('2025-01-01'),
   postsPerPage: 10,
   banner: {
@@ -39,6 +38,9 @@ export const siteConfig: SiteConfig = {
   },
 };
 
+// To avoid circular dependency
+const t = L[siteConfig.lang].web;
+
 export const buildConfig: BuildConfig = {
   showDraftsOnDev: true,
   inferRemoteImageSize: {
@@ -90,37 +92,29 @@ export const linksConfig: LinksConfig = {
 export const navbarConfig: NavbarConfig = {
   navbarCenterItems: [
     {
-      title: I18nKey.archive,
+      title: t.navigation.archive.title(),
       items: [
-        { text: I18nKey.time, href: '/archives/' },
-        { text: I18nKey.categories, href: '/archives/categories/' },
-        { text: I18nKey.tags, href: '/archives/tags/' },
+        { text: t.navigation.archive.time(), href: '/archives/' },
+        { text: t.navigation.archive.categories(), href: '/archives/categories/' },
+        { text: t.navigation.archive.tags(), href: '/archives/tags/' },
       ],
     },
-    { text: I18nKey.links, href: '/links/' },
-    { text: I18nKey.about, href: '/about/' },
+    { text: t.navigation.friendLinks(), href: '/links/' },
+    { text: t.navigation.about(), href: '/about/' },
   ],
   navbarRightItems: {
     onlyWide: [
       {
         icon: 'material-symbols:rss-feed-rounded',
-        text: I18nKey.subscribe,
+        text: t.button.subscribe(),
         href: '/rss.xml',
         blank: true,
       },
-      {
-        icon: 'material-symbols:casino',
-        text: I18nKey.randomPost,
-        onclick: {
-          id: 'random-post-btn',
-          function: getRandomPost,
-        },
-      },
     ],
     always: [
       {
         icon: 'material-symbols:search-rounded',
-        text: I18nKey.search,
+        text: t.button.search(),
         onclick: 'search_modal.showModal()',
       },
     ],