llms.txt を使って Astro ブログを AI が読めるようにする

Dev.to / 2026/3/20

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical Usage

要点

  • AIモデルはますますブログやドキュメントの内容を直接読み取っており、しばしばナビゲーションのマークアップやスクリプトといったノイズを含むページから生のテキストをスクレイピングします。
  • llms.txt は、AIリーダーを導くサイトマップとして機能する、/llms.txt にある Markdown ファイルという、シンプルな標準として紹介されています。
  • 著者は AI同士のコンテンツの循環と、人間と AI の著者性の境界がぼやけることについて振り返りつつ、両方の読者層にとっての実用的なアクセシビリティを強調しています。
  • 本投稿はこの変化に対する実用的な対応として llms.txt を位置づけ、創作活動者に対して AI に読みやすさを最適化することを推奨する一方、人間の読みやすさを犠牲にしないことを促しています。

この1年ほどの間に、何かが変わってきたと感じています。私のブログ投稿、ドキュメントページ、個人サイトの読者は、もはやブラウザをスクロールする人間だけではありません。ますます、多くの“読者”は AI モデル — Claude、ChatGPT、Gemini — が質問をした開発者に代わってコンテンツを取り込む存在です。AI アシスタントに「TanStack Start を Cloudflare にデプロイするにはどうすればよいですか」と入力すると、答えは私のブログから来るかもしれません。しかし AI は私が丁寧にスタイリングしたページを決して見ることはありません。 ナビゲーションのマークアップ、SVG アイコン、JavaScript のアーティファクトで生のテキストが混在しているのをAIは見るだけです。

このことには奇妙な皮肉があります。私はブログ記事を書き、AI がそれを読み、要約し、それを別の人に返します — その人はそれに基づいて自分のブログ記事を書く手助けを別の AI に依頼するかもしれません。全体のループはますます AI から AI へと移行しており、人間は発端ではあるものの、必ずしも読者ではなくなっています。ドキュメントサイトは、開発者が上から下へ読み進めるものではなく、文脈をその場で引き出すコーディングアシスタントによって消費されています。聴衆は変わり、しかも静かに変わりました。

もしこれが principled な理由で私を困らせているふりをするなら、私は偽善者でしょう。このブログの投稿の多くは、AI のアシストによって下書きされたり磨かれたりしています。それが現代の執筆の現実です — AI は私の思考を整理し、荒い文体を滑らかにし、二度目の推敲で見逃すかもしれない点を拾ってくれます。「私がこれを書いた」と「AI がこれを書いた」の境界は実際には曖昧になっており、正直な作家の多くも同じだと認めるでしょう。

それでも私が感じる downstream effect の影響は、下流の影響です。AI がより多くのコンテンツを作成し、AI がより多くのコンテンツを消費するとき、ループ内の本当に人間起源の思考の割合は小さくなっていきます。これは壊滅的だとは言いません — アイデアは依然として人間のもので、意図も人間のものですが — しかし、それに気づくために一時停止して notice する価値はあります。我々は機械が機械のために読むためのインフラを作っており、人間は周辺の余白にいて、 prompting and approving。

この点についての大きな結論はありません。今の世界はこういうものだというだけです。そしてそれが事実なら、実用的なことは、私が実際に公表する内容 — どのように作成されたとしても — が、両方の読者にとってできる限りアクセスしやすいものであることを保証することです。

それはまさに llms.txt の目的です。シンプルで新興の標準 — 言語モデルのサイトマップとして機能する Markdown ファイル /llms.txt — が、清潔で構造化されたコンテンツへと AI が実際に解析できる形で示します。robots.txt のように考えてください。ただしクローラーに対してnot をインデックスしないと指示するのではなく、AI モデルにto 読ませるべき内容を指示します。

So here's what I did: I made my blog speak both languages. A styled, human-friendly site for browsers, and a clean Markdown feed for AI. Below is how I integrated it into my Astro blog.

計画

実装は三つの要素から成ります:

  1. /llms.txt — すべての投稿を一覧にし、それぞれの Markdown 版へのリンクを含むインデックスファイル。
  2. /llms-full.txt — すべての投稿を一つのファイルに連結したもの。AI モデルが完全な文脈を欲する時用。
  3. /posts/{slug}.md — 投稿ごとにクリーンな Markdown エンドポイントがあり、個々の記事を HTML ラッパーなしで取得できます。

三つともビルド時に静的ファイルとして事前レンダリングされるため、実行時コストはゼロです。

ステップ 1: MDX からクリーン Markdown へ

私の投稿は MDX — JSX コンポーネント、インポート、式が散りばめられた Markdown — で書かれています。AI モデルにはそれらは不要です。コードブロックをそのまま含んだプレーンな Markdown が必要です。

私は unified エコシステムを使いました — 具体的には remark-parseremark-mdxremark-stringify、および unist-util-visit — 小さな処理パイプラインを構築するために:

// src/lib/llms-txt.ts
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkMdx from "remark-mdx";
import remarkStringify from "remark-stringify";
import type { Root } from "mdast";
import { visit, SKIP } from "unist-util-visit";

function remarkStripMdx() {
  return (tree: Root) => {
    visit(tree, (node, index, parent) => {
      if (index === undefined || !parent) return;
      if (
        node.type === "mdxjsEsm" ||
        node.type === "mdxJsxFlowElement" ||
        node.type === "mdxJsxTextElement" ||
        node.type === "mdxFlowExpression" ||
        node.type === "mdxTextExpression"
      ) {
        parent.children.splice(index, 1);
        return [SKIP, index];
      }
    });
  };
}

const processor = unified()
  .use(remarkParse)
  .use(remarkMdx)
  .use(remarkStripMdx)
  .use(remarkStringify, { bullet: "-", fences: true, listItemIndent: "one" });

export async function mdxToMarkdown(mdx: string): Promise<string> {
  const stripped = mdx.replace(/^---
[\s\S]*?
---
?/, "");
  return String(await processor.process(stripped)).trim();
}

// ... shared helpers (getSortedPosts, formatPost, etc.) shown below ...

The pipeline: remark-parse は Markdown を解析し、remark-mdx は MDX の構文を理解し、カスタム remarkStripMdx プラグインが unist-util-visit を用いて AST を歩き回り、MDX 固有のノード(インポート、エクスポート、JSX コンポーネント、JS 式)をすべて削除し、remark-stringify は残ったものをクリーンな Markdown に再度シリアライズします。

ステップ 2: 3つのエンドポイント

プロセッサを用意すれば、エンドポイントは最小限になります。同じ src/lib/llms-txt.ts ファイル内で、Astro の Content Collections API を用いて DRY を保つための共通ヘルパーをいくつか追加しました:

// src/lib/llms-txt.ts
// ... imports, remarkStripMdx plugin, processor, mdxToMarkdown shown above ...

import { getCollection } from "astro:content";
export interface PostMeta { title: string;
description: string;
date: Date;
tags: string[];
body: string;
slug: string;
} export async function formatPost(post: PostMeta, baseUrl: string): Promise<string> { const md = await mdxToMarkdown(post.body); const date = post.date.toISOString().split("T")[0]; const tags = post.tags.length ? `- **Tags:** `${post.tags.join(",")} : ""; return `# `${post.title} - **URL:** ${baseUrl}/posts/${post.slug} - **Date:** ${date} ${tags}- **Description:** ${post.description} --- ${md}`; } export async function getSortedPosts() { const posts = await getCollection("blogPost", ({ data }) => !data.draft); return posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); }
export function resolveBaseUrl(site: URL | undefined): string { return site?.toString().replace(/\/$/, \"\") ?? \"https://example.com\"; }

次に、それぞれの Astro エンドポイント が薄いラッパーになります:

/llms.txt — インデックス

// src/pages/llms.txt.ts
import type { APIRoute } from \"astro\";
import { getSortedPosts, resolveBaseUrl } from \"@/lib/llms-txt\";

export const prerender = true;

export const GET: APIRoute = async ({ site }) =\> {
  const baseUrl = resolveBaseUrl(site);
  const posts = await getSortedPosts();

  const lines = [
    \"# My Blog\",
    \"\",
    \"> A short description of your site.\",
    \"\",
    \"## Blog Posts\",
    \"\",
    ...posts.map(
      (p) => `- [${p.data.title}](${baseUrl}/posts/${p.id}.md): ${p.data.description}`
    ),
    \"## Optional\",
    \"\",
    `- [Full blog content](${baseUrl}/llms-full.txt): All posts in one file`,
  ];

  return new Response(lines.join(\"
\"), {
    headers: { \"Content-Type\": \"text/plain; charset=utf-8\" }
  });
};

/llms-full.txt — すべてを1つのファイルに

// src/pages/llms-full.txt.ts
import type { APIRoute } from \"astro\";
import { getSortedPosts, resolveBaseUrl, toPostMeta, formatPost } from \"@/lib/llms-txt\";

export const prerender = true;

export const GET: APIRoute = async ({ site }) => {
  const baseUrl = resolveBaseUrl(site);
  const posts = await getSortedPosts();
  const sections = await Promise.all(
    posts.map((p) => formatPost(toPostMeta(p), baseUrl))
  );

  return new Response(
    [\"# Full Blog Content\", \"\", sections.join(\"

---

")].join(\"

"),
    { headers: { \"Content-Type\": \"text/plain; charset=utf-8\" }
  );
};

/posts/{slug}.md — 各投稿の Markdown

// src/pages/posts/[...slug].md.ts
import type { APIRoute } from "astro";
import { getSortedPosts, resolveBaseUrl, toPostMeta, formatPost } from "@/lib/llms-txt";

export const prerender = true;

export async function getStaticPaths() {
  const posts = await getSortedPosts();
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

export const GET: APIRoute = async ({ site, props }) => {
  const { post } = props as { post: Awaited<ReturnType<typeof getSortedPosts>>[number] };

  return new Response(await formatPost(toPostMeta(post), resolveBaseUrl(site)), {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
};
That's it for the backend. Three files, each under 20 lines of actual logic.

主要な検出方法 は規約に基づく: llms.txt の仕様はファイルをよく知られたパスに置く(https://yoursite.com/llms.txt)、robots.txt のように。AIシステムは仕様を知っていればリンクを必要とせず直接取得できます。ただし、リンクをたどってコンテンツを発見するクローラには、URLを最初からHTMLに表示しておく必要があります。

実施すべきこと:

  1. <link> in <head> — llms.txt を見つけられるよう、link タグを読むパーサーに機械可読のヒントを追加します:
<link rel="alternate" type="text/plain" href="https://yoursite.com/llms.txt" title="llms.txt - AI-readable site index" />
  1. 表示用の静的リンク — 常に表示されるプレーンなアンカーをどこかに配置します(例: RSS/フィードセクションやフッター)。JavaScript は不要、ドロップダウンもなし。リンクは最初にサーバーでレンダリングされた HTML に含まれている必要があります。

  2. robots.txt — お持ちでない場合はサイトのルートに作成します。クローラーがそれを探す場合は llms.txt を指すコメントを追加できます。

  3. 投稿ごとのリンク — 各ブログ投稿ページに、その投稿の .md バージョンへのシンプルな専用リンクを追加します。ヘッダーにすでにあるので、CopyForAI のドロップダウンを重複させません。最小限の Astro コンポーネントを使用して、静的な <a href="/posts/{slug}.md"> をレンダリングします — いつでも HTML に含まれるようにして、クローラーが任意の記事ページから個別の投稿の Markdown を発見できるようにします。

<!-- src/components/PostMarkdownLink.astro -->
---
interface Props { slug: string; className?: string; }
const { slug, className = "" } = Astro.props;
---
<a href={`/posts/${slug}.md`} class={className} title="View markdown version for AI">
  Markdown for AI
</a>

これは人間の利便性のためのヘッダのドロップダウン機能(クリップボードへコピー、ブログの全表示)を維持しつつ、すべての投稿ページに AI が読める版へのクローラ対応リンクがあることを保証します。

要点

全体の実装は6つのファイルにまたがって約200行です。主要な依存関係は unifiedremark-parseremark-mdx、および remark-stringify — MDXをMarkdownへ変換する処理を、脆弱な正規表現に頼ることなくAST操作を通じて正しく実行する、実績あるライブラリです。

技術的な部分は正直言って容易な部分でした。残るのはその背後にある感覚です。私たちは何年もかけてレスポンシブなレイアウト、ダークモード、タイポグラフィ、スクロールアニメーションを人間の目のために磨き上げてきました。今、それらを気にしない聴衆のために、第二の出力形式を追加しています。生のテキスト、明確な構造、そしてそれ以外は何も求めない聴衆です。

そしてそれで構わないかもしれません。個人のブログの未来は、単に人間が読む場所だけではなく、AIシステムが参照するより大きな知識グラフのノードでもあります。私の文章が誰かの問題解決に役立つなら、それを直接読んだか、AI が要約して読ませたかは問題でしょうか。正直、私にはよく分かりません。

そして私が確信しているのは、この変化は私たちがそれを好むかどうかにかかわらず起きているということです。現実的な対応としては、それを半ばまで受け止めること — 人間のために書き続けつつ、機械にも読みやすくすること。これこそがllms.txtが本当に意味するすべてです:今この瞬間、読者が実際に誰であるかを認識する小さな行動です。