one.ilmsg.in.th

Next.js ร่วมกับ gray-matter

Okay, มาสร้างบทเรียน Next.js ร่วมกับ gray-matter เพื่อสร้างหน้าแสดงรายการบทความ (List Page) และหน้ารายละเอียดบทความ (Detail Page) จากไฟล์ Markdown กันครับ บทเรียนนี้จะเน้นความละเอียดทีละขั้นตอน

เป้าหมาย: สร้างเว็บไซต์ง่ายๆ ที่:

  1. หน้าแรก (/): แสดงรายการบทความทั้งหมด โดยดึงข้อมูลชื่อเรื่อง, วันที่, และ slug (ส่วนหนึ่งของ URL) มาจาก Frontmatter ของไฟล์ Markdown
  2. หน้ารายละเอียดบทความ (/posts/[slug]): แสดงเนื้อหาเต็มของบทความ Markdown ที่เลือก

เทคโนโลยีที่เราจะใช้:

  • Next.js: Framework สำหรับสร้าง React Application ที่รองรับ Server-Side Rendering (SSR) และ Static Site Generation (SSG) ซึ่งเหมาะมากกับการทำเว็บ Blog/Content
  • Markdown: รูปแบบการเขียนข้อความที่แปลงเป็น HTML ได้ง่าย นิยมใช้เขียนบทความ, เอกสาร
  • gray-matter: Library สำหรับแยกส่วน Frontmatter (ข้อมูล Metadata ที่เขียนในรูปแบบ YAML หรือ JSON ด้านบนสุดของไฟล์ Markdown) ออกจากเนื้อหาหลัก (Content)
  • remark & remark-html: Library สำหรับแปลงเนื้อหา Markdown เป็น HTML (จำเป็นต้องใช้เพื่อแสดงผลบนหน้าเว็บ)

ขั้นตอนที่ 1: ตั้งค่าโปรเจกต์ Next.js

  1. สร้างโปรเจกต์ Next.js ใหม่: เปิด Terminal หรือ Command Prompt แล้วรันคำสั่ง:

    npx create-next-app@latest my-markdown-blog
    # หรือ yarn create next-app my-markdown-blog
    cd my-markdown-blog
    

    (เลือกใช้ TypeScript หรือ JavaScript ตามความถนัด ในตัวอย่างนี้จะใช้ JavaScript เป็นหลัก แต่แนวคิดเหมือนกัน)

  2. ติดตั้ง Dependencies ที่จำเป็น:

    npm install gray-matter remark remark-html
    # หรือ yarn add gray-matter remark remark-html
    

ขั้นตอนที่ 2: เตรียมไฟล์ Markdown สำหรับบทความ

  1. สร้างโฟลเดอร์สำหรับเก็บไฟล์ Markdown: ใน root directory ของโปรเจกต์ (my-markdown-blog) ให้สร้างโฟลเดอร์ชื่อ posts (หรือชื่ออื่นตามต้องการ)

    my-markdown-blog/
    ├── posts/
    ├── pages/
    ├── public/
    ├── styles/
    └── ... (ไฟล์อื่นๆ)
    
  2. สร้างไฟล์ Markdown ตัวอย่าง: สร้างไฟล์ .md 2-3 ไฟล์ในโฟลเดอร์ posts เช่น:

    • posts/first-post.md:

      ---
      title: 'โพสต์แรกของฉัน'
      date: '2023-10-27'
      author: 'Your Name'
      ---
      
      นี่คือเนื้อหาของ **โพสต์แรก** ของฉัน ยินดีต้อนรับ!
      
      *   รายการที่ 1
      *   รายการที่ 2
      
      ลองใช้ Markdown ดูสิ
      
    • posts/second-post.md:

      ---
      title: 'เรียนรู้ Next.js และ Markdown'
      date: '2023-10-28'
      author: 'Another Author'
      ---
      
      ## หัวข้อรอง
      
      เรากำลังเรียนรู้วิธีใช้ `gray-matter` เพื่อแยก frontmatter และใช้ `remark` เพื่อแปลง Markdown เป็น HTML
      
      ```javascript
      console.log('Hello, Markdown!');
      
    • posts/yet-another-post.md:

      ---
      title: 'โพสต์ที่สามเกี่ยวกับอะไรดี'
      date: '2023-10-26'
      author: 'Your Name'
      ---
      
      เนื้อหาโพสต์ที่สาม... ลองใส่รูปภาพดูไหม (สมมติว่ามีรูปใน `public/images`)
      
      ![Alt text](/images/sample.jpg)
      

    สำคัญ:

    • Frontmatter: ส่วนที่อยู่ระหว่าง --- ด้านบนสุด คือ Metadata ของบทความ เราจะใช้ gray-matter ดึงข้อมูลส่วนนี้ (เช่น title, date)
    • ชื่อไฟล์: ชื่อไฟล์ (เช่น first-post.md) จะถูกใช้เป็น slug สำหรับ URL ของหน้ารายละเอียด (เช่น /posts/first-post)

ขั้นตอนที่ 3: สร้าง Library Function สำหรับอ่านข้อมูล Markdown

เพื่อไม่ให้โค้ดซ้ำซ้อน เราจะสร้างฟังก์ชันกลางสำหรับอ่านและประมวลผลไฟล์ Markdown

  1. สร้างโฟลเดอร์ lib: ใน root directory ของโปรเจกต์

  2. สร้างไฟล์ lib/posts.js:

    // lib/posts.js
    import fs from 'fs';
    import path from 'path';
    import matter from 'gray-matter';
    import { remark } from 'remark';
    import html from 'remark-html';
    
    // หา path ไปยังโฟลเดอร์ posts
    const postsDirectory = path.join(process.cwd(), 'posts');
    
    export function getSortedPostsData() {
      // อ่านชื่อไฟล์ทั้งหมดใน /posts
      const fileNames = fs.readdirSync(postsDirectory);
    
      const allPostsData = fileNames.map((fileName) => {
        // ลบ ".md" ออกจากชื่อไฟล์เพื่อใช้เป็น id (slug)
        const id = fileName.replace(/\.md$/, '');
    
        // อ่านไฟล์ markdown เป็น string
        const fullPath = path.join(postsDirectory, fileName);
        const fileContents = fs.readFileSync(fullPath, 'utf8');
    
        // ใช้ gray-matter เพื่อแยก frontmatter และ content
        const matterResult = matter(fileContents);
    
        // รวม data จาก frontmatter กับ id
        return {
          id,
          ...(matterResult.data as { title: string; date: string }), // Cast type ถ้าใช้ TS
        };
      });
    
      // เรียงลำดับโพสต์ตามวันที่ (ล่าสุดก่อน)
      return allPostsData.sort((a, b) => {
        if (a.date < b.date) {
          return 1;
        } else {
          return -1;
        }
      });
    }
    
    export function getAllPostIds() {
      // ฟังก์ชันนี้จะคืนค่า array ของ id (slug) ทั้งหมด
      // สำหรับใช้ใน getStaticPaths
      const fileNames = fs.readdirSync(postsDirectory);
    
      // คืนค่า array หน้าตาแบบนี้:
      // [
      //   { params: { id: 'first-post' } },
      //   { params: { id: 'second-post' } }
      // ]
      return fileNames.map((fileName) => {
        return {
          params: {
            id: fileName.replace(/\.md$/, ''),
          },
        };
      });
    }
    
    export async function getPostData(id: string) {
      // ฟังก์ชันนี้จะคืนข้อมูลทั้งหมดของโพสต์ที่ระบุ id (slug)
      // รวมถึงเนื้อหาที่แปลงเป็น HTML แล้ว
      const fullPath = path.join(postsDirectory, `${id}.md`);
      const fileContents = fs.readFileSync(fullPath, 'utf8');
    
      // ใช้ gray-matter แยก frontmatter
      const matterResult = matter(fileContents);
    
      // ใช้ remark แปลง markdown content เป็น HTML
      const processedContent = await remark()
        .use(html)
        .process(matterResult.content);
      const contentHtml = processedContent.toString();
    
      // รวม data, id, และ contentHtml
      return {
        id,
        contentHtml,
        ...(matterResult.data as { title: string; date: string }), // Cast type ถ้าใช้ TS
      };
    }
    

    อธิบายโค้ดใน lib/posts.js:

    • fs, path: Module ของ Node.js สำหรับทำงานกับ File System และ Path
    • matter: ฟังก์ชันจาก gray-matter สำหรับ parse ไฟล์ Markdown
    • remark, remark-html: ใช้สำหรับแปลง Markdown เป็น HTML
    • postsDirectory: เก็บ path เต็มไปยังโฟลเดอร์ posts
    • getSortedPostsData():
      • อ่านไฟล์ทั้งหมดใน postsDirectory
      • วนลูปแต่ละไฟล์:
        • สร้าง id (slug) จากชื่อไฟล์
        • อ่านเนื้อหาไฟล์
        • ใช้ matter() แยก data (frontmatter) และ content
        • คืนค่า object ที่มี id และข้อมูลจาก data
      • เรียงลำดับโพสต์ตาม date
    • getAllPostIds():
      • อ่านชื่อไฟล์ทั้งหมด
      • สร้าง array ของ object ที่มี key params ซึ่งข้างในมี id (slug) ของแต่ละโพสต์ รูปแบบนี้จำเป็นสำหรับ getStaticPaths ใน Next.js
    • getPostData(id):
      • รับ id (slug) เข้ามา
      • หา path ของไฟล์ Markdown ที่ตรงกับ id
      • อ่านเนื้อหาไฟล์
      • ใช้ matter() แยก data และ content
      • ใช้ remark().use(html).process() แปลง matterResult.content (เนื้อหา Markdown) เป็น contentHtml (สตริง HTML) (ขั้นตอนนี้สำคัญมากสำหรับการแสดงผล)
      • คืนค่า object ที่มี id, contentHtml, และข้อมูลจาก data

ขั้นตอนที่ 4: สร้างหน้าแสดงรายการบทความ (List Page)

แก้ไขไฟล์ pages/index.js (หรือ pages/index.tsx ถ้าใช้ TypeScript)

// pages/index.js
import Head from 'next/head';
import Link from 'next/link';
import { getSortedPostsData } from '../lib/posts'; // Import ฟังก์ชันที่เราสร้าง
import styles from '../styles/Home.module.css'; // หรือใช้ CSS Modules/Tailwind

// ฟังก์ชันนี้จะรันตอน build time บน server
export async function getStaticProps() {
  const allPostsData = getSortedPostsData(); // เรียกใช้ฟังก์ชันดึงข้อมูลโพสต์
  return {
    props: {
      allPostsData, // ส่งข้อมูลโพสต์ไปเป็น props ให้ component
    },
  };
}

// Component หลักของหน้า Home
export default function Home({ allPostsData }) { // รับ props ที่ส่งมาจาก getStaticProps
  return (
    <div className={styles.container}>
      <Head>
        <title>My Markdown Blog</title>
        <meta name="description" content="Blog สร้างด้วย Next.js และ Markdown" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>
          ยินดีต้อนรับสู่ Blog ของฉัน
        </h1>

        <section className={`${styles.grid} ${styles.blogList}`}> {/* เพิ่ม class จัดสไตล์ */}
          <h2>บทความล่าสุด</h2>
          <ul>
            {allPostsData.map(({ id, date, title }) => (
              <li key={id} className={styles.listItem}>
                <Link href={`/posts/${id}`}>
                  <a>{title}</a>
                </Link>
                <br />
                <small className={styles.lightText}>
                  {/* อาจจะต้อง format date เพิ่มเติม */}
                  {date}
                </small>
              </li>
            ))}
          </ul>
        </section>
      </main>
    </div>
  );
}

อธิบาย pages/index.js:

  1. getStaticProps:
    • นี่คือฟังก์ชันพิเศษของ Next.js ที่ใช้สำหรับ Static Site Generation (SSG)
    • มันจะถูกเรียก ตอน Build Time (ไม่ใช่ตอน User ร้องขอหน้าเว็บ) บนฝั่ง Server
    • เราเรียก getSortedPostsData() จาก lib/posts.js เพื่อดึงข้อมูลโพสต์ทั้งหมดที่เรียงลำดับแล้ว
    • return { props: { allPostsData } }: ข้อมูลที่ return ใน props จะถูกส่งไปให้ Home component
  2. Home Component:
    • รับ allPostsData มาจาก props
    • ใช้ .map() เพื่อวนลูปสร้างรายการ (<li>) ของแต่ละโพสต์
    • ใช้ <Link href={/posts/${id}}> ของ Next.js เพื่อสร้างลิงก์ไปยังหน้ารายละเอียดของแต่ละโพสต์ URL จะเป็น /posts/first-post, /posts/second-post เป็นต้น
    • แสดง title และ date ของแต่ละโพสต์

ขั้นตอนที่ 5: สร้างหน้ารายละเอียดบทความ (Detail Page) - Dynamic Routes

  1. สร้างโฟลเดอร์ posts ภายใน pages: โฟลเดอร์นี้จะเก็บ Dynamic Route ของเรา

    my-markdown-blog/
    ├── lib/
    ├── posts/
    ├── pages/
    │   ├── api/
    │   ├── _app.js
    │   ├── index.js
    │   └── posts/      <-- สร้างโฟลเดอร์นี้
    │       └── [id].js  <-- สร้างไฟล์นี้ (หรือ [slug].js)
    ├── public/
    ├── styles/
    └── ...
    
  2. สร้างไฟล์ pages/posts/[id].js: (หรือ [slug].js ถ้าต้องการใช้ชื่อ slug) ชื่อไฟล์ใน [] บ่งบอกว่านี่คือ Dynamic Route Segment

    // pages/posts/[id].js
    import Head from 'next/head';
    import Link from 'next/link';
    import { getAllPostIds, getPostData } from '../../lib/posts'; // Import functions
    import styles from '../../styles/Post.module.css'; // สร้างไฟล์ CSS นี้ด้วย
    
    // ฟังก์ชันนี้จะรันตอน build time เพื่อบอก Next.js ว่ามี path อะไรบ้างที่จะต้องสร้างเป็น static page
    export async function getStaticPaths() {
      const paths = getAllPostIds(); // ดึง array ของ { params: { id: '...' } }
      return {
        paths,
        fallback: false, // ถ้าเข้า path ที่ไม่มีใน paths ให้แสดงหน้า 404
        // fallback: true // จะพยายาม generate หน้าใหม่ถ้ายังไม่มี (เหมาะกับข้อมูลเยอะๆ)
        // fallback: 'blocking' // เหมือน true แต่จะรอจน generate เสร็จก่อนส่งให้ user
      };
    }
    
    // ฟังก์ชันนี้จะรันตอน build time สำหรับ *แต่ละ* path ที่ได้จาก getStaticPaths
    export async function getStaticProps({ params }) {
      // params.id จะมีค่า slug ของหน้านั้นๆ (เช่น 'first-post')
      const postData = await getPostData(params.id); // ดึงข้อมูลโพสต์ + contentHtml
      return {
        props: {
          postData, // ส่งข้อมูลโพสต์ไปให้ component
        },
      };
    }
    
    // Component หลักของหน้ารายละเอียดโพสต์
    export default function Post({ postData }) { // รับ props จาก getStaticProps
      return (
        <div className={styles.container}>
          <Head>
            <title>{postData.title}</title>
            {/* เพิ่ม Meta tags อื่นๆ ได้ตามต้องการ */}
          </Head>
    
          <article className={styles.article}>
            <h1 className={styles.title}>{postData.title}</h1>
            <div className={styles.meta}>
              {/* แสดงวันที่ หรือ ผู้เขียน */}
              Date: {postData.date}
              {postData.author && ` | Author: ${postData.author}`}
            </div>
            <hr className={styles.separator} />
            {/* แสดงเนื้อหา HTML ที่แปลงมาจาก Markdown */}
            {/* สำคัญ: ใช้ dangerouslySetInnerHTML เพราะ contentHtml เป็นสตริง HTML */}
            <div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
          </article>
    
          <div className={styles.backToHome}>
            <Link href="/">
              <a>← กลับไปหน้าแรก</a>
            </Link>
          </div>
        </div>
      );
    }
    

อธิบาย pages/posts/[id].js:

  1. getStaticPaths:
    • ฟังก์ชันพิเศษของ Next.js ที่ต้องใช้คู่กับ getStaticProps ใน Dynamic Routes ([id].js)
    • หน้าที่ของมันคือบอก Next.js ว่า มี id (หรือ slug) อะไรบ้าง ที่จะต้องสร้างเป็นหน้า Static ล่วงหน้าตอน Build Time
    • เราเรียก getAllPostIds() จาก lib/posts.js ซึ่งคืนค่า array ในรูปแบบที่ getStaticPaths ต้องการ ([{ params: { id: '...' } }, ...])
    • fallback: false: หมายความว่าถ้าผู้ใช้เข้า URL ที่ไม่มี id อยู่ใน paths ที่เราสร้างไว้ Next.js จะแสดงหน้า 404 Not Found
  2. getStaticProps:
    • ฟังก์ชันนี้จะถูกเรียก สำหรับแต่ละ id ที่ getStaticPaths คืนค่ามา ตอน Build Time
    • { params }: Next.js จะส่ง object params เข้ามา ซึ่งจะมี key ตรงกับชื่อไฟล์ใน [] (ในที่นี้คือ id) ดังนั้น params.id จะมีค่าเป็น slug ของหน้านั้นๆ (เช่น 'first-post')
    • เราเรียก getPostData(params.id) จาก lib/posts.js เพื่อดึงข้อมูล เฉพาะ ของโพสต์นั้น รวมถึง contentHtml ที่แปลงแล้ว
    • return { props: { postData } }: ส่งข้อมูล postData (title, date, contentHtml, etc.) ไปให้ Post component
  3. Post Component:
    • รับ postData มาจาก props
    • แสดง title, date (และ author ถ้ามี)
    • dangerouslySetInnerHTML={{ __html: postData.contentHtml }}: นี่คือส่วนสำคัญในการแสดงเนื้อหา HTML ที่เราได้จากการแปลง Markdown ด้วย remark. React ปกติจะไม่ render HTML ตรงๆ เพื่อป้องกัน XSS attacks แต่ในกรณีนี้เรารู้ว่า HTML มาจาก Markdown ที่เราควบคุม เราจึงใช้ prop นี้เพื่อบอก React ให้ render HTML string นั้นออกมา ต้องมั่นใจว่าแหล่งที่มาของ Markdown ปลอดภัย
    • มีลิงก์สำหรับกลับไปหน้าแรก

ขั้นตอนที่ 6: เพิ่มสไตล์ (Optional แต่แนะนำ)

สร้างไฟล์ CSS เพื่อให้หน้าเว็บดูดีขึ้น

  1. styles/Home.module.css (ตัวอย่าง):

    /* styles/Home.module.css */
    .container {
      padding: 0 2rem;
    }
    
    .main {
      min-height: 100vh;
      padding: 4rem 0;
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    
    .title {
      margin: 0;
      line-height: 1.15;
      font-size: 4rem;
      text-align: center;
      margin-bottom: 2rem;
    }
    
    .blogList {
      width: 100%;
      max-width: 800px; /* จำกัดความกว้าง */
    }
    
    .blogList h2 {
        margin-bottom: 1rem;
        border-bottom: 1px solid #eaeaea;
        padding-bottom: 0.5rem;
    }
    
    .blogList ul {
      list-style: none;
      padding: 0;
    }
    
    .listItem {
      margin: 1rem 0;
      padding: 1rem;
      border: 1px solid #eaeaea;
      border-radius: 5px;
      transition: background-color 0.2s ease;
    }
    .listItem:hover {
        background-color: #fafafa;
    }
    
    .listItem a {
      font-size: 1.5rem;
      color: #0070f3;
      text-decoration: none;
    }
    .listItem a:hover {
        text-decoration: underline;
    }
    
    .lightText {
      color: #666;
      font-size: 0.9rem;
    }
    
  2. styles/Post.module.css (ตัวอย่าง):

    /* styles/Post.module.css */
    .container {
      padding: 2rem;
      max-width: 800px;
      margin: 0 auto; /* จัดกลาง */
    }
    
    .article {
      line-height: 1.6;
    }
    
    .title {
      font-size: 2.5rem;
      margin-bottom: 0.5rem;
    }
    
    .meta {
      color: #666;
      font-size: 0.9rem;
      margin-bottom: 1rem;
    }
    
    .separator {
        border: 0;
        height: 1px;
        background: #eaeaea;
        margin: 2rem 0;
    }
    
    /* สไตล์สำหรับเนื้อหา Markdown ที่แปลงเป็น HTML */
    .article h2 {
        font-size: 1.8rem;
        margin-top: 2rem;
        margin-bottom: 1rem;
    }
    .article p {
        margin-bottom: 1rem;
    }
    .article code {
        background: #f4f4f4;
        padding: 0.2em 0.4em;
        border-radius: 3px;
        font-family: monospace;
    }
    .article pre code {
        display: block;
        padding: 1rem;
        overflow-x: auto; /* ทำให้ scroll แนวนอนได้ถ้า code ยาว */
    }
    .article blockquote {
        border-left: 3px solid #ccc;
        padding-left: 1rem;
        margin-left: 0;
        color: #666;
    }
    .article ul, .article ol {
        padding-left: 2rem;
        margin-bottom: 1rem;
    }
    .article img {
        max-width: 100%;
        height: auto;
        display: block; /* ป้องกัน space ใต้รูป */
        margin: 1rem 0;
    }
    
    
    .backToHome {
      margin-top: 3rem;
    }
    
    .backToHome a {
      color: #0070f3;
      text-decoration: none;
    }
    .backToHome a:hover {
        text-decoration: underline;
    }
    

ขั้นตอนที่ 7: ทดสอบการทำงาน

  1. รัน Development Server:
    npm run dev
    # หรือ yarn dev
    
  2. เปิดเบราว์เซอร์: ไปที่ http://localhost:3000
    • คุณควรเห็นหน้าแรกพร้อมรายการบทความที่คุณสร้างไว้
    • คลิกที่ชื่อบทความ คุณควรจะถูกพาไปยังหน้ารายละเอียดของบทความนั้น (เช่น http://localhost:3000/posts/first-post)
    • หน้ารายละเอียดควรแสดงชื่อเรื่อง, วันที่, และเนื้อหาที่แปลงจาก Markdown เป็น HTML อย่างถูกต้อง
    • ลองเพิ่ม/แก้ไขไฟล์ .md ในโฟลเดอร์ posts แล้วรีเฟรชหน้าเว็บ (ใน dev mode, Next.js มักจะอัปเดตให้อัตโนมัติ)

สรุปและขั้นตอนต่อไป:

ตอนนี้คุณได้สร้างเว็บ Blog พื้นฐานด้วย Next.js ที่ดึงข้อมูลจากไฟล์ Markdown โดยใช้ gray-matter และ remark ได้สำเร็จแล้ว!

สิ่งที่คุณได้เรียนรู้:

  • การตั้งค่าโปรเจกต์ Next.js
  • การใช้ gray-matter เพื่อ parse Markdown frontmatter
  • การใช้ remark และ remark-html เพื่อแปลง Markdown content เป็น HTML
  • การใช้ fs และ path ของ Node.js เพื่ออ่านไฟล์
  • การใช้ getStaticProps เพื่อดึงข้อมูลสำหรับ Static Generation (SSG) ในหน้า List
  • การใช้ Dynamic Routes ([id].js) ร่วมกับ getStaticPaths และ getStaticProps เพื่อสร้างหน้ารายละเอียดสำหรับแต่ละบทความ
  • การแสดงผล HTML ที่ได้จาก Markdown โดยใช้ dangerouslySetInnerHTML

ขั้นตอนต่อไปที่น่าสนใจ:

  • การจัดรูปแบบวันที่ (Date Formatting): ใช้ library อย่าง date-fns หรือ dayjs เพื่อแสดงวันที่ในรูปแบบที่อ่านง่ายขึ้น
  • การทำ Pagination: ถ้ามีบทความจำนวนมาก ควรแบ่งหน้าแสดงผลในหน้า List
  • การเพิ่ม Tags หรือ Categories: เพิ่มข้อมูล tag/category ใน frontmatter และสร้างหน้าสำหรับกรองบทความตาม tag/category
  • การเพิ่มฟีเจอร์ค้นหา (Search): สร้างช่องค้นหาเพื่อกรองบทความ
  • การปรับปรุง SEO: เพิ่ม meta tags ที่ละเอียดขึ้นในแต่ละหน้า
  • การ Deploy: นำเว็บขึ้น Host จริง (เช่น Vercel, Netlify ซึ่งทำงานกับ Next.js ได้ดีมาก)
  • การใช้ MDX: ถ้าต้องการใช้ Component ของ React ภายในไฟล์ Markdown ลองศึกษา MDX (@next/mdx)

หวังว่าบทเรียนนี้จะเป็นประโยชน์และละเอียดเพียงพอนะครับ! หากมีคำถามเพิ่มเติม ถามได้เลยครับ