'use strict'; // ============================================================================ // 생성 사이트 시스템 문구 (v1.3.16) // // 생성 사이트에 서비스가 넣는 모든 글자 — 모듈 위젯(회원·게시판·문의·예약·갤러리·지도·후기·FAQ·설문) 화면 문구, // 폼 라벨, 오류 메시지, 동의문, 쿠키·법적 고지, 사이트 소유자에게 가는 알림 메일 — 를 코드에 직접 쓰지 않고 // locales/site/ko.json 의 id 로만 쓴다. 이 모듈이 id → 글자를 언어별로 돌려준다. // // 언어별 번역은 세 겹으로 찾는다 (뒤에 있을수록 우선): // 1) locales/site/<언어>.json — 소스에 함께 저장하는 씨앗 번역 (영어는 전부, 다른 언어는 일부) // 2) data/site-i18n/_runtime/<언어>.json — 다국어 사이트가 그 언어를 처음 쓸 때 AI 가 채운 번역 캐시(서비스 전체 공용) // 3) data/site-i18n/_runtime/<언어>.override.json — 관리자가 고친 번역 (관리자 콘솔 › 언어 › 사이트 문구) // 없으면 한국어 원문을 쓴다. {이름} 자리 표시는 값으로 채운다. // ============================================================================ const fs = require('fs'); const path = require('path'); const ROOT = path.join(__dirname, '..'); const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data'); const RUNTIME_DIR = path.join(DATA_ROOT, 'site-i18n', '_runtime'); const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (_) { return null; } }; let SOURCE = null; // { id: { text, group } } function source() { if (!SOURCE) SOURCE = (readJson(path.join(ROOT, 'locales', 'site', 'ko.json')) || {}).messages || {}; return SOURCE; } const cache = new Map(); // 언어 → { id: 번역 } (파일을 쓸 때 지운다) /** 언어의 번역표 (씨앗 → AI 캐시 → 관리자 수정 순으로 덮어씀) */ function table(lang) { if (!lang || lang === 'ko') return {}; if (cache.has(lang)) return cache.get(lang); const safe = String(lang).replace(/[^\w-]/g, ''); const t = { ...((readJson(path.join(ROOT, 'locales', 'site', `${safe}.json`)) || {}).messages || {}), ...(readJson(path.join(RUNTIME_DIR, `${safe}.json`)) || {}), ...(readJson(path.join(RUNTIME_DIR, `${safe}.override.json`)) || {}) }; cache.set(lang, t); return t; } /** {이름} 자리 채우기 */ const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m)); /** 한국어 원문 */ function text(id, vars) { const m = source()[id]; return fill(m ? m.text : id, vars); } /** 언어별 문구 (번역이 없으면 한국어 원문) */ function tr(lang, id, vars) { const t = table(lang)[id]; return t ? fill(t, vars) : text(id, vars); } /** 번역 함수 하나로 묶기: const t = sitemsg.forLang('en'); t('board.empty') */ const forLang = (lang) => (id, vars) => tr(lang, id, vars); /** * 오류 객체: 메시지는 한국어 원문, msgId·msgVars 를 함께 달아 API 가 요청 언어로 바꿔 보낼 수 있게 한다. * @example throw sitemsg.err('err.noPost', 404) */ function err(id, status = 400, vars) { return Object.assign(new Error(text(id, vars)), { status, msgId: id, msgVars: vars }); } /** API 오류 응답용: 오류 객체 → 요청 언어 메시지 */ function errorText(e, lang) { return e && e.msgId ? tr(lang, e.msgId, e.msgVars) : (e && e.message) || ''; } /** 위젯 스크립트에 넣을 표 (id → 그 언어 글자, 번역 없으면 원문) */ function clientTable(lang, ids) { const src = source(); const t = table(lang); const out = {}; for (const id of ids || Object.keys(src)) out[id] = t[id] || (src[id] ? src[id].text : id); return out; } /** 아직 번역이 없는 id (AI 번역 대상) */ function missing(lang) { if (!lang || lang === 'ko') return []; const t = table(lang); return Object.keys(source()).filter((id) => !t[id]); } /** AI 번역 결과 저장 (서비스 전체 공용 캐시) */ function saveRuntime(lang, add) { const safe = String(lang).replace(/[^\w-]/g, ''); fs.mkdirSync(RUNTIME_DIR, { recursive: true }); const f = path.join(RUNTIME_DIR, `${safe}.json`); const cur = readJson(f) || {}; for (const [k, v] of Object.entries(add || {})) if (source()[k] && typeof v === 'string' && v.trim()) cur[k] = v.trim(); fs.writeFileSync(f, JSON.stringify(cur, null, 1)); cache.delete(lang); return Object.keys(cur).length; } /** 관리자 수정 번역 저장 (빈 문자열이면 그 항목 수정 취소) */ function saveOverride(lang, patch) { const safe = String(lang).replace(/[^\w-]/g, ''); fs.mkdirSync(RUNTIME_DIR, { recursive: true }); const f = path.join(RUNTIME_DIR, `${safe}.override.json`); const cur = readJson(f) || {}; for (const [k, v] of Object.entries(patch || {})) { if (!source()[k]) continue; if (typeof v === 'string' && v.trim()) cur[k] = v.trim(); else delete cur[k]; } fs.writeFileSync(f, JSON.stringify(cur, null, 1)); cache.delete(lang); return cur; } /** * AI 로 빠진 문구 채우기. translateFn(texts[], to, from) → string[] (lib/multilang 과 같은 모양). * 자리 표시({name})가 빠지거나 바뀐 번역은 버린다(원문으로 표시됨). * @returns {number} 새로 채운 개수 */ async function ensure(lang, translateFn) { const ids = missing(lang); if (!ids.length) return 0; const src = source(); const add = {}; for (let i = 0; i < ids.length; i += 100) { const batch = ids.slice(i, i + 100); const tr0 = await translateFn(batch.map((id) => src[id].text), lang, 'ko'); batch.forEach((id, k) => { const v = tr0[k]; const ph = (s) => (String(s).match(/\{\w+\}/g) || []).sort().join(','); if (typeof v === 'string' && v.trim() && ph(v) === ph(src[id].text)) add[id] = v; }); } saveRuntime(lang, add); return Object.keys(add).length; } /** 관리자 화면용 목록: id·그룹·원문·번역·출처 */ function listFor(lang) { const src = source(); const safe = String(lang).replace(/[^\w-]/g, ''); const seed = (readJson(path.join(ROOT, 'locales', 'site', `${safe}.json`)) || {}).messages || {}; const ai = readJson(path.join(RUNTIME_DIR, `${safe}.json`)) || {}; const ov = readJson(path.join(RUNTIME_DIR, `${safe}.override.json`)) || {}; return Object.entries(src).map(([id, m]) => ({ id, group: m.group, text: m.text, translation: ov[id] || ai[id] || seed[id] || '', origin: ov[id] ? 'admin' : ai[id] ? 'ai' : seed[id] ? 'seed' : '' })); } /** 위젯 템플릿의 {{t:id}} 자리를 그 언어 글자로, {{msgs:id1,id2}} 를 JSON 표(HTML 속성용)로 바꾼다 */ function renderTemplate(html, lang) { return html.replace(/\{\{t:([\w.]+)\}\}/g, (m, id) => tr(lang, id).replace(/"/g, '"')) .replace(/\{\{msgs:([\w.,]+)\}\}/g, (m, ids) => JSON.stringify(clientTable(lang, ids.split(','))).replace(/&/g, '&').replace(/'/g, ''')); } module.exports = { source, text, tr, forLang, err, errorText, clientTable, missing, ensure, saveRuntime, saveOverride, listFor, renderTemplate, table };