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

รีวิวเคสจริง 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 สร้างให้จึงอยู่ในโครงนี้ทุกที่ที่ทำได้

src/content.config.ts
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-05
author: ada
cover: ./hello-world.jpg
coverAlt: "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 below
import { 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>
src/actions/index.ts
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 generated
interface 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 tutorial
import 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>

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: Required

astro check ไม่เคยรัน validation แบบนี้เลย — มันตรวจ type ของ source file ไม่ใช่ content file — บั๊กแบบนี้จึงมองไม่เห็นจนกว่าจะมีใครรัน astro sync, astro dev, หรือ astro build ทางแก้คือเปลี่ยนชื่อ key ใน frontmatter กลับเป็น title (หรือถ้า postTitle คือชื่อที่ตั้งใจจริง ๆ ทั้งโปรเจกต์ ก็เปลี่ยนชื่อ field ใน schema ให้ตรงกัน) — สองชื่อนี้ต้องเหมือนกันเป๊ะ และไม่มีอะไรจับความเพี้ยนระหว่างสองไฟล์นี้ได้นอกจากการ sync จริง

หน้า 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

[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

พอแก้ 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);

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>

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 200

comment ที่สมบูรณ์ถูกสร้างขึ้นโดยไม่มีการยืนยันตัวตนเลย 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 401

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 เดียวกันเป๊ะ ๆ กลับมาเป็น &lt;img src=x onerror=alert(&#39;xss&#39;)&gt; เป็นแค่ text เฉย ๆ

src/content.config.ts
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-05
author: ada
cover: ./hello-world.jpg
coverAlt: "A sunrise over a keyboard"
---
src/pages/blog/[slug].astro
---
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>
src/actions/index.ts
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 fix
interface Props { excerpt: string }
const { excerpt } = Astro.props;
---
<p class="excerpt" set:html={excerpt} />
src/components/PostExcerptFixed.astro
---
// 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>
src/pages/blog/index.astro
---
// 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>
src/__tests__/excerpt.test.ts
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('&lt;img src=x onerror=alert(&#39;xss&#39;)&gt;');
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 แล้ว

ห้าในเจ็ดบั๊กนี้คือเรื่อง ไฟล์หนึ่งสมมติข้อเท็จจริงเกี่ยวกับอีกไฟล์หนึ่ง — 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
บั๊กแต่ละจุดอยู่ตรงไหนใน request flow ของบล็อกนี้
schema ต้องการ title: z.string() คำสั่งจริงตัวไหนที่ทำให้เกิด error นี้ และทำไม astro check ถึงจับไม่ได้ก่อน?
[InvalidContentEntryDataError] blog → hello-world data does not match collection schema.

  title**: **title: Required
ทำไม Astro.glob('../../data/blog/*.md') ถึง fail astro check ด้วย ts(2339) ในขณะที่ getStaticPaths() ที่หายไปกลับ fail แค่ตอน astro build จริง?
post มาจาก getCollection('blog') เกิดอะไรขึ้นบน Astro 7.3.5?
const { Content } = await post.render();
author ถูกประกาศเป็น reference('authors') ใน schema ทำไม post.data.author.name ถึง fail astro check แทนที่จะคืน undefined แบบเงียบ ๆ?
request นี้ไม่มี session cookie เลย HTTP 200 นี้พิสูจน์อะไรเกี่ยวกับ handler ของ postComment?
$ curl -s -X POST .../_actions/postComment -d '{"postId":"42","body":"first post!"}'
[{"id":1,"postId":2,"body":3},"...","42","first post!"]
HTTP 200
test render component นี้ด้วย excerpt = "Great post! <img src=x onerror=alert('xss')>" HTML ที่ render ออกมามีอะไร?
interface Props { excerpt: string }
const { excerpt } = Astro.props;
---
<p class="excerpt" set:html={excerpt} />
ทำไม dynamic route แบบ static ที่มี getStaticPaths() เป็น function ว่างเปล่า (ประกาศไว้ แต่ไม่ return อะไร) ถึง fail ต่างจากไฟล์ที่ไม่มี export getStaticPaths() เลย?