- 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>
397 lines
18 KiB
JavaScript
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&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,'&').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();
|