2024-05-26
Computer Science
00

Contents

1. Using the Open-Source Project (https://github.com/xnx3/translate)
2. Using the Google Website Translator Plugin

This article documents two approaches for implementing one-click “中 / En” language switching in VanBlog, or in a similar blog theme with a navigation bar.

The first approach is based on the open-source project xnx3/translate, while the second uses Google Website Translator.

The main difference between the two approaches is that the open-source project uses AI translation and generally produces better translation quality, whereas Google Translate may only be accessible outside certain regions and usually provides poorer translation quality.

You can choose either approach depending on your site's performance requirements and network environment.

The article also provides the corresponding CSS, JavaScript, and HTML needed to hide translation banners, prevent the language-switching button itself from being translated, and reliably insert the switch into a specified position in the navigation bar.

1. Using the Open-Source Project (https://github.com/xnx3/translate)

  • Custom CSS
css
.footer-powered-by-vanblog{ display: none} /* Wrapper container: used for insertion into the navigation bar */ #vb-lang-wrap{ display: inline-flex; align-items: center; } /* Fine-tune spacing: move it closer to the light/dark-mode switch on the left while keeping a reasonable distance from RSS on the right. If you are not satisfied with the spacing, adjust: - margin-left: -8 ~ 0 - margin-right: 8 ~ 16 */ .nav-action #vb-lang-wrap{ margin-left: -6px; margin-right: 12px; } /* The label itself: retain a square area for absolutely positioning the two labels */ .lang-circle{ --s: 28px; /* Icon placeholder size; use 30/32 if you want it larger */ position: relative; width: var(--s); height: var(--s); cursor: pointer; user-select: none; /* Color: black in light mode and white in dark mode */ color: #111; background: transparent; border: 0; padding: 0; line-height: 1; transform: translateZ(0); transition: transform .15s ease; } .lang-circle:hover{ transform: scale(1.15); } /* Dark mode: white; compatible with Tailwind dark mode / data-theme */ .dark .lang-circle, [data-theme="dark"] .lang-circle{ color: #fff; } /* Two text labels: retain the original absolute positioning and font size */ .lang-circle span{ position: absolute; font-family: system-ui, -apple-system, "Segoe UI", Arial, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 700; font-size: 10px; line-height: 1; /* Dimmed by default */ opacity: .45; transition: opacity .2s ease; /* Use currentColor */ color: currentColor; } /* 中: upper left */ .lang-circle .zh{ top: 5px; left: 6px; } /* En: lower right */ .lang-circle .en{ bottom: 5px; right: 5px; } /* Highlight the currently selected language using the checkbox state */ #lang-toggle:not(:checked) + .lang-circle .zh{ opacity: 1; } #lang-toggle:checked + .lang-circle .en{ opacity: 1; } /* Completely remove the circular/curved decorations from older versions */ .lang-circle::before, .lang-circle::after{ content: none !important; } /* ===== Legacy/common selectors ===== */ iframe.goog-te-banner-frame, .goog-te-banner-frame, .goog-te-banner-frame.skiptranslate, .goog-te-banner, .goog-te-balloon-frame, #goog-gt-tt, .goog-tooltip, .goog-tooltip:hover { display: none !important; } /* Force removal of the top offset that Google may add to body/html */ html, body { top: 0 !important; margin-top: 0 !important; } /* ===== Newer versions, commonly using the VIpgJd- prefix ===== */ .VIpgJd-ZVi9od-ORHb, /* Top banner container */ .VIpgJd-ZVi9od-ORHb-OEVmcd, /* Top banner */ .VIpgJd-ZVi9od-aZ2wEe-wOHMyf, /* Related wrapper */ .VIpgJd-ZVi9od-l4eHX-hSRGPd, /* Possible tooltip/popup */ .VIpgJd-ZVi9od-SmfZ-OEVmcd { /* Possible alternate top banner */ display: none !important; } /* Hide the built-in language-selection floating panel from translate.js */ #translate, #translateSelectLanguage, #translateSelectLanguageTag, .translateSelectLanguage, .translateSelectLanguageTag, .translate-select-language, .translate-select-language-tag { display: none !important; }
  • Custom Script

Replace https://xumin.net/c/translate.js in the custom script below with the URL of your own translate.js.

Its contents can be identical to https://xumin.net/c/translate.js.

You can create translate.js as a custom page in VanBlog. The URL can generally be configured as:

text
https://<your-domain>/c/translate.js
js
(function () { const LS_KEY = "vb_lang"; const SOURCE_LANG = "zh-CN"; const TARGET_EN = "en"; const T_ZH = "chinese_simplified"; const T_EN = "english"; function getSavedLang() { return localStorage.getItem(LS_KEY) || SOURCE_LANG; } function setSavedLang(lang) { localStorage.setItem(LS_KEY, lang); document.documentElement.setAttribute("data-vb-lang", lang); } function setToggleCheckedByLang(lang) { const toggle = document.getElementById("lang-toggle"); if (toggle) toggle.checked = lang === TARGET_EN; } function initTranslateOnce() { if (window.__vb_translate_init_promise) { return window.__vb_translate_init_promise; } window.__vb_translate_init_promise = new Promise((resolve, reject) => { function doInit() { try { translate.language.setLocal(T_ZH); translate.service.use("client.edge"); if (translate.ignore) { if (translate.ignore.class) { translate.ignore.class["notranslate"] = true; } if (translate.ignore.id) { translate.ignore.id["vb-lang-wrap"] = true; translate.ignore.id["lang-toggle"] = true; } } translate.execute(); resolve(); } catch (e) { reject(e); } } if (window.translate && typeof window.translate.execute === "function") { doInit(); return; } const s = document.createElement("script"); s.src = "https://xumin.net/c/translate.js?v=20260508"; s.async = true; s.onload = doInit; s.onerror = () => reject(new Error("Failed to load translate.js")); document.head.appendChild(s); }); return window.__vb_translate_init_promise; } function switchTranslateLang(target) { const t = window.translate; if (!t) return; try { if (typeof t.changeLanguage === "function") { t.changeLanguage(target); } if (typeof t.execute === "function") { t.execute(); } } catch (e) { console.error("Failed to switch language:", e); } } async function applyLang(lang) { setSavedLang(lang); setToggleCheckedByLang(lang); if (lang === SOURCE_LANG) { if (window.translate) { switchTranslateLang(T_ZH); } return; } await initTranslateOnce(); switchTranslateLang(T_EN); } function ensureMounted() { const wrap = document.getElementById("vb-lang-wrap"); const nav = document.querySelector(".nav-action"); if (!wrap) return false; if (!nav) { wrap.style.display = "none"; return false; } const rss = nav.querySelector('[title="RSS 订阅"]'); if (rss) { if (wrap.parentElement !== nav || wrap.nextElementSibling !== rss) { nav.insertBefore(wrap, rss); } } else { if (!nav.contains(wrap)) { nav.appendChild(wrap); } } wrap.style.display = "inline-flex"; return true; } document.addEventListener("change", (e) => { if (e.target && e.target.id === "lang-toggle") { const lang = e.target.checked ? TARGET_EN : SOURCE_LANG; applyLang(lang).catch(console.error); } }); window.addEventListener("load", () => { ensureMounted(); const saved = getSavedLang(); setSavedLang(saved); setToggleCheckedByLang(saved); if (saved === TARGET_EN) { setTimeout(() => { applyLang(TARGET_EN).catch(console.error); }, 300); } }); const obs = new MutationObserver(() => { ensureMounted(); }); obs.observe(document.documentElement, { childList: true, subtree: true, }); ensureMounted(); })();
  • Custom HTML (Body)
html
<!-- Language switcher: excluded from translation --> <div id="vb-lang-wrap" style="display:none;" class="notranslate" translate="no" lang="zh-CN"> <input type="checkbox" id="lang-toggle" hidden> <label for="lang-toggle" class="lang-circle notranslate" translate="no" lang="zh-CN" title="Switch language" aria-label="Switch between Chinese and English"> <span class="zh notranslate" translate="no" lang="zh-CN"></span> <span class="en notranslate" translate="no" lang="en">En</span> </label> </div>

2. Using the Google Website Translator Plugin

  • Custom CSS
css
.footer-powered-by-vanblog{ display: none} /* ====== Prevent the Google Translate top banner from pushing the page downward ====== */ .goog-te-banner-frame.skiptranslate { display: none !important; } body { top: 0 !important; } #goog-gt-tt, .goog-te-balloon-frame { display: none !important; } /* ====== Language switcher: preserve the relative positions of "中/En" while removing the circular outline ====== */ /* Wrapper container: used for insertion into the navigation bar */ #vb-lang-wrap{ display: inline-flex; align-items: center; } /* Fine-tune spacing: move it closer to the light/dark-mode switch on the left while keeping a reasonable distance from RSS on the right. If you are not satisfied, adjust: - margin-left: -8 ~ 0 - margin-right: 8 ~ 16 */ .nav-action #vb-lang-wrap{ margin-left: -6px; margin-right: 12px; } /* The label itself: retain a square area for absolutely positioning the two labels */ .lang-circle{ --s: 28px; /* Icon placeholder size; use 30/32 if you want it larger */ position: relative; width: var(--s); height: var(--s); cursor: pointer; user-select: none; /* Color: black in light mode and white in dark mode */ color: #111; background: transparent; border: 0; padding: 0; line-height: 1; transform: translateZ(0); transition: transform .15s ease; } .lang-circle:hover{ transform: scale(1.15); } /* Dark mode: white; compatible with Tailwind dark mode / data-theme */ .dark .lang-circle, [data-theme="dark"] .lang-circle{ color: #fff; } /* Two text labels: retain the original absolute positioning and font size */ .lang-circle span{ position: absolute; font-family: system-ui, -apple-system, "Segoe UI", Arial, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 700; font-size: 10px; line-height: 1; /* Dimmed by default */ opacity: .45; transition: opacity .2s ease; /* Use currentColor */ color: currentColor; } /* 中: upper left */ .lang-circle .zh{ top: 5px; left: 6px; } /* En: lower right */ .lang-circle .en{ bottom: 5px; right: 5px; } /* Highlight the currently selected language using the checkbox state */ #lang-toggle:not(:checked) + .lang-circle .zh{ opacity: 1; } #lang-toggle:checked + .lang-circle .en{ opacity: 1; } /* Completely remove the circular/curved decorations from older versions */ .lang-circle::before, .lang-circle::after{ content: none !important; } /* ===== Legacy/common selectors ===== */ iframe.goog-te-banner-frame, .goog-te-banner-frame, .goog-te-banner-frame.skiptranslate, .goog-te-banner, .goog-te-balloon-frame, #goog-gt-tt, .goog-tooltip, .goog-tooltip:hover { display: none !important; } /* Force removal of the top offset that Google may add to body/html */ html, body { top: 0 !important; margin-top: 0 !important; } /* ===== Newer versions, commonly using the VIpgJd- prefix ===== */ .VIpgJd-ZVi9od-ORHb, /* Top banner container */ .VIpgJd-ZVi9od-ORHb-OEVmcd, /* Top banner */ .VIpgJd-ZVi9od-aZ2wEe-wOHMyf, /* Related wrapper */ .VIpgJd-ZVi9od-l4eHX-hSRGPd, /* Possible tooltip/popup */ .VIpgJd-ZVi9od-SmfZ-OEVmcd { /* Possible alternate top banner */ display: none !important; }
  • Custom Script
js
(function () { const LS_KEY = "vb_lang"; const SOURCE_LANG = "zh-CN"; const TARGET_EN = "en"; function getSavedLang() { return localStorage.getItem(LS_KEY) || SOURCE_LANG; } function setSavedLang(lang) { localStorage.setItem(LS_KEY, lang); document.documentElement.setAttribute("data-vb-lang", lang); } function setToggleCheckedByLang(lang) { const toggle = document.getElementById("lang-toggle"); if (toggle) toggle.checked = (lang === TARGET_EN); } function setGoogTransCookie(targetLang) { const v = `/${SOURCE_LANG}/${targetLang}`; document.cookie = `googtrans=${v};path=/;max-age=31536000`; document.cookie = `googtrans=${v};path=/;domain=${location.hostname};max-age=31536000`; } function ensureGTMount() { let mount = document.getElementById("google_translate_element"); if (!mount) { mount = document.createElement("div"); mount.id = "google_translate_element"; mount.style.cssText = "position:fixed;left:-9999px;top:0;"; // Important: mount it at the end of body to avoid React/Next-managed nodes where possible document.body.appendChild(mount); } return mount; } function waitForCombo(timeout = 8000) { const start = Date.now(); return new Promise((resolve, reject) => { (function check() { const combo = document.querySelector(".goog-te-combo"); if (combo) return resolve(combo); if (Date.now() - start > timeout) { return reject(new Error("goog-te-combo not found")); } setTimeout(check, 100); })(); }); } // Load/initialize Google Translate only when needed // to avoid modifying the DOM during Next.js hydration function loadGoogleTranslate() { if (window.google?.translate?.TranslateElement) { return Promise.resolve(); } if (window.__vb_gt_loading) { return window.__vb_gt_loading; } window.__vb_gt_loading = new Promise((resolve, reject) => { window.googleTranslateElementInit = function () { try { ensureGTMount(); new google.translate.TranslateElement( { pageLanguage: SOURCE_LANG, includedLanguages: `${SOURCE_LANG},${TARGET_EN}`, autoDisplay: false, }, "google_translate_element" ); resolve(); } catch (e) { reject(e); } }; const s = document.createElement("script"); s.src = "https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"; s.async = true; s.onerror = () => reject(new Error("Failed to load Google Translate script")); document.head.appendChild(s); }); return window.__vb_gt_loading; } async function applyLang(lang) { setSavedLang(lang); setToggleCheckedByLang(lang); setGoogTransCookie(lang); // Switching back to Chinese: // reloading is the most reliable way to restore the original text, // because translated content may otherwise remain if (lang === SOURCE_LANG) { location.reload(); return; } // Switching to English: load Google Translate on demand and trigger it await loadGoogleTranslate(); const combo = await waitForCombo(); // Support values such as en / zh-CN const wanted = lang === TARGET_EN ? ["en", "en-US", "en-GB"] : ["zh-CN", "zh"]; const opt = Array.from(combo.options).find(o => wanted.includes(o.value)); if (opt) { combo.value = opt.value; combo.dispatchEvent(new Event("change", { bubbles: true })); } } // Insert between the light/dark-mode switch and RSS subscription, // i.e. immediately before RSS function ensureMounted() { const wrap = document.getElementById("vb-lang-wrap"); const nav = document.querySelector(".nav-action"); if (!wrap) return false; if (!nav) { wrap.style.display = "none"; return false; } const rss = nav.querySelector('[title="RSS 订阅"]'); if (rss) { if (wrap.parentElement !== nav || wrap.nextElementSibling !== rss) { nav.insertBefore(wrap, rss); } } else { if (!nav.contains(wrap)) { nav.appendChild(wrap); } } wrap.style.display = "inline-flex"; return true; } // Listen for checkbox changes document.addEventListener("change", (e) => { if (e.target && e.target.id === "lang-toggle") { const lang = e.target.checked ? TARGET_EN : SOURCE_LANG; applyLang(lang).catch(console.error); } }); window.addEventListener("load", () => { ensureMounted(); // Synchronize the UI state const saved = getSavedLang(); setSavedLang(saved); setToggleCheckedByLang(saved); setGoogTransCookie(saved); // If English was selected previously, delay translation slightly // to avoid the Next.js hydration stage if (saved === TARGET_EN) { setTimeout(() => { applyLang(TARGET_EN).catch(console.error); }, 1200); } }); // The navigation bar may be re-rendered, // so continuously ensure that the switch remains mounted const obs = new MutationObserver(() => ensureMounted()); obs.observe(document.documentElement, { childList: true, subtree: true }); ensureMounted(); })();
  • Custom HTML (Body)
html
<!-- Language switcher: excluded from translation --> <div id="vb-lang-wrap" style="display:none;" class="notranslate" translate="no" lang="zh-CN"> <input type="checkbox" id="lang-toggle" hidden> <label for="lang-toggle" class="lang-circle notranslate" translate="no" lang="zh-CN" title="Switch language" aria-label="Switch between Chinese and English"> <span class="zh notranslate" translate="no" lang="zh-CN"></span> <span class="en notranslate" translate="no" lang="en">En</span> </label> </div>