แคตตาล็อกบั๊กที่ AI สร้าง
pattern ทุกอันด้านล่างเขียนขึ้นให้มีหน้าตาเหมือน Astro ที่ AI ผู้ช่วยเขียนออกมาจริง ๆ — ส่วนใหญ่ลื่นไหล น่าเชื่อถือ และมีรูปร่างจาก training data ตั้งแต่ Astro 2 ถึง 4 — แล้วนำไป type-check, build, หรือรันจริงกับ probe Astro 7.3.5 จริง ๆ ผลลัพธ์ astro check, astro build, curl, และ console ที่ยกมาคัดลอกมาตรงตัวจากการรันจริงเหล่านั้น ไม่ได้เขียนจากความจำ สังเกตว่าอันไหน type-check สะอาด: 18 จาก 22 pattern นี้ผ่าน astro check โดยไม่มี error เลย — มีแค่ entry 6, 9, 12, และ 19 เท่านั้นที่เป็น type error จริงในปัจจุบัน ที่เหลือทั้งหมดต้องมี build จริง, request จริง, หรือการอ่าน source ที่ติดตั้งจริงถึงจะเห็นได้ 22 pattern จัดกลุ่มตามลำดับการอ่านเดียวกับ วิธีอ่านโค้ด Astro ที่ไม่ได้เขียนเอง: rendering mode & adapter, island & client directive, content & data, Astro ฝั่ง server, security, performance & caching, และ tooling & production ดู Review Checklist ต่อจากบทเรียนนี้สำหรับ 22 คำถามเดียวกันในรูปแบบหน้าเดียว
Rendering mode & adapter
หัวข้อที่มีชื่อว่า “Rendering mode & adapter”1. output: 'hybrid' ใน config ของ Astro 7
หัวข้อที่มีชื่อว่า “1. output: 'hybrid' ใน config ของ Astro 7”import { defineConfig } from 'astro/config';import node from '@astrojs/node';
export default defineConfig({ output: 'hybrid', adapter: node({ mode: 'standalone' }),});Astro 4.x มี output mode สามแบบ — 'static', 'hybrid', 'server' Astro v5 รวม 'hybrid' เข้ากับ 'static' ซึ่งตั้งแต่นั้นมาหมายถึง “static โดยปริยาย โดยให้ prerender = false เลือก route แต่ละอันเข้าสู่ on-demand rendering” training data ที่เขียนอิง Astro 2–4 (และ tutorial จำนวนมากที่ยังอยู่ออนไลน์) ไม่เคยรู้เรื่องนี้ — output: 'hybrid' คือรูปร่าง config ที่ LLM มักเลือกใช้สำหรับเว็บไซต์ที่ผสม static/dynamic
อาการ — ผลลัพธ์จริง จากการโหลด config นี้จริง ๆ (astro dev/build/sync/preview ตรวจสอบ config เหมือนกันทุกคำสั่ง; harness โหมด check ของคอร์สนี้เก็บ fence astro.config.mjs ของบทเรียนไว้ใน module tree ที่ไม่มีผล เพื่อไม่ให้ config ของบทเรียนหนึ่งไปทับของอีกบทเรียน — ซึ่งเป็นเหตุผลว่าทำไม fence นี้ผ่าน astro check โดยไม่มี error แม้ว่ามันจะพังทันทีในฐานะ config จริง):
[config] Astro found issue(s) with your configuration:
! The output: "hybrid" option has been removed. Use output: "static" (the default) instead, which now behaves the same way.วิธีแก้: ตัด output ออกไปเลย (หรือตั้งเป็น 'static' ค่าเริ่มต้น) และเก็บ adapter ไว้ — prerender = false ต่อ route ทำสิ่งที่ 'hybrid' เคยทำอยู่แล้ว:
import { defineConfig } from 'astro/config';import node from '@astrojs/node';
export default defineConfig({ output: 'static', // the default — already includes per-route prerender = false adapter: node({ mode: 'standalone' }),});จับได้ด้วย: ไม่ใช่ astro check ใน harness ของคอร์สนี้ (fence config ถูกตรวจในฐานะ module ที่ไม่มีผลจริง ไม่ได้ถูกโหลดเป็น config จริง) — มีแค่ astro dev/build/sync/preview ที่ตรวจสอบไฟล์จริง ให้ผลเหมือนกันทุกคำสั่ง พิสูจน์แล้วข้างต้น
2. Route แบบ on-demand ที่ไม่มีการติดตั้ง adapter
หัวข้อที่มีชื่อว่า “2. Route แบบ on-demand ที่ไม่มีการติดตั้ง adapter”---export const prerender = false;const now = new Date().toISOString();---<p>Rendered at {now}</p>ไม่มีอะไรผิดในไฟล์นี้เอง — prerender = false คือวิธีที่บันทึกไว้ตรง ๆ สำหรับให้ route หนึ่งเข้าสู่ on-demand rendering บั๊กอยู่ที่สิ่งที่ ขาดหายไป จากโปรเจกต์: ไม่มี adapter ใน astro.config.mjs โค้ดที่ AI เขียนมักเขียนหน้าเพจถูกต้องแต่ลืมว่า config เป็นชิ้นส่วนที่จำเป็นแยกต่างหาก
อาการ — ผลลัพธ์จริง จาก astro build จริงของโปรเจกต์นี้เป๊ะ ๆ:
[NoAdapterInstalled] Cannot use server-rendered pages without an adapter. Please install and configure the appropriate server adapter for your final deployment. Hint: See https://docs.astro.build/en/guides/on-demand-rendering/ for more information. Error reference: https://docs.astro.build/en/reference/errors/no-adapter-installed/หน้านี้เป๊ะ ๆ — export const prerender = false, การอ่าน Date ธรรมดา — ผ่าน astro check โดยไม่มี error เลย ไม่มีอะไรดูผิดจนกว่าจะลอง build หรือ deploy จริง
วิธีแก้: ติดตั้งและตั้งค่า adapter สำหรับแพลตฟอร์มเป้าหมาย:
import { defineConfig } from 'astro/config';import node from '@astrojs/node';
export default defineConfig({ adapter: node({ mode: 'standalone' }),});จับได้ด้วย: ไม่ใช่ astro check — พิสูจน์แล้วข้างต้น หน้านี้ type-check สะอาด มีแค่ astro build/deploy จริงเท่านั้นที่เจอ NoAdapterInstalled
3. อ่าน Astro.cookies/Astro.session บนหน้าที่ prerender
หัวข้อที่มีชื่อว่า “3. อ่าน Astro.cookies/Astro.session บนหน้าที่ prerender”---const userId = Astro.cookies.get('session_id')?.value;// no `export const prerender` anywhere in this file — the project's own// output default (static) applies, and nothing here overrides it---<h1>Welcome back{userId ? `, ${userId}` : ''}</h1>โดยไม่มีการ override ต่อ route และไม่มี output: 'server' ระดับโปรเจกต์ route นี้ prerender แค่ครั้งเดียวตอน build — ไม่มี request จริงให้ Astro.cookies อ่านได้ userId จะได้ undefined สำหรับทุกคนตลอดไป ไม่ใช่ต่อผู้เข้าชมแต่ละคนตามที่โค้ดสื่อ
อาการ — build warning จริง (Astro.cookies อ่าน request header ภายในตัวเอง เลยขึ้น warning เดียวกันเป๊ะที่โมดูล rendering ของคอร์สนี้บันทึกไว้กับ Astro.session):
[WARN] `Astro.request.headers` was used when rendering the route `src/pages/account.astro'`. `Astro.request.headers` is not available on prerendered pages. If you need access to request headers, make sure that the page is server-rendered using `export const prerender = false;` or by setting `output` to `"server"` in your Astro config to make all your pages server-rendered by default.วิธีแก้: ให้ route นี้เข้าสู่ on-demand rendering:
---export const prerender = false;const userId = Astro.cookies.get('session_id')?.value;---<h1>Welcome back{userId ? `, ${userId}` : ''}</h1>จับได้ด้วย: ไม่ใช่ astro check — ไฟล์นี้ type-check สะอาดทั้งสองแบบ มีแค่ astro build จริง (พิสูจน์แล้วในบทเรียน Rendering & Output Modes) ที่พิมพ์ warning นี้
4. อ่าน Astro.request.headers บนหน้าที่ prerender
หัวข้อที่มีชื่อว่า “4. อ่าน Astro.request.headers บนหน้าที่ prerender”---const ua = Astro.request.headers.get('user-agent') ?? 'unknown';---<p data-ua={ua}>Personalized for your browser</p>ต้นเหตุเดียวกับ entry 3 แค่ API ต่างกัน: การอ่าน header ของ request ตรง ๆ แทนที่จะผ่าน Astro.cookies/Astro.session ก็ยังต้องการ request จริงที่หน้า prerender ไม่มี
อาการ — build warning จริงตัวเดียวกัน กลไกเดียวกัน:
[WARN] `Astro.request.headers` was used when rendering the route `src/pages/analytics-banner.astro'`. `Astro.request.headers` is not available on prerendered pages.ua ถูกฝังไว้ตอน build เป็นค่าอะไรก็ตามที่ synthetic request ของ build process มีติดมา — string เดียวกัน สำหรับ browser ของผู้เข้าชมจริงทุกคน ตลอดไป
วิธีแก้: export const prerender = false บน route เหมือน entry 3
จับได้ด้วย: ไม่ใช่ astro check มีแค่ build จริง พิสูจน์แล้วข้างต้นและใน Rendering & Output Modes
Island & client directive
หัวข้อที่มีชื่อว่า “Island & client directive”5. client:load บนทุก island ไม่ว่าจะอยู่ตำแหน่งไหน
หัวข้อที่มีชื่อว่า “5. client:load บนทุก island ไม่ว่าจะอยู่ตำแหน่งไหน”import { useState } from 'preact/hooks';
export default function WidgetCounter({ start }: { start: number }) { const [n, setN] = useState(start); return <button onClick={() => setN(n + 1)}>Count: {n}</button>;}---import WidgetCounter from '../components/WidgetCounter.tsx';---<WidgetCounter start={0} client:load /><WidgetCounter start={0} client:idle /><WidgetCounter start={0} client:load />client:load คือ directive ที่ LLM เลือกโดยปริยาย บน component ที่โต้ตอบได้ทุกตัว ไม่ว่าจะอยู่ตำแหน่งไหนของหน้า มันไม่ใช่ “JavaScript มากกว่า” ในแง่ bundle ใหญ่กว่า — มันคือ JavaScript ชิ้นเดียวกัน กับ directive อื่น แค่ถูก fetch และ hydrate โดยไม่มีเงื่อนไขใด ๆ เลย ทันทีที่หน้าโหลด ไม่ว่า component จะอยู่เหนือ fold หรือลึกไปสามหน้าจอ
อาการ — markup ที่ build จริง component เดียวกันกับสาม directive ต่างกัน (astro build, tag <astro-island> สำหรับสามการเรียกข้างบน):
<astro-island uid="2equCG" component-url="/_astro/Counter.CZh5qZhJ.js" renderer-url="/_astro/client.CYoOuZrL.js" props="{"start":[0,0]}" ssr client="load" ...><astro-island uid="2equCG" component-url="/_astro/Counter.CZh5qZhJ.js" renderer-url="/_astro/client.CYoOuZrL.js" props="{"start":[0,0]}" ssr client="idle" ...><astro-island uid="2equCG" component-url="/_astro/Counter.CZh5qZhJ.js" renderer-url="/_astro/client.CYoOuZrL.js" props="{"start":[0,0]}" ssr client="load" ...>component-url กับ renderer-url — สอง chunk ที่ประกอบเป็นน้ำหนัก JavaScript ของ island นี้ — เหมือนกันตัวต่อตัวไม่ว่าจะเป็น client:load, client:idle, หรือ client:visible มีแค่ attribute client="..." (ว่า loader script ตัวไหนรัน) ที่ต่างกัน client:load ไม่ได้ส่งโค้ดมากกว่า — มันส่งโค้ด ชุดเดียวกัน โดยไม่มีการเลื่อนเวลาเลย แย่งชิง main thread กับทุกอย่างทันทีที่หน้าโหลด
วิธีแก้: สงวน client:load ไว้กับสิ่งที่ต้องทำงานทันทีที่หน้าใช้งานได้จริง ๆ ตั้งค่าที่เหลือเป็น client:visible โดยปริยาย:
---import WidgetCounter from '../components/WidgetCounter.tsx';---<WidgetCounter start={0} client:load /><WidgetCounter start={0} client:visible /><WidgetCounter start={0} client:visible />จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — ไม่มี lint rule ไม่มี diagnostic ของ astro check ที่ตรวจ directive ที่เร่งเกินไป มีแค่การตรวจ markup <astro-island> ที่ build จริง, Lighthouse trace, หรือแผง Network ของ browser เท่านั้นที่เห็น พิสูจน์แล้วข้างต้น ดู Client Directives สำหรับ directive ทั้งห้าและข้อแลกเปลี่ยนของแต่ละอัน
6. Function prop ที่ส่งเข้า island
หัวข้อที่มีชื่อว่า “6. Function prop ที่ส่งเข้า island”export default function SelectableProductCard({ name, price }: { name: string; price: number }) { return <div class="card"><h3>{name}</h3><p>${price}</p></div>;}---// @expect-error ts(2322) — onSelect isn't declared on SelectableProductCard's Props, so the extra prop is rejected at the type levelimport SelectableProductCard from '../components/SelectableProductCard.tsx';---<SelectableProductCard name="Mug" price={12} onSelect={() => console.log('selected')} client:visible />Prop ของ island เดินทางไป browser เป็น string แบบ JSON ใน attribute props ของ element <astro-island> เพราะฉะนั้นต้อง serializable — function ข้ามการเดินทางนี้ไม่ได้ เพราะ Props type ของ SelectableProductCard ข้างบนซื่อสัตย์ ({ name: string; price: number } ไม่มี onSelect) TypeScript จับความไม่ตรงกันได้ก่อนที่จะ ship อะไรเลย:
อาการ — ผลลัพธ์จริงของ astro check:
src/pages/shop-item.astro:5:33 - error ts(2322): Type '{ name: string; price: number; onSelect: () => void; "client:visible": true; }'is not assignable to type 'IntrinsicAttributes & { name: string; price: number; }'. Property 'onSelect' does not exist on type 'IntrinsicAttributes & { name: string; price: number; }'.แต่ถ้า prop type ของ component หลวม (.jsx ธรรมดา หรือ prop ที่ไม่มี type/any) ไม่มีอะไรหยุด build — และพฤติกรรมตอน runtime ก็ไม่ใช่ error เช่นกัน มันคือข้อมูลหายไปแบบเงียบ ๆ: serializer ของ Astro ตกไปใช้ JSON.stringify สำหรับอะไรก็ตามที่มันไม่รู้จักเป็นพิเศษ และการ stringify function ในตำแหน่งของ array จะได้ null ไม่ใช่ exception ถ้า component เรียก props.onSelect() มันจะ throw onSelect is not a function ตอน hydration — runtime error ที่งงงวยหนึ่งหน้าเพจหลังจากความผิดพลาดจริง
วิธีแก้: เก็บ handler ไว้ฝั่ง server (Action หรือ <form>) หรือปรับโครงสร้างให้ island รับแค่ข้อมูลที่ serializable แล้ว dispatch event ของตัวเอง:
---import SelectableProductCard from '../components/SelectableProductCard.tsx';---<SelectableProductCard name="Mug" price={12} client:visible />จับได้ด้วย: astro check ทันที เมื่อ Props type ของ component ซื่อสัตย์ — พิสูจน์แล้วข้างต้นด้วย ts(2322) จริง ถ้า prop type หลวม/any ไม่มีอะไรจับได้เลย ดู Client Directives สำหรับความผิดพลาดรูปแบบเดียวกันเป๊ะที่พิสูจน์กับ DirectiveCounter ของคอร์สนี้เอง รวมถึงกรณี null แบบเงียบ
7. client:only โดยไม่ระบุ framework
หัวข้อที่มีชื่อว่า “7. client:only โดยไม่ระบุ framework”import { useState } from 'preact/hooks';
export default function OnlyCounter({ start }) { const [n, setN] = useState(start); return <button onClick={() => setN(n + 1)}>Count: {n}</button>;}---// src/pages/only-demo.astro — a project with both @astrojs/preact and @astrojs/react installedimport OnlyCounter from '../components/OnlyCounter.jsx';---<OnlyCounter start={0} client:only />client:only ข้ามการ render บน server ไปเลย เพราะฉะนั้น Astro ไม่มีอะไร render ไว้ให้ infer framework ได้ ถ้าติดตั้ง UI integration แค่ หนึ่งตัว Astro จะ infer ให้เงียบ ๆ — โค้ดเป๊ะ ๆ นี้ build ผ่านสบายบนไซต์ของคอร์สนี้ที่มี integration เดียว (Preact) ทันทีที่มี integration ที่รองรับ JSX ตัวที่ สอง ถูกเพิ่มเข้ามาที่ไหนก็ตามในโปรเจกต์ การ infer นั้นจะกำกวมทันที และการ build client:only เปล่า ๆ จะ throw จริง:
[NoClientOnlyHint] Unable to render `Counter`. When using the `client:only` hydrationstrategy, Astro needs a hint to use the correct renderer. Hint: Did you mean to pass `client:only="react|preact|solid-js|vue (jsx)"`? Error reference: https://docs.astro.build/en/reference/errors/no-client-only-hint/ส่วนที่อันตรายสำหรับการ review: โค้ดนี้ ถูกต้องวันนี้ บนโปรเจกต์ที่มี integration เดียว และกลายเป็น build ล้มเหลวในวันที่เพื่อนร่วมทีมเพิ่ม framework ตัวที่สองสำหรับ component ที่ไม่เกี่ยวข้องกันเลย — โดยไม่มีการเปลี่ยนแปลงไฟล์นี้เลย
วิธีแก้: ระบุชื่อ framework เสมอ ไม่ว่าจะติดตั้ง integration กี่ตัวในตอนนี้:
---import OnlyCounter from '../components/OnlyCounter.jsx';---<OnlyCounter start={0} client:only="preact" />จับได้ด้วย: ไม่ใช่ astro check (การ infer renderer เกิดตอน build ไม่ใช่ตอน type-check) มีแค่ astro build จริง และเฉพาะเมื่อมี integration ตัวที่สองอยู่จริง ดู Client Directives สำหรับ error เป๊ะ ๆ นี้ที่พิสูจน์กับ build จริงที่มีสอง integration
8. <slot /> ใน component ของ Preact/React
หัวข้อที่มีชื่อว่า “8. <slot /> ใน component ของ Preact/React”export default function Card({ title }: { title: string }) { return ( <div class="card"> <h2>{title}</h2> <slot /> </div> );}---import Card from '../components/Card.tsx';---<Card title="Hello"><p>child content</p></Card><slot /> เป็น syntax template ของ Astro เอง มีความหมายแค่ในตัว compiler ของไฟล์ .astro เท่านั้น ภายใน component ของ framework <slot /> เป็นแค่ JSX ของ element <slot> ของ HTML ธรรมดาที่ว่างเปล่า (ตัวเดียวกับที่ Shadow DOM ใช้) — มันไม่เกี่ยวอะไรกับการ render สิ่งที่ถูกส่งมาเป็น children เลย และ children ก็ถูกทิ้งไปแบบเงียบ ๆ
อาการ — ผลลัพธ์ที่ build จริง astro build, <p>child content</p> ไม่ปรากฏเลย:
<!DOCTYPE html><div class="card"><h2>Hello</h2><slot></slot></div>build ไม่มี error และไม่มี warning เลย — tag <slot> เปล่า ๆ ตามธรรมชาติ render อยู่ใน output และ <p>child content</p> หายไปทั้งหมด ไม่มีอยู่ที่ไหนในหน้าเลย
วิธีแก้: ใช้กลไก children ของ framework เองแทน:
import type { ComponentChildren } from 'preact';
export default function CardFixed({ title, children }: { title: string; children?: ComponentChildren }) { return ( <div class="card"> <h2>{title}</h2> {children} </div> );}จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — <slot /> เป็น JSX ที่ถูกต้อง (element ของ DOM จริง) เพราะฉะนั้นทั้ง astro check และ build ไม่เคยตี flag เลย พิสูจน์แล้วข้างต้น build เสร็จสมบูรณ์ สะอาด ในขณะที่ทิ้ง content ไปแบบเงียบ ๆ
Content & data
หัวข้อที่มีชื่อว่า “Content & data”9. Astro.glob() ใน Astro 7
หัวข้อที่มีชื่อว่า “9. Astro.glob() ใน Astro 7”---// @expect-error ts(2339) — Astro.glob no longer exists on the globalconst posts = await Astro.glob('../data/blog/*.md');---<ul> {posts.map((p) => <li>{p.frontmatter.title}</li>)}</ul>Astro.glob() ถูก deprecate ใน Astro 5 และถูกเอาออกทั้งหมดใน Astro 6 — training data ก่อนหน้านั้นยังผลิตโค้ดแบบนี้อยู่ตลอด เพราะมันเคยเป็นวิธีมาตรฐานในการดึงไฟล์ Markdown จำนวนมากในท้องถิ่นมาทีเดียวมานานหลายปี
อาการ — ผลลัพธ์จริงของ astro check ก่อนที่ build จะรันเลยด้วยซ้ำ:
error ts(2339): Property 'glob' does not exist on type'Readonly<AstroGlobal<Record<string, any>, AstroComponentFactory, Record<string, string | undefined>>>'.วิธีแก้: content collection ที่มี glob loader มาแทนที่ พร้อมการตรวจสอบ schema และ type ที่ generate ให้ ซึ่ง API เก่าไม่เคยมี:
---import { getCollection } from 'astro:content';
const posts = await getCollection('blog');---<ul> {posts.map((p) => <li>{p.data.title}</li>)}</ul>จับได้ด้วย: astro check ทันที — type error จริงในปัจจุบัน ไม่ใช่เรื่องประหลาดใจตอน runtime ดู Data Fetching สำหรับประวัติการเอาออกเต็ม ๆ และทางเลือก live collection สำหรับข้อมูลที่ต้องสดจริง ๆ ตอน request
10. Dynamic route แบบ static ที่ไม่มี getStaticPaths()
หัวข้อที่มีชื่อว่า “10. Dynamic route แบบ static ที่ไม่มี getStaticPaths()”---const { slug } = Astro.params;---<p>Product: {slug}</p>ส่วน dynamic ของ route ([slug]) ในโปรเจกต์ static ต้องมี getStaticPaths() เพื่อบอก Astro ว่าจะสร้างหน้าไหนบ้าง — ถ้าไม่มี ก็ไม่มีอะไรให้ build วนสร้าง ไฟล์นี้เป๊ะ ๆ ผ่าน type-check โดยไม่มี error: Astro.params.slug เป็นการอ่านที่ถูกต้องสมบูรณ์แบบ
อาการ — build error จริงที่มีชื่อ:
[GetStaticPathsRequired] `getStaticPaths()` function is required for dynamic routes.Make sure that you `export` a `getStaticPaths()` function from your dynamic route. Hint: If you meant for this route to be server-rendered, set `export const prerender = false;` in the page.วิธีแก้: export getStaticPaths() (หรือถ้า route นี้ต้องการเป็น on-demand จริง ๆ ก็ถอนตัวจาก static generation ไปเลย):
---import { getCollection } from 'astro:content';
export async function getStaticPaths() { const posts = await getCollection('blog'); return posts.map((post) => ({ params: { slug: post.id } }));}
const { slug } = Astro.params;---<p>Product: {slug}</p>จับได้ด้วย: ไม่ใช่ astro check — พิสูจน์แล้วข้างต้น (และใน Dynamic Routes) ไฟล์นี้เป๊ะ ๆ type-check สะอาด มีแค่ astro build/astro sync รอบสร้าง route จริงเท่านั้นที่รายงาน GetStaticPathsRequired
11. Frontmatter ที่ไม่ตรงกับ schema ของ collection
หัวข้อที่มีชื่อว่า “11. Frontmatter ที่ไม่ตรงกับ schema ของ collection”import { defineCollection } from 'astro:content';import { z } from 'astro/zod';import { glob } from 'astro/loaders';
const blog = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/data/blog' }), schema: z.object({ title: z.string(), pubDate: z.coerce.date(), }),});
export const collections = { blog };---title: "Bad Post"pubDate: "soon"---pubDate: z.coerce.date() รัน "soon" ผ่าน new Date("soon") ซึ่งได้ object Invalid Date ของ JavaScript จริง ๆ — ไม่ใช่ string ไม่ใช่การ parse ที่ล้มเหลวแล้วถูกจับ — และ Zod รายงาน object นั้นว่าเป็น type ผิด
อาการ — error จริง ระบุชื่อ collection, entry, และ field:
[InvalidContentEntryDataError] blog → bad-post data does not match collection schema.pubDate: Expected type "date", received "object"“received object” (ไม่ใช่ “string”) คือสัญญาณว่า coercion สร้าง Invalid Date ขึ้นมา ไม่ใช่แค่ปฏิเสธ string ตรง ๆ — ควรอ่านให้ละเอียด เพราะง่ายที่จะสมมติว่าการ parse ล้มเหลวใด ๆ จะบอกว่า “received string”
วิธีแก้: เขียนวันที่จริงที่ parse ได้ใน frontmatter (pubDate: 2026-01-03) — schema และ loader คงเดิมตามที่เขียนไว้
จับได้ด้วย: ไม่ใช่ harness โหมด check เริ่มต้นของคอร์สนี้ (content.config.ts ของบทเรียนเอง type-check เป็น module ธรรมดา; ชื่อ/field ของ collection มาจาก collection คงที่ของ probe คอร์สนี้ ไม่ใช่ schema ของบทเรียน) มีแค่ astro sync/astro build จริงกับไฟล์ของบทเรียนเอง พิสูจน์แล้วใน Schemas & References ที่รายงาน InvalidContentEntryDataError ทันที
12. entry.render() แทน render(entry)
หัวข้อที่มีชื่อว่า “12. entry.render() แทน render(entry)”---// @expect-error ts(2551) — render is not a method on a collection entry; see proseimport { getEntry } from 'astro:content';
const post = await getEntry('blog', 'hello-world');if (!post) throw new Error('Post not found');
const { Content } = await post.render();---<article><Content /></article>entry.render() ถูก deprecate ตั้งแต่ Astro 5 และ Astro 6 เอาออกทั้งหมด — training data ก่อนหน้านั้นยังเขียนมันเป็นการเรียก method อยู่ entry ที่ได้จาก getCollection/getEntry เป็น object ธรรมดาที่ serializable (เพื่อให้ส่งเป็น page props จาก getStaticPaths ได้ เป็นต้น) method จะรอดจากการเดินทางนั้นไม่ได้ ซึ่งเป็นเหตุผลเป๊ะ ๆ ว่าทำไม render ถึงมีอยู่แค่ในรูป standalone import ตอนนี้ และทำไม type ของ entry ที่ generate ถึงไม่มี method render เลย
อาการ — ผลลัพธ์จริงของ astro check ในปัจจุบัน กับ type ที่ generate จากการติดตั้ง 7.3.5 (type error จริง ไม่ใช่แค่เรื่องประหลาดใจตอน runtime):
error ts(2551): Property 'render' does not exist on type '{ id: string; body?: string; collection: "blog";data: {...}; rendered?: RenderedContent; filePath?: string; digest?: string | number }'. Did you mean 'rendered'?การข้าม type system ไป (entry ที่ type หลวม หรือแปะ // @ts-expect-error เพื่อปิดเสียงมัน) ยังคงล้มเหลวตอน runtime อยู่ดี โยนมาจากในหน้าที่ render:
[ERROR] TypeError: post.render is not a function at .../chunks/index_*.mjs:699:33 at async renderToString (.../chunks/server_*.mjs:1861:25)[ERROR] [build] Caught error rendering /blog/hello-world/: TypeError: post.render is not a functionวิธีแก้: import render จาก astro:content และเรียกเป็น standalone function:
---import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'hello-world');if (!post) throw new Error('Post not found');
const { Content } = await render(post);---<article><Content /></article>จับได้ด้วย: astro check ทันที — type ของ entry ที่ generate ไม่มี method render เลย พิสูจน์แล้วข้างต้นด้วย ts(2551) จริงพร้อมคำแนะนำ “Did you mean ‘rendered’?” ดู Content Collections สำหรับ TypeError ตอน runtime ที่เกิดขึ้นถ้า type error ถูกข้ามไป
Astro ฝั่ง server
หัวข้อที่มีชื่อว่า “Astro ฝั่ง server”13. Action ที่ไม่มีการตรวจสอบสิทธิ์
หัวข้อที่มีชื่อว่า “13. Action ที่ไม่มีการตรวจสอบสิทธิ์”import { defineAction, ActionError } from 'astro:actions';import { z } from 'astro/zod';
const db = { comments: { delete: async (id: string) => ({ ok: true }) } };
export const server = { // "the delete button only shows for the comment's author, so this is safe" deleteComment: defineAction({ input: z.object({ commentId: z.string() }), handler: async (input) => { await db.comments.delete(input.commentId); return { ok: true }; }, }),};Astro compile ทุก action เป็น HTTP endpoint จริงที่ /_actions/<name> — เข้าถึงได้โดยใครก็ตามที่รู้หรือเดาชื่อถูก ไม่ว่าจะเคยโหลดหน้าที่มีปุ่มลบหรือไม่ก็ตาม comment ในโค้ดที่ generate มาบอกบั๊กจริงตรง ๆ: การซ่อนปุ่มควบคุมสิ่งที่ UI แสดง ไม่ใช่สิ่งที่ endpoint ยอมรับ ไม่มีอะไรตรงนี้ตรวจว่าใครเป็นคนเรียกก่อนจะลบ
อาการ — ผลลัพธ์จริง จาก build ที่รันอยู่ของ handler นี้เป๊ะ ๆ ไม่มี session cookie เลย:
$ curl -s -X POST http://127.0.0.1:4351/_actions/deleteComment \ -H "Content-Type: application/json" -d '{"commentId":"42"}' \ -w "\nHTTP %{http_code}\n"{"ok":true}HTTP 200200 โดยไม่มีการตรวจ auth เลย — ผู้เรียกนิรนามคนไหนก็ลบ comment ไหนก็ได้ด้วยการเดาหรือไล่ commentId เหมือนที่บทเรียน Actions พิสูจน์ไว้ด้วยตัวอย่าง deletePost ของตัวเองและ 401 จริงเมื่อแก้แล้ว
วิธีแก้: ปฏิเสธก่อน ก่อนจะแตะฐานข้อมูล และจำกัดขอบเขตการแก้ไขให้เฉพาะผู้เรียก:
import { defineAction, ActionError } from 'astro:actions';import { z } from 'astro/zod';
const db = { comments: { deleteOwned: async (id: string, userId: string) => ({ ok: true }) } };
export const server = { deleteComment: defineAction({ input: z.object({ commentId: z.string() }), handler: async (input, context) => { if (!context.locals.user) { throw new ActionError({ code: 'UNAUTHORIZED', message: 'You must be signed in.' }); } await db.comments.deleteOwned(input.commentId, context.locals.user.id); return { ok: true }; }, }),};จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — ไม่มี diagnostic ของ astro check ไม่มี lint rule ที่ตรวจการขาด auth check ในตัว handler ของ Action มีแค่ curl จริงกับ endpoint (พิสูจน์แล้วข้างต้น และด้วย 401 จริงเมื่อแก้แล้วใน Actions) เท่านั้นที่เห็น
Security
หัวข้อที่มีชื่อว่า “Security”14. Secret ที่ถูกส่งเข้า prop ของ island ที่ hydrate
หัวข้อที่มีชื่อว่า “14. Secret ที่ถูกส่งเข้า prop ของ island ที่ hydrate”export default function CheckoutBadge({ apiKey }: { apiKey: string }) { return <span data-key={apiKey}>configured</span>;}---import CheckoutBadge from '../components/CheckoutBadge.tsx';
const stripeSecretKey = import.meta.env.STRIPE_SECRET_KEY; // read server-side — looks safe---<CheckoutBadge apiKey={stripeSecretKey} client:load />การอ่าน secret ผ่าน import.meta.env ใน frontmatter เป็นฝั่ง server จริง ๆ และในตัวมันเองก็ไม่มีปัญหา — นี่ไม่ใช่จุดรั่ว จุดรั่วคือบรรทัดถัดไปเลย: การส่งค่าที่ resolve แล้วนั้นเป็น prop ไปยัง island แบบ client:* prop ของ island ทุกตัวถูก serialize ลงใน HTML ของหน้าเพจ เพื่อให้ script การ hydrate ของ browser อ่านกลับมาได้ — และการ serialize นั้นไม่มีแนวคิดเรื่อง “อันนี้เป็น secret” เลย
อาการ — HTML ที่ build จริง มองเห็นได้ผ่าน view-source โดยใครก็ตาม ไม่มี build error หรือ warning เลย:
<astro-island ... props="{"apiKey":[0,"sk_live_TOTALLY_SECRET_VALUE_12345"]}" ssr client="load" ...> <span data-key="sk_live_TOTALLY_SECRET_VALUE_12345">configured</span></astro-island>ค่า secret จริงอยู่ในข้อความธรรมดา สองครั้ง ใน response ที่ browser ของผู้เข้าชมทุกคนได้รับ การเปลี่ยนไปใช้ astro:env/server ที่ตรวจสอบด้วย schema (access: 'secret') ไม่ได้ แก้ปัญหานี้เอง — boundary นั้นหยุดแค่การ import module เข้าโค้ดฝั่ง client เท่านั้น (build error ServerOnlyModule จริง แยกต่างหาก พิสูจน์แล้วใน SSR & Adapters) มันไม่ได้บอกอะไรเลยว่า server component ทำอะไรกับค่านั้น หลังจาก import มาถูกต้องแล้ว พิสูจน์แล้วโดยตรงกับ build จริง: การเพิ่ม STRIPE_SECRET_KEY เข้า env.schema ด้วย context: 'server', access: 'secret', import จาก astro:env/server ใน frontmatter (การ import ที่ถูกต้องสมบูรณ์แบบ) แล้วส่งค่าที่ resolve แล้วนั้นเข้าบรรทัด <CheckoutBadge apiKey={...} client:load /> เดียวกันเป๊ะ ก็ยังได้ attribute props ที่รั่วเหมือนเดิม — schema บังคับใช้ boundary ของการ import ไม่ใช่สิ่งที่ข้ามมันไปหลังจากนั้น
วิธีแก้: อย่าให้ค่าที่ resolve แล้วของ secret ไปถึง prop ที่ client hydrate เลย ไม่ว่า API ไหนอ่านมันมา ตรวจสอบฝั่ง server แล้วส่งให้ client แค่ผลลัพธ์ที่ไม่ใช่ secret:
---import CheckoutBadge from '../components/CheckoutBadge.tsx';
const configured = Boolean(import.meta.env.STRIPE_SECRET_KEY); // a boolean, not the secret itself---<CheckoutBadge apiKey={configured ? 'configured' : 'missing'} client:load />จับได้ด้วย: ไม่มีอะไรอัตโนมัติสำหรับความผิดพลาดแบบส่ง prop ต่อ — error ServerOnlyModule ของ astro:env (พิสูจน์แล้วใน SSR & Adapters) จับได้แค่การ import ตรงเข้าโค้ดฝั่ง client เท่านั้น ซึ่งเป็นความผิดพลาดคนละแบบ มีแค่การอ่าน HTML ที่ build จริง (หรือ response ที่ browser จริงได้รับ) เท่านั้นที่เห็นอันนี้
15. set:html กับ input ของ user ที่ไม่ผ่านการกรอง
หัวข้อที่มีชื่อว่า “15. set:html กับ input ของ user ที่ไม่ผ่านการกรอง”---const { commentHtml } = Astro.props; // from a comment field a user submitted---<div class="comment" set:html={commentHtml} />set:html ข้ามการ escape ค่าเริ่มต้นของ Astro — เหมือน dangerouslySetInnerHTML ใน React หรือ v-html ใน Vue เป๊ะ — และ type ของมันคือ any เพราะฉะนั้นไม่มีอะไรในระดับ type ที่แยก string ที่เชื่อถือได้จาก CMS ออกจาก input ดิบที่ผู้โจมตีควบคุมได้
อาการ — ผลลัพธ์จริง render ผ่าน Container API ด้วย string ที่มีรูปร่างเป็น XSS ที่ field comment จริงอาจมีได้:
<div class="comment"><img src="x" onerror="alert(document.cookie)"></div>tag <img onerror=...> render ออกมาตรงตัว — payload XSS ที่รันได้จริง ทันทีที่ browser parse มัน ไม่มีอะไรใน build หรือ astro check ตี flag เรื่องนี้ — งานทั้งหมดของ set:html คือการข้าม escape
วิธีแก้: ส่ง content ที่ไม่เชื่อถือผ่าน sanitizer HTML จริง ๆ ก่อนที่จะถึง set:html เสมอ (อย่าเขียน regex ตัดแท็กเอง):
---// Stand-in for a real sanitizer (e.g. `sanitize-html`) — replace with your installed package.declare function sanitizeHtml(html: string): string;
const { commentHtml } = Astro.props;const safeHtml = sanitizeHtml(commentHtml);---<div class="comment" set:html={safeHtml} />จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — type ของ set:html คือ any โดยตั้งใจ เพราะฉะนั้น astro check ไม่มีรูปร่างให้ปฏิเสธ มีแค่การ render จริง (พิสูจน์แล้วข้างต้นด้วย Container API) เท่านั้นที่เห็น payload ทำงาน ดู Astro Syntax Deep Dive สำหรับการเปรียบเทียบ set:html/set:text แบบเต็ม
🐛 หา bug ในโค้ดนี้
---const { commentHtml } = Astro.props;---<div class="comment" set:html={commentHtml} />เฉลย
type ของ set:html คือ any เพราะฉะนั้นไม่มีอะไรทำให้ astro check ล้มเหลว บั๊กอยู่ที่ว่า commentHtml มาจากไหน ซึ่งไม่มี fence หรือ type signature ไหนบอกคุณได้ พิสูจน์ผ่าน Container API: ค่าที่มีรูปร่างแบบผู้โจมตีอย่าง <img src="x" onerror="alert(document.cookie)"> render ออกมาตรงตัว ทำงานทันทีที่ browser parse มัน วิธีแก้คือส่งค่าใด ๆ ที่ย้อนกลับไปหา user input ผ่าน sanitizer จริง (sanitize-html หรือเทียบเท่า) ก่อนที่จะถึง set:html เสมอ — ไม่ใช่ regex ที่เขียนเอง และไม่ใช่ “คงไม่เป็นไรหรอก มันแค่ comment”
Performance & cache
หัวข้อที่มีชื่อว่า “Performance & cache”16. is:inline บน script ที่ตั้งใจให้ใช้ร่วมกัน
หัวข้อที่มีชื่อว่า “16. is:inline บน script ที่ตั้งใจให้ใช้ร่วมกัน”<div class="analytics-widget"> <script is:inline> console.log('tracking pageview'); </script></div>---import Analytics from '../components/Analytics.astro';---<Analytics /><Analytics /><Analytics /><script> ธรรมดา (ไม่ใช่ is:inline) ใน component ของ Astro ถูก bundle แค่ครั้งเดียวต่อหน้า ไม่ว่า component จะถูกใช้กี่ครั้ง — is:inline ถอนตัวออกจากสิ่งนั้นทั้งหมด ปล่อย tag ออกมาตรงตัว ครั้งละหนึ่ง instance โค้ดที่ AI เขียนมักเลือก is:inline แบบสัญชาตญาณ (มักคัดลอก analytics snippet ดิบ ๆ มาตรงตัว) โดยไม่รู้ตัวว่ามันปิดการ deduplicate ที่ script ปกติได้ฟรีไปด้วย
อาการ — ผลลัพธ์ที่ build จริง สาม instance ของ component เดียวกัน:
<div class="analytics-widget"><script>console.log('tracking pageview');</script></div><div class="analytics-widget"><script>console.log('tracking pageview');</script></div><div class="analytics-widget"><script>console.log('tracking pageview');</script></div><script> เหมือนกันตรงตัวสามอัน — การเรียก tracking รันสามครั้งในการโหลดหน้าเดียว และหน้าเพจ ship โค้ดสามชุดที่ควรจะเป็นชุดเดียว
วิธีแก้: ตัด is:inline ออก เว้นแต่ต้องการ tag ดิบที่ไม่ผ่านการประมวลผล ต่อ instance จริง ๆ — script ธรรมดา bundle และ deduplicate ให้อัตโนมัติ:
<div class="analytics-widget"> <script> console.log('tracking pageview'); </script></div>จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — is:inline คือการถอนตัวที่ถูกต้อง ตั้งใจ เพราะฉะนั้นทั้ง astro check และ build ไม่ถือว่าเป็น error มีแค่การตรวจ HTML ที่ build จริง (พิสูจน์แล้วข้างต้น) เท่านั้นที่เห็นการซ้ำ ดู Styles & Scripts สำหรับการเปรียบเทียบ bundled กับ is:inline แบบเต็ม
17. สมมติว่า routeRules/cache ตั้ง Cache-Control ของ browser
หัวข้อที่มีชื่อว่า “17. สมมติว่า routeRules/cache ตั้ง Cache-Control ของ browser”import { defineConfig, memoryCache } from 'astro/config';import node from '@astrojs/node';
export default defineConfig({ adapter: node({ mode: 'standalone' }), cache: { provider: memoryCache() }, routeRules: { // "cache this response at the browser/CDN for an hour" '/blog/[slug]': { maxAge: 3600 }, },});routeRules/cache คือ cache layer ภายในของ Astro เอง ไม่ใช่ทางลัดสำหรับตั้ง Cache-Control การอ่าน compiler ที่ติดตั้งตรง ๆ maxAge ของ route ที่ตรงกันถูกแปลงเป็น header CDN-Cache-Control ภายในที่ขั้นตอนถัดไปลบทิ้งก่อนที่ response จะถึง client
อาการ — request จริงกับ route ที่ build จากด้วย config ข้างบน render UUID สุ่มต่อการ render:
$ curl -s -D - http://127.0.0.1:4701/blog/hello-world/HTTP/1.1 200 OKcontent-type: text/htmlx-astro-cache: MISS<p>5e3bae66-70b5-4adf-9236-ffa79ff2f9ec</p>
$ curl -s -D - http://127.0.0.1:4701/blog/hello-world/HTTP/1.1 200 OKcontent-type: text/htmlx-astro-cache: HIT<p>5e3bae66-70b5-4adf-9236-ffa79ff2f9ec</p>Astro serve render เดิมซ้ำ (MISS → HIT) — แต่ทั้งสอง response ไม่มี header Cache-Control หรือ CDN-Cache-Control ที่ client มองเห็นเลย routeRules ตัดสินว่า Astro เอง จะ re-run หน้าเพจไหม มันไม่ได้บอกอะไร browser เลย
วิธีแก้: ตั้ง header ที่ browser เห็นด้วยตัวเอง เป็นบรรทัดอิสระของตัวเอง คู่กับ routeRules ถ้าต้องการทั้งสอง layer:
---export const prerender = false;Astro.response.headers.set('Cache-Control', 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400');---จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — config ทั้งสองถูกต้อง เพราะฉะนั้นทั้ง astro check และ build ไม่คัดค้าน มีแค่ curl จริงกับ server ที่ build แล้ว (พิสูจน์แล้วข้างต้น และใน Caching & Edge) เท่านั้นที่เห็นว่า header ที่ browser เห็นหายไป
18. transition:persist ที่ไม่มี element ที่ตรงกันบนหน้าปลายทาง
หัวข้อที่มีชื่อว่า “18. transition:persist ที่ไม่มี element ที่ตรงกันบนหน้าปลายทาง”---import { ClientRouter } from 'astro:transitions';import AudioPlayer from '../components/AudioPlayer.tsx';---<html> <head><ClientRouter /></head> <body> {Astro.url.pathname === '/' && <AudioPlayer transition:persist client:load />} <slot /> </body></html>ผู้เขียนต้องการให้ audio player เล่นต่อเนื่องข้ามการนำทาง เลยเพิ่ม transition:persist — แต่ครอบ island ไว้ในเงื่อนไขที่ render แค่บนหน้าแรกเท่านั้น client router ของ Astro จับคู่ element ที่ persist ระหว่างเอกสาร เก่า กับ ใหม่ โดยไล่หาหน้าที่กำลังจะออกไปหา [data-astro-transition-persist] แล้วค้นหา id เดียวกันบนหน้าที่กำลังเข้ามา ถ้าหน้าที่เข้ามาไม่มี element ที่ตรงกันเลย (เพราะเงื่อนไขข้างบนก็ไม่ได้ render มันที่นั่นเหมือนกัน) ไม่มีอะไร error — การจับคู่ถูกข้ามไปเฉย ๆ
อาการ — พิสูจน์โดยตรงจากตรรกะการสลับของ compiler ที่ติดตั้ง (astro/dist/transitions/swap-functions.js) loop การจับคู่เป๊ะ ๆ ที่ตัดสินว่า element จะ persist หรือไม่:
for (const el of oldElement.querySelectorAll(`[${PERSIST_ATTR}]`)) { const id = el.getAttribute(PERSIST_ATTR); const newEl = newElement.querySelector(`[${PERSIST_ATTR}="${id}"]`); if (!newEl) continue; persistPairs.push({ old: el, newTarget: newEl }); // ...}if (!newEl) continue; — ไม่มี error ไม่มี warning ไม่มีข้อความ console ใด ๆ เลย การนำทางออกจาก / แค่สลับ DOM ของหน้าใหม่ตามปกติ — element เสียง (และ state การเล่น) ถูกทิ้งเหมือน node ที่ไม่ persist ตัวอื่น บั๊กมองไม่เห็นใน source เลย: transition:persist สะกดถูกต้องและทำตามที่มันบอกเป๊ะ ๆ บนหน้าเดียวที่มัน render อยู่จริง
วิธีแก้: render element ที่ persist แบบไม่มีเงื่อนไขใน layout ที่ใช้ร่วมกัน (หรือบนทุกหน้าที่มันต้องรอดข้ามไป) เพื่อให้ทั้งเอกสารที่ออกไปและเข้ามามี node ที่ตรงกันเสมอ:
---import { ClientRouter } from 'astro:transitions';import AudioPlayer from '../components/AudioPlayer.tsx';---<html> <head><ClientRouter /></head> <body> <AudioPlayer transition:persist="player" client:load /> <slot /> </body></html>จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — พิสูจน์แล้วข้างต้นโดยตรงจาก source ที่ติดตั้ง การไม่พบคู่ที่ตรงกันคือ continue แบบเงียบ ไม่ใช่เส้นทาง error ดู View Transitions สำหรับ markup ที่ build จริงของ transition:persist ตอนที่มันจับคู่ได้
19. <ViewTransitions /> — ชื่อ import เก่า
หัวข้อที่มีชื่อว่า “19. <ViewTransitions /> — ชื่อ import เก่า”---// @expect-error ts(2305) — ViewTransitions is not exported by astro:transitions; see proseimport { ViewTransitions } from 'astro:transitions';---<html> <head><ViewTransitions /></head> <body><slot /></body></html>Astro รุ่นเก่า (และ tutorial จำนวนมากที่ยังอยู่ออนไลน์) เรียก component นี้ว่า <ViewTransitions /> มันไม่ได้แค่ deprecate หรือ alias ใน 7.3.5 — มันไม่ถูก export เลย
อาการ — ผลลัพธ์จริง build จริง:
[ERROR] [vite] ✗ Build failed[MISSING_EXPORT] "ViewTransitions" is not exported by "\0astro:transitions". ╭─[ src/layouts/Layout.astro:2:10 ] 2 │ import { ViewTransitions } from "astro:transitions"; │ ───────┬─────── │ ╰───────── Missing exportวิธีแก้: component ถูกเปลี่ยนชื่อ — module specifier เดิม ตำแหน่งเดิมใน <head> แค่ชื่อใหม่:
---import { ClientRouter } from 'astro:transitions';---<html> <head><ClientRouter /></head> <body><slot /></body></html>จับได้ด้วย: astro check ทันที — ambient module declaration ของ astro:transitions ไม่มี ViewTransitions อยู่ในรายการแล้ว เพราะฉะนั้นนี่คือ ts(2305) จริงในปัจจุบัน มันยัง fail เหมือนกันเป๊ะเป็น build error หนักตอนที่ Vite พยายาม resolve module ดู View Transitions สำหรับการเปลี่ยนชื่อแบบเต็มและเหตุผลที่มันเป็น hard error ไม่ใช่การ deprecate แบบนุ่มนวล
20. สมมติว่า image.domains/remotePatterns กันรูปภาพระยะไกลทุกรูป
หัวข้อที่มีชื่อว่า “20. สมมติว่า image.domains/remotePatterns กันรูปภาพระยะไกลทุกรูป”---import { Image } from 'astro:assets';
const { product } = Astro.props;// astro.config.mjs only lists "cdn.example.com" in image.domains---<Image src={product.imageUrl} width={600} height={400} alt={product.name} />product.imageUrl ชี้ไปที่ host ที่ไม่อยู่ใน image.domains/remotePatterns — ผู้เขียนสมมติว่า allowlist ป้องกัน <Image> ระยะไกลทุกอัน แต่การตรวจสอบรันแค่บนเส้นทางที่ต้อง fetch ไฟล์ (การ optimize มัน หรืออ่านมิติจริงผ่าน inferSize) เพราะมี width/height ชัดเจนอยู่แล้ว Astro ไม่จำเป็นต้อง fetch อะไรเลย เพราะฉะนั้น allowlist ไม่ถูกตรวจสอบเลย
อาการ — ผลลัพธ์จริง สิ่งนี้ build และรัน on-demand ได้โดยไม่มี error เลย:
<img src="https://not-allowed.example.com/photo.jpg" alt="Widget" loading="lazy" decoding="async" width="600" height="400">image service ในเครื่องของ Astro ข้าม optimization แบบเงียบ ๆ และปล่อย URL ดิบที่ไม่ได้รับอนุญาตออกมาโดยไม่แตะต้อง — ไม่มี build ล้มเหลว ไม่มี warning แค่ <img> ธรรมดาที่ดูเหมือนผ่าน <Image> แต่ไม่ได้ผ่านจริง
วิธีแก้: เพิ่ม host จริงเข้า image.domains/remotePatterns (เพื่อให้ optimize จริง) หรือใช้ <img> ธรรมดาโดยตั้งใจเมื่อไม่ต้องการ optimize สำหรับ host นั้นจริง ๆ — อย่าพึ่ง <Image> บวก dimension ที่ hardcode ไว้เพื่อบังคับ allowlist เพราะมันไม่ทำ:
export default { image: { domains: ['cdn.example.com', 'not-allowed.example.com'], },};จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — พิสูจน์แล้วข้างต้นและใน Images & Assets นี่คือช่องว่างจริงในปัจจุบันระหว่างสิ่งที่ allowlist ถูกบันทึกไว้ว่าทำ กับเส้นทางเดียวที่มันถูกตรวจสอบจริง
Tooling & production
หัวข้อที่มีชื่อว่า “Tooling & production”21. markdown.remarkPlugins เป็น key ระดับบนสุด
หัวข้อที่มีชื่อว่า “21. markdown.remarkPlugins เป็น key ระดับบนสุด”import { defineConfig } from 'astro/config';import remarkToc from 'remark-toc';
export default defineConfig({ markdown: { remarkPlugins: [remarkToc], },});Astro 7 render Markdown ด้วย native Sätteri pipeline โดยปริยาย markdown.remarkPlugins, rehypePlugins, gfm, และ smartypants ยังใช้งานได้ในฐานะ key ระดับบนสุด แต่ถูกแปะ @deprecated โดยตรงใน config schema ที่ติดตั้ง — แต่ละอันชี้ไปที่ทางแก้เดียวกัน: ส่งเข้า markdown.processor ที่ตั้งค่าไว้แทน
อาการ — ผลลัพธ์จริงของ astro check (deprecation warning จริงของ TypeScript ไม่ใช่ build ล้มเหลว — config นี้ยังทำงานได้วันนี้):
warning ts(6385): 'remarkPlugins' is deprecated.เพราะนี่เป็น warning ไม่ใช่ error --minimumFailingSeverity ค่าเริ่มต้นของ astro check ("error") ปล่อยให้มัน exit 0 — พลาดง่ายใน CI เว้นแต่จะตั้ง warning ให้เข้มขึ้นเอง
วิธีแก้: ติดตั้ง @astrojs/markdown-remark และส่ง plugin เข้า unified() ที่กำหนดให้ markdown.processor:
import { defineConfig } from 'astro/config';import { createMarkdownProcessor } from '@astrojs/markdown-remark';import remarkToc from 'remark-toc';import { unified } from 'unified';
export default defineConfig({ markdown: { processor: unified({ remarkPlugins: [remarkToc] }), },});จับได้ด้วย: astro check เป็น warning (พิสูจน์แล้วข้างต้น ตรวจตรงกับ config schema ที่ติดตั้ง) แต่ไม่ใช่ error ที่บล็อก build เว้นแต่ --minimumFailingSeverity จะถูกตั้งให้เข้มขึ้น ดู Markdown & MDX สำหรับรายการ key ที่เลิกใช้แล้วแบบเต็ม
22. รัน astro preview เป็น production server
หัวข้อที่มีชื่อว่า “22. รัน astro preview เป็น production server”{ "scripts": { "build": "astro build", "start": "npm run build && astro preview --host 0.0.0.0 --port 8080" }}astro preview เป็นการตรวจสอบในเครื่องที่มีประโยชน์จริง — มัน serve build output จริง และสำหรับ adapter ที่ต่อ previewEntrypoint ไว้ (adapter Node ของคอร์สนี้ทำ) มันถึงกับรัน SSR entry จริงด้วย ไม่ใช่แค่ไฟล์ static script deploy และ Dockerfile ที่ AI เขียนมักเลือกใช้มันเหมือนเป็นคำสั่ง production เพราะทั้งชื่อและพฤติกรรมในเครื่องที่ทำงานได้จริงต่างก็ชี้ไปทางนั้น
อาการ — เอกสาร CLI reference ทางการของ Astro เอง ตรงตัว:
Starts a local server to serve the contents of your static directory (dist/ by default)created by running `astro build`.
It is not designed to be run in production. For help with production hosting, check outour guide on Deploying an Astro Website.เอกสารของ Astro เองชัดเจนและไม่คลุมเครือ: astro preview คือการตรวจสอบก่อน deploy ในเครื่อง ไม่ใช่ process ที่ deployment จริงควรรันเลย
วิธีแก้: รัน production entrypoint จริงของ adapter (สำหรับ adapter Node คือไฟล์ server ที่ build แล้วโดยตรง) แทนที่จะ script ให้ astro preview เป็น start:
{ "scripts": { "build": "astro build", "start": "node ./dist/server/entry.mjs" }}จับได้ด้วย: ไม่มีอะไรอัตโนมัติ — script ทั้งสองรันโดยไม่มี error ในเครื่อง เพราะ astro preview serve build output จริง ๆ มีแค่เอกสารของ Astro เอง (ยกมาข้างต้น) และ production deployment จริง (ที่ขาด hardening เฉพาะแพลตฟอร์มที่ adapter/host จริงมีให้) เท่านั้นที่เผยความต่าง ดู SSR & Adapters สำหรับหน้าตา production entrypoint จริงของ adapter Node ทั้งแบบ standalone และ middleware