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:

middleware.ts
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-agents

Khô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:

lib/agent-readiness/middleware-predicates.ts
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:

lib/agent-readiness/markdown-converters.ts
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 ![alt](/path.png) 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ỉ titledate 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.