#!/usr/bin/env node
/**
* 밍글 스튜디오 DevLog 빌드 스크립트
* 마크다운 파일을 HTML로 변환하여 DevLog 페이지 생성
*
* 사용법: node build-blog.js
*
* 디렉토리 구조:
* blog/posts/{slug}/
* ko.md ← 마크다운
* images/ ← 글별 이미지
*
* 출력:
* devlog/{slug}.html ← 글 페이지
* devlog/index.json ← 목록용 메타데이터
*/
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const { marked } = require('marked');
const POSTS_DIR = path.join(__dirname, 'blog', 'posts');
const SITE_URL = 'https://minglestudio.co.kr';
// marked 설정
marked.use({
renderer: {
image(token) {
const src = token.href;
const alt = token.text || '';
const title = token.title ? ` title="${token.title}"` : '';
return `
${alt}`;
}
}
});
function build() {
if (!fs.existsSync(POSTS_DIR)) {
console.log('blog/posts/ 디렉토리가 없습니다.');
return;
}
const slugs = fs.readdirSync(POSTS_DIR).filter(f =>
fs.statSync(path.join(POSTS_DIR, f)).isDirectory()
);
if (slugs.length === 0) {
console.log('빌드할 글이 없습니다.');
return;
}
const allPosts = [];
for (const slug of slugs) {
const postDir = path.join(POSTS_DIR, slug);
const mdFile = path.join(postDir, 'ko.md');
if (!fs.existsSync(mdFile)) {
console.warn(`[SKIP] ${slug}/ko.md 없음`);
continue;
}
const { data, content } = matter(fs.readFileSync(mdFile, 'utf-8'));
const title = data.title || slug;
const description = data.description || '';
const date = data.date || '';
const thumbnail = data.thumbnail || '';
const category = data.category || '';
// 이미지 복사
const imgSrcDir = path.join(postDir, 'images');
if (fs.existsSync(imgSrcDir)) {
copyDirSync(imgSrcDir, path.join(__dirname, 'devlog', slug, 'images'));
}
// HTML 생성
const htmlContent = marked(content);
const postHtml = buildPostPage({ slug, title, description, date, thumbnail, category, htmlContent, mdContent: content });
const outDir = path.join(__dirname, 'devlog');
fs.mkdirSync(path.join(outDir, slug), { recursive: true });
fs.writeFileSync(path.join(outDir, `${slug}.html`), postHtml, 'utf-8');
console.log(`[OK] devlog/${slug}.html`);
allPosts.push({ slug, date, category, thumbnail, title, description });
}
// 날짜 내림차순 정렬
allPosts.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
// index.json 생성
const indexPath = path.join(__dirname, 'devlog', 'index.json');
fs.writeFileSync(indexPath, JSON.stringify(allPosts, null, 2), 'utf-8');
console.log(`[OK] devlog/index.json (${allPosts.length}건)`);
}
// FAQ 구조화 데이터 추출
function extractFAQ(mdContent) {
const faq = [];
const lines = mdContent.split('\n');
let i = 0;
while (i < lines.length) {
const qMatch = lines[i].match(/^\*\*Q\.\s*(.+?)\*\*$/);
if (qMatch) {
const question = qMatch[1];
const answerLines = [];
i++;
while (i < lines.length && !lines[i].match(/^\*\*Q\./) && !lines[i].match(/^---/) && !lines[i].match(/^## /)) {
if (lines[i].trim()) answerLines.push(lines[i].trim());
i++;
}
if (answerLines.length > 0) {
faq.push({ question, answer: answerLines.join(' ') });
}
} else {
i++;
}
}
return faq;
}
function buildPostPage({ slug, title, description, date, thumbnail, category, htmlContent, mdContent }) {
const pageTitle = `${title} - 밍글 스튜디오 DevLog`;
const pageUrl = `${SITE_URL}/devlog/${slug}`;
const thumbUrl = thumbnail ? `${SITE_URL}/blog/posts/${slug}/${thumbnail}` : `${SITE_URL}/images/logo/mingle-OG.png`;
const y = date ? new Date(date + 'T00:00:00').getFullYear() : '';
const m = date ? new Date(date + 'T00:00:00').getMonth() + 1 : '';
const d = date ? new Date(date + 'T00:00:00').getDate() : '';
const dateFormatted = date ? `${y}년 ${m}월 ${d}일` : '';
return `
${escapeHtml(pageTitle)}
${(() => {
const faq = extractFAQ(mdContent || '');
if (faq.length === 0) return '';
return `
`;
})()}
본문 바로가기
`;
}
function escapeHtml(str) {
return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const srcPath = path.join(src, entry);
const destPath = path.join(dest, entry);
if (fs.statSync(srcPath).isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
build();