'use strict'; // ============================================================================ // 사이트 템플릿 (v1.3.10) // // 관리자가 빌더로 만든 사이트를 "템플릿" 으로 등록하고, 사용자에게 사용을 허용한 템플릿으로 새 사이트를 시작하게 한다. // - 등록 = 그 순간의 사이트 파일을 data/templates/<템플릿ID>/files/ 로 복사(스냅숏). 이후 원본 사이트를 고쳐도 템플릿은 // 그대로이며, "다시 담기" 를 눌러야 바뀐다. 사이트 고유 값(통계 스크립트, 검색·AI 최적화 관리 구역, 웹메일 진입 파일)은 뺀다. // - 디렉터리: 기본은 업종 구조(brief.js INDUSTRIES 의 대분류/세부 업종, 'ind:대분류' · 'ind:대분류/세부'), 필요하면 // 관리자가 별도 디렉터리를 만든다('dir:', 상위 디렉터리 지정 가능). // - 해시태그: 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; } // ---------- 스냅숏 복사 ---------- /** * 사이트 폴더 → 템플릿 폴더. 사이트 고유 값은 뺀다: * - 접속 통계 스크립트(