본문 바로가기
카테고리 없음

[React + markdown] MD 문서 데이터 추출 및 파일 저장

by spare8433 2025. 5. 29.

처리 흐름

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");  // 파일 저장
}