Published on

How to realize CSS i18n?

How to realize CSS i18n?

TIP提示

You can click this button to toggle locales.

在一个页面中实现 i18n

为了使 Valaxy 成为一个国际化的项目,i18n 是必不可少的。

常见的 i18n 方案为采用不同的路径(如 /zh-CN/)或解析不同的域名(cn.xxx.xxx)来分别维护。

此外还可使用 crowdin 平台辅助用户进行多语言翻译。

但对于博客来说,这显然都很麻烦。 当你需要 i18n 时,你不得不同时维护多个目录下的文章。 当文章间存在相同的示例时,你还需要维护相同的内容。非常不优雅。

Valaxy 中, 站点的独立字段部分(如文章目录:Table of Contents)基于 vue-i18n 实现, 而文章内容部分的大段文本则采用另一种 CSS i18n 的方案。

我想先看看效果

i18n in One Page

In order to make Valaxy an international project, i18n is essential.

Common i18n schemes are maintained separately using different paths (e.g. /zh-CN/) or resolving different domain names (cn.xxx.xxx).

In addition, the crowdin platform can be used to assist users with multilingual translations.

But for blogs, this is obviously all a hassle. When you need i18n, you have to maintain articles in multiple directories at the same time. You also have to maintain the same content when the same examples exist between articles. Very inelegant.

In Valaxy, the The standalone fields of the site (e.g. Table of Contents) are implemented based on vue-i18n. The large text sections of the article content section use a different CSS i18n scheme.

I want to see the result first.

Vue-i18n

配置 Vite Vue-i18n 插件 @intlify/unplugin-vue-i18n

Config Vite Vue-i18n plugin @intlify/unplugin-vue-i18n

ts
import path from 'node:path'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    VueI18n({
      runtimeOnly: true,
      compositionOnly: true,
      include: [path.resolve(__dirname, 'locales/**')],
    }),
  ],
})

locales 目录下配置 zh-CN.ymlen.yml

Write zh-CN.yml and en.yml in locales.

yaml
# zh-CN.yml
sidebar:
  toc: 文章目录
yaml
# en.yml
sidebar:
  toc: Table of Contents

并在主入口文件(如 main.ts)中初始化:

and initialized in the main entry file (e.g. main.ts).

ts
/*
 * All i18n resources specified in the plugin `include` option can be loaded
 * at once using the import syntax
 */
import messages from '@intlify/unplugin-vue-i18n/messages'

// import { createApp } from 'vue'
// import App from './App.vue'

import { createI18n } from 'vue-i18n'

const i18n = createI18n({
  legacy: false,
  locale: 'en',
  messages,
})

// const app = createApp(App)
app.use(i18n)

此时即可在 Vue 中使用 t('') 来翻译对应字段文本。

You can then use t('') in Vue to translate the text of the corresponding field.

vue
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
</script>

<template>
  <h2> {{ t("sidebar.toc") }} </h2>
</template>

Messages when SSG

vue-i18n 支持使用虚拟模块 @intlify/unplugin-vue-i18n/messages 的方式来导入多语言。

可惜的是,它并没有完美地支持 SSR。#78 | intlify/bundle-tools

而 Vite 的 import.meta.globEager 导入必须使用静态字符串。

vue-i18n supports importing multiple languages by using the virtual module @intlify/unplugin-vue-i18n/messages.

Unfortunately, it doesn’t support SSR perfectly.#78 | intlify/bundle-tools

And Vite’s import.meta.globEager import must use a static string.

ts
const messages = Object.fromEntries(
  Object.entries(
    import.meta.globEager('../../locales/*.y(a)?ml')
  )
    .map(([key, value]) => {
      const yaml = key.endsWith('.yaml')
      return [key.slice(14, yaml ? -5 : -4), value.default]
    }),
)

当拥有确定目录时,它是奏效的,但 Valaxy 还需要将 Valaxy 自身的 locales 与主题的 locales 以及用户自定义的 locales 进行合并。 这意味着我们不能使用变量来拼接字符串进行导入,对于不同包管理器的目录结构不同,我们很难确定这些 locales 处于何处的相对位置。

因此我采用插件虚拟模块(@valaxyjs/locales)的形式实现(依次导入各目录下的 locales 数据并合并):

Vite 虚拟模块的原理其实就是拼接字符串。

It works when there is a defined directory, but Valaxy also needs to merge Valaxy’s own locales with the theme’s locales and user-defined locales. This means that we cannot use variables to splice strings for import, and it is difficult to determine the relative location of where these locales are for different package managers with different directory structures.

So I implemented it in the form of a plugin virtual module (@valaxyjs/locales):

The principle of the Vite virtual module is actually a spliced string.

ts
import type { Plugin } from 'vite'

// import the locales data in each directory in turn and merge them
function generateLocales(roots: string[]) {
  const imports: string[] = [
    'const messages = { "zh-CN": {}, en: {} }',
  ]
  const languages = ['zh-CN', 'en']

  roots.forEach((root, i) => {
    languages.forEach((lang) => {
      const langYml = `${root}/locales/${lang}.yml`
      if (fs.existsSync(langYml) && fs.readFileSync(langYml, 'utf-8')) {
        const varName = lang.replace('-', '') + i
        // in windows, you need to change slash
        // more info you can refer 'packages/valaxy/src/node/plugins/index.ts'
        imports.push(`import ${varName} from "${langYml}"`)
        imports.push(`Object.assign(messages['${lang}'], ${varName})`)
      }
    })
  })

  imports.push('export default messages')
  return imports.join('\n')
}

export function createValaxyPlugin(options: ResolvedValaxyOptions): Plugin {
  // ...
  const roots = [options.clientRoot, options.themeRoot, options.userRoot]

  return {
    name: 'Valaxy',

    load(id) {
      // ...
      if (id === '/@valaxyjs/locales')
        return generateLocales(roots)
    },

    async handleHotUpdate(ctx) {
      // ...
    },
  }
}

最后在 i18n 的初始化文件加载:

Finally load in the i18n initialization file:

ts
// i18n.ts
import messages from '/@valaxyjs/locales'

const i18n = createI18n({
  legacy: false,
  locale: 'en',
  messages,
})
app.use(i18n)

CSS i18n - Another solution

CSS i18n - 另一种互补解决方案

CSS i18n - Another complementary solution

文章部分拥有大段的文本,而 vue-i18n 的场景则在于一些独立的字段翻译。

而传统的分文件独立管理的方式,对于博客来说其实并不方便。 大多数情况,你并不会想专门建立一个文件夹来管理它。

因此我尝试使用纯 CSS 解决该问题。

While the article section has large sections of text, the scenario of vue-i18n lies in some separate field translations.

And the traditional way of managing them independently in separate files is not really convenient for blogs. In most cases, you don’t want to create a dedicated folder to manage it.

So I tried to solve the problem using pure CSS.

思路思路

即借助 CSS 规则,根据对应语言,显示对应区块内容。 大体方案:通过 markdown-it-container 设置 fence 预编译 Markdown, 为需要进行 i18n 的段落包裹新的 <div lang="zh-CN"></div>,并使用 CSS 默认隐藏它们。 当页面初始化或切换语言时,为 html 添加对应语言类,编写对应 CSS 以在该类下显示对应语言的区块。

IDEAIDEA

That is, with the help of CSS rules, the content of the corresponding block is displayed according to the corresponding language. The general solution: set fence to pre-compile Markdown via markdown-it-container. Wrap new <div lang="zh-CN"></div>s for the paragraphs that need to be i18n and hide them by default with CSS. When the page initializes or switches languages, add the corresponding language class to html and write the corresponding CSS to display the corresponding language block under that class.

优势

  • 可在同一个 Markdown 文件中进行维护,书写便捷
  • 预加载与实时切换
  • URL 不变,便于管理与分享,且切换无需刷新页面
  • 渐进式翻译(只翻译部分内容并可共用示例内容等)
  • 当你在同一个文件编写文档时,GitHub Copilot (VSCode 插件) 甚至很容易帮助你补全翻译!

劣势

  • 多语言内容被渲染在同一页面中,增加冗余(但我觉得这微小的体积完全是可以接受的)

Advantages:

  • Can be maintained in the same Markdown file, easy to write
  • Pre-loading and real-time switching
  • URLs remain unchanged, easy to manage and share, and switch without refreshing the page
  • Progressive translation (only part of the content is translated and can share example content, etc.)
  • When you are writing a document in the same file, GitHub Copilot (VSCode Extension) can even help you complete the translation!

Disadvantages:

  • Multi-language content is rendered in the same page, adding redundancy (but I think the tiny size is perfectly acceptable)

Result

效果如下(点击按钮切换):

The effect is as follows (click the button to switch).

另一种 i18n 方案。

更多内容:…

Another i18n method.

More info…

中文

English


书写方式如下:

Written like this:

md
::: zh-CN
另一种 i18n 方案。

更多内容:...
:::

::: en
Another i18n method.

More info...
:::

::: zh-CN
中文
:::

::: en
English
:::

Steps

实现步骤

为了能够借助 CSS 处理 i18n,我们借助 markdown-it-container 的 fence 包裹 Markdown 中需要参与 i18n 的内容。

To be able to handle i18n with CSS, we use markdown-it-container’s fence to wrap Markdown content that needs to participate in i18n.

ts
export function containerPlugin(md: MarkdownIt) {
  // ...
  const languages = ['zh-CN', 'en']

  languages.forEach((lang) => {
    md.use(container, lang, {
      render: (tokens: Token[], idx: number) => tokens[idx].nesting === 1 ? `<div lang="${lang}">\n` : '</div>\n',
    })
  })
}

这可以使:

This allows:

md
::: zh-CN
中文
:::

变成 <div lang="zh-CN"></div> 的形式。

lang 是 HTML 的一个标准字段。

为避免 class 命名冲突,我们可以采用 CSS attribute 的查询方式。

首先将 i18n 全部隐藏:

Be <div lang="zh-CN"></div>.

lang is a standard field in HTML.

To avoid class naming conflicts, we can use the CSS attribute query.

First, hide all i18n:

scss
html[lang] {
  .markdown-body {
    div[lang] {
      display: none;
    }
  }
}

编写 CSS/SCSS 规则,设定 html lang 为对应语言时,显示对应语言的元素即可。

Write CSS/SCSS rules and set html lang to display elements in the corresponding language when it is the corresponding language.

scss
$languages: zh-CN, en;

@each $lang in $languages {
  html[lang="#{$lang}"] {
    // only for markdown
    .markdown-body {
      div[lang="#{$lang}"] {
        display: block;
      }
    }
  }
}

为了帮助用户记住自己的语言,还请不要忘记初始化。

To help users remember their language, please also don’t forget to initialize.

html
<!DOCTYPE html>
<html lang="en" class="i18n">

<head>
  <!-- ... -->
  <script>
    (function() {
      const locale = localStorage.getItem('valaxy-locale') || 'en'
      document.documentElement.setAttribute('lang', locale)
    })()
  </script>
</head>
<body>...</body>
</html>

切换语言时则可做如下处理:

When switching languages, the following can be done.

ts
function toggleLocales(lang: val) {
  // ...
  // save locale
  localStorage.setItem('valaxy-locale', lang)
  // set html lang
  document.documentElement.setAttribute('lang', lang)
}

值得一提的是,在查看 lang 文档时,我意外地发现 :lang 也是一种支持的选择器。 因此上述的 CSS 中 [lang="xxx"] 也可以替换为 :lang(xxx)

但是 :lang() 也会命中默认语言的 div(没有 lang 字段,但处于含有 lang 的标签中),因此为了安全,我们还是应该使用 class 的属性查询。

It’s worth mentioning that when looking at the lang documentation, I accidentally found that :lang is also a supported selector. So [lang="xxx"] in the CSS above could also be replaced with :lang(xxx).

However, :lang() will also hit the default language div (which has no lang field but is in a tag containing lang), so to be safe we should still use the class attribute query.

我认为 vue-i18n 与 CSS i18n 的互补,可以非常好地解决单页内的 i18n 切换。 不妨一试?

I think vue-i18n complements CSS i18n and could be a very good solution for i18n switching within a single page. Why not give it a try?


Q.E.D.