- i18n 엔진 구현 (js/i18n.js): 언어 감지, JSON 로드, DOM 번역, 한국어 복원 지원 - 번역 JSON 파일 생성 (i18n/ko.json, en.json, zh.json, ja.json) - 517키 동기화 - 전체 HTML 페이지 data-i18n 태깅 (8개 페이지 + header/footer 컴포넌트) - 언어 스위처 UI 및 CSS 추가 (header + common.css) - JS 동적 문자열 번역 적용 (common/contact/gallery/main/portfolio.js) - 한국어 복원 버그 수정: 원본 텍스트를 data-i18n-ko 속성에 저장하여 복원 - 일본어 브랜드명 통일: ミングルスタジオ → Mingle Studio Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
339 lines
12 KiB
JavaScript
339 lines
12 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 로드
|
|
*/
|
|
async init() {
|
|
const lang = this.detectLanguage();
|
|
if (lang !== DEFAULT_LANG) {
|
|
await this.loadLang(lang);
|
|
}
|
|
this.currentLang = lang;
|
|
document.documentElement.lang = lang;
|
|
this.ready = true;
|
|
},
|
|
|
|
/**
|
|
* 언어 감지 우선순위: 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;
|
|
},
|
|
|
|
/**
|
|
* 언어 전환
|
|
*/
|
|
async setLang(lang) {
|
|
if (!SUPPORTED_LANGS.includes(lang)) return;
|
|
if (lang === this.currentLang) return;
|
|
|
|
this.currentLang = lang;
|
|
localStorage.setItem(STORAGE_KEY, lang);
|
|
document.documentElement.lang = lang;
|
|
|
|
await this.loadLang(lang);
|
|
this.translateDOM();
|
|
},
|
|
|
|
/**
|
|
* 언어 스위처 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;
|
|
})();
|