refactor(frontend): modularize i18n architecture

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent bf29129a4b
commit 9f138d09e6
118 changed files with 11940 additions and 5368 deletions
+118
View File
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
import { createI18n, useI18n, useLocaleOptions } from '@/i18n'
import { translateLegacyText } from '@/i18n/messages'
import { transformLegacyTemplateI18n } from '@/i18n/legacy-template-transform'
describe('i18n infrastructure', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.lang = ''
})
it('installs a single app-level translator and updates document language', () => {
const app = createApp(defineComponent({ setup: () => () => h('div') }))
app.use(createI18n())
expect(['zh-CN', 'en-US']).toContain(document.documentElement.lang)
expect(app.config.globalProperties.$t('site.home.login')).toBeTruthy()
expect(app.config.globalProperties.$legacyT('保存')).toBeTruthy()
expect(localStorage.getItem('aether_locale')).toBe(document.documentElement.lang)
})
it('switches locale, persists it, and interpolates params', async () => {
const Probe = defineComponent({
setup() {
const { t, setLocale } = useI18n()
const { currentLocaleLabel } = useLocaleOptions()
return { t, setLocale, currentLocaleLabel }
},
render() {
return h('div', [
h('span', { id: 'label' }, this.currentLocaleLabel),
h('span', { id: 'message' }, this.t('site.privacy.currentVersion', { version: '2' })),
])
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Probe)
app.use(createI18n())
const vm = app.mount(root) as InstanceType<typeof Probe> & {
setLocale: (locale: 'zh-CN' | 'en-US') => void
}
vm.setLocale('en-US')
await nextTick()
expect(document.documentElement.lang).toBe('en-US')
expect(localStorage.getItem('aether_locale')).toBe('en-US')
expect(root.textContent).toContain('English')
expect(root.textContent).toContain('Current version: 2')
app.unmount()
root.remove()
})
it('exposes legacy text translation through the app-level helper', async () => {
const Probe = defineComponent({
setup() {
const { legacyT, setLocale } = useI18n()
const source = ref('保存')
return { legacyT, setLocale, source }
},
render() {
return h('button', { title: this.legacyT('关闭') }, this.legacyT(this.source))
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Probe)
app.use(createI18n())
const vm = app.mount(root) as InstanceType<typeof Probe> & {
setLocale: (locale: 'zh-CN' | 'en-US') => void
source: string
}
vm.setLocale('en-US')
await nextTick()
expect(root.querySelector('button')?.textContent).toBe('Save')
expect(root.querySelector('button')?.getAttribute('title')).toBe('Close')
vm.source = '保存中...'
await nextTick()
expect(root.querySelector('button')?.textContent).toBe('Saving...')
app.unmount()
root.remove()
})
it('rewrites template text and static attributes without touching code blocks', () => {
const result = transformLegacyTemplateI18n(`
<button title="关闭">取消</button>
<input placeholder="全部状态">
<code>复制 配置</code>
`)
expect(result.changed).toBe(true)
expect(result.needsHelper).toBe(true)
expect(result.code).toContain(`:title='__aetherLegacyT("关闭")'`)
expect(result.code).toContain(`{{ __aetherLegacyT("取消") }}`)
expect(result.code).toContain(`:placeholder='__aetherLegacyT("全部状态")'`)
expect(result.code).toContain('<code>复制 配置</code>')
})
it('translates common legacy phrases without adding new message entry points', () => {
expect(translateLegacyText('请求记录清理策略', 'en-US')).toBe('Request log cleanup policy')
expect(translateLegacyText(' 发布于 2026-01-01 ', 'en-US')).toBe(' Published at 2026-01-01 ')
expect(translateLegacyText('git clone https://github.com/fawney19/Aether.git', 'en-US')).toBe('git clone https://github.com/fawney19/Aether.git')
})
})
+134
View File
@@ -0,0 +1,134 @@
import type { Ref } from 'vue'
import { nextTick, watch } from 'vue'
import { translateLegacyText, type Locale } from './messages'
const cjkPattern = /[\u4e00-\u9fff]/
const skippedTags = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'KBD', 'SAMP', 'TEXTAREA'])
const translatableAttributes = ['alt', 'aria-label', 'placeholder', 'title']
const originalText = new WeakMap<Text, string>()
const originalAttributes = new WeakMap<Element, Map<string, string>>()
let observer: MutationObserver | null = null
let scheduled = false
function shouldSkipElement(element: Element | null): boolean {
let current: Element | null = element
while (current) {
if (skippedTags.has(current.tagName) || current.hasAttribute('contenteditable')) {
return true
}
current = current.parentElement
}
return false
}
function translateTextNode(node: Text, locale: Locale): void {
if (shouldSkipElement(node.parentElement)) return
if (locale !== 'en-US') {
const original = originalText.get(node)
if (original !== undefined && node.nodeValue !== original) {
node.nodeValue = original
}
return
}
const current = node.nodeValue ?? ''
const source = originalText.get(node) ?? current
if (!cjkPattern.test(source)) return
const translated = translateLegacyText(source, locale)
if (!originalText.has(node)) {
originalText.set(node, source)
}
if (translated !== current) {
node.nodeValue = translated
}
}
function translateElementAttributes(element: Element, locale: Locale): void {
if (shouldSkipElement(element)) return
let originals = originalAttributes.get(element)
for (const attribute of translatableAttributes) {
const current = element.getAttribute(attribute)
if (current === null) continue
if (locale !== 'en-US') {
const original = originals?.get(attribute)
if (original !== undefined && current !== original) {
element.setAttribute(attribute, original)
}
continue
}
const source = originals?.get(attribute) ?? current
if (!cjkPattern.test(source)) continue
const translated = translateLegacyText(source, locale)
if (!originals) {
originals = new Map()
originalAttributes.set(element, originals)
}
if (!originals.has(attribute)) {
originals.set(attribute, source)
}
if (translated !== current) {
element.setAttribute(attribute, translated)
}
}
}
function translateDom(root: ParentNode, locale: Locale): void {
if (root instanceof Element) {
translateElementAttributes(root, locale)
}
const elements = root.querySelectorAll?.('*') ?? []
for (const element of elements) {
translateElementAttributes(element, locale)
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
while (node) {
translateTextNode(node as Text, locale)
node = walker.nextNode()
}
}
function scheduleDomTranslation(locale: Ref<Locale>): void {
if (scheduled) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
if (document.body) {
translateDom(document.body, locale.value)
}
})
}
export function installLegacyDomTranslator(locale: Ref<Locale>): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return
if (observer) return
void nextTick(() => scheduleDomTranslation(locale))
watch(locale, () => {
void nextTick(() => scheduleDomTranslation(locale))
})
observer = new MutationObserver(() => {
scheduleDomTranslation(locale)
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: translatableAttributes,
characterData: true,
childList: true,
subtree: true,
})
}
+122
View File
@@ -0,0 +1,122 @@
import type { App, InjectionKey, Ref } from 'vue'
import { computed, inject, readonly, ref } from 'vue'
import {
defaultLocale,
messages,
supportedLocales,
translateLegacyText,
type Locale,
type MessageKey,
} from './messages'
import { installLegacyDomTranslator } from './dom-translator'
type Params = Record<string, string | number>
interface I18nContext {
locale: Ref<Locale>
setLocale: (locale: Locale) => void
t: (key: MessageKey, params?: Params) => string
legacyT: (value: string) => string
}
const STORAGE_KEY = 'aether_locale'
const i18nKey: InjectionKey<I18nContext> = Symbol('aether-i18n')
const locale = ref<Locale>(readInitialLocale())
function isLocale(value: string | null | undefined): value is Locale {
return !!value && supportedLocales.includes(value as Locale)
}
function readInitialLocale(): Locale {
if (typeof window === 'undefined') return defaultLocale
const stored = localStorage.getItem(STORAGE_KEY)
if (isLocale(stored)) return stored
const preferred = navigator.languages?.find(language => {
const normalized = normalizeLocale(language)
return isLocale(normalized)
})
const normalizedPreferred = normalizeLocale(preferred)
return isLocale(normalizedPreferred) ? normalizedPreferred : defaultLocale
}
function normalizeLocale(value: string | undefined): string | undefined {
if (!value) return undefined
const lower = value.toLowerCase()
if (lower.startsWith('zh')) return 'zh-CN'
if (lower.startsWith('en')) return 'en-US'
return value
}
function setLocale(nextLocale: Locale): void {
locale.value = nextLocale
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale
}
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, nextLocale)
}
}
function formatMessage(template: string, params?: Params): string {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`))
}
function t(key: MessageKey, params?: Params): string {
const bundle = messages[locale.value] ?? messages[defaultLocale]
const template = bundle[key] ?? messages[defaultLocale][key] ?? key
return formatMessage(template, params)
}
function legacyT(value: string): string {
return translateLegacyText(value, locale.value)
}
const context: I18nContext = {
locale,
setLocale,
t,
legacyT,
}
export function createI18n() {
return {
install(app: App) {
app.provide(i18nKey, context)
app.config.globalProperties.$t = t
app.config.globalProperties.$legacyT = legacyT
setLocale(locale.value)
installLegacyDomTranslator(locale)
}
}
}
export function useI18n() {
return inject(i18nKey, context)
}
export function setI18nLocale(locale: Locale): void {
setLocale(locale)
}
export function getI18nLocale(): Locale {
return locale.value
}
export function useLocaleOptions() {
const { locale: currentLocale, setLocale: applyLocale } = useI18n()
const currentLocaleLabel = computed(() => {
return currentLocale.value === 'zh-CN' ? t('common.chinese') : t('common.english')
})
return {
locale: readonly(currentLocale),
supportedLocales,
currentLocaleLabel,
setLocale: applyLocale,
}
}
export type { Locale, MessageKey }
@@ -0,0 +1,371 @@
import type { Plugin } from 'vite'
const cjkPattern = /[\u4e00-\u9fff]/
const helperName = '__aetherLegacyT'
const helperImportName = '__useAetherI18n'
const skipTags = new Set(['script', 'style', 'code', 'pre', 'kbd', 'samp', 'textarea'])
const voidTags = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])
const translatableAttributeNames = new Set([
'alt',
'aria-label',
'cancel-text',
'client-label',
'confirm-text',
'description',
'drop-title',
'empty-message',
'empty-text',
'entity-label',
'filter-title',
'label',
'manual-placeholder',
'message',
'path-hint',
'placeholder',
'provider-label',
'search-placeholder',
'subtitle',
'title',
])
interface TemplateTransformResult {
code: string
changed: boolean
needsHelper: boolean
}
interface TagInfo {
closing: boolean
name: string
selfClosing: boolean
skipSubtree: boolean
}
interface TagStackEntry {
name: string
skip: boolean
}
function toExpressionString(value: string): string {
return JSON.stringify(value).replace(/'/g, "\\'")
}
function wrapExpression(expression: string): string {
const trimmed = expression.trim()
if (!trimmed || trimmed.includes(helperName)) {
return expression
}
return `${helperName}(${trimmed})`
}
function renderTranslatedText(value: string): string {
return `{{ ${helperName}(${toExpressionString(value)}) }}`
}
function findTagEnd(source: string, start: number): number {
let quote: string | null = null
for (let index = start; index < source.length; index++) {
const char = source[index]
if (quote) {
if (char === quote) {
quote = null
}
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (char === '>') {
return index
}
}
return -1
}
function looksLikeTagStart(source: string, index: number): boolean {
const next = source[index + 1]
return !!next && /[A-Za-z!/]/.test(next)
}
function parseTagInfo(tag: string): TagInfo | null {
if (tag.startsWith('<!--') || tag.startsWith('<!') || tag.startsWith('<?')) {
return null
}
const match = tag.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:._-]*)/)
if (!match) {
return null
}
const name = match[2].toLowerCase()
const closing = !!match[1]
const selfClosing = closing ? false : /\/\s*>$/.test(tag) || voidTags.has(name)
const skipSubtree = !closing && (skipTags.has(name) || /\sv-pre(?:[\s=>]|$)/.test(tag))
return {
closing,
name,
selfClosing,
skipSubtree,
}
}
function isTranslatableAttribute(attributeName: string): boolean {
const normalized = attributeName
.replace(/^:/, '')
.replace(/^v-bind:/, '')
.split('.')[0]
.toLowerCase()
return translatableAttributeNames.has(normalized)
}
function isBoundAttribute(attributeName: string): boolean {
return attributeName.startsWith(':') || attributeName.startsWith('v-bind:')
}
function transformTagAttributes(tag: string): TemplateTransformResult {
let changed = false
let needsHelper = false
const attributePattern = /(\s)([:@]?[A-Za-z_][\w:.-]*)(\s*=\s*)(["'])([\s\S]*?)\4/g
const code = tag.replace(
attributePattern,
(fullMatch, prefix: string, attributeName: string, equals: string, quote: string, value: string) => {
if (!isTranslatableAttribute(attributeName) || attributeName.startsWith('@')) {
return fullMatch
}
if (isBoundAttribute(attributeName)) {
const wrapped = wrapExpression(value)
if (wrapped === value) {
return fullMatch
}
changed = true
needsHelper = true
return `${prefix}${attributeName}${equals}${quote}${wrapped}${quote}`
}
if (!cjkPattern.test(value)) {
return fullMatch
}
changed = true
needsHelper = true
return `${prefix}:${attributeName}='${helperName}(${toExpressionString(value)})'`
},
)
return { code, changed, needsHelper }
}
function transformTextSegment(segment: string): TemplateTransformResult {
if (!segment) {
return { code: segment, changed: false, needsHelper: false }
}
let changed = false
let needsHelper = false
let cursor = 0
let code = ''
const interpolationPattern = /\{\{([\s\S]*?)\}\}/g
let match: RegExpExecArray | null
while ((match = interpolationPattern.exec(segment))) {
const staticText = segment.slice(cursor, match.index)
if (cjkPattern.test(staticText)) {
code += renderTranslatedText(staticText)
changed = true
needsHelper = true
} else {
code += staticText
}
const expression = match[1]
const wrapped = wrapExpression(expression)
code += `{{ ${wrapped} }}`
if (wrapped !== expression) {
changed = true
needsHelper = true
}
cursor = match.index + match[0].length
}
const tail = segment.slice(cursor)
if (cjkPattern.test(tail)) {
code += renderTranslatedText(tail)
changed = true
needsHelper = true
} else {
code += tail
}
return changed ? { code, changed, needsHelper } : { code: segment, changed: false, needsHelper: false }
}
function closeTag(stack: TagStackEntry[], tagName: string): void {
const index = stack.findLastIndex(entry => entry.name === tagName)
if (index >= 0) {
stack.splice(index)
}
}
function isInsideSkippedTag(stack: TagStackEntry[]): boolean {
return stack.some(entry => entry.skip)
}
export function transformLegacyTemplateI18n(template: string): TemplateTransformResult {
let code = ''
let changed = false
let needsHelper = false
let cursor = 0
const stack: TagStackEntry[] = []
while (cursor < template.length) {
if (template.startsWith('<!--', cursor)) {
const end = template.indexOf('-->', cursor + 4)
const nextCursor = end >= 0 ? end + 3 : template.length
code += template.slice(cursor, nextCursor)
cursor = nextCursor
continue
}
if (template[cursor] !== '<' || !looksLikeTagStart(template, cursor)) {
const nextTag = template.indexOf('<', cursor + 1)
const nextCursor = nextTag >= 0 ? nextTag : template.length
const segment = template.slice(cursor, nextCursor)
if (isInsideSkippedTag(stack)) {
code += segment
} else {
const transformed = transformTextSegment(segment)
code += transformed.code
changed = changed || transformed.changed
needsHelper = needsHelper || transformed.needsHelper
}
cursor = nextCursor
continue
}
const tagEnd = findTagEnd(template, cursor)
if (tagEnd < 0) {
code += template.slice(cursor)
break
}
const rawTag = template.slice(cursor, tagEnd + 1)
const info = parseTagInfo(rawTag)
const shouldTransformAttributes = !!info && !info.closing && !isInsideSkippedTag(stack) && !info.skipSubtree
if (shouldTransformAttributes) {
const transformed = transformTagAttributes(rawTag)
code += transformed.code
changed = changed || transformed.changed
needsHelper = needsHelper || transformed.needsHelper
} else {
code += rawTag
}
if (info) {
if (info.closing) {
closeTag(stack, info.name)
} else if (!info.selfClosing) {
stack.push({ name: info.name, skip: info.skipSubtree })
}
}
cursor = tagEnd + 1
}
return { code, changed, needsHelper }
}
function injectScriptSetupHelper(source: string): string {
if (source.includes(`legacyT: ${helperName}`)) {
return source
}
const helperSource = `\nimport { useI18n as ${helperImportName} } from '@/i18n'\nconst { legacyT: ${helperName} } = ${helperImportName}()\n`
const scriptSetupMatch = source.match(/<script\s+setup(?:\s[^>]*)?>/)
if (scriptSetupMatch?.index !== undefined) {
const insertAt = scriptSetupMatch.index + scriptSetupMatch[0].length
return `${source.slice(0, insertAt)}${helperSource}${source.slice(insertAt)}`
}
return `${source}\n<script setup lang="ts">${helperSource}</script>\n`
}
function transformVueSource(source: string): TemplateTransformResult {
const templateMatch = source.match(/<template(?:\s[^>]*)?>([\s\S]*?)<\/template>/)
if (!templateMatch || templateMatch.index === undefined) {
return { code: source, changed: false, needsHelper: false }
}
const templateContent = templateMatch[1]
const transformed = transformLegacyTemplateI18n(templateContent)
if (!transformed.changed) {
return { code: source, changed: false, needsHelper: false }
}
const templateStart = templateMatch.index + templateMatch[0].indexOf(templateContent)
const templateEnd = templateStart + templateContent.length
const nextSource = `${source.slice(0, templateStart)}${transformed.code}${source.slice(templateEnd)}`
const code = transformed.needsHelper ? injectScriptSetupHelper(nextSource) : nextSource
return {
code,
changed: true,
needsHelper: transformed.needsHelper,
}
}
export function legacyTemplateI18nPlugin(): Plugin {
return {
name: 'aether-legacy-template-i18n',
enforce: 'pre',
transform(source, id) {
const filename = id.split('?')[0]
if (!filename.endsWith('.vue')) {
return null
}
const transformed = transformVueSource(source)
if (!transformed.changed) {
return null
}
return {
code: transformed.code,
map: null,
}
},
}
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
import type { MessageKey } from './messages'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$t: (key: MessageKey, params?: Record<string, string | number>) => string
$legacyT: (value: string) => string
}
}