feat: add runtime i18n with English, Vietnamese, and Simplified Chinese support
- Implement runtime i18n using MutationObserver for automatic DOM translation
- Add language switcher dropdown in dashboard header (EN/VI/ZH)
- Support 3 languages: English (default), Tiếng Việt, 简体中文
- Add translation files: vi.json (197 entries), zh-CN.json (513 entries, cleaned)
- Translate dashboard UI: sidebar menu, header, settings, MITM page
- Use cookie-based locale persistence with /api/locale endpoint
- Zero component changes required - translations applied at runtime
- Fix Header flicker on route change with key={pathname}
Co-authored-by: eachann <each1024@qq.com>
Based on PR #247 from decolua/9router with runtime approach
Made-with: Cursor
This commit is contained in:
parent
b7b4ac5592
commit
afb83f4563
12 changed files with 1092 additions and 9 deletions
162
src/i18n/runtime.js
Normal file
162
src/i18n/runtime.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, normalizeLocale } from "./config";
|
||||
|
||||
let translationMap = {};
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
let reloadCallbacks = [];
|
||||
|
||||
// Read locale from cookie
|
||||
function getLocaleFromCookie() {
|
||||
if (typeof document === "undefined") return DEFAULT_LOCALE;
|
||||
const cookie = document.cookie
|
||||
.split(";")
|
||||
.find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`));
|
||||
const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : DEFAULT_LOCALE;
|
||||
return normalizeLocale(value);
|
||||
}
|
||||
|
||||
// Load translation map
|
||||
async function loadTranslations(locale) {
|
||||
if (locale === "en") {
|
||||
translationMap = {};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/i18n/literals/${locale}.json`);
|
||||
translationMap = await response.json();
|
||||
} catch (err) {
|
||||
console.error("Failed to load translations:", err);
|
||||
translationMap = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Translate text - exported for use in components
|
||||
export function translate(text) {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return text;
|
||||
if (currentLocale === "en") return text;
|
||||
return translationMap[trimmed] || text;
|
||||
}
|
||||
|
||||
// Get current locale - exported for use in components
|
||||
export function getCurrentLocale() {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
// Register callback for locale changes
|
||||
export function onLocaleChange(callback) {
|
||||
reloadCallbacks.push(callback);
|
||||
return () => {
|
||||
reloadCallbacks = reloadCallbacks.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
// Process text node
|
||||
function processTextNode(node) {
|
||||
if (!node.nodeValue || !node.nodeValue.trim()) return;
|
||||
|
||||
// Skip if parent is script, style, code, or structural elements
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
// Skip if parent or any ancestor has data-i18n-skip attribute
|
||||
let element = parent;
|
||||
while (element) {
|
||||
if (element.hasAttribute && element.hasAttribute('data-i18n-skip')) {
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
const tagName = parent.tagName?.toLowerCase();
|
||||
|
||||
// Skip elements that don't allow text nodes
|
||||
const skipTags = [
|
||||
"script", "style", "code", "pre",
|
||||
"colgroup", "table", "thead", "tbody", "tfoot", "tr",
|
||||
"select", "datalist", "optgroup"
|
||||
];
|
||||
|
||||
if (skipTags.includes(tagName)) return;
|
||||
|
||||
// Store original text if not already stored
|
||||
if (!node._originalText) {
|
||||
node._originalText = node.nodeValue;
|
||||
}
|
||||
|
||||
// Use original text for translation
|
||||
const original = node._originalText;
|
||||
const translated = translate(original);
|
||||
|
||||
// Only update if different to avoid unnecessary DOM mutations
|
||||
if (translated !== node.nodeValue) {
|
||||
node.nodeValue = translated;
|
||||
}
|
||||
}
|
||||
|
||||
// Process all text nodes in element
|
||||
function processElement(element) {
|
||||
if (!element) return;
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
element,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
let node;
|
||||
const nodesToProcess = [];
|
||||
|
||||
// Collect all nodes first to avoid live collection issues
|
||||
while ((node = walker.nextNode())) {
|
||||
nodesToProcess.push(node);
|
||||
}
|
||||
|
||||
// Process collected nodes
|
||||
nodesToProcess.forEach(processTextNode);
|
||||
}
|
||||
|
||||
// Initialize runtime i18n
|
||||
export async function initRuntimeI18n() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
currentLocale = getLocaleFromCookie();
|
||||
await loadTranslations(currentLocale);
|
||||
|
||||
// Process existing DOM
|
||||
processElement(document.body);
|
||||
|
||||
// Watch for new nodes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
processElement(node);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
processTextNode(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Reload translations when locale changes
|
||||
export async function reloadTranslations() {
|
||||
currentLocale = getLocaleFromCookie();
|
||||
await loadTranslations(currentLocale);
|
||||
|
||||
// Notify all registered callbacks
|
||||
reloadCallbacks.forEach(callback => callback());
|
||||
|
||||
// Re-process entire DOM (will use stored original text)
|
||||
processElement(document.body);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue