RSSAmplifier

Rick Cogley · Mar 23, 2026

SvelteKit + Cloudflare WorkersでAIエージェント向けMarkdownを配信する

0
Sign in to vote or save

Rick Cogley · Rick Cogley

AIによるクロール・要約の波が押し寄せています。GoogleのGemini、OpenAIのGPT、AnthropicのClaude、Perplexityなど、あらゆるエージェントがあなたのサイトにアクセスしています。HTMLを返せば、<div>の山をパースするためにトークンを浪費します。Markdownを返せば、クリーンなコンテキストが即座に得られます。

CloudflareはAIエージェント向けMarkdown配信ガイドを公開し、Transform RulesとAIゲートウェイを使う方法を紹介しました。その後Michael Wolsonが、Transform Rulesでヘッダを設定しエッジでコンテンツネゴシエーションを行う無料プラン向けの応用記事を書いています。

私はSvelteKitをCloudflare Workers上で動かしています。状況が異なり、よりシンプルです。以下、実装方法とTransform Rulesが不要だった理由を説明します。

WorkersにおけるTransform Rulesの問題

Wolsonのアプローチは巧妙です。Cloudflare Transform RuleでAcceptヘッダに基づいてカスタムヘッダを注入し、アプリケーション側でそのヘッダを見てレスポンス形式を決定します。これは静的サイトの実際の問題を解決します。CDNキャッシュです。差別化されたキャッシュキーがなければ、キャッシュされたHTMLレスポンスがMarkdownをリクエストしたボットに返されたり、その逆が起きたりします。

Workersにはこの問題がありません。リクエストのたびにWorkerが実行されるため、Pages Functionsの動的ルートの前にCDNキャッシュレイヤーが挟まりません。Workerは生のAcceptヘッダを直接参照でき、レンダリング前に分岐できます。

アーキテクチャ

実装はSvelteKitのサーバーフック、つまりルートハンドラの前に実行されるミドルウェアレイヤーにフックします。

flowchart TD
    A[リクエスト受信] --> B{Accept: text/markdown<br/>または ?format=md}
    B -->|はい| C[handleMarkdownRequest]
    C --> D{ルートに一致?}
    D -->|はい| E[Service Binding経由で<br/>APIデータを取得]
    E --> F[Markdownとしてフォーマット]
    F --> G[Responseを返却<br/>text/markdown + ヘッダ]
    D -->|いいえ| H[SSRにフォールスルー]
    B -->|いいえ| H
    H --> I[通常のHTMLレスポンス]
    G --> J[セキュリティヘッダを追加]
    I --> J
    J --> K[クライアントへのレスポンス]

重要なポイントは、すべてのコンテンツがすでにAPIに生のMarkdownフィールドとして存在していることです。投稿にはcontent(Markdown)、記事にはcontent(Markdown)、ページにはcontent(Markdown)があります。HTMLからMarkdownへの変換は不要で、レンダリングステップをスキップするだけです。

検出

2つのシグナルがMarkdownレスポンスをトリガーします:

  1. Accept: text/markdownヘッダ(新たな慣例に基づく)
  2. ?format=mdクエリパラメータ(ブラウザでのテスト用)
function wantsMarkdown(request: Request): boolean {
  if (request.headers.get('accept')?.includes('text/markdown')) return true;
  const url = new URL(request.url);
  return url.searchParams.get('format') === 'md';
}

フック

hooks.server.tsで、Markdownチェックはレガシーリダイレクトの後、resolve(event)の前に実行されます。つまり、Markdownリクエストに対してSvelteKitはSvelteコンポーネントを一切レンダリングしません:

if (wantsMarkdown(event.request)) {
  const mdResponse = await handleMarkdownRequest(event);
  if (mdResponse) {
    // セキュリティヘッダは引き続き適用
    mdResponse.headers.set('X-Content-Type-Options', 'nosniff');
    mdResponse.headers.set('X-Frame-Options', 'DENY');
    // ...
    return mdResponse;
  }
}
// Markdownハンドラのないルートは通常のHTMLにフォールスルー
const response = await resolve(event);

Markdownハンドラのないルート(/security/tweet-archiveなど)は通常のSSRにフォールスルーします。406エラーもページ破損もありません。

ルートハンドラ

各ルートはパス名に対する正規表現パターンで、APIからデータを取得してレスポンスをフォーマットするハンドラとペアになっています:

ルート データソース
/ 最近の投稿 + プレゼンスステータス
/now /nowページの全データ
/posts 公開済みマイクロポスト(直近50件)
/posts/:slug 単一投稿:生のcontentフィールド
/articles 公開済み記事一覧
/articles/:slug 単一記事:生のcontentフィールド
/pages/:slug 単一ページ:生のcontentフィールド

個別のコンテンツページは、最小限のフロントマターとともにMarkdownをそのまま返します:

# Article Title

**Published:** 2026-02-18 · **Stream:** tech
**URL:** https://cogley.jp/articles/some-slug

---

[raw markdown content from API]

一覧ページは、プレビューを省略しURLを含む構造化インデックスを提供します。

フルダンプ:/llms-full.txt

すべてのコンテンツを一括で取得したいエージェント向けに、/llms-full.txtはすべての記事、すべてのページ、nowページ、直近50件の投稿を1つのMarkdownドキュメントに結合して返します。このエンドポイントはAcceptヘッダに関係なく常にMarkdownを返します。機械向けに特化したエンドポイントです。

既存の/llms.txt(サイト構造と利用可能なセクションを説明)と組み合わせることで、エージェントに完全なディスカバリーパスが提供されます:

flowchart LR
    A[エージェントがサイトを発見] --> B[GET /llms.txt]
    B --> C[サイト構造を把握]
    C --> D{すべて必要?}
    D -->|はい| E[GET /llms-full.txt]
    D -->|いいえ| F[GET /articles/specific-slug<br/>Accept: text/markdown]

トークン推定

すべてのMarkdownレスポンスにはx-markdown-tokensヘッダが含まれ、概算トークン数(content.length / 4)を返します。正確ではありません。実際のトークン化はモデルによって異なります。しかし、エージェントが処理前にレスポンスサイズをざっくり把握するのに役立ちます。

このアプローチがWorkersで有効な理由

Cloudflareのブログやwolsonのアプローチは、Workersには存在しないキャッシュの問題を解決しています:

懸念事項 静的/CDNサイト Workers/Pages Functions
CDNキャッシュの衝突 実際の問題:同じURL、異なるAccept 問題なし:Workerが常に実行される
Transform Rulesの必要性 あり:キャッシュキーの差別化に必要 なし
Vary: Accept 単独では不十分(CDNが無視する) 正しく動作する(CDNレイヤーなし)
実装 エッジルール + オリジンロジック オリジンロジックのみ

Vary: Acceptの設定はHTTPの正確性のためにベストプラクティスですが、CDNキャッシュされた静的サイトのように重要な役割を果たすわけではありません。

プロフィールサイトへの適用

rick.cogley.jpのプロフィールサイトでも同じパターンを使用しています。プロフィールセクションはcontent_htmlのみ保存しており(生のMarkdownではない)、ハンドラがstripHtml()でクリーンなテキストを抽出します。生のMarkdownほどリッチではありませんが、AIエージェントが私について理解しようとする際にHTMLタグよりはるかに有用です。

テスト方法

# Acceptヘッダ経由でMarkdownを取得
curl -s -H "Accept: text/markdown" https://cogley.jp/now

# クエリパラメータ経由でMarkdownを取得
curl -s "https://cogley.jp/now?format=md"

# ヘッダを確認
curl -sI -H "Accept: text/markdown" https://cogley.jp/posts

# サイト全体のダンプ
curl -s https://cogley.jp/llms-full.txt

# 通常のHTML(Markdownシグナルなし)
curl -s https://cogley.jp/now

今から設計し直すなら

ゼロから構築するなら、すべてのコンテンツをMarkdownで保存し、HTMLをオンデマンドでレンダリングするでしょう。これは基本的にこのサイトがすでに行っていることです。/apiにはエディタが出力するMarkdownフィールドがすべて揃っています。HTMLレンダリングはSvelteKitのルートハンドラでmarkedを使って行われます。

HTMLファーストのコンテンツ(CMS出力、リッチテキストエディタ)を扱うサイトでは、HTMLからMarkdownへの変換ステップが必要です。turndownのようなライブラリが対応しますが、出力はソースMarkdownほどクリーンではありません。新しいシステムを設計するなら、Markdownで保存してください。

参考リンク

Read the original on cogley.jp

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.