#!/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}
${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
${BACK_TO_LIST[lang]} ${category ? `${escapeHtml(category)}` : ''}

${escapeHtml(title)}

${htmlContent}
`; } 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();