Files

93 lines
6.9 KiB
JavaScript

'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, '&quot;'))
.replace(/\{\{msgs:([\w.,]+)\}\}/g, (m, ids) => JSON.stringify(clientTable(lang, ids.split(','))).replace(/&/g, '&amp;').replace(/'/g, '&#39;'));
}
module.exports = { source, text, tr, forLang, err, errorText, clientTable, missing, ensure, saveRuntime, saveOverride, listFor, renderTemplate, table };