รีวิวเคสจริง 1: บล็อกที่มี content collection, comment, และหน้า listing
ฟีเจอร์นี้คืออะไร
หัวข้อที่มีชื่อว่า “ฟีเจอร์นี้คืออะไร”โจทย์ที่ให้ AI คือ “สร้างบล็อกที่มี content collection สำหรับโพสต์, หน้า detail ต่อโพสต์ที่แสดงชื่อผู้เขียนกับลิงก์โพสต์ที่เกี่ยวข้อง, ฟอร์ม comment ที่ผูกกับ Action, และหน้า listing ที่แสดง excerpt ของทุกโพสต์” ผลลัพธ์ด้านล่างคือแบบร่างที่ AI มักส่งกลับมา — ทุกไฟล์ดูเหมือนมาจาก tutorial Astro เมื่อไม่นานมานี้ และหลายส่วนก็คอมไพล์ผ่านจริง ๆ อ่านตามลำดับการอ่านที่ index ของโมดูลนี้แนะนำ: content กับ data ก่อน แล้วค่อย server-side Astro แล้วก็ security โค้ดนี้มี บั๊กจริง 7 จุด ซึ่งส่วนใหญ่ astro check จับไม่ได้ — ดู AI bug catalog สำหรับ pattern ทั่วไปเบื้องหลังแต่ละจุด probe ของคอร์สนี้เอง (tools/probe) กำหนดชื่อ field ของ collection ตายตัวตามที่ใช้ด้านล่าง (title, pubDate, draft, tags, status, author — เป็น reference('authors') — cover, coverAlt ใน blog; name ใน authors) โค้ดที่ AI สร้างให้จึงอยู่ในโครงนี้ทุกที่ที่ทำได้
โค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้างให้”import { defineCollection, reference } from 'astro:content';import { z } from 'astro/zod';import { glob } from 'astro/loaders';
const authors = defineCollection({ loader: glob({ pattern: '**/*.json', base: './src/data/authors' }), schema: z.object({ name: z.string() }),});
const blog = defineCollection({ loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/data/blog' }), schema: ({ image }) => z.object({ title: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), tags: z.array(z.string()).default([]), status: z.enum(['draft', 'published', 'archived']).default('published'), author: reference('authors'), cover: image(), coverAlt: z.string(), }),});
export const collections = { authors, blog };schema ด้านบนต้องการ title — แต่ไฟล์ Markdown จริงที่ AI สร้างให้โพสต์แรกไม่ได้ใช้ key นี้เลย:
# src/data/blog/hello-world.md — frontmatter as generated (renamed the field partway through, kept going)---postTitle: "Hello World"pubDate: 2026-01-05author: adacover: ./hello-world.jpgcoverAlt: "A sunrise over a keyboard"------// @expect-error TS2551 (entry.render) + TS2339 (reference read as data) + a build-only GetStaticPathsRequired — three bugs in one file, walked through belowimport { getCollection, getEntry } from 'astro:content';
export async function getStaticPaths() { // (nothing here — the AI never wrote this function at all)}
const { slug } = Astro.params;const posts = await getCollection('blog');const post = posts.find((p) => p.id === slug);if (!post) throw new Error('Post not found');
const { Content } = await post.render();---<article> <h1>{post.data.title}</h1> <p>By {post.data.author.name}</p> <Content /></article>import { defineAction } from 'astro:actions';import { z } from 'astro/zod';
const db = { comments: { create: async (data: { postId: string; body: string }) => ({ id: crypto.randomUUID(), ...data }), },};
export const server = { postComment: defineAction({ input: z.object({ postId: z.string(), body: z.string().min(1).max(2000), }), handler: async (input) => { // (no auth check at all — the form only renders behind a login page, // so the AI assumed that was enough) const comment = await db.comments.create({ postId: input.postId, body: input.body }); return comment; }, }),};---// src/components/PostExcerptBad.astro — as generatedinterface Props { excerpt: string }const { excerpt } = Astro.props;---<p class="excerpt" set:html={excerpt} />---// @expect-error TS2339 (Astro.glob) — as generated, a listing page written like a pre-v6 tutorialimport PostExcerptBad from '../../components/PostExcerptBad.astro';
const posts = await Astro.glob('../../data/blog/*.md');---<ul> {posts.map((post) => ( <li> <h2>{post.frontmatter.title}</h2> <PostExcerptBad excerpt={post.frontmatter.excerpt} /> </li> ))}</ul>ไล่รีวิวทีละจุด
หัวข้อที่มีชื่อว่า “ไล่รีวิวทีละจุด”Content & data: schema ที่ต้องการ field ที่ frontmatter ไม่เคยตั้งค่า
หัวข้อที่มีชื่อว่า “Content & data: schema ที่ต้องการ field ที่ frontmatter ไม่เคยตั้งค่า”schema ใน content.config.ts ประกาศ title: z.string() เป็น field บังคับ ไฟล์ Markdown ด้านบนใช้ postTitle ไม่ใช่ title — การเปลี่ยนชื่อที่ไปถึงแค่ schema แต่ไปไม่ถึง content จริง
🐛 หา bug ในโค้ดนี้
schema: z.object({title: z.string(),pubDate: z.coerce.date(),// ...})เฉลย
ไม่มีอะไรผิดในตัว schema เองเลย มันเป็น Zod ที่ถูกต้องสมบูรณ์ บั๊กนี้อยู่ที่ขอบเขตระหว่างไฟล์นี้กับ frontmatter จริงเท่านั้น และมีแค่ astro sync (หรือ astro dev/build ที่ sync ก่อนเสมอ) เท่านั้นที่อ่านทั้งสองอย่างพร้อมกัน พิสูจน์ใน probe: ไฟล์ hello-world.md จริงที่ใช้ postTitle แทน title ทำให้ astro sync fail ทันที โดยระบุชื่อ collection, entry, และ field ที่หายไปตรง ๆ:
[InvalidContentEntryDataError] blog → hello-world data does not match collection schema.
title**: **title: Requiredastro check ไม่เคยรัน validation แบบนี้เลย — มันตรวจ type ของ source file ไม่ใช่ content file — บั๊กแบบนี้จึงมองไม่เห็นจนกว่าจะมีใครรัน astro sync, astro dev, หรือ astro build ทางแก้คือเปลี่ยนชื่อ key ใน frontmatter กลับเป็น title (หรือถ้า postTitle คือชื่อที่ตั้งใจจริง ๆ ทั้งโปรเจกต์ ก็เปลี่ยนชื่อ field ใน schema ให้ตรงกัน) — สองชื่อนี้ต้องเหมือนกันเป๊ะ และไม่มีอะไรจับความเพี้ยนระหว่างสองไฟล์นี้ได้นอกจากการ sync จริง
Content & data: Astro.glob() หายไปแล้ว
หัวข้อที่มีชื่อว่า “Content & data: Astro.glob() หายไปแล้ว”หน้า listing ดึงทุกโพสต์ด้วย Astro.glob('../../data/blog/*.md') — วิธีแบบก่อน v6 ที่ดึงหลายไฟล์ในเครื่องมาทีเดียว
🐛 หา bug ในโค้ดนี้
const posts = await Astro.glob('../../data/blog/*.md');เฉลย
Astro.glob() ถูก deprecate ตั้งแต่ Astro 5 และถูกลบออกทั้งหมดใน Astro 6 — มันไม่มีอยู่บน Astro global อีกต่อไปแล้ว พิสูจน์ใน probe ด้วย astro check กับ call นี้ตรง ๆ บน Astro 7.3.5 ที่ติดตั้งจริง:
error ts(2339): Property 'glob' does not exist on type'Readonly<AstroGlobal<Record<string, any>, Record<string, any>>>'.
const posts = await Astro.glob('../../data/blog/*.md');นี่คือ type error ไม่ใช่ความประหลาดใจตอน runtime แบบเงียบ ๆ — astro check จับได้ทันที ก่อน build จะรันด้วยซ้ำ ทางแก้คือใช้ content collection: getCollection('blog') ซึ่งเพิ่มการ validate และ type ให้ทุก entry แทนที่จะคืน object frontmatter แบบไม่มี type
Content & data: dynamic route ที่ไม่มี getStaticPaths()
หัวข้อที่มีชื่อว่า “Content & data: dynamic route ที่ไม่มี getStaticPaths()”[slug].astro เป็น dynamic route แบบ static (ไม่มี export const prerender = false ที่ไหนเลย) ที่ไม่เคย export getStaticPaths() เลย — function ถูกประกาศไว้แต่ body ว่างเปล่า แถมไม่ได้ return อะไรออกมาด้วย
🐛 หา bug ในโค้ดนี้
export async function getStaticPaths() {// (nothing here)}
const { slug } = Astro.params;const posts = await getCollection('blog');const post = posts.find((p) => p.id === slug);เฉลย
สัญญาทั้งหมดของไฟล์ dynamic route แบบ static คือ getStaticPaths() ต้อง return รายการ path ที่จะ build — Astro ตรวจสิ่งนี้ก่อนที่โค้ดในส่วน component จะรันแม้แต่บรรทัดเดียว และจะไม่เดาให้ function ที่ว่างเปล่าแล้ว return undefined ก็คือกรณีที่ขาดหายไปนี้เป๊ะ ๆ พิสูจน์แล้วในบทเรียน Dynamic Routes ของคอร์สนี้กับ astro build จริงบน 7.3.5:
[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.การอ่าน Astro.params แบบที่ไฟล์นี้ทำต่อคือ pattern แบบ on-demand — มันสมเหตุสมผลก็ต่อเมื่อคู่กับ export const prerender = false และมี adapter โดยไม่มี getStaticPaths() เหลืออยู่เลย ไม่ใช่ทิ้งไว้เป็น stub เปล่า ๆ ทางแก้ตรงนี้ (จำนวนโพสต์ที่รู้แน่นอน) คือ getStaticPaths() จริงที่ map getCollection('blog') ให้เป็น { params: { slug: post.id }, props: { post } } ต่อ entry แล้วอ่าน post จาก Astro.props แทนที่จะ fetch แล้วหาใหม่ด้วย id
Content & data: entry.render() method ที่ไม่มีอยู่แล้ว
หัวข้อที่มีชื่อว่า “Content & data: entry.render() method ที่ไม่มีอยู่แล้ว”พอแก้ getStaticPaths() แล้ว build ไปถึง template ของไฟล์นี้ บรรทัดถัดไปที่ fail คือ await post.render()
🐛 หา bug ในโค้ดนี้
const { Content } = await post.render();เฉลย
entry ที่ได้จาก getCollection/getEntry คือ plain data ที่ serialize ได้ — render มีอยู่แค่ในรูป standalone function ที่ import จาก astro:content เท่านั้น ตั้งแต่ Astro 5 deprecate รูป method นี้ และ Astro 6 ก็ลบออกไปเลย พิสูจน์ใน probe ด้วย astro check กับ call นี้ตรง ๆ บน Astro 7.3.5 (ใช้ type CollectionEntry<'blog'> ที่ generate จากโปรเจกต์นี้เอง):
error ts(2551): Property 'render' does not exist on type '{ id: string; body?:string | undefined; collection: "blog"; data: { title: string; pubDate: Date;draft: boolean; tags: string[]; status: "draft" | "published" | "archived";author: { collection: "authors"; id: string; }; cover: { ...; }; coverAlt:string; }; rendered?: RenderedContent | undefined; filePath?: strin...'.Did you mean 'rendered'?
const { Content } = await post.render();ต่างจากกรณี Astro.glob() ตรงที่อันนี้ astro check จับได้ด้วย — TypeScript รู้โครงสร้างของ collection entry แน่ชัด และ render ไม่ได้เป็นส่วนหนึ่งของมัน ทางแก้คือ import { render } from 'astro:content' แล้วเรียก const { Content } = await render(post);
Content & data: อ่าน field reference() ราวกับเป็น entry ที่ resolve แล้ว
หัวข้อที่มีชื่อว่า “Content & data: อ่าน field reference() ราวกับเป็น entry ที่ resolve แล้ว”template อ่าน post.data.author.name ตรง ๆ — author ถูกประกาศเป็น reference('authors') ใน schema ไม่ใช่ object ผู้เขียนที่ฝังมาด้วย
🐛 หา bug ในโค้ดนี้
<p>By {post.data.author.name}</p>เฉลย
reference('authors') validate ว่า author: ada ใน frontmatter ชี้ไปที่ entry จริงใน collection authors และเก็บเป็น id ที่ validate แล้ว ไม่ใช่ข้อมูลของผู้เขียน พิสูจน์ใน probe ด้วย astro check กับ expression นี้ตรง ๆ โดยใช้ type ที่ generate ของโปรเจกต์นี้เอง:
error ts(2339): Property 'name' does not exist on type '{ collection: "authors"; id: string; }'.
const authorName = post.data.author.name; ~~~~นี่คือ compile-time error ไม่ใช่ undefined แบบเงียบ ๆ — TypeScript รู้ว่า post.data.author คือ { collection: 'authors'; id: string } ไม่มีอะไรมากกว่านั้น ทางแก้คือ resolve ก่อน: const author = await getEntry(post.data.author); ... <p>By {author?.name}</p>
Server-side Astro: comment Action ที่ไม่มีการตรวจสอบสิทธิ์
หัวข้อที่มีชื่อว่า “Server-side Astro: comment Action ที่ไม่มีการตรวจสอบสิทธิ์”handler ของ postComment ไม่เคยตรวจว่าใครเป็นคนเรียกเลย — มันสมมติว่าประตูล็อกอินของฟอร์มเองเพียงพอแล้ว
🐛 หา bug ในโค้ดนี้
handler: async (input) => {const comment = await db.comments.create({ postId: input.postId, body: input.body });return comment;}เฉลย
Astro compile ทุก Action ให้เป็น HTTP endpoint จริงที่ /_actions/<name> — ใครก็เข้าถึงได้ถ้ารู้ชื่อ ไม่ว่าจะเคยโหลดหน้าที่มีฟอร์ม “ต้องล็อกอินก่อน” หรือไม่ก็ตาม พิสูจน์กับ build จริงของ handler นี้ตรง ๆ โดยไม่มี session cookie เลย:
$ curl -s -X POST http://127.0.0.1:5201/_actions/postComment \ -H "Content-Type: application/json" -d '{"postId":"42","body":"first post!"}' \ -w "\nHTTP %{http_code}\n"[{"id":1,"postId":2,"body":3},"0dc16240-7cd5-4622-9329-71fdb9a43d4e","42","first post!"]HTTP 200comment ที่สมบูรณ์ถูกสร้างขึ้นโดยไม่มีการยืนยันตัวตนเลย HTTP 200 — request นี้ไม่เคยแตะหน้าที่ฟอร์มอยู่เลยด้วยซ้ำ astro check ไม่มีอะไรจะพูดเรื่องนี้: handler นี้ type-check ผ่านสมบูรณ์ บั๊กคือการตรวจสอบ runtime ที่ขาดหายไป ไม่ใช่ type ที่ผิด ทางแก้คือปฏิเสธก่อน ก่อนแตะฐานข้อมูล: if (!context.locals.user) throw new ActionError({ code: 'UNAUTHORIZED', message: '...' }) — พิสูจน์กับ request เดิมเป๊ะ ๆ หลังเพิ่มการตรวจสอบแล้ว:
$ curl -s -X POST http://127.0.0.1:5201/_actions/postComment \ -H "Content-Type: application/json" -d '{"postId":"42","body":"first post!"}' \ -w "\nHTTP %{http_code}\n"{"type":"AstroActionError","code":"UNAUTHORIZED","status":401,"message":"You must be signed in to comment."}HTTP 401Security: set:html บน excerpt ของโพสต์
หัวข้อที่มีชื่อว่า “Security: set:html บน excerpt ของโพสต์”PostExcerptBad render prop excerpt ของมันด้วย set:html — น่าจะเพื่อให้ excerpt ที่มาจาก Markdown ของโพสต์เก็บ formatting ไว้ได้ แต่หน้า listing ส่ง post.frontmatter.excerpt ผ่านตรง ๆ โดยไม่มี sanitizer เลย และ excerpt ก็เป็น field แบบที่ CMS, สรุปที่มาจาก comment, หรือ script import แบบ bulk ใส่ text ที่ผู้ไม่หวังดีควบคุมได้ลงไปได้
🐛 หา bug ในโค้ดนี้
interface Props { excerpt: string }const { excerpt } = Astro.props;---<p class="excerpt" set:html={excerpt} />เฉลย
set:html ข้าม escaping ค่าเริ่มต้นของ Astro ไปทั้งหมด — มัน render string เป็น markup ตรง ๆ เหมือน dangerouslySetInnerHTML ใน React หรือ v-html ใน Vue เป๊ะ ๆ พิสูจน์ด้วย Container API กับ component นี้ตรง ๆ และ excerpt ที่มีรูปแบบเหมือนผู้ไม่หวังดีเขียน:
✓ PostExcerptBad: set:html executes attacker markup verbatim✓ PostExcerptFixed: plain {excerpt} escapes the same input
Test Files 1 passed (1) Tests 2 passed (2)test แรก render PostExcerptBad ด้วย excerpt = "Great post! <img src=x onerror=alert('xss')>" แล้วยืนยันว่า tag <img onerror=...> ปรากฏตรงตัวเป๊ะใน HTML ที่ออกมา — XSS payload จริงที่ทำงานทันทีที่ browser parse มัน ทางแก้คือลบ set:html ออก แล้วปล่อยให้ escaping ค่าเริ่มต้นของ Astro ทำงาน: <p class="excerpt">{excerpt}</p> — test ที่สองพิสูจน์ว่า input เดียวกันเป๊ะ ๆ กลับมาเป็น <img src=x onerror=alert('xss')> เป็นแค่ text เฉย ๆ
โค้ดที่แก้แล้ว
หัวข้อที่มีชื่อว่า “โค้ดที่แก้แล้ว”import { defineCollection, reference } from 'astro:content';import { z } from 'astro/zod';import { glob } from 'astro/loaders';
const authors = defineCollection({ loader: glob({ pattern: '**/*.json', base: './src/data/authors' }), schema: z.object({ name: z.string() }),});
const blog = defineCollection({ loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/data/blog' }), schema: ({ image }) => z.object({ title: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), tags: z.array(z.string()).default([]), status: z.enum(['draft', 'published', 'archived']).default('published'), author: reference('authors'), cover: image(), coverAlt: z.string(), }),});
export const collections = { authors, blog };# src/data/blog/hello-world.md — frontmatter, fixed: title matches the schema---title: "Hello World"pubDate: 2026-01-05author: adacover: ./hello-world.jpgcoverAlt: "A sunrise over a keyboard"------import { getCollection, getEntry, render } from 'astro:content';
// FIX (getStaticPaths missing): the real contract for a static dynamic// route — one path per entry, the entry itself handed through as a prop.export async function getStaticPaths() { const posts = await getCollection('blog'); return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));}
const { post } = Astro.props;
// FIX (reference read as data): resolve the id with getEntry before// reading anything off it.const author = await getEntry(post.data.author);
// FIX (entry.render() legacy): render(entry) is the standalone function,// not a method on the entry.const { Content } = await render(post);---<article> <h1>{post.data.title}</h1> <p>By {author?.data.name}</p> <Content /></article>import { defineAction, ActionError } from 'astro:actions';import { z } from 'astro/zod';
const db = { comments: { create: async (data: { postId: string; body: string; userId: string }) => ({ id: crypto.randomUUID(), ...data }), },};
export const server = { postComment: defineAction({ input: z.object({ postId: z.string(), body: z.string().min(1).max(2000), }), handler: async (input, context) => { // FIX (no auth check): reject first, before touching the database — // an Action is a public endpoint regardless of which page's form // calls it. if (!context.locals.user) { throw new ActionError({ code: 'UNAUTHORIZED', message: 'You must be signed in to comment.' }); } const comment = await db.comments.create({ postId: input.postId, body: input.body, userId: context.locals.user.id, }); return comment; }, }),};---// src/components/PostExcerptBad.astro — kept only for the test below, contrasted with the fixinterface Props { excerpt: string }const { excerpt } = Astro.props;---<p class="excerpt" set:html={excerpt} />---// FIX (set:html on the excerpt): plain {excerpt} — Astro's default// escaping renders any markup as inert text instead of executing it.interface Props { excerpt: string }const { excerpt } = Astro.props;---<p class="excerpt">{excerpt}</p>---// FIX (Astro.glob): a real, typed, validated content collection.import { getCollection } from 'astro:content';import PostExcerptFixed from '../../components/PostExcerptFixed.astro';
const posts = (await getCollection('blog')).filter((post) => !post.data.draft);---<ul> {posts.map((post) => ( <li> <h2>{post.data.title}</h2> <PostExcerptFixed excerpt={(post.body ?? '').slice(0, 140)} /> </li> ))}</ul>import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { describe, it, expect } from 'vitest';import PostExcerptBad from '../components/PostExcerptBad.astro';import PostExcerptFixed from '../components/PostExcerptFixed.astro';
const attackerExcerpt = "Great post! <img src=x onerror=alert('xss')>";
describe('excerpt rendering: set:html vs escaped text', () => { it('PostExcerptBad: set:html executes attacker markup verbatim', async () => { const container = await AstroContainer.create(); const html = await container.renderToString(PostExcerptBad, { props: { excerpt: attackerExcerpt } }); expect(html).toContain("<img src=x onerror=alert('xss')>"); });
it('PostExcerptFixed: plain {excerpt} escapes the same input', async () => { const container = await AstroContainer.create(); const html = await container.renderToString(PostExcerptFixed, { props: { excerpt: attackerExcerpt } }); expect(html).toContain('<img src=x onerror=alert('xss')>'); expect(html).not.toContain("<img src=x onerror=alert('xss')>"); });});type-check ผ่านสะอาดกับ astro check บน Astro 7.3.5 ที่ติดตั้งจริง และรันกับไฟล์ test ด้านบนเป๊ะ ๆ:
✓ PostExcerptBad: set:html executes attacker markup verbatim✓ PostExcerptFixed: plain {excerpt} escapes the same input
Test Files 1 passed (1) Tests 2 passed (2)เปลี่ยนอะไรไปบ้างและทำไม
หัวข้อที่มีชื่อว่า “เปลี่ยนอะไรไปบ้างและทำไม”| # | บั๊ก | หมวด | ทางแก้ |
|---|---|---|---|
| 1 | postTitle ใน frontmatter เทียบกับ title ใน schema |
Content & data | เปลี่ยนชื่อ key ใน frontmatter ให้ตรงกับ schema — พิสูจน์ด้วย astro sync จริงที่ให้ InvalidContentEntryDataError |
| 2 | Astro.glob('../../data/blog/*.md') |
Content & data | getCollection('blog') — astro check จริงจับได้ที่ ts(2339) |
| 3 | getStaticPaths() ว่างเปล่าบน dynamic route แบบ static |
Content & data | return { params, props } หนึ่งอันต่อ entry จาก getCollection('blog') — ถ้าไม่ทำจะเจอ GetStaticPathsRequired ตอน build จริง |
| 4 | post.render() |
Content & data | render(post) ที่ import จาก astro:content — astro check จริงจับได้ที่ ts(2551) |
| 5 | post.data.author.name บน field reference() |
Content & data | await getEntry(post.data.author) ก่อน — astro check จริงจับได้ที่ ts(2339) |
| 6 | handler ของ postComment ไม่มีการตรวจสอบสิทธิ์ |
Server-side Astro | if (!context.locals.user) throw new ActionError(...) — พิสูจน์ด้วย curl จริงจาก 200 → 401 |
| 7 | set:html={excerpt} บน text ที่ไม่ sanitize |
Security | {excerpt} ธรรมดา — พิสูจน์ด้วย Container API: markup ตรงตัว เทียบกับ text ที่ escape แล้ว |
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ห้าในเจ็ดบั๊กนี้คือเรื่อง ไฟล์หนึ่งสมมติข้อเท็จจริงเกี่ยวกับอีกไฟล์หนึ่ง — schema สมมติว่า frontmatter ใช้ key เดิม, template สมมติว่า render/reference ยังทำงานแบบก่อน v6, หน้า listing สมมติว่า Astro.glob ยังมีอยู่ content layer ของ Astro เก่งมากในการพิสูจน์ schema ที่กำหนด กับ data ที่กำหนด หรือ type ที่กำหนด กับ expression ที่กำหนด — แต่มันไม่มีทางสังเกตว่าสองส่วนของโปรเจกต์เหินห่างจากกันไปแล้ว เพราะจากมุมมองของมัน ทั้งสองส่วนถูกต้องในตัวเอง อีกสองบั๊กที่เหลือ (auth check ที่หายไป, set:html บน text ที่ไม่ sanitize) ไม่ใช่เรื่อง version drift ของ framework เลย — มันคือสมมติฐานด้าน security (“ประตูล็อกอินคุมพอแล้ว”, “text นี้ปลอดภัย”) ที่ไม่มีอะไรใน Astro ตรวจให้เลย
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้า
content.config.tsกับ page component ทั้งคู่ type-check ผ่าน แปลว่า content ถูกต้อง” พิสูจน์แล้วข้างต้น:astro checkไม่เคยอ่าน frontmatter ของไฟล์ Markkdown แต่ละไฟล์เลย — validation แบบนั้นเกิดตอนastro sync/dev/buildเท่านั้น และ schema/frontmatter ที่ไม่ตรงกันจะมองไม่เห็นจนกว่าจะรันคำสั่งใดคำสั่งหนึ่งนั้น - “handler ของ Action เข้าถึงได้แค่ผ่านฟอร์มที่เรียกมันเท่านั้น” พิสูจน์แล้วข้างต้นด้วย
curlจริง: ทุก Action คือ public POST endpoint ที่/_actions/<name>ไม่ว่าหน้าไหน (หรือไม่มีหน้าเลย) เป็นคนส่ง request มา - “field
reference()มีข้อมูลของ entry ที่ลิงก์อยู่แล้ว” พิสูจน์แล้วข้างต้น:post.data.authorคือ{ collection: 'authors'; id: string }— id ที่ validate แล้ว ไม่ใช่ข้อมูลผู้เขียน การอ่าน.nameตรง ๆ คือ compile-time error ไม่ใช่undefinedแบบเงียบ ๆ
flowchart TD
A["content.config.ts: schema"] -->|"1: postTitle vs title"| B["src/data/blog/*.md"]
B --> C["getCollection('blog')"]
C --> D["src/pages/blog/index.astro"]
D -->|"2: Astro.glob() removed"| D
D --> E["PostExcerptBad: set:html(excerpt)"]
E -->|"7: XSS via unsanitized excerpt"| E
C --> F["src/pages/blog/[slug].astro"]
F -->|"3: empty getStaticPaths()"| F
F -->|"4: post.render() removed"| F
F -->|"5: post.data.author.name"| F
G["comment form"] --> H["postComment Action"]
H -->|"6: no auth check"| H