mingle-website/build-blog.js
68893236+KINDNICK@users.noreply.github.com 65ad313797 Remove: 다국어(en/ja/zh) 전체 제거, 한국어 전용으로 전환
- en/, ja/, zh/ 디렉토리 전체 삭제
- i18n/ 번역 JSON + js/i18n.js 삭제
- 전체 HTML에서 언어 스위처, hreflang 태그 제거
- common.css lang-switcher CSS 135줄 제거
- schedule.js 다국어 로직 제거 (한국어 직접 사용)
- build-blog.js, devlog.js 한국어 전용으로 단순화
- sitemap.xml 한국어 URL만 유지
- build_i18n.js + package.json build:i18n 스크립트 제거

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

307 lines
12 KiB
JavaScript

#!/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 `<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 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 `<!DOCTYPE html><html lang="ko"><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="밍글 스튜디오">
<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="ko_KR">
<meta property="og:site_name" content="밍글 스튜디오">
<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=20260406">
<link rel="stylesheet" href="/css/devlog.css?v=20260406">
<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": "밍글 스튜디오" },
"publisher": { "@type": "Organization", "name": "밍글 스튜디오" },
"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">본문 바로가기</a>
<div id="header-placeholder">
<nav class="navbar" aria-label="메인 내비게이션">
<div class="nav-container">
<div class="nav-logo">
<a href="/">
<img src="/images/logo/mingle-logo.webp" alt="밍글 스튜디오 로고">
<span>밍글 스튜디오</span>
</a>
</div>
<ul id="nav-menu" class="nav-menu">
<li><a href="/about" class="nav-link">About</a></li>
<li><a href="/services" class="nav-link">Services</a></li>
<li><a href="/portfolio" class="nav-link">Portfolio</a></li>
<li><a href="/gallery" class="nav-link">Gallery</a></li>
<li><a href="/schedule" class="nav-link">Schedule</a></li>
<li><a href="/devlog" class="nav-link active">DevLog</a></li>
<li><a href="/contact" class="nav-link">Contact</a></li>
<li><a href="/qna" class="nav-link">Q&amp;A</a></li>
</ul>
<div class="nav-actions">
<button class="theme-toggle" id="themeToggle" aria-label="다크 모드 전환" title="다크/라이트 모드 전환">
<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" aria-label="메뉴 열기" aria-expanded="false" aria-controls="nav-menu">
<span></span>
<span></span>
<span></span>
</button>
</div>
</div>
</nav>
</div>
<main id="main-content">
<article class="blog-post">
<div class="blog-post-header">
<div class="container">
<a href="/devlog" class="blog-back-link">← 목록으로</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="/devlog" class="blog-back-btn"><i class="fas fa-arrow-left"></i> ← 목록으로</a>
</div>
</div>
</article>
</main>
<div id="footer-placeholder"></div>
<script src="/js/common.js"></script>
</body>
</html>`;
}
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();