feat: support categories/tags i18n by virtual , close #541, #566

This commit is contained in:
YunYouJun 2025-07-13 19:02:40 +08:00
parent a77872498d
commit 00ed99b171
35 changed files with 209 additions and 73 deletions

View File

@ -6,3 +6,18 @@ excerpt:
siteConfig:
title: Site Config Title
subtitle: Next Generation Static Blog Framework.
description: Valaxy Theme Yun Preview
author:
name: YunYouJun
social:
bilibili: Bilibili
sponsor:
alipay: Alipay
wechatpay: WeChat Pay
tag:
notes: Notes
category:
test: Test

View File

@ -9,3 +9,18 @@ post:
siteConfig:
title: 自定义博客名称
subtitle: 下一代静态博客框架
description: Valaxy 主题云预览
author:
name: 云游君
social:
bilibili: 哔哩哔哩
sponsor:
alipay: 支付宝
wechatpay: 微信支付
tag:
notes: 笔记
category:
test: 测试

View File

@ -2,7 +2,7 @@
title: Aplayer 测试
aplayer: false
categories:
- Test
- $locale:category.test
---
```md

View File

@ -1,7 +1,7 @@
---
title: Code Block Test
categories:
- Test
- $locale:category.test
---
```dockerfile

View File

@ -3,7 +3,7 @@ title: Valaxy 开发笔记
date: 2022-03-23
cover: https://cos.yunle.fun/images/bg/astronaut.webp
tags:
- 笔记
- $locale:tag.notes
- valaxy
---

View File

@ -6,7 +6,7 @@ updated: 2022-03-23 19:00:00
categories: Valaxy Notes
tags:
- valaxy
- 笔记
- $locale:tag.notes
top: 1
outline: deep
excerpt: Valaxy aims to be a next generation of static blogging frameworks/generators.

View File

@ -3,7 +3,7 @@ title:
en: Markdown Styles
zh-CN: Markdown 样式
categories:
- Test
- $locale:category.test
- Markdown
---

View File

@ -3,7 +3,7 @@ title:
en: Markdown Extensions Test
zh-CN: Markdown 扩展测试
categories:
- Test
- $locale:category.test
- Markdown
---

View File

@ -1,7 +1,7 @@
---
title: Markdown File Inclusion By @include
categories:
- Test
- $locale:category.test
---
Include content:

View File

@ -2,7 +2,7 @@
title: Time Warning Test
date: 2020-10-01
categories:
- test
- $locale:category.test
time_warning: true
---

View File

@ -1,4 +1,4 @@
import { defineSiteConfig } from 'valaxy'
import { $t, defineSiteConfig } from 'valaxy'
export default defineSiteConfig({
frontmatter: {
@ -9,14 +9,15 @@ export default defineSiteConfig({
// disable show language switch
// languages: ['zh-CN'],
title: 'siteConfig.title',
title: $t('siteConfig.title'),
subtitle: $t('siteConfig.subtitle'),
timezone: 'Asia/Shanghai',
url: 'https://yun.valaxy.site/',
author: {
avatar: 'https://www.yunyoujun.cn/images/avatar.jpg',
name: '云游君',
name: $t('siteConfig.author.name'),
},
description: 'Valaxy Theme Yun Preview.',
description: $t('siteConfig.description'),
social: [
{
name: 'RSS',
@ -61,7 +62,7 @@ export default defineSiteConfig({
color: '#0084FF',
},
{
name: '哔哩哔哩',
name: $t('siteConfig.social.bilibili'),
link: 'https://space.bilibili.com/1579790',
icon: 'i-ri-bilibili-line',
color: '#FF8EB3',
@ -124,7 +125,7 @@ export default defineSiteConfig({
enable: true,
methods: [
{
name: '支付宝',
name: $t('siteConfig.sponsor.alipay'),
url: 'https://cdn.yunyoujun.cn/img/donate/alipay-qrcode.jpg',
color: '#00A3EE',
icon: 'i-ri-alipay-line',
@ -136,7 +137,7 @@ export default defineSiteConfig({
icon: 'i-ri-qq-line',
},
{
name: '微信支付',
name: $t('siteConfig.sponsor.wechatpay'),
url: 'https://cdn.yunyoujun.cn/img/donate/wechatpay-qrcode.jpg',
color: '#2DC100',
icon: 'i-ri-wechat-pay-line',

View File

@ -6,18 +6,36 @@ categories:
- guide
---
## 在配置中使用国际化
如果你想要为 `siteConfig.title`/`siteConfig.description` 添加国际化支持,可以在 `siteConfig` 中设定键值。
例如:
## 设置支持的语言
```ts [site.config.ts]
import { defineSiteConfig } from 'valaxy'
export default defineSiteConfig({
title: 'siteConfig.title',
description: 'siteConfig.description',
languages: ['zh-CN', 'en'],
})
```
## 在配置中使用国际化
如果你想要为 `siteConfig.title`/`siteConfig.description` 添加国际化支持,可以在 `siteConfig` 中设定键值。
::: tip
`$t` 是 Valaxy 提供的一个虚拟函数,它会添加特定的前缀 `$locale:` 以标记此处的文本需要国际化处理。
随后Valaxy 会在页面中自动替换为对应语言的文本。
因此,它在页面上仍然是支持响应式的。
:::
例如:
```ts [site.config.ts]
import { $t, defineSiteConfig } from 'valaxy'
export default defineSiteConfig({
title: $t('siteConfig.title'),
description: $t('siteConfig.description'),
})
```
@ -165,3 +183,39 @@ description:
zh-CN: 一个简单的 i18n 示例
---
```
### 分类/标签 i18n {lang="zh-CN"}
### Category/Tag i18n {lang="en"}
::: tip
**为什么这里不是使用类似 `title`/`description` 的对象方式,而是使用特殊前缀 `$locale:tag.notes` 的方式?**
因为分类和标签仍然需要一个唯一的键值,且它们通常是在多篇文章中复用的。
如果使用对象方式,你将不得不在每篇文章的 `frontmatter` 中重复定义分类和标签的多种语言。
:::
```md [posts/hello-world.md]
---
categories:
- $locale:category.test
tags:
- $locale:tag.notes
---
```
```yaml [locales/zh-CN.yml]
category:
test: 测试
tag:
notes: 笔记
```
```yaml [locales/en.yml]
category:
test: Test
tag:
notes: Notes
```

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { CategoryList, Post } from 'valaxy'
import { useInvisibleElement } from 'valaxy'
import { useInvisibleElement, useValaxyI18n } from 'valaxy'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@ -25,6 +25,7 @@ const router = useRouter()
const collapse = ref(props.collapsable)
const { t } = useI18n()
const { $t } = useValaxyI18n()
const postCollapseElRef = ref<HTMLElement>()
const { show } = useInvisibleElement(postCollapseElRef)
@ -79,7 +80,7 @@ if (props.level === 1) {
@click="jumpToDisplayCategory(parentKey)"
>
<span>
{{ category.name === 'Uncategorized' ? t('category.uncategorized') : category.name }}
{{ category.name === 'Uncategorized' ? t('category.uncategorized') : $t(category.name) }}
</span>
<span class="rounded-full px-1.5 bg-black/5 shadow-sm op-60" text="xs">
{{ category.total }}

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useSiteConfig, useValaxyConfig, useValaxyDark } from 'valaxy'
import { useSiteConfig, useValaxyConfig, useValaxyDark, useValaxyI18n } from 'valaxy'
import pkg from 'valaxy/package.json'
import { capitalize, computed } from 'vue'
import { useI18n } from 'vue-i18n'
@ -21,6 +21,7 @@ const gradientStyles = computed(() => {
})
const { t } = useI18n()
const { $t } = useValaxyI18n()
const config = useValaxyConfig()
const siteConfig = useSiteConfig()
const themeConfig = useThemeConfig()
@ -72,7 +73,7 @@ const footerIcon = computed(() => themeConfig.value.footer.icon || {
>
<div :class="footerIcon.name" />
</a>
<span>{{ siteConfig.author.name }}</span>
<span>{{ $t(siteConfig.author.name) }}</span>
</div>
<div v-if="themeConfig.footer.powered" class="powered" m="2">

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import { useValaxyI18n } from 'valaxy'
import { ref } from 'vue'
import { useYunSpringAnimation } from '../composables/animation'
@ -8,6 +9,8 @@ const props = defineProps<{
count: number
}>()
const { $t } = useValaxyI18n()
const tagRef = ref<HTMLElement>()
useYunSpringAnimation(tagRef, {
i: props.i || 0,
@ -21,7 +24,7 @@ useYunSpringAnimation(tagRef, {
inline-flex my="2" p="1"
class="post-tag cursor-pointer items-baseline leading-4"
>
<span inline-flex>#{{ title }}</span>
<span inline-flex>#{{ $t(title) }}</span>
<span inline-flex text="xs">[{{ count }}]</span>
</span>
</template>

View File

@ -1,8 +1,7 @@
<script lang="ts" setup>
import { useSiteConfig } from 'valaxy'
import { useI18n } from 'vue-i18n'
import { useSiteConfig, useValaxyI18n } from 'valaxy'
const { t } = useI18n()
const { $t } = useValaxyI18n()
const siteConfig = useSiteConfig()
</script>
@ -16,14 +15,14 @@ const siteConfig = useSiteConfig()
<YunAuthorName />
<YunSiteTitle />
<h4
v-if="siteConfig.subtitle"
v-if="$t(siteConfig.subtitle)"
class="site-subtitle block"
text="xs"
>
{{ t(siteConfig.subtitle) }}
{{ $t(siteConfig.subtitle) }}
</h4>
<div v-if="siteConfig.description" class="site-description">
{{ t(siteConfig.description) }}
{{ $t(siteConfig.description) }}
</div>
</div>
</template>

View File

@ -1,12 +1,14 @@
<script setup lang="ts">
import type { SocialLink } from 'valaxy/types'
import { useAppStore } from 'valaxy'
import { useAppStore, useValaxyI18n } from 'valaxy'
import { computed } from 'vue'
const props = defineProps<{
social: SocialLink
}>()
const { $t } = useValaxyI18n()
const appStore = useAppStore()
const color = computed(() => {
return (appStore.isDark && props.social.color === 'black') ? 'white' : props.social.color
@ -16,7 +18,7 @@ const color = computed(() => {
<template>
<a
class="links-of-author-item yun-icon-btn"
rel="noopener" :href="social.link" :title="social.name"
rel="noopener" :href="social.link" :title="$t(social.name)"
target="_blank"
:style="`color:${color}`"
>

View File

@ -1,9 +1,10 @@
<script lang="ts" setup>
import { useSiteConfig } from 'valaxy'
import { useSiteConfig, useValaxyI18n } from 'valaxy'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { $t } = useValaxyI18n()
const siteConfig = useSiteConfig()
const showQr = ref(false)
@ -35,7 +36,7 @@ const sponsorBtnTitle = computed(() => {
:href="method.url" target="_blank"
:style="`color:${method.color}`"
>
<img class="sponsor-method-img" border="~ rounded" p="1" loading="lazy" :src="method.url" :title="method.name">
<img class="sponsor-method-img" border="~ rounded" p="1" loading="lazy" :src="method.url" :title="$t(method.name)">
<div text="xl" m="2" :class="method.icon" />
</a>
</div>

View File

@ -1,13 +1,12 @@
<script setup lang="ts">
import { useSiteConfig } from 'valaxy'
import { useI18n } from 'vue-i18n'
import { useSiteConfig, useValaxyI18n } from 'valaxy'
const siteConfig = useSiteConfig()
const { t } = useI18n()
const { $t } = useValaxyI18n()
</script>
<template>
<div v-if="siteConfig.author.intro" class="site-author-intro" m="t-0 b-4">
{{ t(siteConfig.author.intro) }}
{{ $t(siteConfig.author.intro) }}
</div>
</template>

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { useSiteConfig } from 'valaxy'
import { useSiteConfig, useValaxyI18n } from 'valaxy'
const siteConfig = useSiteConfig()
const { $t } = useValaxyI18n()
</script>
<template>
@ -9,6 +10,6 @@ const siteConfig = useSiteConfig()
class="site-author-name font-black font-serif text-$va-c-text op-80 hover:op-100 flex-center"
to="/about"
>
{{ siteConfig.author.name }}
{{ $t(siteConfig.author.name) }}
</RouterLink>
</template>

View File

@ -1,11 +1,10 @@
<script setup lang="ts">
import { tObject, useFrontmatter, useSiteConfig } from 'valaxy'
import { tObject, useFrontmatter, useSiteConfig, useValaxyI18n } from 'valaxy'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useYunAppStore } from '../../stores'
const { t, locale } = useI18n()
const { $t, locale } = useValaxyI18n()
const yunApp = useYunAppStore()
const fm = useFrontmatter()
@ -71,7 +70,7 @@ function goToLink() {
</span>
</div>
<span v-if="showSiteTitle" class="font-light truncate">
{{ t(siteConfig.title) }}
{{ $t(siteConfig.title) }}
</span>
</div>
</template>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { useMotion } from '@vueuse/motion'
import { ref } from 'vue'
import { useValaxyI18n } from 'valaxy/client'
import { computed, ref } from 'vue'
const props = defineProps<{
social: {
@ -12,7 +13,7 @@ const props = defineProps<{
// animation
delay: number
}>()
const { $t } = useValaxyI18n()
const iconRef = ref<HTMLElement>()
useMotion(iconRef, {
initial: {
@ -34,18 +35,20 @@ useMotion(iconRef, {
},
},
})
const socialName = computed(() => $t(props.social.name))
</script>
<template>
<div
v-tooltip="social.name"
v-tooltip="socialName"
class="size-10 inline-flex-center"
>
<a
ref="iconRef"
class="prologue-social-icon inline-flex-center w-full h-full text-white bg-$c-brand hover:bg-white hover:text-$c-brand"
rel="noopener"
:href="social.link" :title="social.name"
:href="social.link" :title="socialName"
target="_blank"
:style="`--c-brand:${social.color}`"
>

View File

@ -1,13 +1,12 @@
<script setup lang="ts">
import { useSiteConfig } from 'valaxy'
import { useI18n } from 'vue-i18n'
import { useSiteConfig, useValaxyI18n } from 'valaxy'
const siteConfig = useSiteConfig()
const { t } = useI18n()
const { $t } = useValaxyI18n()
</script>
<template>
<div v-if="siteConfig.description" class="site-description text-$va-c-text text-sm">
{{ t(siteConfig.description) }}
{{ $t(siteConfig.description) }}
</div>
</template>

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { useSiteConfig } from 'valaxy'
import { useI18n } from 'vue-i18n'
import { useSiteConfig, useValaxyI18n } from 'valaxy'
import { useRouter } from 'vue-router'
import { useThemeConfig } from '../../composables'
@ -8,8 +7,7 @@ const router = useRouter()
const siteConfig = useSiteConfig()
const themeConfig = useThemeConfig()
const { t } = useI18n()
const { $t } = useValaxyI18n()
// bg-gradient-to-r gradient-text from-#1e3c72 to-dark dark:(from-#66a6ff to-blue-500)
</script>
@ -19,14 +17,14 @@ const { t } = useI18n()
class="site-name text-lg leading-loose"
:class="themeConfig.banner.siteNameClass"
>
{{ t(siteConfig.title) }}
{{ $t(siteConfig.title) }}
</RouterLink>
<span
v-else
class="site-name text-lg leading-loose"
:class="themeConfig.banner.siteNameClass"
>
{{ t(siteConfig.title) }}
{{ $t(siteConfig.title) }}
</span>
</template>

View File

@ -2,20 +2,21 @@
import { defineArticle, useSchemaOrg } from '@unhead/schema-org/vue'
import dayjs from 'dayjs'
import { tObject, useFrontmatter, useSiteConfig } from 'valaxy'
import { tObject, useFrontmatter, useSiteConfig, useValaxyI18n } from 'valaxy'
import { useI18n } from 'vue-i18n'
const siteConfig = useSiteConfig()
const frontmatter = useFrontmatter()
const { locale } = useI18n()
const { $t } = useValaxyI18n()
const article: Parameters<typeof defineArticle>[0] = {
'@type': 'BlogPosting',
'headline': tObject(frontmatter.value.title || '', locale.value),
'description': tObject(frontmatter.value.description || '', locale.value),
'author': [
{
name: siteConfig.value.author.name,
name: $t(siteConfig.value.author.name),
url: siteConfig.value.author.link,
},
],

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useValaxyI18n } from '../composables'
import { useSiteConfig } from '../config'
withDefaults(defineProps<{
@ -10,6 +11,7 @@ withDefaults(defineProps<{
})
const { t, locale } = useI18n()
const { $t } = useValaxyI18n()
const siteConfig = useSiteConfig()
@ -31,7 +33,7 @@ const licenseHtml = computed(() => {
<strong>
{{ t('post.copyright.author') + t('symbol.colon') }}
</strong>
<span>{{ t(siteConfig.author.name) }}</span>
<span>{{ $t(siteConfig.author.name) }}</span>
</li>
<li v-if="url" class="post-copyright-link">
<strong>

View File

@ -6,7 +6,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { tObject } from '../../../shared/utils/i18n'
import { useFrontmatter, useValaxyHead } from '../../composables'
import { useFrontmatter, useValaxyHead, useValaxyI18n } from '../../composables'
import { useTimezone } from '../../composables/global'
// https://github.com/vueuse/head
@ -20,6 +20,7 @@ export function useValaxyApp() {
const fm = useFrontmatter()
const { locale } = useI18n()
const { $t } = useValaxyI18n()
const title = computed(() => tObject(fm.value.title || '', locale.value))
@ -45,7 +46,7 @@ export function useValaxyApp() {
// https://unhead.unjs.io/docs/schema-org/guides/recipes/identity
// Personal Website or Blog
definePerson({
name: siteConfig.value.author.name,
name: $t(siteConfig.value.author.name),
url: siteUrl.value,
image: siteConfig.value.author.avatar,
sameAs: siteConfig.value.social.map(s => s.link),

View File

@ -3,12 +3,13 @@ import pkg from 'valaxy/package.json'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFrontmatter } from '../../composables'
import { useFrontmatter, useValaxyI18n } from '../../composables'
import { useSiteConfig } from '../../config'
import { tObject } from '../../utils'
export function useValaxyHead() {
const { locale, t } = useI18n()
const { locale } = useI18n()
const { $t } = useValaxyI18n()
const fm = useFrontmatter()
const siteConfig = useSiteConfig()
@ -17,7 +18,7 @@ export function useValaxyHead() {
useHead({
title: $title,
titleTemplate: (title) => {
const siteTitle = t(siteConfig.value.title)
const siteTitle = $t(siteConfig.value.title)
return fm.value.titleTemplate || (title ? `${title} - ${siteTitle}` : siteTitle)
},
link: [

View File

@ -6,6 +6,7 @@ import { computed } from 'vue'
// not optimize deps all locales
import { useI18n } from 'vue-i18n'
import { tObject } from '../../shared/utils/i18n'
import { LOCALE_PREFIX } from '../utils'
import 'dayjs/locale/en'
import 'dayjs/locale/zh-cn'
@ -63,3 +64,23 @@ export function useLocaleTitle(fm: Ref<{
return tObject(fm.value.title || '', lang) || ''
})
}
/**
* @experimental
* `$locale:` key key
* locales/
*/
export function useValaxyI18n() {
const { t, locale } = useI18n()
return {
locale,
$t: (key: string) => {
if (key.startsWith(LOCALE_PREFIX)) {
return t(key.slice(LOCALE_PREFIX.length))
}
return key
},
}
}

View File

@ -132,7 +132,7 @@ export function containerPlugin(md: MarkdownItAsync, containerOptions: Container
tokens[idx].nesting === 1 ? `<div class="vp-raw">\n` : `</div>\n`,
})
const languages = ['zh-CN', 'en']
const languages = containerOptions.siteConfig?.languages || ['zh-CN', 'en']
languages.forEach((lang) => {
md.use(container, lang, {
render: (tokens: Token[], idx: number) =>

View File

@ -59,6 +59,7 @@ export async function setupMarkdownPlugins(
.use(preWrapperPlugin, { theme, siteConfig })
.use(snippetPlugin, options?.userRoot)
.use(containerPlugin, {
siteConfig,
...mdOptions.blocks,
...mdOptions?.container,
})

View File

@ -0,0 +1,11 @@
/**
* @experimental
* @see https://github.com/YunYouJun/valaxy/issues/566
* @param key
*
* 使 key
* locales/
*/
export function $t(key: string) {
return `$locale:${key}`
}

View File

@ -1,3 +1,4 @@
export * from './getGitTimestamp'
export * from './helper'
export * from './i18n'
export * from './resolve'

View File

@ -4,7 +4,7 @@ import { toAtFS } from '../utils'
export const templateLocales: VirtualModuleTemplate = {
id: '/@valaxyjs/locales',
async getContent({ roots }) {
async getContent({ roots, config }) {
const imports: string[] = [
'import { createDefu } from "defu"',
'const messages = { "zh-CN": {}, en: {} }',
@ -17,8 +17,7 @@ export const templateLocales: VirtualModuleTemplate = {
})
`,
]
const languages = ['zh-CN', 'en']
const languages = config.siteConfig.languages || ['zh-CN', 'en']
roots.forEach((root, i) => {
languages.forEach((lang) => {
const langYml = `${root}/locales/${lang}.yml`

View File

@ -1 +1,8 @@
export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
/**
* @experimental
* 使 key
* locales/
*/
export const LOCALE_PREFIX = '$locale:'