mingle-website/build-blog.js
68893236+KINDNICK@users.noreply.github.com 5a0c6a8f70 Add: DevLog 페이지 + 블로그 빌드 시스템 + 팝업 제거 + 싸인 이미지 추가
- DevLog(블로그) 인프라: build-blog.js (MD→HTML), devlog.css, devlog.js
- DevLog 목록/포스트 페이지 4개 언어 (ko/en/ja/zh)
- 글 2편 작성 + 번역: 관성식vs광학식, 광학식 파이프라인
- 전체 네비게이션에 DevLog 탭 추가 (37+ HTML)
- 메인 팝업(요금제 변경 안내) 제거 (ko/en/ja/zh)
- i18n.js: 언어별 페이지에서 번역 JSON 항상 로드하도록 수정
- 방문자 싸인 이미지 3장 추가 (webp 변환)
- sitemap, i18n JSON, package.json 업데이트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 03:10:04 +09:00

397 lines
18 KiB
JavaScript

#!/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 `<figure class="blog-figure"><img src="${src}" alt="${alt}"${title} loading="lazy"><figcaption>${alt}</figcaption></figure>`;
}
}
});
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, '<strong>$1</strong>');
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 `<!DOCTYPE html><html lang="${cfg.htmlLang}"><head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-PPTNN6WD');</script>
<!-- End Google Tag Manager -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-R0PBYHVQBS"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-R0PBYHVQBS');
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(pageTitle)}</title>
<link rel="icon" type="image/x-icon" href="/images/logo/mingle-logo.ico">
<link rel="shortcut icon" href="/images/logo/mingle-logo.ico">
<link rel="icon" type="image/webp" href="/images/logo/mingle-logo.webp">
<link rel="apple-touch-icon" href="/images/logo/mingle-logo.webp">
<link rel="canonical" href="${pageUrl}">
<meta name="theme-color" content="#ff8800">
<meta name="description" content="${escapeHtml(description)}">
<meta name="author" content="${cfg.studioName}">
<meta property="og:title" content="${escapeHtml(title)}">
<meta property="og:description" content="${escapeHtml(description)}">
<meta property="og:url" content="${pageUrl}">
<meta property="og:type" content="article">
<meta property="og:image" content="${thumbUrl}">
<meta property="og:locale" content="${cfg.locale}">
<meta property="og:site_name" content="${cfg.studioName}">
<meta property="article:published_time" content="${date}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${escapeHtml(title)}">
<meta name="twitter:description" content="${escapeHtml(description)}">
<meta name="twitter:image" content="${thumbUrl}">
<link href="https://hangeul.pstatic.net/hangeul_static/css/nanum-square.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/common.css?v=20260404">
<link rel="stylesheet" href="/css/devlog.css?v=20260404">
<link rel="alternate" hreflang="ko" href="${SITE_URL}/devlog/${slug}">
<link rel="alternate" hreflang="en" href="${SITE_URL}/en/devlog/${slug}">
<link rel="alternate" hreflang="ja" href="${SITE_URL}/ja/devlog/${slug}">
<link rel="alternate" hreflang="zh" href="${SITE_URL}/zh/devlog/${slug}">
<link rel="alternate" hreflang="x-default" href="${SITE_URL}/devlog/${slug}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": ${JSON.stringify(title)},
"description": ${JSON.stringify(description)},
"datePublished": "${date}",
"author": { "@type": "Organization", "name": "${cfg.studioName}" },
"publisher": { "@type": "Organization", "name": "${cfg.studioName}" },
"url": "${pageUrl}"
}
</script>${(() => {
const faq = extractFAQ(mdContent || '');
if (faq.length === 0) return '';
return `
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": ${JSON.stringify(faq.map(f => ({
"@type": "Question",
"name": f.question,
"acceptedAnswer": { "@type": "Answer", "text": f.answer }
})), null, 8)}
}
</script>`;
})()}
</head>
<body>
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PPTNN6WD"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<a href="#main-content" class="skip-to-content">Skip to content</a>
<div id="header-placeholder">
<nav class="navbar" aria-label="Navigation">
<div class="nav-container">
<div class="nav-logo">
<a href="${cfg.prefix || '/'}">
<img src="/images/logo/mingle-logo.webp" alt="밍글 스튜디오">
<span data-i18n="header.studioName">밍글 스튜디오</span>
</a>
</div>
<ul id="nav-menu" class="nav-menu">
<li><a href="${cfg.prefix}/about" class="nav-link" data-i18n="header.nav.about">About</a></li>
<li><a href="${cfg.prefix}/services" class="nav-link" data-i18n="header.nav.services">Services</a></li>
<li><a href="${cfg.prefix}/portfolio" class="nav-link" data-i18n="header.nav.portfolio">Portfolio</a></li>
<li><a href="${cfg.prefix}/gallery" class="nav-link" data-i18n="header.nav.gallery">Gallery</a></li>
<li><a href="${cfg.prefix}/schedule" class="nav-link" data-i18n="header.nav.schedule">Schedule</a></li>
<li><a href="${cfg.prefix}/devlog" class="nav-link active" data-i18n="header.nav.devlog">DevLog</a></li>
<li><a href="${cfg.prefix}/contact" class="nav-link" data-i18n="header.nav.contact">Contact</a></li>
<li><a href="${cfg.prefix}/qna" class="nav-link" data-i18n="header.nav.qna">Q&amp;A</a></li>
</ul>
<div class="nav-actions">
<div class="lang-switcher">
<button class="lang-btn" aria-label="Language">
<span class="lang-current">${lang.toUpperCase()}</span>
<svg class="lang-chevron" viewBox="0 0 10 6" width="10" height="6" aria-hidden="true">
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<ul class="lang-dropdown">
<li><button data-lang="ko">\u{1F1F0}\u{1F1F7} 한국어</button></li>
<li><button data-lang="en">\u{1F1FA}\u{1F1F8} English</button></li>
<li><button data-lang="zh">\u{1F1E8}\u{1F1F3} 中文</button></li>
<li><button data-lang="ja">\u{1F1EF}\u{1F1F5} 日本語</button></li>
</ul>
</div>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode">
<div class="theme-toggle-thumb">
<svg class="theme-toggle-icon theme-toggle-icon--sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="theme-toggle-icon theme-toggle-icon--moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</div>
</button>
<button class="hamburger" id="hamburger" aria-label="Menu" aria-expanded="false">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
</div>
</div>
</nav>
</div>
<main id="main-content">
<article class="blog-post">
<div class="blog-post-header">
<div class="container">
<a href="${cfg.prefix}/devlog" class="blog-back-link">${BACK_TO_LIST[lang]}</a>
${category ? `<span class="blog-category">${escapeHtml(category)}</span>` : ''}
<h1 class="blog-post-title">${escapeHtml(title)}</h1>
<div class="blog-post-meta">
<time datetime="${date}">${dateFormatted}</time>
</div>
</div>
</div>
<div class="blog-post-body">
<div class="container">
${htmlContent}
</div>
</div>
<div class="blog-post-footer">
<div class="container">
<a href="${cfg.prefix}/devlog" class="blog-back-btn"><i class="fas fa-arrow-left"></i> ${BACK_TO_LIST[lang]}</a>
</div>
</div>
</article>
</main>
<div id="footer-placeholder"></div>
<script src="/js/i18n.js"></script>
<script src="/js/common.js"></script>
</body>
</html>`;
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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();