처리 흐름
MD 파일들 → 파싱 & 데이터 추출 → HTML/JSON 파일 생성
생성되는 파일들
public/html/{slug}.html
- 렌더링용 HTML 파일public/{slug}.json
- 목차 데이터public/site-search.json
- 전체 검색 데이터
1. 메인 처리 함수
import fs from "fs";
import path from "path";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeFormat from "rehype-format";
import rehypeSlug from "rehype-slug";
import rehypeStringify from "rehype-stringify";
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
// md 파일 경로
const CONTENT_DIR = path.join(process.cwd(), "contents");
async function generateStaticFilesFromMarkdown() {
// 1️⃣ MD 파일 수집 및 파싱
const parsedMarkdowns = getAllMarkdownFiles(CONTENT_DIR);
const searchDataList: SearchData[] = [];
for (const parsedMarkdown of parsedMarkdowns) {
const { route, content, frontmatter } = parsedMarkdown;
// 2️⃣ AST로 파싱하여 검색/목차 데이터 추출
const ast = remark().use(remarkGfm).parse(content);
const { searchData, headings } = extractSearchData(ast, route);
// 3️⃣ md 내용을 사용하기 쉬운 형태의 html 로 변환
const htmlResult = await remark()
.use(remarkRehype) // 마크다운을 HTML AST 로 변환
.use(rehypeSlug) // 헤딩에 id 생성
.use(rehypeAutolinkHeadings, { behavior: "wrap" }) // 헤딩을 <a>로 감싸 앵커 생성
.use(rehypeFormat) // HTML 포맷팅
.use(rehypeStringify) // HTML 문자열로 변환
.process(content);
// 4️⃣ html 파일 및 목차 데이터 JSON 파일 각각 저장
ensureWriteFileSync(path.join(process.cwd(), `public/html/${frontmatter.slug}.html`), String(htmlResult));
ensureWriteFileSync(path.join(process.cwd(), `public/${frontmatter.slug}.json`), JSON.stringify(headings));
// 검색 데이터 수집
for (const data of searchData) {
searchDataList.push(data);
}
}
// 5️⃣ 최종 검색 데이터 JSON 파일로 저장
ensureWriteFileSync(path.join(process.cwd(), "public/site-search.json"), JSON.stringify(searchDataList));
}
2. 파일 탐색 및 파싱
해당 파일 경로에 접근하여 및 파일들을 재귀적으로 탐색하여 파일 수집 및 파싱
※ Frontmatter
Frontmatter
는 Markdown 문서의 맨 위에 위치하는 메타데이터 블록으로, 문서에 대한 정보를 구조화된 형태로 담는 데 사용됩니다. 일반적으로 YAML
형식으로 작성되며, ---
로 감싸져 있습니다.
---
title: "문서 제목"
slug: "custom-url"
date: "2024-01-01"
---
# 실제 마크다운 내용
파일 수집 함수
import fs from "fs";
import path from "path";
import matter from "gray-matter"; // md YAML frontmatter 파싱 라이브러리
// md 파일 경로
const CONTENT_DIR = path.join(process.cwd(), "contents");
// md 파일에서 추출될 최종 데이터 형식
interface ParsedMarkdown {
route: string;
content: string;
frontmatter: FrontmatterType;
filePath: string;
}
// 지정된 경로 하위에 있는 md 파일 탐색하여 데이터 정제후 반환
// dir: 폴더 경로
function getAllMarkdownFiles(dir: string): ParsedMarkdown[] {
const entries = fs.readdirSync(dir, { withFileTypes: true }); // 디렉토리 내부 항목들 (파일/폴더 포함)
const results: ParsedMarkdown[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name); // 현재 항목의 전체 경로
// 데이터를 저장시 route 경로 저장하가 위해 루트 기준 상대 경로 및 확장자 제거 작업
const relativePath = path.relative(CONTENT_DIR, fullPath); // 루트 기준 상대 경로
const withoutExt = relativePath.replace(/\.md$/, ""); // 확장자 제거한 경로
if (entry.isDirectory()) {
// 하위 폴더일 경우 재귀적으로 탐색
results.push(...getAllMarkdownFiles(fullPath)); // 하위 경로에 대해 재귀 호출 및 결과 저장
} else if (entry.isFile() && entry.name.endsWith(".md")) {
// .md 파일인 경우만 처리
const raw = fs.readFileSync(fullPath, "utf-8"); // 파일 내용 읽기
const { content, data: frontmatter } = matter(raw); // frontmatter 및 본문 분리
// 기존 파일명 기준이 아닌 md 파일에 지정된 slug 를 기준으로 route 를 구성(md 파일명과 실제 사용될 데이터의 서브라우트를 구분하기 위함)
// OS에 따라 경로 구분자 일관되게 처리 → URL 경로화
const route = frontmatter.slug ? "/" + frontmatter.slug : "/" + withoutExt.replace(/\\/g, "/");
results.push({
route, // 실제 사용될 데이터 경로
content, // 마크다운 본문
frontmatter: frontmatter as FrontmatterType, // YAML frontmatter
filePath: fullPath, // 실제 파일 경로 (디버깅용)
});
}
}
return results; // 누적된 결과 반환
}
3. 데이터 추출 및 전처리
AST 순회하며 검색 데이터 및 목차 데이터 추출
// 마크다운 AST 를 순회하며 사이트내 검색에 사용될 데이터를 추출하는 함수
// ※ AST(Abstract Syntax Tree) - 추상 구문 트리 : 문서를 분석해서 구조적으로 표현한 트리 형태의 데이터
function extractSearchData(ast: Root, route: string): { searchData: SearchData[]; headings: HeadingType[] } {
const searchData: SearchData[] = []; // 검색 데이터 배열
let headingHierarchy: string[] = []; // 헤더 중첩 구조를 저장하기 위한 배열
const headings: HeadingType[] = []; // 목차 데이터 배열
const walk = (node: RootContent) => {
switch (node.type) {
case "heading": { // heading node 의 경우 제목의 형식에 맞는 depth 를 가지고 있음 # -> depth: 1, ## -> depth: 2
const text = extractTextFromNode(node);
if (text.trim()) {
headingHierarchy = updateHeadingHierarchy(headingHierarchy, node.depth, text);
headings.push({
path: `${route}#${text.toLowerCase().replace(/\s+/g, "-")}`,
depth: node.depth,
text,
});
searchData.push({ route, type: node.type, headingHierarchy, text });
}
break;
}
case "tableCell":
case "paragraph":
case "code": {
const text = extractTextFromNode(node);
if (text.trim()) {
searchData.push({ route, type: node.type, headingHierarchy, text });
}
break;
}
// 기타 블록 노드 추가 처리 가능
}
// 자식 노드가 있으면 순회
if ("children" in node && Array.isArray(node.children)) {
node.children.forEach(walk);
}
};
ast.children.forEach(walk);
return { headings, searchData };
}
텍스트 추출 함수
/**
* Markdown을 파싱하면 텍스트가 여러 노드에 나눠져 있을 수 있으므로 각 노드의 value나 하위 노드의 텍스트를 재귀적으로 모아 순수 텍스트만 반환합니다.
* 예시: "**Hello** _world_"
* AST 구조:
* Paragraph
* ├── Strong → Text("Hello")
* └── Emphasis → Text("world")
*
* 결과: "Hello world"
*/
function extractTextFromNode(node: RootContent): string {
if (!node) return "";
if ("value" in node) return node.value;
// 하위 노드들이 존재하면 재귀적으로 추출
if ("children" in node && Array.isArray(node.children))
return node.children.map(extractTextFromNode).join("");
return "";
}
헤딩 계층 구조 관리
/**
* 헤딩 depth에 따른 계층 구조 관리
*
* 예시:
* # Chapter 1 → ["Chapter 1"]
* ## Section 1.1 → ["Chapter 1", "Section 1.1"]
* ### Subsection → ["Chapter 1", "Section 1.1", "Subsection"]
* ## Section 1.2 → ["Chapter 1", "Section 1.2"] // depth 3 제거됨
*/
function updateHeadingHierarchy(hierarchy: string[], depth: number, text: string): string[] {
const newHierarchy = [...hierarchy];
newHierarchy[depth - 1] = text;
return newHierarchy.slice(0, depth);
};
4. 데이터 저장
import fs from "fs";
import path from "path";
// md 파일 경로
const CONTENT_DIR = path.join(process.cwd(), "contents");
// 파일 저장 함수
// 해당 디렉토리 없는 경우에 writeFileSync 시 오류 발생하므로 mkdirSync 디렉토리 생성이 필요할 수 있음
function ensureWriteFileSync(filePath: string, content: string): void {
const dirPath = path.dirname(filePath); // 파일의 디렉토리 추출
// 디렉토리가 없으면 생성
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(filePath, content, "utf-8"); // 파일 저장
}