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

Astro Syntax Deep Dive

ที่ไหนก็ได้ใน template { } จะฝัง JavaScript expression แล้ว render ค่าที่ได้ออกมา ตัวแปรจาก frontmatter ใช้ได้เลย

---
const name = "Ada";
const count = 3;
---
<h1>Hello, {name}</h1>
<p>You have {count} messages.</p>
<p>Total: {count * 2}</p>

มีแค่ expression เท่านั้นที่ใส่ใน { } ได้ — สิ่งที่ evaluate ออกมาเป็นค่า statement (บล็อก if, loop for) ต้องอยู่ใน frontmatter ใน template คุณเขียน control flow เป็น expression แบบที่แสดงข้างล่างนี้

ใช้ idiom logical-AND เพื่อ render บางอย่างเฉพาะตอนเงื่อนไขเป็นจริง และ ternary เพื่อเลือกระหว่างสองทาง:

---
const isLoggedIn = true;
const user = { name: "Ada", role: "admin" };
---
{isLoggedIn && <p>Welcome back, {user.name}</p>}
{user.role === "admin"
? <a href="/admin">Admin panel</a>
: <a href="/account">Your account</a>}

{cond && <div/>} render element เมื่อ cond เป็น truthy และไม่ render อะไรเลยถ้าไม่ใช่ ส่วน ternary จะ render กิ่งใดกิ่งหนึ่งเสมอ

ไม่มี loop directive พิเศษ — คุณ map array ให้เป็น markup เหมือน JSX เป๊ะ:

---
const posts = [
{ id: 1, title: "First" },
{ id: 2, title: "Second" },
];
---
<ul>
{posts.map((post) => <li>{post.title}</li>)}
</ul>

Astro ไม่ต้องการ prop key แบบ React — นี่คือ server rendering ไม่มี reconciliation Map แล้ว return element จบ

เมื่อต้องการ return หลาย element โดยไม่เพิ่ม DOM node ห่อ ใช้ Fragment Astro รองรับทั้ง <Fragment> แบบมีชื่อ และ shorthand <>:

---
const items = ["a", "b"];
---
<Fragment>
<dt>Term</dt>
<dd>Definition</dd>
</Fragment>
{items.map((x) => (
<>
<span>{x}</span>
<br />
</>
))}

Fragment ยังเป็นที่อยู่ของ directive set:* เมื่อไม่มี element ให้ยึด (ถัดไป)

ค่า default แล้ว expression ที่ได้ string จะถูก escape — Astro render เป็น text ดังนั้น <b>hi</b> จะโชว์ tag ตรง ๆ ตามตัวอักษร ปลอดภัย เมื่อมี HTML string ที่เชื่อถือได้จริง ๆ ที่อยากให้ render เป็น markup (เช่น HTML จาก CMS ที่คุณควบคุมเอง) ใช้ set:html:

src/components/SetHtmlDemo.astro
---
const untrusted = "<img src=x onerror=alert('xss')>";
---
<div id="raw" set:html={untrusted} />
<div id="safe" set:text={untrusted} />

set:html ข้าม escaping — รวมถึง content ที่คุณไม่ได้เขียนเอง Render ผ่าน Container API กับ [email protected] ที่ติดตั้งจริง ด้วย string รูปแบบ XSS ที่ field จาก attacker จริง ๆ (comment, username, search query) อาจมี:

<!-- id="raw" (set:html) -->
<div id="raw"><img src=x onerror=alert('xss')></div>
<!-- id="safe" (set:text) -->
<div id="safe">&lt;img src=x onerror=alert(&#39;xss&#39;)&gt;</div>

div id="raw" render tag <img onerror=...> ตรงตัวเป๊ะ — XSS payload จริง ทำงานทันทีที่ browser parse ส่วน set:text (div ข้างล่าง input เดียวกัน) escape ทุกตัวอักษร ดังนั้น tag โผล่มาเป็น text เฉย ๆ นี่ไม่ใช่สมมติฐาน set:html คือ จุด injection ถ้า argument ของมันมี content ที่ attacker แทรกได้ — คิดเหมือนกับ dangerouslySetInnerHTML ใน React หรือ v-html ใน Vue ใช้มันกับ content ที่คุณควบคุมเองหรือผ่าน sanitizer จริงแล้วเท่านั้น

type ของ set:html คือ any และ behavior ตอน runtime ยอมรับมากกว่า “แค่ string” — พิสูจน์ตรงกับ compiler ที่ติดตั้งจริง:

src/components/SetHtmlTypesDemo.astro
---
const promiseVal = Promise.resolve('<b>from a promise</b>');
const responseVal = new Response('<b>from a response</b>');
---
<div id="promise" set:html={promiseVal} />
<div id="response" set:html={responseVal} />
<div id="promise"><b>from a promise</b></div>
<div id="response"><b>from a response</b></div>

Promise<string> ถูก await ก่อนถูกใส่เข้าไป และ Response ถูกอ่าน body เป็น text — ทั้งคู่กลายเป็น markup จริง ไม่ escape สิ่งที่มันไม่รับคือ element JSX/Astro ที่ส่งเป็นค่าตรง ๆ — ตัวนี้ fail ตอน compile ไม่ใช่ runtime:

// @expect-error set:html cannot take a JSX expression directly — only a string, Promise<string>, or Response
---
---
<div set:html={<Fragment><b>from jsx</b></Fragment>} />

พิสูจน์กับ compiler ที่ติดตั้งจริง — parse failure จริง ไม่ใช่แค่ type warning:

Parse failure: Parse failed with 1 error:
Unexpected JSX expression
...${$$unescapeHTML(<Fragment>...
^

set:text บังคับให้ค่าถูกปฏิบัติเป็น plain text (ค่า default ที่ปลอดภัย เขียนออกมาชัด ๆ) — ใช้เมื่อต้องการรับประกันว่าไม่มี HTML ถูก render เลย แม้ string จะเริ่มมีตัวอักษรหน้าตาเหมือน markup ก็ตาม

เมื่อมี object ของ attribute อยากจะ spread เข้า element ด้วย {...obj} — สะดวกสำหรับส่งต่อ prop:

---
const attrs = { id: "cta", class: "btn", "data-track": "signup" };
---
<a href="/join" {...attrs}>Join</a>

คุณคำนวณได้ว่า tag ไหน จะ render กำหนดตัวแปรที่ขึ้นต้นด้วยตัวใหญ่ให้เป็นชื่อ tag แล้วใช้มัน:

---
const level = 2;
const Heading = `h${level}`; // "h2"
---
<Heading>Dynamic heading</Heading> <!-- renders <h2>Dynamic heading</h2> -->

ตัวแปรต้องขึ้นต้นด้วยตัวใหญ่ เพื่อให้ Astro ปฏิบัติมันเป็น component/tag reference แทนที่จะเป็น HTML tag ตรงตัว

flowchart LR
  expr["expression in curly braces"] --> eval["evaluated on the server"]
  eval --> esc["string values are escaped by default"]
  esc --> html["safe HTML output"]
  eval --> raw["set:html bypasses escaping for trusted HTML"]
  raw --> html
expression ใน template กลายเป็น HTML ได้ยังไง

Astro 7 เปลี่ยน compiler เก่าที่เขียนด้วย Go มาเป็น Rust compiler และตอนนี้เป็น compiler เดียวที่ใช้ มันเร็วกว่าและเข้มงวดกว่า: compiler เก่า “แก้ให้” HTML ที่พังแบบเงียบ ๆ ตัวใหม่รายงานเป็น build error สี่ความล้มเหลวจริง รันผ่าน astro check กับ [email protected] ที่ติดตั้งจริง — ไม่ใช่จำมา แต่ทำซ้ำจริง:

1. tag ที่ไม่ปิด:

// @expect-error unclosed <p> — Astro 6 silently closed it for you, Astro 7 fails the build
---
---
<p>Hello world<p>Hello world</p>
src/pages/unclosed.astro:3:2 - error ts(17008): HTML element 'p' has no corresponding closing tag.
<p>Hello world<p>Hello world</p>
~
src/pages/unclosed.astro:4:1 - error astro: Expected a closing tag but instead found the end of the file.

2. Element ที่ปิดผิดลำดับ (<span> ถูกปิดหลัง parent <div> ของมันแทนที่จะปิดก่อน):

// @expect-error mis-nested closing tags
---
---
<div><span>oops</div></span>
src/pages/wrongorder.astro:3:24 - error ts(17002): Expected corresponding JSX closing tag for 'Fragment'.
<div><span>oops</div></span>
~~~~
src/pages/wrongorder.astro:3:7 - error ts(17008): HTML element 'span' has no corresponding closing tag.
<div><span>oops</div></span>
~~~~
src/pages/wrongorder.astro:3:16 - error astro: Expected a matching closing tag but instead found '</div>'.
<div><span>oops</div></span>
~~~~~~

3. { เดี่ยว ๆ ในเนื้อความ (ไม่ได้ตั้งใจให้เป็น expression — ประโยคที่บังเอิญใช้ปีกกา):

// @expect-error a `{` outside a fence is parsed as the start of an expression, not literal text
---
---
<p>Set theory uses { and } to denote a set.</p>
src/pages/curlytext.astro:3:22 - error ts(2304): Cannot find name 'and'.
<p>Set theory uses { and } to denote a set.</p>
~~~

4. Expression ที่ “return object” — อันนี้ไม่ fail build เลย มันเป็น JavaScript และ Astro ที่ valid render ออกมาเหมือนกับที่ String(obj) จะทำ:

src/components/ObjExprDemo.astro
---
const obj = { a: 1, b: 2 };
---
<p>{obj}</p>
<p>[object Object]</p>

นี่เป็น footgun จริง แค่ไม่ใช่ตอน compile-time: object ที่หย่อนเข้า { } จะ stringify กลายเป็น text ไร้ประโยชน์ [object Object] แทนที่จะ error ดังนั้น bug นี้จะโผล่มาแค่ตอนมองหน้าเว็บที่ render ออกมา (หรือ snapshot test) ถ้าตั้งใจจะ render field ให้เขียน {obj.a} ถ้าตั้งใจจะ debug ด้วย JSON ให้เขียน {JSON.stringify(obj)}

การเปลี่ยนที่สองของ Astro 7 คือ whitespace option compressHTML ตอนนี้ default เป็น 'jsx' ดังนั้น whitespace ระหว่าง element เป็นไปตามกฎ JSX — newline ระหว่าง element แบบ inline สอง element ไม่ทำให้เกิดช่องว่างที่เห็นได้อีกต่อไป พิสูจน์ด้วย Container API ตัด attribute debug data-astro-source-* ออกเพื่อให้อ่านง่าย:

src/components/WhitespaceDemo.astro
<span>hello</span>
<em>world</em>
<span>hello</span><em>world</em>

นั่น render เป็น “helloworld” ไม่มีช่องว่าง — ยืนยันกับ output จริง ไม่ใช่สมมติเอา วิธีแก้คือเขียนชัด ๆ เหมือนกับที่จะทำใน React:

src/components/WhitespaceFixedDemo.astro
<span>hello</span>{" "}<em>world</em>
<span>hello</span> <em>world</em>

ถ้า template เก่าขนาดใหญ่พึ่ง behavior เดิม ตั้ง compressHTML: true ใน astro.config.mjs เพื่อคงมันไว้ระหว่าง migrate โปรเจกต์ใหม่ควรเขียน {" "} ตรงที่ต้องการช่องว่างเลย

ลองเลย: compile โค้ดที่ผิดจริงในเบราว์เซอร์ ความผิดพลาดสี่แบบข้างบนพิสูจน์ด้วย Rust compiler จริงผ่าน astro check — แต่ compiler ตัวนั้นรันในเบราว์เซอร์ไม่ได้ (WASM build ตัวเดียวของมัน link ไม่ผ่านเมื่อโหลดจาก CDN: LinkError: ... "napi_create_async_work": function import requires a callable) playground นี้เลยโหลด @astrojs/compiler รุ่นคลาสสิกที่ทำมาให้รันในเบราว์เซอร์ได้แทน — เป็น Go compiler รุ่นก่อน 7.0 ที่ยังเผยแพร่ไว้เพื่อจุดประสงค์นี้โดยเฉพาะ มันปล่อยผ่านง่ายกว่ามาก (แท็กที่ไม่ปิดหรือปิดผิดลำดับข้างบน compile ผ่านโดยไม่มี diagnostic เลย) แต่มันยังจับความผิดพลาดจริงของ Astro ที่ไม่ขึ้นกับเวอร์ชันได้อยู่ — set:html บน element ที่ยังมี child node เหลืออยู่:

set:html ที่ยังมี children เหลืออยู่

---
---
<div set:html={"<b>Overwritten</b>"}>
This text is ignored -- set:html replaces all children.
</div>

กด Compile: แผง diagnostics จะโชว์ WARNING_SET_WITH_CHILDREN จริง — “set:html directive will overwrite child nodes” — และแผง output จะโชว์ JS ที่ compile แล้ว ให้เห็นเองว่าข้อความไม่ได้ไปโผล่ใน output ที่ render ออกมาเลย (พิสูจน์ตอนสร้าง playground นี้: diagnostic ตัวนี้ขึ้นเฉพาะตอน child เป็น text node แบบข้างบนเท่านั้น — ถ้าเปลี่ยนเป็น element ซ้อนอย่าง <p>...</p> ใน compiler build นี้ warning จะหายไปเงียบ ๆ เป็นช่องโหว่จริงที่ค่อนข้างเซอร์ไพรส์ — ไม่ใช่ทุกกรณี “ยังมี children” จะถูกจับได้หมด)

ความต่างที่สำคัญแต่ subtle จาก React: template ของ Astro ใช้ชื่อ attribute แบบ HTML มาตรฐาน เป็น class ไม่ใช่ className, for ไม่ใช่ htmlFor คุณกำลังเขียน superset ของ HTML ดังนั้นชื่อแบบ HTML คือของจริง

is:raw บอก compiler ให้หยุด parse ลูกของ element เป็น syntax ของ Astro เลย — { } ข้างในมันเป็น text ตรงตัว ไม่ใช่ expression:

src/components/IsRawDemo.astro
<div is:raw>
{ this is not an expression, is:raw stops the compiler from parsing it }
<p>literal { braces } stay literal</p>
</div>

output ที่พิสูจน์แล้ว — ปีกกาผ่านมาโดยไม่ถูกแตะ และ <p> ข้างในไม่ถูก parse เป็น element เลยด้วยซ้ำ:

<div>
{ this is not an expression, is:raw stops the compiler from parsing it }
<p>literal { braces } stay literal</p>
</div>

ใช้เมื่อกำลัง render ตัวอย่างโค้ดหรือ client-side templating snippet (Handlebars, Vue template) ที่เต็มไปด้วย { } ที่ไม่อยากให้ Astro แตะ

define:vars (ที่แนะนำไปก่อนหน้าสำหรับ <style>) ก็ใช้กับ element อื่นได้เพื่อฉีดค่าจาก frontmatter เป็น CSS custom property แบบ inline — พูดถึงลึก ๆ สำหรับ <style> ในบทถัดไป

class:list รวมแหล่งที่มาของ class name หลายแบบ — array, object ({ className: boolean }), conditional string, และ plain string — เข้าเป็น class attribute เดียว ใช้เพิ่มเติมจาก class แบบ static:

src/components/ClassListDemo.astro
---
const isActive = true;
const size = 'lg';
---
<div class="base" class:list={['btn', { active: isActive, disabled: false }, size && `size-${size}`]}>x</div>

output ที่รวมแล้วพิสูจน์จริง — disabled ถูกตัดออก (ค่าเป็น false) และ template string ที่มี guard falsy ชนะเพราะ size เป็น truthy และ class="base" แบบ static ถูกเก็บไว้และรวมเข้าด้วยกัน:

<div class="base btn active size-lg">x</div>

component render ตัวเองได้ด้วย <Astro.self> ส่ง prop ใหม่ทุกครั้ง — นี่คือวิธีสร้าง tree (comment ซ้อนกัน, nav แบบ recursive, file browser) จาก component definition เดียว:

src/components/SelfRecursion.astro
---
interface Props { depth: number }
const { depth } = Astro.props;
---
{depth > 0 ? (
<div class="node">
depth {depth}
<Astro.self depth={depth - 1} />
</div>
) : <div class="leaf">leaf</div>}

Render ด้วย depth={3} ผ่าน Container API — output ซ้อนกันจริง ระดับละหนึ่ง recursive call จบที่ branch leaf:

<div class="node">depth 3<div class="node">depth 2<div class="node">depth 1<div class="leaf">leaf</div></div></div></div>

ไม่มี recursion limit built-in นอกเหนือจาก stack ของตัวเอง — guard เงื่อนไขจบ (depth > 0 ข้างบน) แบบเดียวกับที่ทำใน recursive function ปกติ ไม่งั้น prop ที่แย่ (เช่น depth เริ่มต้นติดลบที่ไม่ถึง base case เลย) จะ recurse จนกว่า process จะพัง

นอกจาก <slot /> ใน template แล้ว Astro.slots เป็น API ใน frontmatter: has(name) บอกว่า slot ได้รับ content หรือเปล่า และ render(name, args?) return content ของ slot เป็น HTML string (await เพราะ render เป็น async) — มีประโยชน์เมื่อต้องการ markup เป็นค่าแทนที่จะหย่อนตรง ๆ ใน template ด้วย <slot /> slots-and-composition (บทถัดไป) ใช้ parameter args ของ render เพื่อสร้าง function-as-slot (“scoped slot”) ที่นี่พิสูจน์แค่กลไกของมันเอง:

src/components/SlotsHasRenderDemo.astro
---
const hasDefault = Astro.slots.has('default');
const rendered = await Astro.slots.render('default', ['scoped-arg']);
---
<p>has:{String(hasDefault)}</p>
<div id="rendered" set:html={rendered} />

snippet ที่ generate มาด้วย template expression ดูถูกต้องได้และยังผิดในแบบที่โผล่มาแค่ตอน runtime หรือ input เฉพาะ เช็คสิ่งเหล่านี้:

  • มีค่าไหนถูกส่งตรงเข้า set:html โดยไม่ผ่านการ sanitize บ้างไหม — comment body, username, อะไรก็ตามที่มาจาก request? พิสูจน์แล้วข้างบน: set:html render argument ของมันตรงตัวเป๊ะ รวม XSS payload
  • Expression render object หรือ array ตรง ๆ ไหม ({user} แทนที่จะเป็น {user.name})? มันไม่ error — render เป็น [object Object] เงียบ ๆ พิสูจน์แล้วข้างบน
  • มี { โผล่ในเนื้อความธรรมดาไหม นอก code fence และไม่ได้ตั้งใจให้เป็น expression? มันจะถูก parse เป็นจุดเริ่ม expression (พิสูจน์แล้วข้างบนด้วย error Cannot find name จริง) ไม่ใช่โชว์ตรงตัว
  • class ถูกเขียนเป็น className หรือ for เป็น htmlFor ไหม — copy มาจากตัวอย่าง React โดยไม่แปลงเป็นชื่อ attribute แบบ HTML-native ของ Astro?
  • snippet สมมติไหมว่า <span>/<em> (หรือ inline element คล้าย ๆ กัน) ที่อยู่คนละบรรทัดจะ render มีช่องว่างระหว่างกัน? ตั้งแต่ Astro 7 compressHTML: 'jsx' แปลว่าจะไม่มี — พิสูจน์แล้วข้างบน — เว้นแต่จะใส่ {" "} ชัด ๆ

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

---
const comment = Astro.props.comment; // arbitrary user-submitted text
---
<div class="comment-body" set:html={comment} />
เฉลย

โค้ดนี้ดูเหมือนโค้ด “render field จาก CMS” ธรรมดา แต่ comment ในที่นี้ถูกอธิบายว่าเป็น text ที่ user ส่งมา — input ประเภทเดียวกับที่พิสูจน์แล้วข้างบนว่าผ่าน set:html โดยไม่ถูก escape เลย ถ้า comment มี <img src=x onerror=...> (หรือ <script> tag, หรือ attribute onclick) มันจะทำงานใน browser ของทุกคนที่เข้าดู ตรงตามที่พิสูจน์ไว้ก่อนหน้านี้ ทางแก้คือใช้ set:text={comment} (ถ้าไม่ควรมี markup เลย) หรือส่ง comment ผ่าน HTML sanitizer จริง ๆ (เช่น sanitize-html หรือ DOMPurify ฝั่ง server) ก่อนที่มันจะไปถึง set:html — อย่าเชื่อ string ดิบ ๆ ที่บอกว่า “user-submitted” ในจุดนี้เด็ดขาด

Layer template ของ Astro คือกฎ expression ของ JSX ที่ยึดเข้ากับ HTML จริง: { } คือ “หย่อนเข้าไปใน JavaScript” ที่เหลือคือ HTML ตามที่ browser parse อยู่แล้ว นั่นแปลว่า compiler บังคับใช้ความถูกต้องของ HTML เอง (พิสูจน์แล้วข้างบน: tag ที่ไม่ปิดและซ้อนผิดตอนนี้เป็น build error ไม่ใช่ถูกแก้เงียบ ๆ) และแปลว่า whitespace ระหว่าง element เป็นไปตามกฎ collapse ของ JSX ไม่ใช่ raw-HTML set:html คือทางออกเดียวที่ตั้งใจหนีจาก “ทุกอย่าง escape เป็น default” — ปฏิบัติกับทุกค่าที่ไปถึงมันเหมือนกับว่ามันจะถูก execute โดย browser เพราะมันจะเป็นแบบนั้นจริง ๆ

Escape (default) set:html
ความปลอดภัย ปลอดภัย default — tag render เป็น text ปลอดภัยเท่าที่ input ปลอดภัย — พิสูจน์แล้วข้างบนด้วย XSS payload จริง
ใช้กับ ค่าไหนก็ตามที่คุณไม่ได้เขียน/ตรวจสอบเองว่าเป็น HTML HTML ที่เชื่อถือได้ (field จาก CMS ที่ sanitize แล้ว, markup ที่คุณ generate เอง)
รับ ค่าที่ stringify ได้ทุกแบบ string, Promise<string> (await ให้อัตโนมัติ), หรือ Response (อ่าน body เป็น text) — ไม่ใช่ JSX element ตรง ๆ
  • “object ที่หย่อนเข้า { } จะโชว์ error ให้เห็นความผิดพลาด” พิสูจน์แล้วข้างบน: มัน render เป็น [object Object] ตอน runtime ไม่มี build error เลย
  • “set:html รับแค่ plain string” พิสูจน์แล้วข้างบน: มันรับ Promise<string> และ Response ด้วย แต่ไม่รับ JSX/Astro element ที่ส่งตรง ๆ (เป็น compile error จริงอีกแบบ)
  • “Newline ระหว่าง inline element ใน HTML render เป็นช่องว่างเสมอ เหมือนที่ browser ทำปกติ” compressHTML: 'jsx' ที่เป็น default ของ Astro 7 collapse whitespace นั้น — พิสูจน์แล้วข้างบน <span>hello</span>\n<em>world</em> render “helloworld” ไม่ใช่ “hello world”
  • “{ โดด ๆ ในประโยคแค่ print ตรงตัว เหมือนที่ทำใน HTML ธรรมดา” พิสูจน์แล้วข้างบน: มันถูก parse เป็นจุดเริ่ม expression และ fail build เว้นแต่ content นั้นจะเป็น expression จริง ๆ (หรือ element ใช้ is:raw)
src/components/SetHtmlDemo.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect } from 'vitest';
import SetHtmlDemo from './SetHtmlDemo.astro';
describe('set:html vs set:text', () => {
it('set:html emits raw markup verbatim; set:text escapes the same input', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(SetHtmlDemo);
expect(html).toContain("<img src=x onerror=alert('xss')>");
expect(html).toContain('&lt;img src=x onerror=alert(&#39;xss&#39;)&gt;');
});
});
render list จาก array ใน template ของ Astro ทำยังไง?
set:html ทำอะไร และความเสี่ยงคืออะไร?
อยาก return sibling element สองตัวโดยไม่เพิ่ม wrapper node ใช้อะไร?
ชื่อ attribute ไหนถูกต้องใน template ของ Astro?
พิสูจน์ด้วย Container API กับ [email protected] อันนี้ render ออกมาเป็นอะไร และทำไม?
---
---
<span>hello</span>
<em>world</em>
เกิดอะไรขึ้นตอน build และ render?
---
const obj = { a: 1, b: 2 };
---
<p>{obj}</p>
compiler ของ Astro รายงาน Cannot find name 'and' บน <p>Set theory uses { and } to denote a set.</p> อะไรคือสาเหตุจริง ๆ?