ข้ามไปยังเนื้อหา

Markdown & MDX

วางไฟล์ .md ลงใน src/pages/ แล้วไฟล์นั้นจะกลายเป็น route ที่ render เป็น HTML — file-based routing แบบเดียวกับ .astro เป็นวิธีที่เร็วที่สุดในการเขียน content

---
title: Hello World
---
# Hello World
This is **Markdown** with a `code span` and a [link](/x).
- item one
- item two

บนสุดคือ frontmatter — บล็อก YAML คั่นด้วย --- — เก็บ metadata อย่าง title หรือ date ตัว body ของ Markdown เอง render เป็น HTML ธรรมดา

Markdown ดิบ ๆ render ออกมาเป็น HTML fragment เปล่า ๆ ไม่มี shell มาห่อด้วย chrome ของไซต์ ให้ชี้ key layout ใน frontmatter ไปที่ component layout

---
layout: ../../layouts/BaseLayout.astro
title: Hello World
---
Content here goes into the layout's default slot.

layout จะได้รับ frontmatter ของหน้าเป็น props (ผ่าน Astro.props.frontmatter) และ Markdown ที่ render แล้วจะเติมลงใน default <slot /> ของ layout:

src/layouts/BaseLayout.astro
---
const { frontmatter } = Astro.props;
---
<html>
<head><title>{frontmatter.title}</title></head>
<body>
<article><slot /></article> <!-- the Markdown body lands here -->
</body>
</html>

พิสูจน์ตั้งแต่ต้นจนจบด้วย build จริงของคู่นี้เป๊ะ ๆ — Astro.props.frontmatter ของ layout ป้อน <title> ถูกต้อง แม้ว่า (พิสูจน์แล้วข้างบน) body ของ Markdown เองจะไม่เคย evaluate { } เลยก็ตาม:

<html><head><title>Hello Layout</title></head><body><article><p>Body content here.</p>
</article></body></html>

.mdx คือ Markdown ที่มีพลังแบบ JSX เปิดใช้ด้วย integration @astrojs/mdx ต่างจาก .md ธรรมดา .mdx evaluate expression { } จริง รับตัวแปรที่ export และให้คุณ import แล้ว render component ได้ตรงในร้อยแก้วของคุณ:

---
title: Hello MDX
---
export const revision = 3;
# {frontmatter.title}
Revision: {revision}

พิสูจน์ด้วย build จริง — ทั้ง frontmatter interpolation และตัวแปรที่ export evaluate ถูกต้อง ตรงข้ามกับหลักฐานของ .md ธรรมดาข้างบน:

<h1 id="hello-mdx">Hello MDX</h1>
<p>Revision: 3</p>

การ import แล้ว render component ก็ทำแบบเดียวกัน รวมถึง island ที่มี client directive — component Astro หรือ framework เพราะ MDX ประกอบกับทุกอย่างใน Astro ได้:

---
title: Release notes
layout: ../../layouts/BaseLayout.astro
---
import Callout from '../../components/Callout.astro';
import Chart from '../../components/Chart.jsx';
# {frontmatter.title}
Regular Markdown paragraphs work as usual.
<Callout title="Heads up">
You can drop a component into the middle of prose.
</Callout>
And even an interactive island:
<Chart client:visible data={[1, 2, 3]} />

<Fragment> ก็ใช้ได้ และไม่เพิ่ม wrapper element ให้ร้อยแก้วรอบ ๆ — พิสูจน์ด้วย build จริง (output ของ frontmatter/exported-variable จากข้างบน ตามด้วย content ของ Fragment เองทันที ไม่มี tag พิเศษล้อมรอบ):

<Fragment>
<p>a fragment inside prose</p>
</Fragment>
<p>a fragment inside prose</p>
flowchart LR
  md["Markdown or MDX file"] --> fm["frontmatter provides metadata"]
  fm --> layout["layout key wraps it in a shell"]
  md --> body["body renders to HTML"]
  body --> comp["MDX can also embed Astro and framework components"]
  layout --> out["final page"]
  comp --> out
Markdown render เป็น HTML; MDX ฝัง component ได้ด้วย

การ import Content (และค่าที่ export อะไรก็ตาม) จากไฟล์ .mdx ทำให้ไฟล์ .astro render output ทั้งหมดของไฟล์นั้นได้ — และ components prop remap ว่า element Markdown ไหนถูก render ด้วย component ไหน โดยไม่ต้องแตะไฟล์ .mdx เลย:

src/pages/content-comp.mdx
export const title = 'Custom Content';
# {title}
Some *body* text with a custom heading renderer.
src/pages/content-comp-host.astro
---
import { Content } from './content-comp.mdx';
---
<Content components={{ h1: 'h2' }} />

พิสูจน์ด้วย build จริง — title ที่ export ยัง evaluate อยู่ และ heading # ... ที่ปกติจะ compile เป็น <h1> กลับ render เป็น <h2> แทนเพราะการ remap ของ components:

<h2 id="title">Custom Content</h2>
<p>Some <em>body</em> text with a custom heading renderer.</p>

นี่คือวิธีที่ design system บังคับใช้ heading component ของตัวเอง (พร้อม style ของ anchor-link ที่สม่ำเสมอ เป็นต้น) ข้ามทุกไฟล์ Markdown/MDX โดยที่ผู้เขียนแต่ละคนไม่ต้อง import มันเอง

ตั้งแต่ Astro 7 ไฟล์ .md และ .mdx ถูก render ด้วย Sätteri ซึ่งเป็น Markdown pipeline แบบ native ที่ขับเคลื่อนด้วย Rust ของ Astro เอง แทน pipeline remark/rehype (unified) ที่เป็น JavaScript โดย default สำหรับ content ธรรมดาไม่มีอะไรเปลี่ยนนอกจากความเร็ว — นี่เป็นหนึ่งในเหตุผลที่ build ของ Astro 7 เร็วขึ้น 15–61% (แหล่งที่มา: Astro 7.0 release notes)

จุดที่มีผลคือ plugin — และวิธีที่แม่นยำในการทำให้มันทำงานต่อก็เปลี่ยนไป เช็คตรงกับ config type schema ของ [email protected] ที่ติดตั้งจริง markdown.remarkPlugins, markdown.rehypePlugins, markdown.gfm, และ markdown.smartypants ทั้งหมดถูก mark เป็น @deprecated แต่ละตัวชี้ไปที่ทางแก้เดียวกัน: ส่งมันเข้า markdown.processor ที่ตั้งค่าไว้แทน markdown.shikiConfig กับ markdown.syntaxHighlight ไม่ ถูก deprecate — syntax highlighting ของ code block เป็น layer แยกที่มีผลไม่ว่า Markdown processor ตัวไหน render ร้อยแก้วรอบ ๆ

astro.config.mjs
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import remarkToc from 'remark-toc';
export default defineConfig({
markdown: {
// Correct for 7.3.5: plugins are arguments to unified(), not sibling
// top-level markdown.* keys (those are the deprecated form).
processor: unified({
remarkPlugins: [remarkToc],
}),
},
});
astro.config.mjs
// @expect-error deprecated in [email protected] — remarkPlugins/rehypePlugins as top-level markdown.* keys still work but are marked @deprecated in the installed config schema; pass them to unified() instead (shown above)
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
export default defineConfig({
markdown: {
processor: unified(),
remarkPlugins: [/* ... */],
rehypePlugins: [/* ... */],
},
});

@astrojs/markdown-remark เป็น package ที่ต้องติดตั้งแยก (เช่นเดียวกับ @astrojs/markdown-satteri ถ้าอยากตั้งค่า option ของ Sätteri เอง เช่น features: { gfm: false } แบบชัด ๆ แทนที่จะพึ่ง default ของมัน) กฎง่าย ๆ คือ เริ่มด้วย Sätteri (default ไม่ต้องติดตั้งอะไร) แล้วค่อยสลับ processor เมื่อ plugin ที่คุณต้องใช้จริง ๆ ไม่มีของ native ให้แทน

หยิบ .md มาใช้เมื่อไฟล์เป็น content ล้วน ๆ — บล็อกโพสต์, หน้า doc, โน้ต .md เรียบง่ายกว่า ไม่มี overhead ของ component และ — พิสูจน์แล้วข้างบน — body ของมันเป็นแค่ text เปล่ากับ syntax ของ Markdown เท่านั้น ไม่มี expression

หยิบ .mdx มาใช้เมื่อ content ต้องฝัง component หรือ evaluate JavaScript จริง — กล่อง callout, chart, demo ที่ interactive, ตัวแปรที่ export, frontmatter ที่ interpolate ตรงในร้อยแก้ว (พิสูจน์แล้วข้างบน ตรงข้ามกับ .md) ต้นทุนคือ integration เพิ่ม, การ process ที่หนักขึ้นเล็กน้อย และ parser ที่เข้มงวดกว่า — พิสูจน์ในหัวข้อถัดไป — ดังนั้นอย่า default ทุกอย่างเป็น MDX ใช้ MDX เฉพาะตรงที่คุณผสม content, component และ expression จริง ๆ

body ของ MDX ไม่ใช่แค่ Markdown — integration @astrojs/mdx parse มันด้วย parser ที่รู้จัก JSX ดังนั้นร้อยแก้วธรรมดาบังเอิญดูเหมือน syntax ได้ สามความล้มเหลวจริง ทำซ้ำกับ build จริง:

1. { โดด ๆ ที่เริ่มดูเหมือน object-literal ในร้อยแก้ว:

// @expect-error a `{` in prose is parsed as an MDX expression, and this one isn't valid JS
Set the value like this: { port: 3000 } in your config.
3:32: Could not parse expression with oxc: Expected `,` or `)` but found `:` (mdx-jsx:unexpected-character)

2. บรรทัดที่ขึ้นต้นด้วยคำว่า import (แม้จะเป็นร้อยแก้วธรรมดา):

// @expect-error a line beginning with "import" is parsed as the start of an ESM import statement
import your data from wherever you like.
3:13: Could not parse esm with oxc: Expected `from` but found `Identifier` (mdxjs-rs:oxc)

3. การอ้าง type แบบ generic ในร้อยแก้ว เช่น Array<T>:

// @expect-error `<T>` looks like a JSX opening tag with no matching close
A generic function like Array<T> takes a type parameter.
3:30: Expected a closing tag for `<T>` (3:30) before the end of `paragraph` (mdx-jsx:unexpected-character)

แต่ละอันแก้ได้แบบเดียวกัน: ใส่ข้อความที่มีปัญหาไว้ใน inline code span (`{ port: 3000 }`, `import`, `Array<T>`) — code span เป็น text เฉื่อยชา ไม่เคยถูก parse เป็น expression หรือ JSX เหมือนกับการ escape { ในบทที่พูดถึง is:raw ในบท syntax-deep เป๊ะ แต่ตรงนี้เกิดขึ้นอัตโนมัติกับอะไรก็ตามที่อยู่ในเครื่องหมาย backtick อยู่แล้ว

  • ไฟล์ .md ที่ generate มามี {frontmatter.something} หรือ expression { } อื่นใน body ไหม copy มาจากตัวอย่าง MDX? พิสูจน์แล้วข้างบน: ใน .md ธรรมดามัน render เป็น text ที่ไร้ประโยชน์ตรงตัว — แปลงไฟล์เป็น .mdx หรือย้ายค่าไปไว้ใน layout แทน
  • astro.config.mjs ที่ generate มาตั้ง markdown.remarkPlugins หรือ markdown.gfm เป็น key ระดับบนสุดไหม? พิสูจน์แล้วข้างบน (เช็คตรงกับ config schema ที่ติดตั้งจริง): พวกนั้นถูก deprecate ใน [email protected] — ส่งเข้า unified() ผ่าน markdown.processor แทน ไม่งั้นอาจเงียบ ๆ หยุดมีผลใน major version ถัดไป
  • config ที่ใช้ remark/rehype plugin ที่ copy มาจากบล็อก Astro รุ่นก่อน 7 ถูกสมมติว่า “ใช้ได้เลย” ไหม? Sätteri คือ default ตัวใหม่ — โปรเจกต์ที่ต้องการ plugin พวกนั้นเป๊ะ ๆ ต้อง opt-in ด้วย processor: unified({ ... }) ชัด ๆ พิสูจน์แล้วข้างบน
  • ร้อยแก้วของ MDX มี { ดิบ ๆ, บรรทัดที่ขึ้นต้นด้วย import/export, หรือ generic type แบบ <Word> อยู่นอก code span ไหม? พิสูจน์แล้วข้างบนด้วย parse error จริงสามแบบต่างกัน — แต่ละอันต้องอยู่ใน backtick หรือ escape ด้วยวิธีอื่น

🐛 หา bug ในโค้ดนี้

---
title: My Post
---
# {frontmatter.title}
Written on { new Date().toDateString() }.
เฉลย

นี่คือไฟล์ .md ธรรมดา (นามสกุลไม่ใช่ .mdx) ที่ใช้ syntax เฉพาะของ MDX พิสูจน์แล้วก่อนหน้านี้ในบทนี้: body ของ Markdown ธรรมดาไม่เคย evaluate { } เลย — ทั้ง heading และบรรทัด “Written on” จะ render expression ในปีกกาเป็น text ที่ไร้ประโยชน์ตรงตัว ({frontmatter.title}, { new Date().toDateString() }) ไม่ใช่ title ของหน้าหรือวันที่ปัจจุบัน ทางแก้คือเปลี่ยนชื่อไฟล์เป็น .mdx (ซึ่ง evaluate สิ่งเหล่านี้จริง) หรือถ้า content ควรเป็น Markdown ธรรมดาต่อไป ให้ย้ายค่าที่ dynamic เข้าไปใน component layout ที่หน้านี้ใช้ผ่าน key layout ใน frontmatter แล้วอ้าง Astro.props.frontmatter ที่นั่นแทน

src/layouts/BaseLayout.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect } from 'vitest';
import BaseLayout from './BaseLayout.astro';
describe('layout receives frontmatter as props', () => {
it('renders the title from Astro.props.frontmatter and fills the default slot with the body', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(BaseLayout, {
props: { frontmatter: { title: 'Hello Layout' } },
slots: { default: '<p>Body content here.</p>' },
});
expect(html).toContain('<title>Hello Layout</title>');
expect(html).toContain('<article');
expect(html).toContain('<p>Body content here.</p>');
});
});

การวางไฟล์ Markdown ตรง ๆ ใน src/pages/ ใช้ได้ แต่ไม่สเกลไปยังบล็อกหรือไซต์ doc ขนาดใหญ่: ไม่มี schema validation, ไม่มี frontmatter ที่มี type, ไม่มีการ query วิธีสมัยใหม่ในการจัดการไฟล์ Markdown/MDX จำนวนมาก คือ content collection — ชั้น content ที่มี type, validate และ query ได้ — ที่เป็นเนื้อหาทั้งหมดของโมดูลถัดไป หน้าหลวม ๆ โอเคสำหรับไฟล์ไม่กี่ไฟล์; collection คือวิธีรันไซต์ content จริงจัง

body ของไฟล์ .md คือ Markdown และไม่ใช่อย่างอื่นเลย — ไม่มี expression ไม่มี import ไม่มี component; มีแค่ frontmatter แบบ YAML ของมัน และผ่าน layout เท่านั้นที่ค่าใน frontmatter นั้นจะไปถึง behavior แบบ dynamic ได้ body ของไฟล์ .mdx คือ Markdown บวก ภาษา JS/JSX ที่ฝังจริง — พิสูจน์แล้วข้างบน { } evaluate, export/import ทำงาน, component render ได้ — นี่แหละคือเหตุผลที่ parser ของมันเข้มงวดกว่ากับอะไรก็ตามที่แค่ ดูเหมือน syntax นั้นโผล่มาในร้อยแก้วธรรมดา

.md .mdx
body คือ Markdown เท่านั้น — { } เป็น text เฉื่อยชา (พิสูจน์แล้วข้างบน) Markdown + รู้จัก JSX — { } evaluate, export/import ทำงาน (พิสูจน์แล้วข้างบน)
component ไม่รองรับใน body import แล้ว render component ของ Astro/framework ได้ รวมถึง island
จุดเสี่ยงในร้อยแก้ว ไม่มีนอกเหนือจาก syntax ของ Markdown เอง { โดด ๆ, บรรทัดที่ขึ้นต้นด้วย import/export, หรือ text รูปแบบ <Tag> ทำ build พังได้ (พิสูจน์แล้วข้างบน สามแบบ)
เหมาะกับ content ล้วน ๆ — โพสต์, doc, โน้ต content ที่ผสมร้อยแก้วกับ component หรือต้องการค่าที่คำนวณ
  • “{frontmatter.title} ทำงานเหมือนกันใน .md กับ .mdx” พิสูจน์แล้วข้างบน: มัน evaluate ใน .mdx แต่ render เป็น text ตรงตัวใน .md — id frontmattertitle บน heading (ที่ผิด) คือหลักฐาน
  • “Astro 7 เลิกรองรับ remark/rehype plugin ไปเลย” พิสูจน์แล้วข้างบน: processor unified() ของ @astrojs/markdown-remark ยังรันมันได้อยู่ — สิ่งที่เปลี่ยนคือมันไม่ถูกตั้งค่าเป็น key ระดับบนสุด markdown.* ที่เป็น sibling อีกต่อไป (แบบนั้น deprecate แล้ว) แต่เป็น argument ของ unified() แทน
  • “key layout ใน frontmatter ถูกเลิกใช้แล้ว” ตามเอกสารทางการ: มันไม่ได้ถูกเลิกใช้ มัน scope แคบ — รู้จักเฉพาะหน้าหลวม ๆ ใน src/pages/ และแค่ไม่ถูกอ่านเลยเมื่อ content อยู่ใน collection
  • “คำอย่าง import หรือ generic type <T> ปลอดภัยในร้อยแก้วเสมอเพราะมันแค่ text ภาษาอังกฤษ/ใกล้เคียงโค้ด” พิสูจน์แล้วข้างบนด้วย MDX parse error จริงสามแบบต่างกัน: parser ของ MDX มองหา syntax แบบ JSX/ESM ทุกที่นอก code span ไม่ว่าคนอ่านจริงจะตีความว่าเป็นร้อยแก้วชัดเจนแค่ไหนก็ตาม
พิสูจน์ด้วย build จริง ไฟล์ .md ธรรมดานี้ render อะไรออกมาจริง ๆ?
---
title: Hello World
---

# {frontmatter.title}
ความสามารถที่นิยาม MDX เหนือ Markdown ธรรมดาคืออะไร พิสูจน์แล้วในบทนี้?
เมื่อไหร่ควรใช้ .md มากกว่า .mdx?
วิธีสมัยใหม่ในการจัดการไฟล์ Markdown/MDX จำนวนมากในสเกลใหญ่คืออะไร?
remark plugin ของคุณหยุดทำงานหลังอัปเกรดเป็น Astro 7 เพราะอะไร และทำยังไง?
บรรทัดนี้ เขียนเป็นร้อยแก้วธรรมดาในไฟล์ .mdx ทำให้เกิด build error จริง เพราะอะไร?
import your data from wherever you like.
พิสูจน์ด้วย build จริง components prop ตรงนี้ทำอะไร?
---
import { Content } from './post.mdx';
---
<Content components={{ h1: 'h2' }} />