Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
454 lines
46 KiB
JavaScript
454 lines
46 KiB
JavaScript
'use strict';
|
|
// ============================================================================
|
|
// 다국어 사이트 생성 (v1.3.10, v1.3.16 로케일 원칙·x-default·현지 형식·품질 게이트)
|
|
//
|
|
// 원칙 (v1.3.16 에서 문서로 고정 — README "다국어 사이트" 참고)
|
|
// 1) URL 은 로케일 코드로 구분한다: /<로케일 코드>/<페이지>. 로케일 코드는 BCP 47 언어 태그(ISO 639-1 언어 코드,
|
|
// 필요할 때만 지역 코드를 붙임: zh-CN, zh-TW, en-GB, pt-PT, es-MX)이며, 기본 언어도 예외 없이 폴더를 쓴다(/ko/).
|
|
// 공용 CSS·JS·이미지는 /assets/ 에 두고 ../assets/ 로 쓴다. 사이트 맨 위 주소(/)는 언어 선택 페이지다.
|
|
// 2) x-default: 어떤 언어에도 맞지 않는 방문자에게 보일 언어를 사이트마다 명시한다(site.langs.xDefault, 기본 = 기본 언어).
|
|
// hreflang="x-default", 언어 선택 페이지의 자동 이동, sitemap 대체 주소에 모두 이 값을 쓴다.
|
|
// 3) AI 는 기본 언어 폴더만 만든다. 다른 언어 페이지는 서비스가 기본 언어 HTML 을 그대로 복사하면서 "글자만" 번역해 만든다
|
|
// → 언어별 페이지 구조(섹션·순서·파일 이름·링크)가 기본 구조와 항상 같다.
|
|
// 4) 현지 형식: 날짜·시각(시간대)·숫자·통화·주소·전화번호·이름 순서·법적 고지를 data-wm-* 속성의 원래 값에서
|
|
// 언어마다 그 지역 형식으로 다시 쓴다(renderFormats). 아랍어·히브리어·페르시아어는 dir="rtl".
|
|
// 5) 번역은 문장 단위로 사이트별 저장소(data/site-i18n/<사이트ID>/<언어>.json)에 쌓고, 수정 후에는 바뀐 문장만 번역한다.
|
|
// 시스템 문구(위젯·오류·메일)는 lib/sitemsg.js 카탈로그(id)를 쓴다.
|
|
// 6) 언어별 SEO·AEO·GEO 는 lib/seo.js 가 언어 폴더마다 따로 만든다. 품질 게이트(quality)가 번역 누락률·원문 잔존율·
|
|
// 링크 대응·언어별 검수 상태를 검증한다.
|
|
// ============================================================================
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const sitemsg = require('./sitemsg');
|
|
|
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
|
const STORE = path.join(DATA_ROOT, 'site-i18n');
|
|
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
|
|
|
/**
|
|
* 지원 로케일. 키(code)가 URL 폴더 이름.
|
|
* html: <html lang>·hreflang 값(BCP 47) locale: Intl 날짜·숫자 로케일 og: og:locale dir: 글 방향
|
|
* name: 그 언어로 쓴 이름(언어 선택용) label: 빌더 화면용 이름 country: 기본 국가(주소·전화 표기 기준)
|
|
* tz: 기본 시간대 currency: 기본 통화 nameOrder: 'fg'(성 이름) | 'gf'(이름 성) nameSep: 성·이름 사이 글자
|
|
* addr: 주소 순서 'big'(국가→시도→도로명, 동아시아) | 'small'(도로명→도시→지역 우편번호→국가)
|
|
*/
|
|
const LOCALES = {
|
|
ko: { name: '한국어', html: 'ko', locale: 'ko-KR', og: 'ko_KR', dir: 'ltr', label: '한국어', country: 'KR', tz: 'Asia/Seoul', currency: 'KRW', nameOrder: 'fg', nameSep: '', addr: 'big' },
|
|
en: { name: 'English', html: 'en', locale: 'en-US', og: 'en_US', dir: 'ltr', label: '영어', country: 'US', tz: 'America/New_York', currency: 'USD', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
'en-GB': { name: 'English (UK)', html: 'en-GB', locale: 'en-GB', og: 'en_GB', dir: 'ltr', label: '영어(영국)', country: 'GB', tz: 'Europe/London', currency: 'GBP', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
ja: { name: '日本語', html: 'ja', locale: 'ja-JP', og: 'ja_JP', dir: 'ltr', label: '일본어', country: 'JP', tz: 'Asia/Tokyo', currency: 'JPY', nameOrder: 'fg', nameSep: ' ', addr: 'big' },
|
|
'zh-CN': { name: '简体中文', html: 'zh-Hans', locale: 'zh-CN', og: 'zh_CN', dir: 'ltr', label: '중국어(간체)', country: 'CN', tz: 'Asia/Shanghai', currency: 'CNY', nameOrder: 'fg', nameSep: '', addr: 'big' },
|
|
'zh-TW': { name: '繁體中文', html: 'zh-Hant', locale: 'zh-TW', og: 'zh_TW', dir: 'ltr', label: '중국어(번체)', country: 'TW', tz: 'Asia/Taipei', currency: 'TWD', nameOrder: 'fg', nameSep: '', addr: 'big' },
|
|
vi: { name: 'Tiếng Việt', html: 'vi', locale: 'vi-VN', og: 'vi_VN', dir: 'ltr', label: '베트남어', country: 'VN', tz: 'Asia/Ho_Chi_Minh', currency: 'VND', nameOrder: 'fg', nameSep: ' ', addr: 'small' },
|
|
th: { name: 'ไทย', html: 'th', locale: 'th-TH', og: 'th_TH', dir: 'ltr', label: '태국어', country: 'TH', tz: 'Asia/Bangkok', currency: 'THB', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
id: { name: 'Bahasa Indonesia', html: 'id', locale: 'id-ID', og: 'id_ID', dir: 'ltr', label: '인도네시아어', country: 'ID', tz: 'Asia/Jakarta', currency: 'IDR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
mn: { name: 'Монгол', html: 'mn', locale: 'mn-MN', og: 'mn_MN', dir: 'ltr', label: '몽골어', country: 'MN', tz: 'Asia/Ulaanbaatar', currency: 'MNT', nameOrder: 'fg', nameSep: ' ', addr: 'small' },
|
|
ru: { name: 'Русский', html: 'ru', locale: 'ru-RU', og: 'ru_RU', dir: 'ltr', label: '러시아어', country: 'RU', tz: 'Europe/Moscow', currency: 'RUB', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
fr: { name: 'Français', html: 'fr', locale: 'fr-FR', og: 'fr_FR', dir: 'ltr', label: '프랑스어', country: 'FR', tz: 'Europe/Paris', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
de: { name: 'Deutsch', html: 'de', locale: 'de-DE', og: 'de_DE', dir: 'ltr', label: '독일어', country: 'DE', tz: 'Europe/Berlin', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
es: { name: 'Español', html: 'es', locale: 'es-ES', og: 'es_ES', dir: 'ltr', label: '스페인어', country: 'ES', tz: 'Europe/Madrid', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
'es-MX': { name: 'Español (México)', html: 'es-MX', locale: 'es-MX', og: 'es_MX', dir: 'ltr', label: '스페인어(멕시코)', country: 'MX', tz: 'America/Mexico_City', currency: 'MXN', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
pt: { name: 'Português (Brasil)', html: 'pt-BR', locale: 'pt-BR', og: 'pt_BR', dir: 'ltr', label: '포르투갈어(브라질)', country: 'BR', tz: 'America/Sao_Paulo', currency: 'BRL', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
'pt-PT': { name: 'Português (Portugal)', html: 'pt-PT', locale: 'pt-PT', og: 'pt_PT', dir: 'ltr', label: '포르투갈어(포르투갈)', country: 'PT', tz: 'Europe/Lisbon', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
ar: { name: 'العربية', html: 'ar', locale: 'ar', og: 'ar_AR', dir: 'rtl', label: '아랍어', country: 'SA', tz: 'Asia/Riyadh', currency: 'SAR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
he: { name: 'עברית', html: 'he', locale: 'he-IL', og: 'he_IL', dir: 'rtl', label: '히브리어', country: 'IL', tz: 'Asia/Jerusalem', currency: 'ILS', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
fa: { name: 'فارسی', html: 'fa', locale: 'fa-IR', og: 'fa_IR', dir: 'rtl', label: '페르시아어', country: 'IR', tz: 'Asia/Tehran', currency: 'IRR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
tr: { name: 'Türkçe', html: 'tr', locale: 'tr-TR', og: 'tr_TR', dir: 'ltr', label: '터키어', country: 'TR', tz: 'Europe/Istanbul', currency: 'TRY', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
|
|
};
|
|
/**
|
|
* 아무 언어 표기(ko, ko-KR, zh-Hans, pt-BR, en_us …) → 서비스 로케일 코드. 모르는 것은 'ko'.
|
|
* 위젯이 보내는 <html lang> 값이나 브라우저 언어를 폴더 코드로 맞출 때 쓴다.
|
|
*/
|
|
function codeOf(tag) {
|
|
const t = String(tag || '').trim().replace('_', '-'); if (!t) return 'ko';
|
|
if (LOCALES[t]) return t;
|
|
const low = t.toLowerCase(); for (const [c, L] of Object.entries(LOCALES)) if (c.toLowerCase() === low || L.html.toLowerCase() === low || L.locale.toLowerCase() === low) return c;
|
|
const base = low.split('-')[0]; if (base === 'zh') return /tw|hk|hant/.test(low) ? 'zh-TW' : 'zh-CN'; if (base === 'pt') return /pt-pt/.test(low) ? 'pt-PT' : 'pt';
|
|
return LOCALES[base] ? base : 'ko';
|
|
}
|
|
/**
|
|
* 생성·변경 옵션 값 정리: { default, list, xDefault } — 기본 언어가 목록 맨 앞, 2개 이상일 때만 다국어.
|
|
* xDefault: 목록 안의 로케일 코드 또는 'root'(언어 선택 페이지). 없거나 잘못되면 기본 언어.
|
|
*/
|
|
function normalize(input) {
|
|
let o = input; if (typeof o === 'string') { try { o = JSON.parse(o); } catch (_) { o = {}; } }
|
|
o = o || {}; const list = [...new Set((Array.isArray(o.list) ? o.list : []).filter((c) => LOCALES[c]))];
|
|
const def = LOCALES[o.default] ? o.default : (list[0] || 'ko'); if (!list.includes(def)) list.unshift(def); else { list.splice(list.indexOf(def), 1); list.unshift(def); }
|
|
const l2 = list.slice(0, 12); const xDefault = o.xDefault === 'root' || l2.includes(o.xDefault) ? o.xDefault : def;
|
|
return { default: def, list: l2, xDefault };
|
|
}
|
|
/** x-default 가 가리키는 언어 (root 이면 언어 선택 페이지 → 자동 이동 실패 시 기본 언어) */
|
|
const xDefaultOf = (site) => { const L = site.langs || {}; return L.xDefault && (L.xDefault === 'root' || (L.list || []).includes(L.xDefault)) ? L.xDefault : L.default; };
|
|
/**
|
|
* 사이트 현지 설정 (site.locale): 영업 시간대·표시 통화·사업장 국가. 기본은 기본 언어 로케일의 값.
|
|
* 가격·영업시간은 "사업장 기준" 값이라 언어가 바뀌어도 통화·시간대는 그대로 두고 표기 형식만 바꾼다.
|
|
*/
|
|
function siteLocale(site) { const def = (site.langs && site.langs.default) || codeOf(site.uiLang); const L = LOCALES[def] || LOCALES.ko; const s = site.locale || {}; return { timeZone: s.timeZone || L.tz, currency: s.currency || L.currency, country: s.country || L.country }; }
|
|
const isMulti = (site) => !!(site && site.langs && site.langs.list && site.langs.list.length > 1);
|
|
|
|
/** 생성·수정 프롬프트에 붙는 다국어 규칙 (AI 는 기본 언어 폴더만 만든다) */
|
|
function promptRules(site) {
|
|
const L = site.langs; const d = L.default; const others = L.list.slice(1).map((c) => `${LOCALES[c].label}(${c})`).join(', ');
|
|
return `[다국어 사이트 규칙 — 반드시 지킴]
|
|
- 이 사이트는 ${L.list.map((c) => `${LOCALES[c].label}(${c})`).join(', ')} 다국어 사이트입니다. 당신은 기본 언어 ${LOCALES[d].label}(${d}) 페이지만 만들고 고칩니다. ${others} 페이지는 서비스가 기본 언어 페이지를 그대로 번역해 자동으로 만들므로 직접 만들거나 고치지 마세요(만들어도 덮어써집니다).
|
|
- 기본 언어 페이지는 모두 "${d}/" 폴더 안에 둡니다 (예: ${d}/index.html, ${d}/about.html). 사이트 맨 위 index.html 은 만들지 않습니다(언어 선택 페이지는 서비스가 만듭니다).
|
|
- 공용 CSS·JS·이미지·글꼴은 "assets/" 폴더에 두고 페이지에서 "../assets/…" 로 참조합니다(예: assets/css/style.css → <link href="../assets/css/style.css">). 첨부 파일은 "../uploads/…" 로 참조합니다. 페이지끼리는 같은 폴더 상대 경로(about.html)로 연결합니다.
|
|
- 다른 규칙의 "메인 페이지 index.html", "css/style.css" 는 이 사이트에서는 각각 "${d}/index.html", "assets/css/style.css" 를 뜻합니다(이 다국어 규칙이 우선).
|
|
- 각 페이지 머리글(header)에 언어 선택 자리 <nav data-wm-langs aria-label="Language"></nav> 를 하나 둡니다(서비스가 채움). 기본은 드롭다운(select)입니다. 사용자가 언어 선택을 링크 나열 등 다른 방식으로 지시한 경우에만 <nav data-wm-langs="links" aria-label="Language"></nav> 로 둡니다. 드롭다운 모양은 .wm-lang-select 에 CSS 로 꾸밉니다.
|
|
- 언어·지역마다 표기가 다른 값은 글자로 박지 말고 원래 값을 속성에 둡니다(서비스가 언어별 현지 형식으로 바꿈, 안의 글자는 기본 언어 표기로):
|
|
날짜 <time data-wm-date="2026-03-01">…</time> · 날짜+시각 <time data-wm-datetime="2026-03-01T10:00:00+09:00">…</time>(사업장 시간대로 표시) · 숫자 <span data-wm-number="1500000">…</span>
|
|
가격 <span data-wm-price="15000" data-currency="KRW">…</span> · 전화 <a data-wm-tel="+82-2-1234-5678">…</a>
|
|
주소 <address data-wm-address='{"street":"테헤란로 1","locality":"강남구","region":"서울특별시","postal":"06000","country":"KR"}'>…</address> · 사람 이름 <span data-wm-name='{"family":"홍","given":"길동"}'>…</span>
|
|
법적 고지 <span data-wm-legal="copyright" data-name="상호">…</span>, <a href="privacy.html" data-wm-legal="privacy">…</a>, <a data-wm-legal="terms">…</a>, 쿠키 안내 <p data-wm-legal="cookie-notice">…</p>, 사업자 정보 <p data-wm-legal="business-info" data-name="상호" data-owner="대표" data-biz-no="000-00-00000">…</p>
|
|
- 화살표처럼 방향이 있는 아이콘에는 data-wm-mirror 를 붙입니다(오른쪽→왼쪽 언어에서 좌우 반전).
|
|
- CSS 는 오른쪽→왼쪽 언어(아랍어 등)에서도 그대로 맞도록 논리 속성(margin-inline-start, padding-inline, inset-inline, text-align: start/end)을 씁니다. 왼쪽/오른쪽을 고정하는 left/right 정렬은 꼭 필요할 때만.`;
|
|
}
|
|
|
|
// ---------- HTML 문장 뽑기·바꿔 넣기 ----------
|
|
/** lib/seo.js 의 관리 구역 (번역 원본에서는 걷어 낸다 — 언어별로 따로 넣기 때문) */
|
|
const MANAGED_RE = { seo: /<!-- wm-seo:start -->[\s\S]*?<!-- wm-seo:end -->\n/, faq: /<!-- wm-aeo-faq:start -->[\s\S]*?<!-- wm-aeo-faq:end -->\n/ };
|
|
const SKIP_TAGS = new Set(['script', 'style', 'code', 'pre', 'textarea', 'svg', 'noscript']);
|
|
// v1.5.36 (요구 2): 모듈 위젯이 화면에 그대로 쓰는 data-* 속성(data-title="문의하기", data-button="문의 보내기", data-label, data-note-label …)도 번역한다.
|
|
// 1.5.31 까지는 alt·title·placeholder·aria-label 만 번역해 다른 언어 페이지의 문의폼 제목·버튼이 한국어로 남았다(sample.ariweb.net/en/ 2026-10-15).
|
|
const ATTR_RE = /\s(alt|title|placeholder|aria-label|data-title|data-button|data-label|data-note-label|data-desc|data-empty|data-placeholder|data-submit)="([^"]*)"/g;
|
|
/** 번역할 만한 글자인지: 글자가 있고 숫자·기호·URL·메일만으로 된 것이 아님 */
|
|
const translatable = (t) => { const s = t.replace(/&[a-z#0-9]+;/gi, ' ').trim(); return s.length > 0 && /\p{L}/u.test(s) && !/^(https?:|mailto:|tel:|www\.)\S*$/i.test(s) && !/^[\w.+-]+@[\w.-]+$/.test(s); };
|
|
/**
|
|
* HTML 을 훑으며 번역 대상 조각마다 fn(원문) 을 불러 바꾼 HTML 을 돌려준다(fn 이 null 이면 그대로).
|
|
* 텍스트 노드, 번역 대상 속성, <title>, meta description / og:title / og:description 의 content.
|
|
*/
|
|
function mapText(html, fn) {
|
|
let out = ''; let i = 0; let skip = null; const seg = (t) => { if (!translatable(t)) return t; const core = t.trim(); const r = fn(core); return r == null ? t : t.replace(core, r); };
|
|
while (i < html.length) {
|
|
const lt = html.indexOf('<', i); if (lt < 0) { out += skip ? html.slice(i) : seg(html.slice(i)); break; }
|
|
out += skip ? html.slice(i, lt) : seg(html.slice(i, lt));
|
|
if (html.startsWith('<!--', lt)) { const e = html.indexOf('-->', lt); const end = e < 0 ? html.length : e + 3; out += html.slice(lt, end); i = end; continue; }
|
|
const gt = html.indexOf('>', lt); if (gt < 0) { out += html.slice(lt); break; }
|
|
let tag = html.slice(lt, gt + 1); const m = tag.match(/^<\/?([a-zA-Z][\w-]*)/); const name = m ? m[1].toLowerCase() : '';
|
|
if (tag.startsWith('</')) { if (skip === name) skip = null; out += tag; i = gt + 1; continue; }
|
|
if (!skip) {
|
|
tag = tag.replace(ATTR_RE, (all, a, v) => ` ${a}="${translatable(v) ? (fn(v.trim()) || v).replace(/"/g, '"') : v}"`);
|
|
if (name === 'meta' && /\s(?:name|property)="(?:description|og:title|og:description|twitter:title|twitter:description)"/i.test(tag)) tag = tag.replace(/\scontent="([^"]*)"/, (all, v) => ` content="${translatable(v) ? (fn(v.trim()) || v).replace(/"/g, '"') : v}"`);
|
|
if (name === 'input' && /\stype="(?:submit|button|reset)"/i.test(tag)) tag = tag.replace(/\svalue="([^"]*)"/, (all, v) => ` value="${translatable(v) ? (fn(v.trim()) || v).replace(/"/g, '"') : v}"`);
|
|
}
|
|
if (SKIP_TAGS.has(name) && !tag.endsWith('/>')) skip = name;
|
|
// v1.3.16: 현지 형식 요소(data-wm-date·price·address …) 안의 글자는 서비스가 언어마다 다시 쓰므로 번역하지 않는다
|
|
if (/\sdata-wm-(?:date|datetime|number|price|tel|address|name|legal)\b/i.test(tag) && !tag.endsWith('/>')) { const close = html.indexOf('<', gt + 1); if (close > gt) { out += tag + html.slice(gt + 1, close); i = close; continue; } }
|
|
out += tag; i = gt + 1;
|
|
}
|
|
return out;
|
|
}
|
|
/** 페이지에서 번역할 문장 목록 (중복 제거) */
|
|
function segments(html) { const set = new Set(); mapText(html, (t) => { set.add(t); return null; }); return [...set]; }
|
|
|
|
// ---------- 모듈 위젯 실행 중 문구 ----------
|
|
// v1.3.16: 위젯·오류·메일 문구는 lib/sitemsg.js 카탈로그(id)로 옮겼다. 다른 언어 페이지에는 그 언어 표(id → 글자)를
|
|
// <script type="application/json" data-wm-i18n> 로 넣고 public/wm-modules.js 의 L(id) 가 찾아 쓴다.
|
|
const usesWidgets = (html) => /data-wm-module|data-wm-contact/.test(html);
|
|
const DICT_RE = /<script type="application\/json" data-wm-i18n>[\s\S]*?<\/script>\n?/g;
|
|
|
|
// ---------- 사이트별 번역 저장소 ----------
|
|
const fileOf = (siteId, lang) => path.join(STORE, siteId, `${lang}.json`);
|
|
function loadMemo(siteId, lang) { try { return JSON.parse(fs.readFileSync(fileOf(siteId, lang), 'utf8')); } catch (_) { return {}; } }
|
|
function saveMemo(siteId, lang, memo) { fs.mkdirSync(path.join(STORE, siteId), { recursive: true }); fs.writeFileSync(fileOf(siteId, lang), JSON.stringify(memo)); }
|
|
function removeSite(siteId) { try { fs.rmSync(path.join(STORE, siteId), { recursive: true, force: true }); } catch (_) {} }
|
|
|
|
/** 번역 프롬프트: 문장 배열 → 같은 길이·같은 순서의 번역 배열 (JSON) */
|
|
function translatePrompt(texts, from, to, context) {
|
|
return `웹사이트 문구를 ${LOCALES[from].label}(${from})에서 ${LOCALES[to].label}(${to})로 번역합니다. 아래 JSON 배열의 각 문장을 번역해, 같은 개수·같은 순서의 배열을 {"t": [...]} 로만 답하세요.
|
|
규칙: 웹사이트 문구답게 자연스럽게, 고유명사(상호·인명·주소의 고유 부분)는 원래대로 또는 그 언어에서 통용되는 표기로, 전화번호·이메일·URL·숫자는 그대로, 원문에 없는 내용은 덧붙이지 않습니다. HTML 태그는 없습니다.
|
|
${context ? `[사이트] ${context}\n` : ''}${JSON.stringify(texts)}`;
|
|
}
|
|
|
|
// ---------- 언어 페이지 만들기 ----------
|
|
/** 언어 폴더 안 HTML 목록 (base 폴더 기준 상대 경로) */
|
|
function htmlUnder(dir) { const out = []; const walk = (d, rel) => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.')) continue; const r = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) walk(path.join(d, e.name), r); else if (/\.html?$/i.test(e.name)) out.push(r); } }; walk(dir, ''); return out; }
|
|
/** 언어 선택 링크 HTML (현재 언어 표시) */
|
|
/**
|
|
* 언어 선택 HTML. v1.4.0: 따로 정하지 않으면 드롭다운(<select>)이고, 스크립트가 꺼진 브라우저를 위해 <noscript> 링크를 함께 둔다.
|
|
* 사용자가 다른 방식(링크 나열)을 지시해 AI 가 <nav data-wm-langs="links"> 로 만든 경우에만 링크로 그린다.
|
|
* @param {string} style 'select'(기본) | 'links'
|
|
*/
|
|
function switcherHtml(langs, cur, file, style) {
|
|
if (style === 'links') return switcher(langs, cur, file);
|
|
const up = '../'.repeat(String(file).split('/').length);
|
|
const opts = langs.map((c) => `<option value="${up}${c}/${file}" lang="${LOCALES[c].html}"${c === cur ? ' selected' : ''}>${LOCALES[c].name}</option>`).join('');
|
|
return `<select class="wm-lang-select" aria-label="Language" onchange="if(this.value)location.href=this.value">${opts}</select><noscript>${switcher(langs, cur, file)}</noscript>`;
|
|
}
|
|
function switcher(langs, cur, file) { const up = '../'.repeat(String(file).split('/').length); /* 언어 폴더 밖으로: sub/about.html → ../../ */ return langs.map((c) => `<a href="${up}${c}/${file}" hreflang="${LOCALES[c].html}" lang="${LOCALES[c].html}"${c === cur ? ' aria-current="true"' : ''}>${LOCALES[c].name}</a>`).join(' '); }
|
|
/**
|
|
* v1.4.0 복구: 1.3.16 에서 만든 번역 페이지의 언어 선택 링크 중 "원문 언어" 링크가 그 페이지 언어 폴더를 가리키던 문제를 고친다.
|
|
* 번역을 다시 하지 않고(비용 없음) 각 언어 폴더 페이지의 <nav data-wm-langs> 안만 다시 그린다. 이미 맞으면 파일을 건드리지 않는다.
|
|
* @returns {number} 고친 파일 수
|
|
*/
|
|
function repairSwitchers(site, siteDir) {
|
|
if (!isMulti(site)) return 0; const { default: def, list } = site.langs; let fixed = 0;
|
|
for (const c of list) {
|
|
if (c === def) continue; const dir = path.join(siteDir, c);
|
|
for (const f of htmlUnder(dir)) {
|
|
const fp = path.join(dir, f); let html; try { html = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; }
|
|
const out = html.replace(/(<nav\b[^>]*data-wm-langs(?:=["']([\w-]*)["'])?[^>]*>)[\s\S]*?(<\/nav>)/gi, (all, a, style, b) => `${a}${switcherHtml(list, c, f, style)}${b}`);
|
|
if (out !== html) { fs.writeFileSync(fp, out); fixed += 1; }
|
|
}
|
|
}
|
|
return fixed;
|
|
}
|
|
// ---------- 현지 형식 (v1.3.16) ----------
|
|
const escH = (v) => String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
const unescH = (v) => String(v).replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
/** 속성 값 읽기 (큰따옴표·작은따옴표 모두) */
|
|
const attrOf = (tag, name) => { const m = tag.match(new RegExp(`\\s${name}=(?:"([^"]*)"|'([^']*)')`, 'i')); return m ? unescH(m[1] !== undefined ? m[1] : m[2]) : null; };
|
|
/** JSON 속성 값 읽기 (data-wm-address='{"street":…}') */
|
|
const jsonAttr = (tag, name) => { const v = attrOf(tag, name); if (!v) return null; try { return JSON.parse(v); } catch (_) { return null; } };
|
|
/** 나라별 국가 번호 (전화번호 표기용, 자주 쓰는 것만) */
|
|
const CALLING = { KR: '82', US: '1', GB: '44', JP: '81', CN: '86', TW: '886', VN: '84', TH: '66', ID: '62', MN: '976', RU: '7', FR: '33', DE: '49', ES: '34', MX: '52', BR: '55', PT: '351', SA: '966', IL: '972', IR: '98', TR: '90' };
|
|
/**
|
|
* 전화번호: 원래 값(+82-2-1234-5678 또는 사업장 국가 기준 02-1234-5678)을
|
|
* - 페이지 지역이 사업장 국가와 같으면 국내 표기(02-1234-5678)
|
|
* - 다르면 국제 표기(+82 2-1234-5678)
|
|
* 로 보이고, 링크(tel:)는 항상 국제 번호로 쓴다.
|
|
*/
|
|
function formatTel(raw, pageCountry, homeCountry) {
|
|
const v = String(raw).trim(); const cc = CALLING[homeCountry] || '';
|
|
let intl; let nat;
|
|
if (v.startsWith('+')) { const m = v.match(/^\+(\d{1,3})[\s-]?(.*)$/); const code = m ? m[1] : cc; const rest = m ? m[2] : v.slice(1); intl = `+${code} ${rest}`; nat = (code === '1' ? '' : '0') + rest; if (code !== (CALLING[pageCountry] || '')) nat = null; }
|
|
else { const rest = v.replace(/^0/, ''); intl = cc ? `+${cc} ${rest}` : v; nat = v; if (pageCountry !== homeCountry) nat = null; }
|
|
return { text: nat || intl, href: 'tel:' + intl.replace(/[^\d+]/g, '') };
|
|
}
|
|
/** 국가 이름 (그 언어로, Intl.DisplayNames) */
|
|
function countryName(code, locale) { try { return new Intl.DisplayNames([locale], { type: 'region' }).of(code); } catch (_) { return code; } }
|
|
/**
|
|
* 주소: 부분 값 {street, locality, region, postal, country} 를 그 언어의 순서로 잇는다.
|
|
* big(동아시아): 국가 우편번호 시·도 시·군·구 도로명 / small(그 외): 도로명, 도시, 지역 우편번호, 국가
|
|
* 사업장 국가와 페이지 지역이 같으면 국가는 생략한다.
|
|
*/
|
|
function formatAddress(a, L, homeCountry) {
|
|
const c = a.country || homeCountry; const country = c && c !== L.country ? countryName(c, L.locale) : '';
|
|
if (L.addr === 'big') return [country, a.postal && c === 'JP' ? `〒${a.postal}` : '', a.region, a.locality, a.street, a.postal && c !== 'JP' ? `(${a.postal})` : ''].filter(Boolean).join(L.country === 'KR' ? ' ' : ''); // 한국어는 띄어 쓰고, 일본어·중국어는 붙여 쓴다
|
|
return [a.street, a.locality, [a.region, a.postal].filter(Boolean).join(' '), country].filter(Boolean).join(', ');
|
|
}
|
|
/** 이름: {family, given} → 성·이름 순서(fg) 또는 이름·성(gf) */
|
|
const formatName = (n, L) => (L.nameOrder === 'fg' ? [n.family, n.given] : [n.given, n.family]).filter(Boolean).join(L.nameSep);
|
|
/** 형식을 바꿀 수 있는 속성들: 요소 안이 글자만일 때 그 글자를 바꾼다 (<span data-wm-price="15000">15000</span>) */
|
|
const FORMAT_RE = /(<([a-z][\w-]*)\b([^>]*\sdata-wm-(date|datetime|number|price|tel|address|name|legal)\b[^>]*)>)([^<]*)(<\/\2>)/gi;
|
|
/**
|
|
* 페이지의 data-wm-* 값을 언어 to 의 형식으로 다시 쓴다 (기본 언어 페이지에도 적용 — 원래 값은 속성에 남아 있어 몇 번을 다시 써도 같다).
|
|
* @param {string} html
|
|
* @param {string} to 로케일 코드
|
|
* @param {object} ctx { dict: 번역 메모(주소·이름 부분 값 번역), site, home: siteLocale(site) }
|
|
*/
|
|
function renderFormats(html, to, ctx) {
|
|
const L = LOCALES[to]; const home = ctx.home || { timeZone: L.tz, currency: L.currency, country: L.country }; const dict = ctx.dict || {}; const tt = (v) => (v && dict[v]) || v;
|
|
const msg = (id, vars) => sitemsg.tr(to, id, vars);
|
|
let out = html.replace(FORMAT_RE, (all, open, tag, attrs, kind, inner, close) => {
|
|
const v = attrOf(open, `data-wm-${kind}`); let text = null; let o = open;
|
|
try {
|
|
if (kind === 'date') { const d = new Date(v); if (!Number.isNaN(d.getTime())) text = new Intl.DateTimeFormat(L.locale, { dateStyle: 'long', timeZone: /T/.test(v) ? home.timeZone : 'UTC' }).format(d); }
|
|
else if (kind === 'datetime') { const d = new Date(v); if (!Number.isNaN(d.getTime())) text = new Intl.DateTimeFormat(L.locale, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: attrOf(open, 'data-tz') || home.timeZone, timeZoneName: 'short' }).format(d); } // dateStyle 과 timeZoneName 은 함께 쓸 수 없어 항목별로 지정
|
|
else if (kind === 'number') { const n = Number(v); if (Number.isFinite(n)) text = new Intl.NumberFormat(L.locale).format(n); }
|
|
else if (kind === 'price') { const n = Number(v); if (Number.isFinite(n)) text = new Intl.NumberFormat(L.locale, { style: 'currency', currency: attrOf(open, 'data-currency') || home.currency, currencyDisplay: 'symbol' }).format(n); }
|
|
else if (kind === 'tel') { const t = formatTel(v, L.country, home.country); text = t.text; if (tag.toLowerCase() === 'a') o = /\shref=/.test(o) ? o.replace(/\shref=("[^"]*"|'[^']*')/, ` href="${t.href}"`) : o.replace(/>$/, ` href="${t.href}">`); o = /\sdir=/.test(o) ? o : o.replace(/>$/, ' dir="ltr">'); }
|
|
else if (kind === 'address') { const a = jsonAttr(open, 'data-wm-address'); if (a) text = formatAddress({ street: tt(a.street), locality: tt(a.locality), region: tt(a.region), postal: a.postal, country: a.country }, L, home.country); }
|
|
else if (kind === 'name') { const n = jsonAttr(open, 'data-wm-name'); if (n) text = formatName({ family: tt(n.family), given: tt(n.given) }, L); }
|
|
else if (kind === 'legal') {
|
|
const nm = attrOf(open, 'data-name') || (ctx.site && ctx.site.title) || '';
|
|
const ids = { copyright: 'legal.copyright', privacy: 'legal.privacy', terms: 'legal.terms', 'cookie-notice': 'legal.cookieNotice', 'cookie-accept': 'legal.cookieAccept', 'cookie-decline': 'legal.cookieDecline', consent: 'consent.privacy', 'business-info': 'legal.businessInfo' };
|
|
if (ids[v]) text = msg(ids[v], { year: new Date().getFullYear(), name: tt(nm), owner: tt(attrOf(open, 'data-owner') || ''), bizNo: attrOf(open, 'data-biz-no') || '' });
|
|
}
|
|
} catch (_) { text = null; }
|
|
return text === null ? all : `${o}${escH(text)}${close}`;
|
|
});
|
|
// 오른쪽→왼쪽 언어: 숫자·전화·주소가 뒤집혀 보이지 않게 하고, 방향이 있는 아이콘(data-wm-mirror)은 좌우 반전
|
|
out = out.replace(/<style data-wm-rtl>[\s\S]*?<\/style>\n?/g, '');
|
|
if (L.dir === 'rtl') out = out.replace(/<\/head>/i, '<style data-wm-rtl>[data-wm-tel],[data-wm-price],[data-wm-number]{unicode-bidi:isolate}html[dir=rtl] [data-wm-mirror]{transform:scaleX(-1)}</style>\n</head>');
|
|
return out;
|
|
}
|
|
/** 주소·이름 JSON 속성의 번역할 부분 값 (번역 메모에 함께 넣는다) */
|
|
function formatSegments(html) {
|
|
const out = []; const re = /<[a-z][\w-]*\b[^>]*\sdata-wm-(address|name)=(?:"[^"]*"|'[^']*')[^>]*>/gi; let m;
|
|
while ((m = re.exec(html))) { const o = jsonAttr(m[0], `data-wm-${m[1]}`); if (o) for (const k of ['street', 'locality', 'region', 'family', 'given']) if (o[k] && /\p{L}/u.test(o[k])) out.push(String(o[k])); }
|
|
const nm = /\sdata-wm-legal=["'](?:copyright|business-info)["'][^>]*\sdata-(?:name|owner)=["']([^"']+)["']/gi; while ((m = nm.exec(html))) out.push(unescH(m[1]));
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 기본 언어 HTML → 대상 언어 HTML. 글자만 번역해 넣고 구조는 그대로 둔다.
|
|
* - <html lang dir data-wm-lang>, 언어 선택 nav 채우기, 같은 사이트 안 다른 언어 폴더 링크(/ko/ 등) 바꾸기
|
|
* - 현지 형식(renderFormats), 위젯 번역표(data-wm-i18n: 문구 id → 그 언어 글자, 'v:' 로 시작하는 키는 모듈 설정 값 번역)
|
|
* @param {object} [ctx] { site, home(siteLocale), values: 번역할 모듈 설정 값 목록 }
|
|
*/
|
|
function buildPage(html, from, to, dict, langs, file, ctx = {}) {
|
|
const L = LOCALES[to];
|
|
let out = html.replace(DICT_RE, '');
|
|
if (to !== from) out = mapText(out, (t) => dict[t] || null);
|
|
if (usesWidgets(out) && to !== 'ko') {
|
|
// 위젯 실행 중 문구: 문구 id → 이 언어 글자 표 + 모듈 설정 값(문의 유형 등) 번역. 예전 문의 위젯 스크립트의 한국어 고정 문구도 바꾼다
|
|
const table = sitemsg.clientTable(to); for (const v of ctx.values || []) if (dict[v]) table['v:' + v] = dict[v];
|
|
const src = sitemsg.source(); for (const id of LEGACY_WIDGET_IDS) { const ko = src[id] && src[id].text; const tr = sitemsg.tr(to, id); if (ko && tr !== ko) out = out.split(`'${ko}'`).join(`'${tr.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, ' ')}'`); }
|
|
const tag = `<script type="application/json" data-wm-i18n>${JSON.stringify(table).replace(/</g, '\\u003c')}</script>\n`; out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${tag}</body>`) : out + tag;
|
|
}
|
|
out = out.replace(/<html\b([^>]*)>/i, (all, attrs) => `<html${attrs.replace(/\s(lang|dir|data-wm-lang)="[^"]*"/g, '')} lang="${L.html}" dir="${L.dir}" data-wm-lang="${to}">`);
|
|
// v1.4.0 수정: 원문 언어 폴더를 가리키는 내부 링크(../ko/…)를 이 언어 폴더로 바꾸는 일은 언어 선택 링크를 넣기 "전에" 한다.
|
|
// (1.3.16 에서는 선택 링크를 넣은 뒤에 바꿔, 영어 페이지의 "한국어" 링크까지 ../en/ 으로 바뀌어 원문 언어로 돌아갈 수 없었다)
|
|
if (to !== from) out = out.replace(new RegExp(`(\\s(?:href|src)=")(\\/|\\.\\.\\/)(${from.replace(/[-]/g, '\\-')})\\/`, 'g'), `$1$2${to}/`);
|
|
out = out.replace(/(<nav\b[^>]*data-wm-langs(?:=["']([\w-]*)["'])?[^>]*>)[\s\S]*?(<\/nav>)/gi, (all, a, style, b) => `${a}${switcherHtml(langs, to, file, style)}${b}`);
|
|
if (!/data-wm-langs/.test(out)) out = out.replace(/<body\b[^>]*>/i, (b) => `${b}\n<nav data-wm-langs aria-label="Language" style="text-align:end;padding:6px 12px;font-size:14px">${switcherHtml(langs, to, file, 'select')}</nav>`);
|
|
return renderFormats(out, to, { ...ctx, dict });
|
|
}
|
|
/** 예전(1.3.10 이전) 문의 위젯 스크립트에 한국어로 박혀 있던 문구의 id — 다른 언어 페이지에서 글자를 바로 바꾼다 */
|
|
const LEGACY_WIDGET_IDS = ['common.requiredCheck', 'common.sending', 'common.receivedContact', 'common.sendFailed', 'common.networkFailed'];
|
|
/**
|
|
* 사이트 맨 위 언어 선택 페이지. 브라우저 언어와 맞는 언어 폴더로 이동하고, 맞는 언어가 없으면 x-default 언어로 간다.
|
|
* x-default 가 'root' 이면 브라우저 언어와 맞더라도 이동하지 않고 이 선택 페이지를 그대로 보인다(v1.3.17 B30 — 예전에는 맞는 언어면 이동했다).
|
|
* 스크립트가 없어도 링크로 고를 수 있다.
|
|
*/
|
|
const ROOT_MARK = 'data-wm-lang-root';
|
|
function rootPage(site, langs, def, xDefault) {
|
|
const title = (site.title || '').replace(/</g, '<'); const fallback = xDefault && xDefault !== 'root' ? xDefault : null;
|
|
return `<!doctype html>
|
|
<!-- AI Webmaker 다국어 사이트 언어 선택 페이지 (자동 생성 — 언어 목록·x-default 가 바뀌면 다시 만들어집니다) -->
|
|
<html lang="${LOCALES[fallback || def].html}" ${ROOT_MARK}><head><!-- hreflang 대체 링크·검색사이트 확인 태그는 배포 때 운영 주소 기준으로 들어갑니다 (lib/seo.js) --><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
|
|
${fallback ? '' : '<!-- x-default = 언어 선택 페이지: 자동 이동하지 않는다 (v1.3.17 B30) -->'}${!fallback ? '' : `<script>(function(){var L=${JSON.stringify(langs.map((c) => [c, LOCALES[c].html.toLowerCase()]))};var w=(navigator.languages||[navigator.language||'']).map(function(x){return String(x).toLowerCase()});for(var i=0;i<w.length;i++){for(var j=0;j<L.length;j++){if(w[i]===L[j][1]||w[i]===L[j][0].toLowerCase()){location.replace(L[j][0]+'/');return;}}}for(i=0;i<w.length;i++){for(j=0;j<L.length;j++){if(w[i].split('-')[0]===L[j][1].split('-')[0]){location.replace(L[j][0]+'/');return;}}}location.replace(${JSON.stringify(fallback + '/')});})();</script>`}
|
|
${fallback ? `<noscript><meta http-equiv="refresh" content="0; url=${fallback}/"></noscript>` : ''}</head>
|
|
<body style="font-family:system-ui,sans-serif;text-align:center;padding:40px"><h1>${title}</h1><p>${langs.map((c) => `<a href="${c}/" hreflang="${LOCALES[c].html}" lang="${LOCALES[c].html}">${LOCALES[c].name}</a>`).join(' · ')}</p></body></html>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 다국어 맞추기: 기본 언어 폴더의 모든 HTML 을 다른 언어 폴더로 번역·복사한다.
|
|
* @param {object} site
|
|
* @param {string} siteDir
|
|
* @param {(texts: string[], to: string, from: string) => Promise<string[]>} translateFn AI 번역(서버 syncLanguages 가 구현, 비용 청구)
|
|
* @param {(msg: string) => void} log
|
|
* @param {object} [opts] { values: 번역할 모듈 설정 값(문의 유형·후기 분류 등 방문자에게 보이는 값) }
|
|
* @returns {Promise<{languages: object, translated: number, runtime: number, pages: number}>}
|
|
*/
|
|
async function sync(site, siteDir, translateFn, log = () => {}, opts = {}) {
|
|
if (!isMulti(site)) return null;
|
|
const { default: def, list } = site.langs; const baseDir = path.join(siteDir, def); const xd = xDefaultOf(site);
|
|
if (!fs.existsSync(baseDir)) throw httpError(`기본 언어 폴더(${def}/)가 없습니다. 다국어 사이트는 페이지를 ${def}/ 폴더에 만들어야 합니다`, 409);
|
|
const files = htmlUnder(baseDir); if (!files.length) throw httpError(`기본 언어 폴더(${def}/)에 HTML 이 없습니다`, 409);
|
|
// 번역 원본은 기본 언어 페이지에서 검색·AI 최적화 관리 구역(언어별로 따로 넣음)을 걷어 낸 것
|
|
const raw = Object.fromEntries(files.map((f) => [f, fs.readFileSync(path.join(baseDir, f), 'utf8')]));
|
|
const sources = Object.fromEntries(files.map((f) => [f, raw[f].replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, '').replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2')])); // 언어 선택 링크(언어 이름)는 번역하지 않고 다시 채운다
|
|
const values = [...new Set((opts.values || []).filter((v) => typeof v === 'string' && /\p{L}/u.test(v)))];
|
|
const all = [...new Set([...files.flatMap((f) => [...segments(sources[f]), ...formatSegments(sources[f])]), ...values])];
|
|
const ctx = { site, home: siteLocale(site), values };
|
|
const result = { languages: {}, translated: 0, runtime: 0, pages: files.length };
|
|
/** memo 에 없는 문장만 120개씩 번역 */
|
|
const fill = async (memo, to, texts, from) => {
|
|
const missing = texts.filter((t) => !memo[t]);
|
|
for (let i = 0; i < missing.length; i += 120) {
|
|
const batch = missing.slice(i, i + 120); log(`${LOCALES[to].label} 번역 중 (${Math.min(i + 120, missing.length)}/${missing.length})`);
|
|
const tr = await translateFn(batch, to, from);
|
|
batch.forEach((t, k) => { if (typeof tr[k] === 'string' && tr[k].trim()) memo[t] = tr[k].trim(); });
|
|
saveMemo(site.id, to, memo); result.translated += batch.length;
|
|
}
|
|
};
|
|
site.langs.review = site.langs.review || {};
|
|
for (const to of list) {
|
|
const memo = loadMemo(site.id, to);
|
|
if (to !== def) await fill(memo, to, all, def);
|
|
// 시스템 문구(위젯·오류·동의문·법적 고지) — 서비스 공용 번역 캐시에 없으면 채운다(한 언어당 한 번)
|
|
if (to !== 'ko' && sitemsg.missing(to).length) { log(`${LOCALES[to].label} 시스템 문구 번역 중`); result.runtime += await sitemsg.ensure(to, translateFn); }
|
|
const hash = crypto.createHash('sha1');
|
|
if (to === def) {
|
|
// 기본 언어 페이지는 글자를 바꾸지 않고 lang·dir·언어 선택 링크·현지 형식(+ 한국어가 아니면 위젯 번역표)만 맞춘다
|
|
for (const f of files) { const out = buildPage(raw[f], def, def, memo, list, f, ctx); hash.update(out); if (out !== raw[f]) fs.writeFileSync(path.join(baseDir, f), out, { mode: 0o644 }); }
|
|
} else {
|
|
const dir = path.join(siteDir, to); fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
for (const f of files) { const fp = path.join(dir, f); fs.mkdirSync(path.dirname(fp), { recursive: true, mode: 0o755 }); const out = buildPage(sources[f], def, to, memo, list, f, ctx); hash.update(out); fs.writeFileSync(fp, out, { mode: 0o644 }); }
|
|
// 기본 언어에서 지워진 페이지는 다른 언어에서도 지운다
|
|
for (const f of htmlUnder(dir)) if (!files.includes(f)) { try { fs.unlinkSync(path.join(dir, f)); } catch (_) {} }
|
|
result.languages[to] = { pages: files.length, memo: Object.keys(memo).length };
|
|
}
|
|
// 검수 상태: 이 언어 페이지 내용이 바뀌면 "검수 대기" 로 되돌린다 (품질 게이트의 조건)
|
|
const h = hash.digest('hex').slice(0, 16); const rv = site.langs.review[to] || {};
|
|
if (rv.hash !== h) site.langs.review[to] = { state: to === def ? 'approved' : 'pending', hash: h, at: new Date().toISOString(), by: to === def ? 'source' : null, note: to === def ? '기본 언어(원문)' : '' };
|
|
}
|
|
// 언어 선택 페이지 (사용자가 직접 만든 맨 위 index.html 은 건드리지 않음)
|
|
const rootFp = path.join(siteDir, 'index.html'); let cur = null; try { cur = fs.readFileSync(rootFp, 'utf8'); } catch (_) {}
|
|
if (cur === null || cur.includes(ROOT_MARK) || site.langs.forceRoot) { fs.writeFileSync(rootFp, rootPage(site, list, def, xd), { mode: 0o644 }); delete site.langs.forceRoot; } else log('사이트 맨 위 index.html 이 직접 만든 파일이라 언어 선택 페이지로 바꾸지 않았습니다');
|
|
return result;
|
|
}
|
|
|
|
// ---------- 번역 품질 게이트 (v1.3.16) ----------
|
|
/** 합격 기준 — 관리 탭 다국어 카드와 검증 리포트에 함께 보인다 */
|
|
const QUALITY = { maxMissing: 0.01, maxResidue: 0.02 };
|
|
/** 사이트 안 링크 (외부·메일·전화·앵커·스크립트 제외), 언어 폴더 이름은 {L} 로 바꿔 언어끼리 비교 */
|
|
function internalLinks(html, lang) {
|
|
const out = []; const re = /\shref=["']([^"'#]+)["']/gi; let m;
|
|
while ((m = re.exec(html))) { const h = m[1].trim(); if (/^(?:[a-z][\w+.-]*:|\/\/)/i.test(h)) continue; out.push(h.replace(new RegExp(`(^|/)${lang.replace('-', '\\-')}/`), '$1{L}/')); }
|
|
return out.sort();
|
|
}
|
|
/**
|
|
* 번역 품질 검사 (언어마다):
|
|
* - missing : 번역 누락률 = 번역 메모에 없는 원문 문장 / 전체 원문 문장 (+ 시스템 문구 누락 개수 runtimeMissing)
|
|
* - residue : 원문 잔존율 = 번역 페이지에 원문 그대로 남은 문장 / 번역 페이지 문장 (AI 가 일부러 그대로 둔 고유명사는 제외)
|
|
* - links : 링크 대응 = 페이지마다 사이트 안 링크가 기본 언어 페이지와 같은 짝(언어 폴더만 다름)인지, 페이지가 빠짐없이 있는지
|
|
* - review : 언어별 검수 상태(pending|approved|rejected) — 내용이 바뀌면 pending 으로 돌아간다
|
|
* 모두 통과해야 그 언어가 합격(pass). 기본 언어는 원문이라 검수만 확인한다.
|
|
*/
|
|
function quality(site, siteDir) {
|
|
if (!isMulti(site)) return null;
|
|
const { default: def, list } = site.langs; const files = htmlUnder(path.join(siteDir, def)); const rv = site.langs.review || {};
|
|
const base = Object.fromEntries(files.map((f) => { let h = ''; try { h = fs.readFileSync(path.join(siteDir, def, f), 'utf8').replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, ''); } catch (_) {} return [f, h]; }));
|
|
const baseSegs = new Set(files.flatMap((f) => segments(base[f].replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2'))));
|
|
const languages = {};
|
|
for (const to of list) {
|
|
const r = { review: (rv[to] && rv[to].state) || 'pending', reviewNote: (rv[to] && rv[to].note) || '', reviewBy: (rv[to] && rv[to].by) || null, reviewAt: (rv[to] && rv[to].at) || null };
|
|
if (to === def) { r.source = true; r.pass = true; languages[to] = r; continue; }
|
|
const memo = loadMemo(site.id, to); const total = baseSegs.size; const miss = [...baseSegs].filter((t) => !memo[t]);
|
|
r.missing = { count: miss.length, total, rate: total ? miss.length / total : 0, samples: miss.slice(0, 5) };
|
|
r.runtimeMissing = sitemsg.missing(to).length;
|
|
let segTotal = 0; const resid = []; const linkIssues = []; const missingPages = [];
|
|
for (const f of files) {
|
|
let h; try { h = fs.readFileSync(path.join(siteDir, to, f), 'utf8'); } catch (_) { missingPages.push(f); continue; }
|
|
const segs = segments(h.replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, '').replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2')); segTotal += segs.length;
|
|
for (const t of segs) if (baseSegs.has(t) && memo[t] !== t && t.replace(/[^\p{L}]/gu, '').length >= 2) resid.push(`${f}: ${t.slice(0, 60)}`);
|
|
const noNav = (x) => x.replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2'); // 언어 선택 링크는 언어마다 다른 게 정상
|
|
const a = internalLinks(noNav(base[f]), def); const b = internalLinks(noNav(h), to);
|
|
if (a.join('\n') !== b.join('\n')) linkIssues.push(`${f}: ${a.filter((x) => !b.includes(x)).concat(b.filter((x) => !a.includes(x))).slice(0, 3).join(', ') || '링크 수가 다름'}`);
|
|
}
|
|
r.residue = { count: resid.length, total: segTotal, rate: segTotal ? resid.length / segTotal : 0, samples: resid.slice(0, 5) };
|
|
r.links = { ok: !linkIssues.length && !missingPages.length, issues: linkIssues.slice(0, 5), missingPages };
|
|
r.checks = { missing: r.missing.rate <= QUALITY.maxMissing && r.runtimeMissing === 0, residue: r.residue.rate <= QUALITY.maxResidue, links: r.links.ok, review: r.review === 'approved' };
|
|
r.pass = Object.values(r.checks).every(Boolean);
|
|
languages[to] = r;
|
|
}
|
|
return { thresholds: QUALITY, languages, pass: Object.values(languages).every((x) => x.pass), at: new Date().toISOString() };
|
|
}
|
|
/** 언어별 검수 결정 (approved | rejected | pending) */
|
|
function setReview(site, lang, state, by, note) {
|
|
if (!isMulti(site) || !site.langs.list.includes(lang)) throw httpError('사이트의 언어가 아닙니다');
|
|
if (!['approved', 'rejected', 'pending'].includes(state)) throw httpError('검수 상태는 approved·rejected·pending 중 하나입니다');
|
|
site.langs.review = site.langs.review || {}; const cur = site.langs.review[lang] || {};
|
|
site.langs.review[lang] = { ...cur, state, by: by || null, at: new Date().toISOString(), note: String(note || '').slice(0, 300) };
|
|
return site.langs.review[lang];
|
|
}
|
|
|
|
/**
|
|
* 단일 언어 → 다국어 전환 뒤 정리: AI 는 파일을 "복사" 만 할 수 있어(삭제 도구 없음) 맨 위에 옛 파일이 남는다.
|
|
* - 기본 언어 폴더에 같은 경로가 있는 맨 위 HTML(맨 위 index.html 제외 — 언어 선택 페이지) 은 지운다.
|
|
* - assets/ 에 옮긴 사본이 있고 기본 언어 페이지가 더는 참조하지 않는 맨 위 공용 파일은 지운다.
|
|
* @returns {string[]} 지운 파일
|
|
*/
|
|
function cleanupAfterConvert(site, siteDir) {
|
|
const def = site.langs.default; const skip = new Set([...site.langs.list, 'assets', 'uploads', 'webmail']); const removed = [];
|
|
const baseHtml = htmlUnder(path.join(siteDir, def)).map((f) => { try { return fs.readFileSync(path.join(siteDir, def, f), 'utf8'); } catch (_) { return ''; } }).join('\n');
|
|
const walk = (dir, rel) => { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.')) continue; const r = rel ? `${rel}/${e.name}` : e.name; if (!rel && skip.has(e.name)) continue; const fp = path.join(dir, e.name);
|
|
if (e.isDirectory()) { walk(fp, r); try { if (!fs.readdirSync(fp).length) fs.rmdirSync(fp); } catch (_) {} continue; }
|
|
if (r === 'index.html') continue;
|
|
if (/\.html?$/i.test(e.name)) { if (fs.existsSync(path.join(siteDir, def, r))) { fs.unlinkSync(fp); removed.push(r); } continue; }
|
|
// assets/ 에 옮긴 사본이 있고(AI 가 주석을 덧붙여 내용은 다를 수 있음) 기본 언어 페이지가 옛 경로(../css/x.css)를 더는 쓰지 않으면 지운다
|
|
const used = new RegExp(`["'(\\s]\\.\\./${r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(baseHtml);
|
|
if (fs.existsSync(path.join(siteDir, 'assets', r)) && !used) { fs.unlinkSync(fp); removed.push(r); }
|
|
} };
|
|
walk(siteDir, ''); return removed;
|
|
}
|
|
/** 파일 경로 → 언어 (다국어 사이트에서 'en/about.html' → 'en') */
|
|
function langOf(site, file) { if (!isMulti(site)) return null; const c = String(file).split('/')[0]; return site.langs.list.includes(c) ? c : null; }
|
|
|
|
module.exports = { repairSwitchers, codeOf, xDefaultOf, siteLocale, renderFormats, formatTel, formatAddress, formatName, quality, setReview, QUALITY, cleanupAfterConvert, LOCALES, normalize, isMulti, promptRules, mapText, segments, buildPage, rootPage, sync, langOf, translatePrompt, removeSite, htmlUnder, ROOT_MARK };
|