/* ============================================================================ * 다국어 런타임 (v1.1.2) * 서버가 준 "원문(한국어) → 번역" 사전으로 화면 텍스트를 치환한다. 코드에 번역 키가 없으므로 * 텍스트 노드와 placeholder/title/data-tip 속성의 값이 사전에 있으면 그대로 바꾼다. * 동적으로 추가·변경되는 내용은 MutationObserver 로 따라가며 치환한다. * 언어 선택은 localStorage('wm_lang') 에 저장. 'ko' 는 원문이므로 치환하지 않는다. * v1.3.2: 변수가 들어가는 문장(코드의 템플릿 문자열)은 카탈로그에 "현재 사이트: {1} · 화면: {2}" 같은 패턴으로 들어 있다. * 사전에 정확히 같은 문장이 없으면 패턴과 맞춰 보고, 맞으면 번역문의 {1}·{2} 자리에 화면의 실제 값을 넣는다 * (값 자체가 사전에 있으면 그것도 번역 — 예: "관리 탭" → "Manage tab"). * v1.3.10 속도: ① 서버가 정적 HTML 을 미리 번역해 보낸다(쿠키 wm_lang) ② 사전을 localStorage 에 언어별로 캐시하고 * 서버의 stamp(번역 기준 버전|올린 시각)가 같으면 다시 받지 않는다 ③ 패턴은 첫 글자별로 나눠 둔다. * 언어를 바꾸면 쿠키를 바꾸고 페이지를 다시 연다(서버 번역본과 맞추기 위해). 오른쪽→왼쪽 언어는 dir=rtl. * 전역: window.I18N = { lang, t(text), setLang(lang), languages } * ========================================================================== */ (function () { var ATTRS = ['placeholder', 'title', 'data-tip', 'aria-label', 'alt']; var dict = null; var lang = 'ko'; var languages = [{ lang: 'ko', name: '한국어' }]; try { lang = localStorage.getItem('wm_lang') || (navigator.language || 'ko').slice(0, 2); } catch (e) {} // v1.3.16: 서버가 정한 화면 언어(관리자 강제 기본값 · 사용자 선택 · 사이트 관리 언어)를 먼저 따른다 — var srv = document.querySelector('meta[name="wm-ui-lang"]'); if (srv && srv.content) { lang = srv.content; try { localStorage.setItem('wm_lang', lang); } catch (e) {} } var qs = new URLSearchParams(location.search); if (qs.get('lang')) { lang = qs.get('lang'); try { localStorage.setItem('wm_lang', lang); } catch (e) {} } /** 서버 미리 번역용 쿠키 (1년). 화면 언어와 다르면 맞춘다 */ /** 화면 코드가 사전 준비 후 다시 그리도록 알림 (조각 번역 T() 를 쓴 목록 등) */ function ready() { try { window.dispatchEvent(new CustomEvent('wm:i18n')); } catch (e) {} } function setCookie(l) { try { document.cookie = 'wm_lang=' + encodeURIComponent(l) + '; Path=/; Max-Age=31536000; SameSite=Lax'; } catch (e) {} } if (!new RegExp('(?:^|; )wm_lang=' + lang.replace(/[^\w-]/g, '') + '(?:;|$)').test(document.cookie || '')) setCookie(lang); var orig = new WeakMap(); // 노드 → 원문 (언어를 바꿀 때 원문으로 되돌리기 위해) var patterns = []; // [{ prefix, suffix, re, tr }] — 사전 키 중 {숫자} 자리표시가 있는 것 var buckets = {}; // v1.3.10: 고정 앞글자의 첫 글자 → 패턴 목록 (앞글자가 없는 패턴은 buckets['']) var HANGUL = /[\uac00-\ud7a3]/; /** 사전에서 패턴 항목만 골라 정규식으로 바꾼다. 앞뒤 고정 글자(prefix/suffix)로 먼저 걸러 비용을 줄인다 */ function buildPatterns() { patterns = []; if (!dict) return; Object.keys(dict).forEach(function (k) { if (!/\{\d+\}/.test(k)) return; var pieces = k.split(/\{(\d+)\}/); var src = '^'; var order = []; for (var i = 0; i < pieces.length; i++) { if (i % 2 === 0) src += pieces[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); else { src += '([\\s\\S]+?)'; order.push(pieces[i]); } } var fixedText = k.replace(/\{\d+\}/g, ''); var hc = (fixedText.match(/[\uac00-\ud7a3]/g) || []).length; // weak: 고정 한글이 2자 이하인 패턴("{1}개" 등)은 아무 문장에나 맞을 수 있어, 값에 한글이 없을 때만 쓴다 (v1.3.10) patterns.push({ prefix: pieces[0], suffix: pieces[pieces.length - 1], re: new RegExp(src + '$'), order: order, tr: dict[k], fixed: fixedText.length, weak: hc < 3 }); }); patterns.sort(function (a, b) { return b.fixed - a.fixed; }); // 고정 글자가 많은(더 구체적인) 패턴 먼저 buckets = {}; patterns.forEach(function (p) { var c = p.prefix.charAt(0); (buckets[c] = buckets[c] || []).push(p); }); } /** 문장 하나 번역: 정확히 같은 문장 → 패턴 순서로 찾고, 없으면 null */ function lookup(k, depth) { depth = depth || 0; var v = dict && dict[k]; if (v) return v; if (!patterns.length || !HANGUL.test(k)) return null; var cand = (buckets[k.charAt(0)] || []).concat(buckets[''] || []); for (var i = 0; i < cand.length; i++) { var p = cand[i]; if (k.length < p.prefix.length + p.suffix.length || k.indexOf(p.prefix) !== 0 || k.slice(k.length - p.suffix.length) !== p.suffix) continue; var m = p.re.exec(k); if (!m) continue; if (p.weak) { var bad = false; for (var w = 1; w < m.length; w++) if (HANGUL.test(m[w])) bad = true; if (bad) continue; } var vals = {}; for (var j = 0; j < p.order.length; j++) { var raw = m[j + 1]; var tv = depth < 2 && HANGUL.test(raw) ? lookup(raw.trim(), depth + 1) : null; vals[p.order[j]] = tv ? raw.replace(raw.trim(), tv) : raw; } // 값 안의 문장도 번역(중첩 템플릿) — 2단계까지 return p.tr.replace(/\{(\d+)\}/g, function (all, n) { return vals[n] !== undefined ? vals[n] : all; }); } return null; } function t(text) { if (!dict || !text) return text; var k = String(text).trim(); var v = lookup(k); if (!v) return text; return text.replace(k, v); } function translateNode(n) { if (n.nodeType === 3) { var p = n.parentNode; if (!p || p.nodeName === 'SCRIPT' || p.nodeName === 'STYLE' || p.nodeName === 'TEXTAREA') return; var src = orig.has(n) ? orig.get(n) : n.nodeValue; var k = src.trim(); if (!k) return; if (!orig.has(n)) orig.set(n, src); var v = dict && lookup(k); var next = v ? src.replace(k, v) : src; if (n.nodeValue !== next) n.nodeValue = next; return; } if (n.nodeType !== 1) return; for (var i = 0; i < ATTRS.length; i++) { var a = ATTRS[i]; if (!n.hasAttribute(a)) continue; var key = 'wm-orig-' + a; var o = n.getAttribute('data-' + key) || n.getAttribute(a); if (!n.hasAttribute('data-' + key)) n.setAttribute('data-' + key, o); var tv = dict && lookup(o.trim()); var val = tv ? o.replace(o.trim(), tv) : o; if (n.getAttribute(a) !== val) n.setAttribute(a, val); } if (n.nodeName === 'OPTION' && n.firstChild && n.firstChild.nodeType === 3) translateNode(n.firstChild); } function translateTree(root) { if (!root) return; if (root.nodeType === 3) return translateNode(root); if (root.nodeType !== 1 && root.nodeType !== 9) return; if (root.nodeType === 1) translateNode(root); var walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); var n; while ((n = walker.nextNode())) translateNode(n); if (document.title) document.title = t(document.title); } var observer = null; var pending = false; /** * 화면 변화 따라가기. v1.3.10 수정: 1.3.9 까지는 한 프레임 안에 처음 들어온 변화 묶음만 처리하고 그 사이에 들어온 * 다른 묶음을 버려(pending 동안 return), 동적으로 그린 문구(카드·표·상태 배지 등)가 한국어로 남았다. 또 처리 중 오류가 나면 * 관찰이 다시 연결되지 않았다. 이제 프레임까지 들어온 변화를 모두 모아 처리하고, 오류가 나도 항상 다시 연결한다. */ var queue = []; function flush() { pending = false; var muts = queue; queue = []; observer.disconnect(); try { for (var i = 0; i < muts.length; i++) { var m = muts[i]; try { if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); } else if (m.type === 'attributes') { if (m.target.removeAttribute) m.target.removeAttribute('data-wm-orig-' + m.attributeName); translateNode(m.target); } else { for (var j = 0; j < m.addedNodes.length; j++) translateTree(m.addedNodes[j]); } } catch (e) { /* 한 노드의 오류가 나머지를 막지 않게 */ } } } finally { observe(); } } function startObserver() { if (observer) return; observer = new MutationObserver(function (muts) { for (var i = 0; i < muts.length; i++) queue.push(muts[i]); if (pending) return; pending = true; // 화면이 숨겨진 탭에서는 requestAnimationFrame 이 멈추므로 setTimeout 으로도 한 번 보장 var done = false; var go = function () { if (done) return; done = true; flush(); }; requestAnimationFrame(go); setTimeout(go, 50); }); observe(); } function observe() { observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS }); } function apply(d) { // 사전을 바꿀 때는 원문으로 되돌린 뒤 다시 치환 (orig 에 저장된 원문 사용) if (observer) observer.disconnect(); dict = d; buildPatterns(); translateTree(document.documentElement); document.documentElement.lang = lang; var info = languages.filter(function (l) { return l.lang === lang; })[0]; document.documentElement.dir = info && info.dir === 'rtl' ? 'rtl' : 'ltr'; if (observer) observe(); else if (dict) startObserver(); } /** 언어별 사전 캐시 (localStorage). stamp 가 서버와 같으면 그대로 쓴다 */ function cached(l, stamp) { try { var c = JSON.parse(localStorage.getItem('wm_dict_' + l) || 'null'); return c && c.stamp === stamp ? c.dict : null; } catch (e) { return null; } } function store(l, stamp, d) { try { localStorage.setItem('wm_dict_' + l, JSON.stringify({ stamp: stamp, dict: d })); } catch (e) {} } function load(cb) { fetch('/api/i18n/languages').then(function (r) { return r.json(); }).then(function (j) { languages = j.languages || languages; var info = languages.filter(function (l) { return l.lang === lang; })[0]; if (!info) { lang = 'ko'; setCookie('ko'); } if (lang === 'ko') { apply(null); return cb && cb(); } var hit = cached(lang, info.stamp); if (hit) { if (hit !== dict) apply(hit); ready(); return cb && cb(); } return fetch('/api/i18n/' + encodeURIComponent(lang) + '.json').then(function (r) { return r.ok ? r.json() : null; }).then(function (dd) { if (dd && dd.dict) { store(lang, info.stamp, dd.dict); apply(dd.dict); ready(); } else { lang = 'ko'; apply(null); } cb && cb(); }); }).catch(function () { apply(null); cb && cb(); }); } /** 언어 바꾸기: 저장 → 쿠키 → 페이지 다시 열기 (서버가 그 언어로 미리 번역한 화면을 받는다) */ function setLang(l) { lang = l; try { localStorage.setItem('wm_lang', l); } catch (e) {} setCookie(l); // v1.3.16: 로그인 사용자는 선택을 계정에 저장한다(관리자가 기본 언어를 강제한 뒤에도 사용자가 다시 고르면 그 선택이 우선). 비로그인이면 실패해도 쿠키로 충분 var done = function () { location.reload(); }; try { fetch('/api/me/ui-lang', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lang: l }), credentials: 'same-origin' }).then(done, done); } catch (e) { done(); } } /** 헤더에 언어 선택 상자(#langSelect 또는 .lang-switch 컨테이너)가 있으면 채운다. 언어가 하나뿐이면 숨긴다 */ function renderSwitcher() { var sel = document.getElementById('langSelect'); if (!sel) return; sel.innerHTML = ''; languages.forEach(function (l) { var o = document.createElement('option'); o.value = l.lang; o.textContent = l.name; sel.appendChild(o); }); sel.value = lang; var wrap = sel.closest('.lang-switch') || sel; wrap.style.display = languages.length > 1 ? '' : 'none'; sel.onchange = function () { setLang(sel.value); }; } /** * 화면 코드용 도우미 (v1.3.10) * T(문구) : 사용자 데이터(사이트 이름 등)와 섞어 문장을 만들 때, 고정 문구 조각을 먼저 번역한다. * uiLocale() : 날짜·숫자 표기용 로케일 (ko→ko-KR, ja→ja-JP …). 1.3.9 까지는 'ko-KR' 이 하드코딩되어 다른 언어에서도 한국식 날짜였다. */ var LOCALE_OF = { ko: 'ko-KR', en: 'en-US', ja: 'ja-JP', zh: 'zh-CN', 'zh-CN': 'zh-CN', 'zh-TW': 'zh-TW', mn: 'mn-MN', fr: 'fr-FR', de: 'de-DE', es: 'es-ES', vi: 'vi-VN', th: 'th-TH', id: 'id-ID', ru: 'ru-RU', ar: 'ar', tr: 'tr-TR' }; window.T = function (s) { return t(s); }; /** TF('{1}원', '1,200') → 번역 사전의 '{1}원' 번역(예: '₩{1}')에 값을 넣는다. 사전에 없으면 원문 틀에 넣는다 */ window.TF = function (key) { var args = arguments; var tpl = (dict && dict[key]) || key; return tpl.replace(/\{(\d+)\}/g, function (a, n) { var v = args[Number(n)]; return v === undefined ? a : String(v); }); }; window.uiLocale = function () { return LOCALE_OF[lang] || lang || 'ko-KR'; }; window.I18N = { get lang() { return lang; }, t: t, setLang: setLang, get languages() { return languages; }, refresh: function () { translateTree(document.documentElement); } }; // v1.3.10: 캐시된 사전이 있으면 즉시 쓴다(화면 코드가 처음 그릴 때부터 T() 가 번역). 서버와 다른지는 load() 가 확인해 바꾼다 if (lang !== 'ko') { try { var c0 = JSON.parse(localStorage.getItem('wm_dict_' + lang) || 'null'); if (c0 && c0.dict) { dict = c0.dict; buildPatterns(); } } catch (e) {} } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { if (dict) apply(dict); load(renderSwitcher); }); else { if (dict) apply(dict); load(renderSwitcher); } })();