Skip to content

Commit 6a0675f

Browse files
committed
feat: 블로그 포스트 자동화 시스템 구현
- 사이드바 자동 생성 플러그인 추가 - 포스트 데이터 자동 생성 스크립트 구현 - HMR(Hot Module Replacement) 지원 - Frontmatter 기반 메타데이터 처리 - SRP(Single Responsibility Principle) 준수한 모듈 분리
1 parent 94e6abb commit 6a0675f

4 files changed

Lines changed: 422 additions & 0 deletions

File tree

.vitepress/plugins/posts.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { exec } from "child_process";
2+
import { promisify } from "util";
3+
import { triggerFullReload } from "./utils";
4+
5+
const execAsync = promisify(exec);
6+
7+
/**
8+
* 포스트 생성 스크립트를 실행합니다
9+
*/
10+
async function executePostGeneration() {
11+
await execAsync("node scripts/generate-posts.mjs");
12+
}
13+
14+
/**
15+
* 파일이 pages 디렉토리의 마크다운 파일인지 확인합니다
16+
*/
17+
function isPagesMarkdownFile(filePath: string): boolean {
18+
return filePath.includes("/pages/") && filePath.endsWith(".md");
19+
}
20+
21+
/**
22+
* 빌드 시작 시 포스트를 생성하는 핸들러
23+
*/
24+
async function handleBuildStart() {
25+
try {
26+
console.log("🔄 포스트 목록을 업데이트하는 중...");
27+
await executePostGeneration();
28+
console.log("✅ 포스트 목록 업데이트 완료!");
29+
} catch (error) {
30+
console.warn(
31+
"포스트 목록 생성 실패:",
32+
error instanceof Error ? error.message : String(error),
33+
);
34+
}
35+
}
36+
37+
/**
38+
* 핫 업데이트 시 포스트를 재생성하는 핸들러
39+
*/
40+
async function handleHotUpdate(ctx: any) {
41+
if (isPagesMarkdownFile(ctx.file)) {
42+
try {
43+
console.log("📝 포스트 변경 감지, 목록 업데이트 중...");
44+
await executePostGeneration();
45+
console.log("✅ 포스트 목록 업데이트 완료!");
46+
console.log("📋 사이드바 업데이트를 위해 개발 서버를 재시작해주세요!");
47+
console.log(" 또는 Ctrl+C 후 yarn docs:dev 를 다시 실행하세요.");
48+
49+
triggerFullReload(ctx.server);
50+
} catch (error) {
51+
console.warn(
52+
"포스트 목록 생성 실패:",
53+
error instanceof Error ? error.message : String(error),
54+
);
55+
}
56+
}
57+
return [];
58+
}
59+
60+
/**
61+
* 자동 포스트 생성 Vite 플러그인을 생성합니다
62+
*/
63+
export function createAutoGeneratePostsPlugin() {
64+
return {
65+
name: "auto-generate-posts-and-sidebar",
66+
buildStart: handleBuildStart,
67+
handleHotUpdate: handleHotUpdate,
68+
};
69+
}

.vitepress/plugins/sidebar.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { readdirSync, statSync, readFileSync, Dirent } from "fs";
2+
import { join, extname, basename } from "path";
3+
import type { DefaultTheme } from "vitepress";
4+
import matter from "gray-matter";
5+
6+
interface SidebarItem {
7+
text: string;
8+
link?: string;
9+
items?: SidebarItem[];
10+
collapsed?: boolean;
11+
}
12+
13+
/**
14+
* pages/posts 폴더 구조를 기반으로 자동으로 사이드바를 생성하는 함수
15+
*/
16+
export function generateSidebar(): DefaultTheme.SidebarItem[] {
17+
const postsDir = join(process.cwd(), "pages", "posts");
18+
19+
try {
20+
// posts 폴더가 존재하지 않으면 빈 사이드바 반환
21+
if (!statSync(postsDir).isDirectory()) {
22+
return [];
23+
}
24+
} catch (error) {
25+
// posts 폴더가 없으면 빈 사이드바 반환
26+
return [];
27+
}
28+
29+
const sidebarItems = generateSidebarItems(postsDir, "/pages/posts");
30+
return sidebarItems;
31+
}
32+
33+
function generateSidebarItems(dir: string, basePath: string): SidebarItem[] {
34+
const items: SidebarItem[] = [];
35+
36+
try {
37+
const entries = readdirSync(dir, { withFileTypes: true });
38+
39+
// 파일과 폴더를 분리하여 정렬
40+
const files = entries.filter(
41+
(entry: Dirent) => entry.isFile() && entry.name.endsWith(".md"),
42+
);
43+
const folders = entries.filter((entry: Dirent) => entry.isDirectory());
44+
45+
// 폴더 처리 (카테고리로 취급)
46+
for (const folder of folders.sort((a: Dirent, b: Dirent) => a.name.localeCompare(b.name))) {
47+
const folderPath = join(dir, folder.name);
48+
const folderBasePath = `${basePath}/${folder.name}`;
49+
50+
const folderItems = generateSidebarItems(folderPath, folderBasePath);
51+
52+
if (folderItems.length > 0) {
53+
items.push({
54+
text: formatTitle(folder.name),
55+
items: folderItems,
56+
collapsed: true, // 기본적으로 접혀있도록 설정
57+
});
58+
}
59+
}
60+
61+
// 파일 처리
62+
for (const file of files.sort((a: Dirent, b: Dirent) => a.name.localeCompare(b.name))) {
63+
const fileName = basename(file.name, extname(file.name));
64+
65+
// index.md 파일은 해당 폴더의 대표 페이지로 처리
66+
if (fileName === "index") {
67+
continue;
68+
}
69+
70+
const filePath = join(dir, file.name);
71+
const frontmatterTitle = getFrontmatterTitle(filePath);
72+
73+
items.push({
74+
// frontmatter title 우선, 없으면 파일명 사용
75+
text: frontmatterTitle || formatTitle(fileName),
76+
link: `${basePath}/${fileName}`,
77+
});
78+
}
79+
80+
// index.md 파일이 있으면 첫 번째 항목으로 추가
81+
const indexFile = files.find(
82+
(file: Dirent) => basename(file.name, extname(file.name)) === "index",
83+
);
84+
85+
if (indexFile) {
86+
items.unshift({
87+
text: "개요",
88+
link: basePath === "/pages" ? "/pages/" : basePath,
89+
});
90+
}
91+
} catch (error) {
92+
console.warn(`사이드바 생성 중 오류 발생: ${dir}`, error);
93+
}
94+
95+
return items;
96+
}
97+
98+
/**
99+
* 마크다운 파일에서 frontmatter의 title을 추출합니다
100+
* @param filePath - 마크다운 파일 경로
101+
* @returns frontmatter의 title 또는 null
102+
*/
103+
function getFrontmatterTitle(filePath: string): string | null {
104+
try {
105+
const content = readFileSync(filePath, "utf-8");
106+
const { data } = matter(content);
107+
return data.title || null;
108+
} catch (error) {
109+
console.warn(`Frontmatter 읽기 실패 (${filePath}):`, error);
110+
return null;
111+
}
112+
}
113+
114+
/**
115+
* 파일명/폴더명을 사용자 친화적인 제목으로 변환
116+
*/
117+
function formatTitle(name: string): string {
118+
return name
119+
.replace(/[-_]/g, " ") // 하이픈과 언더스코어를 공백으로 변환
120+
.replace(/\b\w/g, (l) => l.toUpperCase()) // 각 단어의 첫 글자를 대문자로
121+
.trim();
122+
}
123+
124+
/**
125+
* 특정 경로에 대한 사이드바만 생성 (다중 사이드바 사용 시)
126+
*/
127+
export function generateSidebarForPath(path: string): DefaultTheme.SidebarItem[] {
128+
const pagesDir = join(process.cwd(), "pages");
129+
const targetDir = join(pagesDir, path);
130+
131+
try {
132+
if (!statSync(targetDir).isDirectory()) {
133+
return [];
134+
}
135+
} catch (error) {
136+
return [];
137+
}
138+
139+
return generateSidebarItems(targetDir, `/pages/${path}`);
140+
}

.vitepress/plugins/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* 서버에 전체 리로드 신호를 보냅니다
3+
*/
4+
export function triggerFullReload(server: any) {
5+
server.ws.send({ type: "full-reload" });
6+
}

0 commit comments

Comments
 (0)