mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
refactor(frontend): modularize i18n architecture
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
Vendored
+8
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user