#!/usr/bin/env node
/**
* 밍글 스튜디오 블로그 빌드 스크립트
* 마크다운 파일을 HTML로 변환하여 블로그 페이지 생성
*
* 사용법: node build-blog.js
*
* 디렉토리 구조:
* blog/posts/{slug}/
* ko.md, en.md, ja.md, zh.md ← 언어별 마크다운
* images/ ← 글별 이미지
*
* 출력:
* devlog/{slug}.html ← 한국어 글
* en/devlog/{slug}.html ← 영어 글
* ja/devlog/{slug}.html ← 일본어 글
* zh/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 LANGS = ['ko', 'en', 'ja', 'zh'];
const SITE_URL = 'https://minglestudio.co.kr';
// 언어별 설정
const LANG_CONFIG = {
ko: { outDir: 'devlog', htmlLang: 'ko', locale: 'ko_KR', blogTitle: 'DevLog', studioName: '밍글 스튜디오', prefix: '' },
en: { outDir: 'en/devlog', htmlLang: 'en', locale: 'en_US', blogTitle: 'DevLog', studioName: 'Mingle Studio', prefix: '/en' },
ja: { outDir: 'ja/devlog', htmlLang: 'ja', locale: 'ja_JP', blogTitle: 'DevLog', studioName: 'ミングルスタジオ', prefix: '/ja' },
zh: { outDir: 'zh/devlog', htmlLang: 'zh', locale: 'zh_CN', blogTitle: 'DevLog', studioName: '明格工作室', prefix: '/zh' }
};
const NAV_LABELS = {
ko: { about: 'About', services: 'Services', portfolio: 'Portfolio', gallery: 'Gallery', schedule: 'Schedule', devlog: 'DevLog', contact: 'Contact', qna: 'Q&A' },
en: { about: 'About', services: 'Services', portfolio: 'Portfolio', gallery: 'Gallery', schedule: 'Schedule', devlog: 'DevLog', contact: 'Contact', qna: 'Q&A' },
ja: { about: '紹介', services: 'サービス', portfolio: 'ポートフォリオ', gallery: 'ギャラリー', schedule: 'スケジュール', devlog: 'DevLog', contact: 'お問い合わせ', qna: 'Q&A' },
zh: { about: '关于', services: '服务', portfolio: '作品集', gallery: '画廊', schedule: '日程', devlog: 'DevLog', contact: '联系', qna: 'Q&A' }
};
const BACK_TO_LIST = { ko: '← 목록으로', en: '← Back to list', ja: '← 一覧に戻る', zh: '← 返回列表' };
const SHARE_TEXT = { ko: '공유하기', en: 'Share', ja: 'シェアする', zh: '分享' };
const READ_MORE = { ko: '자세히 보기', en: 'Read more', ja: '続きを読む', zh: '阅读更多' };
const PUBLISHED = { ko: '발행일', en: 'Published', ja: '公開日', zh: '发布日期' };
// 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 koFile = path.join(postDir, 'ko.md');
if (!fs.existsSync(koFile)) {
console.warn(`[SKIP] ${slug}/ko.md 없음`);
continue;
}
const koMatter = matter(fs.readFileSync(koFile, 'utf-8'));
const meta = koMatter.data;
// 이미지 디렉토리 확인 및 복사
const imgSrcDir = path.join(postDir, 'images');
if (fs.existsSync(imgSrcDir)) {
for (const lang of LANGS) {
const cfg = LANG_CONFIG[lang];
const imgDestDir = path.join(__dirname, cfg.outDir, slug, 'images');
copyDirSync(imgSrcDir, imgDestDir);
}
}
// 각 언어별 HTML 생성
for (const lang of LANGS) {
const mdFile = path.join(postDir, `${lang}.md`);
if (!fs.existsSync(mdFile)) continue;
const { data, content } = matter(fs.readFileSync(mdFile, 'utf-8'));
const title = data.title || meta.title || slug;
const description = data.description || meta.description || '';
const date = data.date || meta.date || '';
const thumbnail = data.thumbnail || meta.thumbnail || '';
const category = data.category || meta.category || '';
// marked가 한글 문맥에서 **bold** 변환을 누락하는 경우 후처리
const htmlContent = marked(content).replace(/\*\*(.+?)\*\*/g, '$1');
const cfg = LANG_CONFIG[lang];
// 이미지 경로: 블로그 글 HTML에서 images/... 로 상대 참조
const postHtml = buildPostPage({ lang, cfg, slug, title, description, date, thumbnail, category, htmlContent, mdContent: content });
const outDir = path.join(__dirname, cfg.outDir);
fs.mkdirSync(path.join(outDir, slug), { recursive: true });
fs.writeFileSync(path.join(outDir, `${slug}.html`), postHtml, 'utf-8');
console.log(`[OK] ${cfg.outDir}/${slug}.html`);
}
// 메타데이터 수집 (목록용)
allPosts.push({
slug,
date: meta.date || '',
category: meta.category || '',
thumbnail: meta.thumbnail || '',
titles: {},
descriptions: {},
categories: {}
});
for (const lang of LANGS) {
const mdFile = path.join(postDir, `${lang}.md`);
if (!fs.existsSync(mdFile)) continue;
const { data } = matter(fs.readFileSync(mdFile, 'utf-8'));
allPosts[allPosts.length - 1].titles[lang] = data.title || '';
allPosts[allPosts.length - 1].descriptions[lang] = data.description || '';
allPosts[allPosts.length - 1].categories[lang] = data.category || '';
}
}
// 날짜 내림차순 정렬
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] blog/index.json (${allPosts.length}건)`);
}
// 마크다운에서 FAQ 추출: **Q. 질문** + 다음 줄(들)이 답변
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({ lang, cfg, slug, title, description, date, thumbnail, category, htmlContent, mdContent }) {
const pageTitle = `${title} - ${cfg.studioName} ${cfg.blogTitle}`;
const pageUrl = `${SITE_URL}${cfg.prefix}/devlog/${slug}`;
const thumbUrl = thumbnail ? `${SITE_URL}/blog/posts/${slug}/${thumbnail}` : `${SITE_URL}/images/logo/mingle-OG.png`;
const dateFormatted = formatDate(date, lang);
const nav = NAV_LABELS[lang];
return `
${escapeHtml(pageTitle)}
${(() => {
const faq = extractFAQ(mdContent || '');
if (faq.length === 0) return '';
return `
`;
})()}
Skip to content
`;
}
function formatDate(dateStr, lang) {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
if (isNaN(d)) return dateStr;
const y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate();
if (lang === 'ko') return `${y}년 ${m}월 ${day}일`;
if (lang === 'ja') return `${y}年${m}月${day}日`;
if (lang === 'zh') return `${y}年${m}月${day}日`;
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return `${months[m - 1]} ${day}, ${y}`;
}
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();