160 lines
14 KiB
JavaScript
160 lines
14 KiB
JavaScript
'use strict';
|
|||
|
|
// ============================================================================
|
||
|
|
// 사이트 템플릿 (v1.3.10)
|
||
|
|
//
|
||
|
|
// 관리자가 빌더로 만든 사이트를 "템플릿" 으로 등록하고, 사용자에게 사용을 허용한 템플릿으로 새 사이트를 시작하게 한다.
|
||
|
|
// - 등록 = 그 순간의 사이트 파일을 data/templates/<템플릿ID>/files/ 로 복사(스냅숏). 이후 원본 사이트를 고쳐도 템플릿은
|
||
|
|
// 그대로이며, "다시 담기" 를 눌러야 바뀐다. 사이트 고유 값(통계 스크립트, 검색·AI 최적화 관리 구역, 웹메일 진입 파일)은 뺀다.
|
||
|
|
// - 디렉터리: 기본은 업종 구조(brief.js INDUSTRIES 의 대분류/세부 업종, 'ind:대분류' · 'ind:대분류/세부'), 필요하면
|
||
|
|
// 관리자가 별도 디렉터리를 만든다('dir:<id>', 상위 디렉터리 지정 가능).
|
||
|
|
// - 해시태그: tags 배열(# 없이 소문자로 저장). 검색어의 #태그는 모두 포함한 템플릿만, 나머지 낱말은 제목·설명·태그에서 찾는다.
|
||
|
|
// - 사용자 사용 허용(enabled)은 관리자가 정한다. 사용자 목록·미리보기·사용은 enabled 인 것만.
|
||
|
|
// - 사용 = 스냅숏을 새 사이트 폴더로 복사하고, 파일 안의 원본 사이트 ID(모듈 위젯 data-site 등)를 새 사이트 ID 로 바꾼다.
|
||
|
|
//
|
||
|
|
// 저장: data/templates/index.json { dirs: [{id, name, parent, createdAt}], items: [{id, title, description, tags, dir, sourceSiteId,
|
||
|
|
// enabled, files, bytes, createdAt, updatedAt, snapshotAt, by}] }
|
||
|
|
// ============================================================================
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const crypto = require('crypto');
|
||
|
|
|
||
|
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||
|
|
const ROOT = path.join(DATA_ROOT, 'templates');
|
||
|
|
const INDEX = path.join(ROOT, 'index.json');
|
||
|
|
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
||
|
|
const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
|
||
|
|
const TEXT_EXT = /\.(html?|css|js|mjs|json|txt|xml|svg|md)$/i;
|
||
|
|
const SKIP = new Set(['.git', 'node_modules', 'webmail', 'uploads/.tmp']);
|
||
|
|
|
||
|
|
function load() { try { const d = JSON.parse(fs.readFileSync(INDEX, 'utf8')); d.dirs = d.dirs || []; d.items = d.items || []; return d; } catch (_) { return { dirs: [], items: [] }; } }
|
||
|
|
function save(d) { fs.mkdirSync(ROOT, { recursive: true }); const tmp = INDEX + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d, null, 1)); fs.renameSync(tmp, INDEX); }
|
||
|
|
const filesDir = (id) => path.join(ROOT, id, 'files');
|
||
|
|
|
||
|
|
/** 해시태그 정리: "#카페, 미니멀 #강남" → ['카페','미니멀','강남'] */
|
||
|
|
function normTags(v) { const arr = Array.isArray(v) ? v : String(v || '').split(/[,\s]+/); return [...new Set(arr.map((t) => String(t).replace(/^#+/, '').trim().toLowerCase()).filter((t) => t && t.length <= 30))].slice(0, 20); }
|
||
|
|
|
||
|
|
// ---------- 디렉터리 ----------
|
||
|
|
/**
|
||
|
|
* 디렉터리 트리: 업종(대분류 → 세부 업종) + 관리자가 만든 디렉터리. 각 노드에 템플릿 수(count, 하위 포함)를 붙인다.
|
||
|
|
* @param {Array} industries brief.INDUSTRIES ({major, minors})
|
||
|
|
* @param {boolean} enabledOnly 사용자 화면이면 허용된 템플릿만 센다
|
||
|
|
*/
|
||
|
|
function tree(industries, enabledOnly) {
|
||
|
|
const d = load(); const items = d.items.filter((t) => !enabledOnly || t.enabled);
|
||
|
|
const cnt = (pred) => items.filter(pred).length;
|
||
|
|
const ind = industries.map((i) => ({ id: `ind:${i.major}`, name: i.major, kind: 'industry', count: cnt((t) => t.dir === `ind:${i.major}` || t.dir.startsWith(`ind:${i.major}/`)), children: (i.minors || []).map((m) => ({ id: `ind:${i.major}/${m}`, name: m, kind: 'industry', count: cnt((t) => t.dir === `ind:${i.major}/${m}`) })) }));
|
||
|
|
const custom = (parent) => d.dirs.filter((x) => (x.parent || null) === parent).map((x) => { const kids = custom(x.id); const ids = new Set([x.id, ...flat(kids).map((k) => k.id.slice(4))]); return { id: `dir:${x.id}`, name: x.name, kind: 'custom', count: cnt((t) => t.dir.startsWith('dir:') && ids.has(t.dir.slice(4))), children: kids }; });
|
||
|
|
const flat = (nodes) => nodes.flatMap((n) => [n, ...flat(n.children || [])]);
|
||
|
|
return { industries: ind, custom: custom(null), total: items.length };
|
||
|
|
}
|
||
|
|
/** 디렉터리 ID 확인 (업종 경로가 실제 목록에 있는지, 별도 디렉터리가 있는지) */
|
||
|
|
function validDir(dir, industries) {
|
||
|
|
const v = String(dir || ''); if (!v) return 'dir:' ;
|
||
|
|
if (v.startsWith('ind:')) { const [maj, min] = v.slice(4).split('/'); const i = industries.find((x) => x.major === maj); if (i && (!min || (i.minors || []).includes(min))) return v; throw httpError('업종 디렉터리를 찾을 수 없습니다'); }
|
||
|
|
if (v.startsWith('dir:')) { if (load().dirs.some((x) => x.id === v.slice(4))) return v; throw httpError('디렉터리를 찾을 수 없습니다'); }
|
||
|
|
throw httpError('디렉터리를 고르세요');
|
||
|
|
}
|
||
|
|
function addDir(name, parent) {
|
||
|
|
const d = load(); const n = clip(name, 40); if (!n) throw httpError('디렉터리 이름을 입력하세요');
|
||
|
|
const p = parent ? String(parent).replace(/^dir:/, '') : null; if (p && !d.dirs.some((x) => x.id === p)) throw httpError('상위 디렉터리가 없습니다');
|
||
|
|
if (d.dirs.some((x) => x.name === n && (x.parent || null) === p)) throw httpError('같은 이름의 디렉터리가 이미 있습니다', 409);
|
||
|
|
const x = { id: crypto.randomBytes(5).toString('hex'), name: n, parent: p, createdAt: new Date().toISOString() }; d.dirs.push(x); save(d); return x;
|
||
|
|
}
|
||
|
|
function renameDir(id, name) { const d = load(); const x = d.dirs.find((y) => y.id === String(id).replace(/^dir:/, '')); if (!x) throw httpError('디렉터리가 없습니다', 404); const n = clip(name, 40); if (!n) throw httpError('디렉터리 이름을 입력하세요'); x.name = n; save(d); return x; }
|
||
|
|
/** 비어 있는(템플릿·하위 디렉터리가 없는) 디렉터리만 지운다 */
|
||
|
|
function removeDir(id) { const d = load(); const k = String(id).replace(/^dir:/, ''); if (!d.dirs.some((x) => x.id === k)) throw httpError('디렉터리가 없습니다', 404); if (d.dirs.some((x) => x.parent === k)) throw httpError('하위 디렉터리가 있어 지울 수 없습니다', 409); if (d.items.some((t) => t.dir === `dir:${k}`)) throw httpError('템플릿이 들어 있어 지울 수 없습니다. 템플릿을 다른 디렉터리로 옮기세요', 409); d.dirs = d.dirs.filter((x) => x.id !== k); save(d); return true; }
|
||
|
|
|
||
|
|
// ---------- 스냅숏 복사 ----------
|
||
|
|
/**
|
||
|
|
* 사이트 폴더 → 템플릿 폴더. 사이트 고유 값은 뺀다:
|
||
|
|
* - 접속 통계 스크립트(<script … data-wm-stats …>) — 새 사이트 배포 때 다시 넣는다
|
||
|
|
* - 검색·AI 최적화 관리 구역·FAQ 영역(wm-seo / wm-aeo-faq) — 새 사이트에서 따로 만든다
|
||
|
|
* - 자동 생성 파일(sitemap.xml·robots.txt·llms.txt·IndexNow 키), 웹메일 진입 폴더, 숨김 파일
|
||
|
|
*/
|
||
|
|
function copyTree(src, dst, transform) {
|
||
|
|
let files = 0, bytes = 0;
|
||
|
|
const walk = (s, d, rel) => {
|
||
|
|
let ents = []; try { ents = fs.readdirSync(s, { withFileTypes: true }); } catch (_) { return; }
|
||
|
|
fs.mkdirSync(d, { recursive: true, mode: 0o755 });
|
||
|
|
for (const e of ents) {
|
||
|
|
const r = rel ? `${rel}/${e.name}` : e.name; if (e.name.startsWith('.') || SKIP.has(r) || SKIP.has(e.name)) continue;
|
||
|
|
if (!rel && /^(sitemap\.xml|robots\.txt|llms\.txt|[a-f0-9]{32}\.txt)$/.test(e.name)) continue;
|
||
|
|
const sp = path.join(s, e.name); const dp = path.join(d, e.name);
|
||
|
|
if (e.isDirectory()) { walk(sp, dp, r); continue; } if (!e.isFile()) continue;
|
||
|
|
let buf = fs.readFileSync(sp); if (transform && TEXT_EXT.test(e.name)) buf = Buffer.from(transform(buf.toString('utf8'), r), 'utf8');
|
||
|
|
fs.writeFileSync(dp, buf, { mode: 0o644 }); files += 1; bytes += buf.length;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
walk(src, dst, ''); return { files, bytes };
|
||
|
|
}
|
||
|
|
const stripSiteSpecific = (text) => text
|
||
|
|
.replace(/<!-- wm-seo:start -->[\s\S]*?<!-- wm-seo:end -->\n?/g, '').replace(/<!-- wm-aeo-faq:start -->[\s\S]*?<!-- wm-aeo-faq:end -->\n?/g, '')
|
||
|
|
.replace(/<script[^>]*data-wm-stats[^>]*><\/script>\s*/g, '');
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 관리자 사이트 → 템플릿 등록 (또는 tplId 를 주면 그 템플릿의 스냅숏을 다시 담기)
|
||
|
|
* @param {object} site 관리자가 만든 사이트
|
||
|
|
* @param {string} siteDir
|
||
|
|
* @param {object} meta { title, description, tags, dir }
|
||
|
|
*/
|
||
|
|
function register(site, siteDir, meta, industries, by) {
|
||
|
|
const d = load(); const id = crypto.randomBytes(8).toString('hex');
|
||
|
|
const t = { id, title: clip(meta.title || site.title, 80), description: clip(meta.description, 400), tags: normTags(meta.tags), dir: validDir(meta.dir || ((site.plan && site.plan.brief && site.plan.brief.business && site.plan.brief.business.industry && site.plan.brief.business.industry.major) ? `ind:${site.plan.brief.business.industry.major}` : ''), industries), sourceSiteId: site.id, enabled: false, createdAt: new Date().toISOString(), by };
|
||
|
|
if (t.dir === 'dir:') throw httpError('디렉터리를 고르세요 (업종 또는 별도 디렉터리)');
|
||
|
|
if (!t.title) throw httpError('템플릿 이름을 입력하세요');
|
||
|
|
const r = copyTree(siteDir, filesDir(id), stripSiteSpecific); if (!r.files) { fs.rmSync(path.join(ROOT, id), { recursive: true, force: true }); throw httpError('사이트에 파일이 없습니다'); }
|
||
|
|
Object.assign(t, r, { snapshotAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); d.items.push(t); save(d); return t;
|
||
|
|
}
|
||
|
|
function resnapshot(tplId, siteDir) {
|
||
|
|
const d = load(); const t = d.items.find((x) => x.id === tplId); if (!t) throw httpError('템플릿이 없습니다', 404);
|
||
|
|
const tmp = path.join(ROOT, tplId, 'files.new'); fs.rmSync(tmp, { recursive: true, force: true }); const r = copyTree(siteDir, tmp, stripSiteSpecific);
|
||
|
|
if (!r.files) { fs.rmSync(tmp, { recursive: true, force: true }); throw httpError('원본 사이트에 파일이 없습니다'); }
|
||
|
|
fs.rmSync(filesDir(tplId), { recursive: true, force: true }); fs.renameSync(tmp, filesDir(tplId));
|
||
|
|
Object.assign(t, r, { snapshotAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); save(d); return t;
|
||
|
|
}
|
||
|
|
function update(tplId, patch, industries) {
|
||
|
|
const d = load(); const t = d.items.find((x) => x.id === tplId); if (!t) throw httpError('템플릿이 없습니다', 404);
|
||
|
|
if (patch.title !== undefined) { t.title = clip(patch.title, 80); if (!t.title) throw httpError('템플릿 이름을 입력하세요'); }
|
||
|
|
if (patch.description !== undefined) t.description = clip(patch.description, 400);
|
||
|
|
if (patch.tags !== undefined) t.tags = normTags(patch.tags);
|
||
|
|
if (patch.dir !== undefined) t.dir = validDir(patch.dir, industries);
|
||
|
|
if (patch.enabled !== undefined) t.enabled = patch.enabled === true || patch.enabled === 'true';
|
||
|
|
t.updatedAt = new Date().toISOString(); save(d); return t;
|
||
|
|
}
|
||
|
|
function remove(tplId) { const d = load(); const i = d.items.findIndex((x) => x.id === tplId); if (i < 0) throw httpError('템플릿이 없습니다', 404); d.items.splice(i, 1); save(d); fs.rmSync(path.join(ROOT, tplId), { recursive: true, force: true }); return true; }
|
||
|
|
function get(tplId) { return load().items.find((x) => x.id === tplId) || null; }
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 검색. q 의 #태그는 모두 포함해야 하고, 나머지 낱말은 제목·설명·태그 중 어디에든 있으면 된다.
|
||
|
|
* dir 을 주면 그 디렉터리와 하위만 (업종 대분류를 고르면 그 아래 세부 업종 템플릿도 포함).
|
||
|
|
*/
|
||
|
|
function search({ q = '', dir = '', enabledOnly = false } = {}) {
|
||
|
|
const d = load(); const words = String(q).toLowerCase().split(/\s+/).filter(Boolean);
|
||
|
|
const tags = words.filter((w) => w.startsWith('#')).map((w) => w.slice(1)).filter(Boolean); const terms = words.filter((w) => !w.startsWith('#'));
|
||
|
|
const sub = (id) => { const kids = d.dirs.filter((x) => x.parent === id).map((x) => x.id); return [id, ...kids.flatMap(sub)]; };
|
||
|
|
const dirSet = dir.startsWith('dir:') ? new Set(sub(dir.slice(4)).map((x) => `dir:${x}`)) : null;
|
||
|
|
return d.items.filter((t) => (!enabledOnly || t.enabled)
|
||
|
|
&& (!dir || (dirSet ? dirSet.has(t.dir) : (t.dir === dir || t.dir.startsWith(dir + '/'))))
|
||
|
|
&& tags.every((tag) => t.tags.some((x) => x === tag || x.startsWith(tag)))
|
||
|
|
&& terms.every((w) => `${t.title} ${t.description} ${t.tags.join(' ')}`.toLowerCase().includes(w)))
|
||
|
|
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
||
|
|
}
|
||
|
|
/** 자주 쓰인 해시태그 (검색 도우미) */
|
||
|
|
function popularTags(enabledOnly) { const m = {}; for (const t of load().items) if (!enabledOnly || t.enabled) for (const g of t.tags) m[g] = (m[g] || 0) + 1; return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([tag, n]) => ({ tag, n })); }
|
||
|
|
|
||
|
|
/** 템플릿 미리보기 파일 경로 (폴더 밖으로 나가지 않게) */
|
||
|
|
function previewPath(tplId, rel) {
|
||
|
|
const base = filesDir(tplId); const p = path.resolve(base, decodeURIComponent(rel || 'index.html').replace(/^\/+/, '') || 'index.html');
|
||
|
|
if (!p.startsWith(path.resolve(base) + path.sep) && p !== path.resolve(base)) throw httpError('잘못된 경로', 400);
|
||
|
|
return fs.existsSync(p) && fs.statSync(p).isDirectory() ? path.join(p, 'index.html') : p;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 템플릿으로 새 사이트 폴더 채우기: 스냅숏 복사 + 원본 사이트 ID → 새 사이트 ID (모듈 위젯·API 주소 등)
|
||
|
|
*/
|
||
|
|
function instantiate(tplId, siteDir, newSiteId) {
|
||
|
|
const t = get(tplId); if (!t) throw httpError('템플릿이 없습니다', 404);
|
||
|
|
return copyTree(filesDir(tplId), siteDir, (text) => text.split(t.sourceSiteId).join(newSiteId));
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { tree, validDir, addDir, renameDir, removeDir, register, resnapshot, update, remove, get, search, popularTags, previewPath, instantiate, normTags, filesDir };
|