Markdown & MDX
Markdown เป็นหน้า
หัวข้อที่มีชื่อว่า “Markdown เป็นหน้า”วางไฟล์ .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 ธรรมดา
ใส่ layout ให้ Markdown
หัวข้อที่มีชื่อว่า “ใส่ layout ให้ Markdown”Markdown ดิบ ๆ render ออกมาเป็น HTML fragment เปล่า ๆ ไม่มี shell มาห่อด้วย chrome ของไซต์ ให้ชี้ key layout ใน frontmatter ไปที่ component layout
---layout: ../../layouts/BaseLayout.astrotitle: Hello World---
Content here goes into the layout's default slot.layout จะได้รับ frontmatter ของหน้าเป็น props (ผ่าน Astro.props.frontmatter) และ Markdown ที่ render แล้วจะเติมลงใน default <slot /> ของ layout:
---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 บวก JavaScript จริง
หัวข้อที่มีชื่อว่า “MDX: Markdown บวก JavaScript จริง”.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 noteslayout: ../../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
component <Content /> กับ components prop ของมัน
หัวข้อที่มีชื่อว่า “component <Content /> กับ components prop ของมัน”การ import Content (และค่าที่ export อะไรก็ตาม) จากไฟล์ .mdx ทำให้ไฟล์ .astro render output ทั้งหมดของไฟล์นั้นได้ — และ components prop remap ว่า element Markdown ไหนถูก render ด้วย component ไหน โดยไม่ต้องแตะไฟล์ .mdx เลย:
export const title = 'Custom Content';
# {title}
Some *body* text with a custom heading renderer.---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 render Markdown ด้วย Sätteri — ยืนยันกับ config schema
หัวข้อที่มีชื่อว่า “Astro 7 render Markdown ด้วย Sätteri — ยืนยันกับ config schema”ตั้งแต่ 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 ร้อยแก้วรอบ ๆ
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], }), },});// @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 กับ .mdx
หัวข้อที่มีชื่อว่า “.md กับ .mdx”หยิบ .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 จริง ๆ
ปัญหาของ MDX พิสูจน์ด้วย build error จริง
หัวข้อที่มีชื่อว่า “ปัญหาของ MDX พิสูจน์ด้วย build error จริง”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 JSSet 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 statementimport 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 closeA 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 อยู่แล้ว
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- ไฟล์
.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 ที่นั่นแทน
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>'); });});หมายเหตุเรื่อง content collection
หัวข้อที่มีชื่อว่า “หมายเหตุเรื่อง content collection”การวางไฟล์ Markdown ตรง ๆ ใน src/pages/ ใช้ได้ แต่ไม่สเกลไปยังบล็อกหรือไซต์ doc ขนาดใหญ่: ไม่มี schema validation, ไม่มี frontmatter ที่มี type, ไม่มีการ query วิธีสมัยใหม่ในการจัดการไฟล์ Markdown/MDX จำนวนมาก คือ content collection — ชั้น content ที่มี type, validate และ query ได้ — ที่เป็นเนื้อหาทั้งหมดของโมดูลถัดไป หน้าหลวม ๆ โอเคสำหรับไฟล์ไม่กี่ไฟล์; collection คือวิธีรันไซต์ content จริงจัง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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 — idfrontmattertitleบน 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 ไม่ว่าคนอ่านจริงจะตีความว่าเป็นร้อยแก้วชัดเจนแค่ไหนก็ตาม