mingle-website/js/i18n.js
68893236+KINDNICK@users.noreply.github.com 30cd06e9d6 feat: setup i18n build script and SEO optimizations
2026-03-01 14:56:52 +09:00

370 lines
14 KiB
JavaScript

/**
* i18n (국제화) 시스템
* 지원 언어: ko(한국어), en(영어), zh(중국어), ja(일본어)
*/
(function () {
'use strict';
const SUPPORTED_LANGS = ['ko', 'en', 'zh', 'ja'];
const DEFAULT_LANG = 'ko';
const STORAGE_KEY = 'mingle-lang';
const i18n = {
currentLang: DEFAULT_LANG,
translations: {},
cache: {},
ready: false,
/**
* 초기화: 언어 감지 → JSON 로드
*/
init() {
// URL 파싱을 통해 현재 경로 언어 감지
const path = window.location.pathname;
const parts = path.split('/').filter(Boolean);
const pathLang = (parts.length > 0 && SUPPORTED_LANGS.includes(parts[0]) && parts[0] !== DEFAULT_LANG) ? parts[0] : DEFAULT_LANG;
// 1. URL이 가장 높은 우선순위를 가짐
let lang = pathLang;
if (pathLang === DEFAULT_LANG) {
// 2. 루트 경로(/)에 왔을 때는 기존처럼 브라우저/로컬 환경 감지
lang = this.detectLanguage();
} else {
// URL에 명시되어 있다면 해당 언어 환경 저장
localStorage.setItem(STORAGE_KEY, lang);
}
this.currentLang = lang;
document.documentElement.lang = lang;
// 만약 서버에서 미리 번역된 HTML(SSG)이 아니라 클라이언트 사이드에서 변경이 필요한 경우
if (lang !== DEFAULT_LANG && pathLang === DEFAULT_LANG) {
return this.loadLang(lang).then(() => {
this.ready = true;
});
} else {
this.ready = true;
return Promise.resolve();
}
},
/**
* 언어 감지 우선순위: localStorage → 브라우저 언어 → 기본값(ko)
*/
detectLanguage() {
// 1. localStorage에 저장된 언어
const saved = localStorage.getItem(STORAGE_KEY);
if (saved && SUPPORTED_LANGS.includes(saved)) {
return saved;
}
// 2. 브라우저 언어
const browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
const langCode = browserLang.split('-')[0];
if (SUPPORTED_LANGS.includes(langCode) && langCode !== DEFAULT_LANG) {
return langCode;
}
// 3. 기본값
return DEFAULT_LANG;
},
/**
* JSON 번역 파일 로드
*/
async loadLang(lang) {
if (lang === DEFAULT_LANG) {
this.translations = {};
return;
}
// 캐시 확인
if (this.cache[lang]) {
this.translations = this.cache[lang];
return;
}
try {
const response = await fetch(`/i18n/${lang}.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
this.cache[lang] = data;
this.translations = data;
} catch (error) {
console.warn(`[i18n] Failed to load ${lang}.json:`, error);
this.translations = {};
}
},
/**
* 번역 키로 텍스트 가져오기
* @param {string} key - 점(.) 구분 키 (예: "header.studioName")
* @param {string} fallback - 폴백 텍스트
* @returns {string}
*/
t(key, fallback) {
if (this.currentLang === DEFAULT_LANG) {
return fallback || key;
}
const keys = key.split('.');
let value = this.translations;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return fallback || key;
}
}
return typeof value === 'string' ? value : (fallback || key);
},
/**
* DOM 전체 번역 적용
* @param {Element} root - 번역 대상 루트 (기본: document)
*/
translateDOM(root) {
const container = root || document;
const isKorean = this.currentLang === DEFAULT_LANG;
// data-i18n: 텍스트 콘텐츠 번역
container.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
// 원본 한국어 텍스트 저장 (최초 1회)
if (!el.hasAttribute('data-i18n-ko')) {
el.setAttribute('data-i18n-ko', el.innerHTML);
}
if (isKorean) {
el.innerHTML = el.getAttribute('data-i18n-ko');
} else {
const translated = this.t(key, null);
if (translated && translated !== key) {
if (translated.includes('<')) {
el.innerHTML = translated;
} else {
el.textContent = translated;
}
}
}
});
// data-i18n-html: HTML 콘텐츠 번역 (명시적)
container.querySelectorAll('[data-i18n-html]').forEach(el => {
const key = el.getAttribute('data-i18n-html');
if (!el.hasAttribute('data-i18n-ko')) {
el.setAttribute('data-i18n-ko', el.innerHTML);
}
if (isKorean) {
el.innerHTML = el.getAttribute('data-i18n-ko');
} else {
const translated = this.t(key, null);
if (translated && translated !== key) {
el.innerHTML = translated;
}
}
});
// data-i18n-placeholder: placeholder 번역
container.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (!el.hasAttribute('data-i18n-ko-placeholder')) {
el.setAttribute('data-i18n-ko-placeholder', el.placeholder);
}
if (isKorean) {
el.placeholder = el.getAttribute('data-i18n-ko-placeholder');
} else {
const translated = this.t(key, null);
if (translated && translated !== key) {
el.placeholder = translated;
}
}
});
// data-i18n-aria: aria-label 번역
container.querySelectorAll('[data-i18n-aria]').forEach(el => {
const key = el.getAttribute('data-i18n-aria');
if (!el.hasAttribute('data-i18n-ko-aria')) {
el.setAttribute('data-i18n-ko-aria', el.getAttribute('aria-label') || '');
}
if (isKorean) {
el.setAttribute('aria-label', el.getAttribute('data-i18n-ko-aria'));
} else {
const translated = this.t(key, null);
if (translated && translated !== key) {
el.setAttribute('aria-label', translated);
}
}
});
// data-i18n-title: title 속성 번역
container.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (!el.hasAttribute('data-i18n-ko-title')) {
el.setAttribute('data-i18n-ko-title', el.title || '');
}
if (isKorean) {
el.title = el.getAttribute('data-i18n-ko-title');
} else {
const translated = this.t(key, null);
if (translated && translated !== key) {
el.title = translated;
}
}
});
// 메타 태그 번역
this.translateMeta();
// 언어 스위처 활성 상태 업데이트
this.updateSwitcher();
},
/**
* 메타 태그 번역 (title, description, OG 등)
*/
translateMeta() {
const isKorean = this.currentLang === DEFAULT_LANG;
const pageName = this.getPageName();
// 원본 메타 저장 (최초 1회)
if (!this._metaOriginals) {
this._metaOriginals = {
title: document.title,
description: document.querySelector('meta[name="description"]')?.content || '',
ogTitle: document.querySelector('meta[property="og:title"]')?.content || '',
ogDescription: document.querySelector('meta[property="og:description"]')?.content || ''
};
}
if (isKorean) {
document.title = this._metaOriginals.title;
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) metaDesc.content = this._metaOriginals.description;
const ogTitleEl = document.querySelector('meta[property="og:title"]');
if (ogTitleEl) ogTitleEl.content = this._metaOriginals.ogTitle;
const ogDescEl = document.querySelector('meta[property="og:description"]');
if (ogDescEl) ogDescEl.content = this._metaOriginals.ogDescription;
return;
}
// title
const titleKey = `${pageName}.meta.title`;
const title = this.t(titleKey, null);
if (title && title !== titleKey) {
document.title = title;
}
// meta description
const descKey = `${pageName}.meta.description`;
const desc = this.t(descKey, null);
if (desc && desc !== descKey) {
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) metaDesc.content = desc;
}
// OG tags
const ogTitleKey = `${pageName}.meta.ogTitle`;
const ogTitle = this.t(ogTitleKey, null);
if (ogTitle && ogTitle !== ogTitleKey) {
const ogTitleEl = document.querySelector('meta[property="og:title"]');
if (ogTitleEl) ogTitleEl.content = ogTitle;
}
const ogDescKey = `${pageName}.meta.ogDescription`;
const ogDesc = this.t(ogDescKey, null);
if (ogDesc && ogDesc !== ogDescKey) {
const ogDescEl = document.querySelector('meta[property="og:description"]');
if (ogDescEl) ogDescEl.content = ogDesc;
}
},
/**
* 현재 페이지 이름 추출
*/
getPageName() {
const path = window.location.pathname;
const page = path.split('/').pop().replace('.html', '') || 'index';
return page;
},
/**
* 언어 전환
*/
setLang(lang) {
if (!SUPPORTED_LANGS.includes(lang)) return;
if (lang === this.currentLang) return;
localStorage.setItem(STORAGE_KEY, lang);
// SEO 친화적인 URL 리다이렉션 수행
const currentPath = window.location.pathname;
const parts = currentPath.split('/').filter(Boolean);
// 만약 현재 경로의 첫 번째 파트가 지원하는 언어라면(ko 제외), 제거
if (parts.length > 0 && SUPPORTED_LANGS.includes(parts[0]) && parts[0] !== DEFAULT_LANG) {
parts.shift();
}
const baseRoute = '/' + parts.join('/');
const newPath = lang === DEFAULT_LANG ? baseRoute : `/${lang}${baseRoute === '/' ? '' : baseRoute}`;
// URL 변경
window.location.href = newPath || '/';
},
/**
* 언어 스위처 UI 활성 상태 업데이트
*/
updateSwitcher() {
const currentEl = document.querySelector('.lang-current');
if (currentEl) {
currentEl.textContent = this.currentLang.toUpperCase();
}
document.querySelectorAll('.lang-dropdown button[data-lang]').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-lang') === this.currentLang);
});
},
/**
* 언어 스위처 이벤트 바인딩
*/
initSwitcher() {
const switcher = document.querySelector('.lang-switcher');
if (!switcher) return;
const btn = switcher.querySelector('.lang-btn');
const dropdown = switcher.querySelector('.lang-dropdown');
if (btn && dropdown) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
btn.classList.toggle('open');
});
dropdown.querySelectorAll('button[data-lang]').forEach(langBtn => {
langBtn.addEventListener('click', (e) => {
e.stopPropagation();
const lang = langBtn.getAttribute('data-lang');
this.setLang(lang);
dropdown.classList.remove('open');
btn.classList.remove('open');
});
});
// 외부 클릭 시 닫기
document.addEventListener('click', () => {
dropdown.classList.remove('open');
btn.classList.remove('open');
});
}
this.updateSwitcher();
}
};
window.i18n = i18n;
})();