[nextjs]방명록 + markdown파일 / Guestbook + Markdown file

👉🏻 이전 포스트의 기존 방명록에서 데이터를 md(markdown)파일에 저장합니다.
Data from the existing guestbook in the previous post is saved to an MD (Markdown) file.

👉🏻 여기서는 앱 라우트 방식을 사용합니다.
Here, the App Router approach is used.

📁 프로젝트 설치 / Project Installation

npx create-next-app@latest

📁 코드 수정 / Code modification

✔️ route.js

— GET함수 / GET function

export async function GET() {
  const postsDirectory = path.join(process.cwd(), 'app', 'posts');

  if (!fs.existsSync(postsDirectory)) {
    return NextResponse.json([]);
  }

  try {
    const fileNames = fs.readdirSync(postsDirectory);
    const mdFiles = fileNames.filter(fileName => fileName.endsWith('.md'));

    // 각 파일의 상세 정보(수정 시간 등)를 포함한 객체 배열 만들기
    // Create an array of objects containing detailed information (modification time, etc.) for each file
    
    const allPostsData = mdFiles.map((fileName) => {
      const slug = fileName.replace(/\.md$/, '');
      const fullPath = path.join(postsDirectory, fileName);
      
      // 파일의 상태 정보(생성/수정 시간 등)를 가져옵니다.
      // Retrieves file status information (creation/modification time, etc.).
      const stat = fs.statSync(fullPath);
      const fileContents = fs.readFileSync(fullPath, 'utf8');

      return {
        slug,
        content: fileContents,

        // 정렬 기준으로 삼기 위해 파일 수정 시간을 숫자로 변환하여 보관합니다.
        // Convert the file modification time to a number and store it to use as a sorting criterion.
        dateValue: stat.mtime.getTime(), 
      };
    });

    // 최신 수정/생성 시간 기준으로 내림차순(최신순) 정렬하기
    // Sort in descending order (latest first) based on the latest modification/creation time
    allPostsData.sort((a, b) => b.dateValue - a.dateValue);

    // 프론트엔드로 데이터를 넘겨줄 때는 임시 변수인 dateValue를 제외하고 전달 가능합니다.
    // When passing data to the frontend, you can exclude the temporary variable dateValue.
    const sortedPosts = allPostsData.map(({ slug, content }) => ({ slug, content }));

    return NextResponse.json(sortedPosts);

  } catch (error) {
    console.error("파일 읽기 및 정렬 오류 / File reading and sorting error:", error);
    return NextResponse.json({ message: "데이터를 읽어오지 못했습니다/Failed to read data." }, { status: 500 });
  }
}

1)여기는 md파일에서 글을 불러오는 부분입니다.
This is the section where content is loaded from an MD file.

2)app/posts디렉토리에서 fs.readdirSync로 md파일의 내용을 호출합니다.
Retrieve the contents of the .md files using fs.readdirSync in the app/posts directory.

3)파일의 내용을 정렬해서 json데이터로 리턴합니다.
Sorts the file contents and returns them as JSON data.

— POST함수 / POST function

export async function POST(request) {
  try {
    const body = await request.json();
    // 블로그에 맞게 name 대신 title(제목), content(본문)를 받습니다.
    // Instead of name, we receive title and content to fit the blog context.
    const { title, content } = body;

    if (!title || !content) {
      return NextResponse.json(
        { message: '제목과 내용을 모두 입력해주세요.  / Please enter both title and content.' }, 
        { status: 400 }
      );
    }

    // slug만들기,공백을 하이픈으로 대체하고 특수문자 제거
    // Create a slug by replacing spaces with hyphens and removing special characters 
    const slug = title
      .trim()
      .replace(/\s+/g, '-')
      .replace(/[^a-zA-Z0-9가-힣\-]/g, '');

    // 저장할 파일 경로 지정 (app/posts/파일명.md)
    // Specify the file path to save (app/posts/filename.md)
    const postsDirectory = path.join(process.cwd(), 'app', 'posts');
    
    const fullPath = path.join(postsDirectory, `${slug}.md`);

    // 안전장치: 혹시 똑같은 제목의 파일이 이미 존재하면 충돌 방지
    // Safety measure: Prevent collision if a file with the same title already exists
    if (fs.existsSync(fullPath)) {
      return NextResponse.json(
        { message: '이미 동일한 제목의 글이 존재합니다. / A post with the same title already exists.' }, 
        { status: 409 }
      );
    }

    // 마크다운 파일에 들어갈 포맷(Front-matter 포함) 구성하기
    // Construct the format (including front-matter) to be included in the markdown file
    const fileContent = `---
title: "${title}"
date: "${new Date().toISOString().split('T')[0]}"
---

${content}`;

    // app/posts 폴더 안에 파일 생성 및 쓰기!
    // Create and write the file inside the app/posts folder!
    fs.writeFileSync(fullPath, fileContent, 'utf8');

    // 성공 시 브라우저(프론트엔드)로 던져줄 결과값
    // he result to be sent to the browser (frontend) upon success
    return NextResponse.json({ slug, content: fileContent }, { status: 201 });

  } catch (error) {
    console.error("마크다운 파일 생성 오류/Markdown file creation error:", error);
    return NextResponse.json(
      { message: '서버에서 파일을 생성하지 못했습니다. / Failed to create markdown file.' }, 
      { status: 500 }
    );
  }
}

1)글을 md파일에 저장하는 부분입니다.
This is the part where the text is saved to an MD file.

2)포스트마다 md파일을 만들고 글 내용을 md파일에 저장합니다.
For each post, create an MD file and save the content of the post in it.

✔️ page.tsx

— name을 없애고 title로 바꿨기 때문에 관련된 코드는 모두 title로 바꿉니다.
Since name has been removed and replaced with title, update all related code to use title.

— slug는 타입이 없으면 에디터에서 오류로 나타나기 때문에 타입을 지정해줍니다.
Since an error occurs in the editor if the slug lacks a type, we specify a type for it.

interface GuestbookItem {
  id: number;
  title: string;
  slug: string;
  content: string;
}

... 중략 / omitted ...

  const [posts, setPosts] = useState<GuestbookItem[]>([]);
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [isLoading, setIsLoading] = useState(true);

... 중략 / omitted ...

    if (!title.trim() || !content.trim()) {
      alert("제목과 내용을 모두 입력해 주세요./Please enter both title and content.");
      return;
    }

... 중략 / omitted ...

      if (response.ok) {
        setTitle("");
        setContent("");
        fetchPosts();
      } else {
        alert("등록에 실패했습니다./Failed to register the entry.");
      }

— 타이틀이나 내용이 화면에 출력가능하도록 아래처럼 코드를 바꿉니다.
Modify the code as shown below so that the title or content can be displayed on the screen.

      <h2 className="text-2xl font-bold text-gray-800">최근 방명록 목록 / Recent Guestbook Entries</h2>
      {isLoading ? (
        <p>로딩 중/Loading...</p>
      ) : posts.length === 0 ? (
        <p>작성된 방명록이 없습니다. 첫 글을 남겨보세요! / There are no guestbook entries yet. Be the first to leave a message!</p>
      ) : (
        <ul style={{ listStyle: "none", padding: 0 }}>
          {posts.map((post) => (
            <li 
              key={post.slug} 
              style={{ 
                border: "1px solid #f0f0f0", 
                borderRadius: "8px", 
                padding: "20px",
                backgroundColor: "#fff",
                boxShadow: "0 2px 4px rgba(0,0,0,0.02)"
              }}
            >
              {/* 파일 이름에서 대문자화 및 하이픈 제거 / Convert file name to a more readable title format (capitalize and remove hyphens) */}
              <strong className="text-xl text-gray-900 block capitalize mb-2">
                {post.slug.replace(/-/g, " ")}
              </strong>
              
              {/* 마크다운 원본 텍스트 내용 일부 출력 / Display a portion of the original markdown text */}
              <div 
                style={{ 
                  margin: "10px 0 0 0", 
                  color: "#555", 
                  whiteSpace: "pre-wrap",
                  fontSize: "14px",
                  lineHeight: "1.6",
                  backgroundColor: "#fafafa",
                  padding: "12px",
                  borderRadius: "6px"
                }}
              >
                {post.content}
              </div>
            </li>
          ))}
        </ul>
      )}

📁 Nextjs 서버 실행 / Run Nextjs Server

npm run dev

📁 브라우저 접속 http://localhost:3000
Access http://localhost:3000 in your browser.

Screenshot

Leave a Reply