2026-08-20 · 6 phút đọc · build-log · agent-readiness
Trang này phục vụ Markdown cho AI agent thế nào
Khi một client hỏi text/markdown, trang này trả về phần nguồn thay vì khung giao diện. Đây là toàn bộ cơ chế, kèm cả chỗ nó suy giảm.
Một AI agent muốn biết một công ty làm gì sẽ tải trang chủ về, và nhận lại một tài liệu dựng cho trình duyệt: markup điều hướng, payload hydration, logo nhúng thẳng vào HTML, preload font, một hòn đảo consent. Những câu chữ nó thực sự cần nằm đâu đó bên trong. Phần còn lại là chi phí — token bỏ ra để phân tích lớp vỏ, cộng thêm rủi ro rất thật là agent trích một nhãn menu như thể đó là tuyên bố của công ty.
Cách sửa thì cũ và nhàm: content negotiation. Client nói nó muốn text/markdown
thì đưa Markdown cho nó. Đây là nhật ký dựng của phần đó ở đây, viết chủ yếu để
người sửa nó tiếp theo biết dòng nào là dòng chịu lực.
Hỏi Markdown, nhận Markdown#
Việc thương lượng diễn ra ở đúng một chỗ, middleware.ts, trước khi định tuyến:
export default function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
if (acceptsMarkdown(req.headers) && pathIsNegotiable(pathname)) {
const url = req.nextUrl.clone()
url.pathname = pathname === "/" ? "/api/md" : `/api/md${pathname}`
const res = NextResponse.rewrite(url)
res.headers.set("vary", "accept")
return res
}
// …phần còn lại đi vào định tuyến locale
}Hai vị từ và một lần rewrite. acceptsMarkdown đọc header Accept của request.
pathIsNegotiable quyết định URL này có câu trả lời dạng Markdown hay không.
Khi cả hai đúng, request được rewrite — không phải redirect — sang
/api/md/<đúng path đó>, nên URL mà agent trích dẫn cũng chính là URL người đọc
mở. Một địa chỉ, hai cách biểu diễn.
vary: accept là dòng chịu lực. Thiếu nó, một CDN đã cache bản Markdown cho một
request sẽ vui vẻ trả đúng bản đó cho trình duyệt kế tiếp hỏi HTML.
curl -H "Accept: text/markdown" https://anby.ai/blog/how-this-site-serves-markdown-to-agentsKhông phải path nào cũng thương lượng được#
Danh sách cho phép nằm ở lib/agent-readiness/middleware-predicates.ts. URL của
blog được khớp theo hình dạng chứ không liệt kê từng bài, vì bài viết vào hệ
thống dưới dạng file và không ai nên phải nhớ đi đăng ký một bài:
const BLOG_NEGOTIABLE = [
/^\/blog$/,
/^\/blog\/(?!rss\.xml$)[^/]+$/,
/^\/(en|vi)\/blog\/(?!rss\.xml$)[^/]+$/,
]
export function acceptsMarkdown(headers: {
get(name: string): string | null
}): boolean {
if (headers.get("x-md-bypass") === "1") return false
const accept = headers.get("accept") || ""
return accept.toLowerCase().includes("text/markdown")
}Ba chi tiết trong đó là cố ý.
Lookahead phủ định giữ rss.xml ở ngoài. Nó nằm dưới /blog/ nhưng là route
feed chứ không phải slug, và đem đi chuyển đổi thì chỉ ra thứ vô nghĩa.
Các route đã nghỉ hưu hoàn toàn không có mặt trong danh sách. Vài path cũ giờ chỉ trả về redirect; phục vụ Markdown cho một redirect là nói với agent rằng trang đó vẫn còn, tức là ngược hẳn với mục đích của cái redirect ấy.
Còn x-md-bypass tồn tại vì một vòng lặp. Với một trang HTML thường, bộ chuyển
đổi không có gì để đọc từ đĩa, nên nó tải lại chính trang đó từ origin của mình
rồi chuyển đổi phản hồi. Không có header bypass trên lần fetch nội bộ đó,
middleware sẽ thương lượng với chính nó, vĩnh viễn.
Hai nguồn, một đầu ra#
Điều đó cho route hai việc, và hai việc này không tốt ngang nhau.
Với một trang đã render, /api/md tải HTML kèm header bypass, bỏ các nhánh
script, style, nav, footer, svg, rồi chạy Turndown trên phần còn lại.
Đó là một bản dịch, mà bản dịch thì làm rơi mất thứ gì đó.
Với một bài blog thì không có bản dịch nào cả, vì nguồn vốn đã là Markdown từ
trước khi nó thành một trang. Route đọc đúng file .mdx mà component trang đọc:
export async function convertBlogPostToMarkdown(args: {
locale: Locale
slug: string
}): Promise<string | null> {
if (args.locale !== "en" && args.locale !== "vi") return null
const post = await loadBlogPost({ slug: args.slug, locale: args.locale })
if (!post || post.frontmatter.draft) return null
const cleaned = stripJsxComponents(post.body).trim()
const parts = [`# ${post.frontmatter.title}`, post.frontmatter.date, cleaned]
return parts.join("\n\n") + "\n"
}Frontmatter bị gỡ ra, một dòng # title và một dòng ngày được gắn vào, phần
thân được đưa đi gần như nguyên trạng lúc viết. Đó là lý do blog là đường MDX
duy nhất còn nối vào route này: nó là kho nội dung duy nhất mà bản Markdown
agent nhận được và trang người đọc thấy là cùng một văn bản, chứ không phải hai
lần render có thể trôi lệch khỏi nhau.
Chỗ nó suy giảm#
Ba giới hạn nói thẳng, cả ba đều nhìn thấy được trong đoạn code phía trên.
Component bị làm phẳng, và có một loại biến mất hẳn. stripJsxComponents là
một tập regex, không phải trình biên dịch. Component dạng khối giữ lại Markdown
bên trong và mất lớp vỏ — cái callout bạn vừa đọc sẽ tới nơi dưới dạng những
đoạn văn trần, tiêu đề của nó không còn. Component tự đóng thì bị xóa thẳng, tức
là một <Figure /> mang theo cả caption lẫn alt text đi mất. Cú pháp
 thuần Markdown thì sống sót nguyên vẹn, nên một tấm ảnh quan
trọng với lập luận thì nên viết bằng Markdown hơn là bằng component.
Biểu thức bị chặn có chủ đích. Thuộc tính của component là chuỗi trong ngoặc
kép — width="1200", không bao giờ là width={1200}. Một bài viết là dữ liệu,
không phải code, và một kho nội dung để agent đọc thì không nên chứa thứ phải
đem đi thực thi mới hiểu được.
Chỉ title và date sống sót từ frontmatter. Description và tags là metadata
của trang; chúng không bao giờ đi tới bản Markdown. Agent đọc file này nhận được
lập luận, không nhận taxonomy.
Không giới hạn nào trong đó sửa được bằng cách làm mấy con regex khôn hơn. Nó được sửa bằng cách viết những bài mà ý nghĩa nằm trong câu chữ — một ràng buộc tốt hơn vẻ ngoài của nó nhiều.
Bài này chính là ca thử. Nó được viết ở dạng nháp đúng để chạy trọn một lượt cả
đường đi — file trên đĩa, thẻ ở trang index, ảnh chia sẻ, mục trong feed, phản
hồi Markdown — trước khi bất kỳ khâu nào trong đó phải gánh một nội dung đáng
đọc. Khi nó rời trạng thái nháp, lệnh curl phía trên sẽ trả về nó. Trước lúc
đó, lệnh ấy trả về 404, và đó cũng là câu trả lời đúng.