How to realize CSS i18n?

How to realize CSS i18n?

TIP

You can click this button to toggle locales.

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

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/**')],
    }),
  ],
})

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

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

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)

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 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]
    }),
)

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) {
      // ...
    },
  }
}

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 - Another complementary solution

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.

IDEA

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.

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).

Another i18n method.

More info…

English


Written like this:

md

Another i18n method.

More info...


English

Steps

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

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;
    }
  }
}

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)
}

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.

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.