diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 8ef224946a..875432d2e0 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -16,6 +16,7 @@ A skill is a folder with a `SKILL.md` (the instructions) and sometimes `referenc | [`content-write-blog`](content-write-blog/SKILL.md) | Scaffold a new Prisma blog post (frontmatter + section stubs) | "Draft a blog post about connection pooling" | | [`content-create-hero-image`](content-create-hero-image/SKILL.md) | Generate a post's hero (SVG) and social/OG image (PNG) in the Eclipse house style | "Create a cover image for my Compute post" | | [`docs-writer`](docs-writer/README.md) | Write or rewrite developer docs (how-to, concept, reference) | "Write a how-to for deploying to Prisma Compute" | +| [`docs-agent-ready`](docs-agent-ready/SKILL.md) | Hold the docs' agent-readiness invariants (llms.txt budgets, coverage, skill/MCP endpoints) when editing them | "Add a new docs section to llms.txt" | --- diff --git a/.claude/skills/docs-agent-ready/SKILL.md b/.claude/skills/docs-agent-ready/SKILL.md new file mode 100644 index 0000000000..3700c470a0 --- /dev/null +++ b/.claude/skills/docs-agent-ready/SKILL.md @@ -0,0 +1,58 @@ +--- +name: docs-agent-ready +description: Use when adding a new docs section or product area, editing llms.ts / the llms.txt or llms/[...slug] / llms-full.txt routes / get-llm-text / skill.md / .well-known endpoints, or working on the "agent score", "llms.txt", or anything "agent-ready" in the docs and site apps. Explains the invariants the Mintlify agent-readiness audit measures and how to hold them. +metadata: + author: Prisma + version: "2026.7.21" +--- + +# Docs agent-readiness + +Keep Prisma's docs machine-readable so the Mintlify **agent-score** audit does not silently regress as content is added. The score measures whether AI agents can discover and fetch the docs: a working `llms.txt` index, per-page Markdown, a discoverable skill, and MCP discovery. The guard `apps/docs/scripts/lint-agent-ready.ts` (run `pnpm --filter docs lint:agent-ready`) enforces the invariants below on every PR via `.github/workflows/links.yml`. + +## Invariants + +- **Root `llms.txt` < 50k bytes** (warn at 35k). It links to per-area section indexes, not every page. +- **Each section index < 50k bytes** (warn at 40k). Over budget means split the section. +- **Every page is reachable** — each `filterPagesForLLMsIndex` page appears in a section file or the root "Other pages" list. The guard asserts against the generated content, not just membership. +- **Directives in HTML + Markdown** — every page's Markdown (`getLLMText`) starts with the hidden `llms.txt` directive blockquote; the HTML page keeps a hidden directive as its first child. +- **HTML/Markdown parity** via `data-markdown-ignore` on human-only chrome so the Markdown mirrors the page. +- **`llms-full.txt` excludes** legacy `/orm/v6` and the Accelerate/Optimize products (`getLLMsFullPages`). +- **Skill + MCP endpoints live at BOTH roots**: `www.prisma.io` (apps/site) and `/docs` (apps/docs). + +## File map + +| Endpoint | Generated by | +|---|---| +| `/docs/llms.txt` | `apps/docs/src/app/llms.txt/route.ts` → `buildLLMsIndexContent` (llms.ts) | +| `/docs/llms/.txt` | `apps/docs/src/app/llms/[...slug]/route.ts` → `buildLLMsSectionContent` (llms.ts) | +| `/docs/llms-full.txt` | `apps/docs/src/app/llms-full.txt/route.ts` → `getLLMsFullPages` (llms.ts) + `getLLMText` | +| `/docs/.md` | `apps/docs/src/lib/get-llm-text.ts` (`getLLMText`) | +| `/docs/skill.md` | `apps/docs/src/app/skill.md/route.ts` → `apps/docs/src/lib/agent-skill.ts` | +| `/docs/.well-known/mcp[.json]` | `apps/docs/src/lib/mcp-discovery.ts` | +| `/skill.md`, `/.well-known/agent-skills/*` | `apps/site/src/lib/agent-skills.ts` (`buildSkillMarkdown`) | +| `/.well-known/mcp*` (site) | `apps/site/src/lib/agent-skills.ts` (`buildMcpDiscovery`, server cards) | + +The route handlers are thin wrappers: shared builders in `llms.ts` are the single source of truth, so the guard measures exactly what the routes serve. + +## Playbooks + +**(a) Adding a new docs area.** Add an entry to `llmsSections` in `apps/docs/src/lib/llms.ts` with `prefixes` (and `excludePrefixes` if a sub-tree belongs elsewhere). Run `pnpm --filter docs lint:agent-ready`. A **"Catch-all creep"** warning (> 25 pages in root "Other pages") means a new docs area needs its own section here. + +**(b) Section over budget.** When a section fails/warns on size, split it into two sections in `llmsSections` (narrower `prefixes`, or carve a sub-tree out with a new slug). Re-run the guard. + +**(c) Changing page chrome** in `apps/docs/src/app/(docs)/(default)/[[...slug]]/page.tsx`: keep the hidden `llms.txt` directive as the first child, and put `data-markdown-ignore` on any human-only chrome (banners, nav, badges) so it stays out of the Markdown. + +**(d) Changing the CLI workflow or MCP tools** in docs content: update the skill copy in `apps/site/src/lib/agent-skills.ts` AND `apps/docs/src/lib/agent-skill.ts` — they quote real commands and tool names. Keep them in sync with the Prisma Postgres quickstart and `content/docs/ai/tools/mcp-server.mdx`. The `commonQueries` links in `llms.ts` must point to existing pages (the guard fails on stale links). + +**(e) Verification.** + +```bash +pnpm --filter docs lint:agent-ready # all invariants + size table +pnpm --filter docs types:check # types +curl -s https://www.prisma.io/docs/llms.txt | head +curl -s https://www.prisma.io/docs/skill.md | head +curl -s https://www.prisma.io/.well-known/mcp +``` + +The guard prints a size table with per-file headroom so reviewers see how close each file is to its budget. diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 5d8deedc04..d963db4c8f 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -29,3 +29,6 @@ jobs: - name: Validate documentation links run: pnpm run lint:links + + - name: Validate agent-readiness + run: pnpm --filter docs lint:agent-ready diff --git a/apps/docs/package.json b/apps/docs/package.json index d729cbe6a4..912cd98f51 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -14,6 +14,7 @@ "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", "postinstall": "fumadocs-mdx", "lint:links": "tsx ./scripts/lint-links.ts", + "lint:agent-ready": "tsx ./scripts/lint-agent-ready.ts", "lint:external-links": "tsx ./scripts/lint-external-links.ts", "lint:images": "tsx ./scripts/lint-images.ts", "lint:code": "tsx ./scripts/lint-code-blocks.ts", diff --git a/apps/docs/scripts/lint-agent-ready.ts b/apps/docs/scripts/lint-agent-ready.ts new file mode 100644 index 0000000000..a77fc4e970 --- /dev/null +++ b/apps/docs/scripts/lint-agent-ready.ts @@ -0,0 +1,464 @@ +/** + * Agent-readiness guard. + * + * Keeps the Mintlify agent-score setup from silently regressing as docs content + * is added. It runs the SAME shared builders the route handlers use + * (`buildLLMsIndexContent`, `buildLLMsSectionContent`, `getLLMsFullPages` in + * `@/lib/llms`) so it measures exactly what agents fetch in production — no dev + * server required. Follows the `lint-links.ts` pattern: register the fumadocs + * loader, then dynamically import `@/lib/*`. + * + * See `.claude/skills/docs-agent-ready/SKILL.md` for the invariants and playbooks. + */ +import { register } from "node:module"; + +register("fumadocs-mdx/node/loader", import.meta.url); + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const { source } = await import("@/lib/source"); +const llms = await import("@/lib/llms"); +const { getLLMText } = await import("@/lib/get-llm-text"); +const { protectFencedCodeBlocks, protectInlineCode } = await import("@/lib/llm-markdown"); +const { withDocsBasePath } = await import("@/lib/urls"); +const { agentSkillMarkdown } = await import("@/lib/agent-skill"); +const { mcpDiscoveryDocument } = await import("@/lib/mcp-discovery"); + +// Hardcode the production base URL. Byte budgets must be stable and match what +// runs in production, so this must NOT read NEXT_PUBLIC_PRISMA_URL — otherwise the +// budgets would vary by environment and the numbers would not match production. +const baseUrl = "https://www.prisma.io"; + +// Directory of this script (apps/docs/scripts); used for source-level guards. +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +// Budgets. Agents commonly truncate large text feeds at ~100k chars; we hold a +// 50k safety budget for any single file, with earlier warnings so a section can +// be split before it becomes a hard failure. +const ROOT_FAIL = 50_000; +const ROOT_WARN = 35_000; +const SECTION_FAIL = 50_000; +const SECTION_WARN = 40_000; +const CATCHALL_WARN = 25; + +const MCP_URL = "https://mcp.prisma.io/mcp"; +const DIRECTIVE_MARKER = "> For the complete Prisma documentation index"; +const REQUIRED_SKILL_KEYS = [ + "name", + "description", + "license", + "compatibility", + "metadata", + "allowed-tools", +]; + +type Status = "pass" | "warn" | "fail"; +const results: { status: Status; name: string; message: string }[] = []; +const pass = (name: string, message = "") => results.push({ status: "pass", name, message }); +const warn = (name: string, message: string) => results.push({ status: "warn", name, message }); +const fail = (name: string, message: string) => results.push({ status: "fail", name, message }); + +const size = (content: string) => Buffer.byteLength(content, "utf8"); +const pageLink = (url: string) => `(${baseUrl}${withDocsBasePath(url)})`; + +/** + * Returns which required frontmatter keys are missing. Works both on a finished + * skill string (parses the block between the first pair of `---` lines) and, as + * a fallback, on raw TypeScript source where the frontmatter lives inside a + * template literal (keys still start at column 0). + */ +function missingFrontmatterKeys(text: string): string[] { + const fenced = text.match(/^---\n([\s\S]*?)\n---/m); + const block = fenced ? fenced[1] : text; + return REQUIRED_SKILL_KEYS.filter((key) => !new RegExp(`^${key}:`, "m").test(block)); +} + +const allPages = source.getPages(); +const indexPages = llms.filterPagesForLLMsIndex(allPages); +const availableSections = llms.filterAvailableLLMsSections(llms.llmsSections, indexPages); + +// ── Check 1: root llms.txt size ────────────────────────────────────────────── +const rootContent = llms.buildLLMsIndexContent(allPages, baseUrl); +const rootSize = size(rootContent); +if (rootSize >= ROOT_FAIL) { + fail("Root llms.txt size", `${rootSize} bytes >= ${ROOT_FAIL} budget`); +} else if (rootSize >= ROOT_WARN) { + warn("Root llms.txt size", `${rootSize} bytes >= ${ROOT_WARN} warning threshold`); +} else { + pass("Root llms.txt size", `${rootSize} bytes`); +} + +// ── Check 2: per-section sizes ─────────────────────────────────────────────── +const sectionContents = new Map(); +const sizeTable: { name: string; bytes: number; budget: number }[] = [ + { name: "llms.txt (root)", bytes: rootSize, budget: ROOT_FAIL }, +]; +for (const section of llms.llmsSections) { + const content = llms.buildLLMsSectionContent(section, indexPages, baseUrl); + sectionContents.set(section.slug, content); + const bytes = size(content); + sizeTable.push({ name: `llms/${section.slug}.txt`, bytes, budget: SECTION_FAIL }); + const splitHint = "split this section in llmsSections (apps/docs/src/lib/llms.ts)"; + if (bytes >= SECTION_FAIL) { + fail(`Section ${section.slug} size`, `${bytes} bytes >= ${SECTION_FAIL}; ${splitHint}`); + } else if (bytes >= SECTION_WARN) { + warn(`Section ${section.slug} size`, `${bytes} bytes >= ${SECTION_WARN}; ${splitHint}`); + } else { + pass(`Section ${section.slug} size`, `${bytes} bytes`); + } +} + +// ── Check 3: coverage ──────────────────────────────────────────────────────── +// Every index page must be reachable via a section file OR the root "Other +// pages" list. Assert against the actual generated content (not just membership) +// so a builder/route rewrite that drops links is caught. +const haystack = [rootContent, ...sectionContents.values()].join("\n"); +const uncovered = indexPages.filter((page) => !haystack.includes(pageLink(page.url))); +if (uncovered.length > 0) { + fail( + "Page coverage", + `${uncovered.length} page(s) not linked from any section or the root index:\n ${uncovered + .map((p) => p.url) + .join("\n ")}`, + ); +} else { + pass("Page coverage", `all ${indexPages.length} index pages reachable`); +} + +// ── Check 4: catch-all creep ───────────────────────────────────────────────── +const unmatched = llms.getUnmatchedLLMsPages(indexPages, availableSections); +if (unmatched.length > CATCHALL_WARN) { + warn( + "Catch-all creep", + `${unmatched.length} pages fall into the root "Other pages" list (> ${CATCHALL_WARN}); add a dedicated section to llmsSections for the new docs area`, + ); +} else { + pass("Catch-all creep", `${unmatched.length} unmatched pages`); +} + +// ── Check 5: markdown directive placement + description across ALL pages ────── +// The per-page markdown must carry the llms.txt directive as a blockquote +// IMMEDIATELY after the H1, so agents fetching any `.md` page get pointed at the +// index. Assert positionally (H1 line, blank line, directive line) across every +// index page — not a sample. getLLMText runs on preprocessed content, so this is +// fast enough to cover the full set. +const directiveFailures: string[] = []; +const missingDescription: string[] = []; +for (const page of indexPages) { + let text: string; + try { + // getLLMText calls page.data.getText("processed"); verified to work under the + // fumadocs loader used by this script (same loader as lint-links.ts). + text = await getLLMText(page); + } catch (error) { + directiveFailures.push(`${page.url} (getLLMText threw: ${String(error)})`); + continue; + } + + const lines = text.split("\n"); + const h1Index = lines.findIndex((line) => line.startsWith("# ")); + // Format is `# Title\n\n> directive…`, so the directive sits two lines below H1. + if (h1Index === -1 || !(lines[h1Index + 2] ?? "").startsWith(DIRECTIVE_MARKER)) { + directiveFailures.push(page.url); + } + + const description = page.data.description?.trim(); + if (description && !text.includes(description)) { + missingDescription.push(page.url); + } +} + +if (directiveFailures.length > 0) { + fail( + "Directive placement", + `${directiveFailures.length} of ${indexPages.length} page(s) missing the llms.txt directive immediately after the H1:\n ${directiveFailures + .slice(0, 10) + .join("\n ")}`, + ); +} else { + pass("Directive placement", `all ${indexPages.length} pages carry the directive after the H1`); +} + +if (missingDescription.length > 0) { + warn( + "Description in markdown", + `${missingDescription.length} page(s) have a frontmatter description not present in their markdown:\n ${missingDescription + .slice(0, 10) + .join("\n ")}`, + ); +} else { + pass("Description in markdown", "all frontmatter descriptions present in markdown"); +} + +// ── Check 5b: HTML surface source guard ────────────────────────────────────── +// The rendered HTML page carries the same directive via a hidden element. +// Rendering React in this script is not worth it; instead guard at the source +// level that the docs page component emits a hidden element referencing llms.txt +// BEFORE it renders = docsPageRenderIndex) { + fail( + "HTML directive source guard", + "page.tsx references llms.txt only after