diff --git a/.claude/skills/docs-agent-ready/SKILL.md b/.claude/skills/docs-agent-ready/SKILL.md index 3700c470a0..f00a2e1dad 100644 --- a/.claude/skills/docs-agent-ready/SKILL.md +++ b/.claude/skills/docs-agent-ready/SKILL.md @@ -3,7 +3,7 @@ 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" + version: "2026.7.24" --- # Docs agent-readiness @@ -15,8 +15,10 @@ Keep Prisma's docs machine-readable so the Mintlify **agent-score** audit does n - **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. +- **Directives in HTML + Markdown** — every page's Markdown (`getLLMText`) starts with the hidden `llms.txt` directive blockquote; the HTML keeps a hidden directive as the **first child of `` in the root layout** (`apps/docs/src/app/layout.tsx`), NOT inside the page component. Audits measure the directive's byte position in the body and warn when it sits past 50%, which is where it lands if rendered after the sidebar markup. +- **HTML/Markdown parity** via `data-markdown-ignore` on human-only chrome so the Markdown mirrors the page. The OpenAPI explorer (`APIPage` wrapper in `src/components/api-page.tsx`) carries `data-markdown-ignore` because the interactive reference has no markdown equivalent — the `.md` serves the generated API summary instead. +- **Markdown keeps real headings** — fumadocs' processed output emits headings as bare `Text [#anchor]` lines; `getLLMText` restores `##` markers from the page toc (`restoreHeadingMarkers` in `llm-markdown.ts`). Without them, parity checkers strip list-like heading text ("## 1. Set up …") and agents see prose instead of structure. +- **`
` blocks are converted** to a bold summary line + dedented body (`formatDetails` in `llm-markdown.ts`); serialized `
` children are 2-space indented, which silently breaks the code fences inside for markdown consumers. - **`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). @@ -32,6 +34,8 @@ Keep Prisma's docs machine-readable so the Mintlify **agent-score** audit does n | `/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) | +| `/docs/mcp` (MCP proxy) | `apps/docs/src/app/mcp/route.ts` → proxies protocol traffic to `mcp.prisma.io/mcp` | +| `/mcp` (site, MCP traffic) | header-matched `beforeFiles` rewrites in `apps/site/next.config.mjs` (browser GETs still get the marketing page) | 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. @@ -41,7 +45,7 @@ The route handlers are thin wrappers: shared builders in `llms.ts` are the singl **(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. +**(c) Changing page chrome.** The hidden `llms.txt` directive lives in `apps/docs/src/app/layout.tsx` as the first child of `` — keep it there (before `` within the first 10% of the (nav/script/style-stripped) `` on sampled pages and warns when every match sits past 50%; the parity check compares HTML text segments against the `.md`, strips `data-markdown-ignore` elements from the HTML side, and only treats a fenced code block as protected when the fence starts at column 0 — which is why `
` bodies must be dedented and headings must keep their `#` markers. The separate "MCP Server Discoverable" check probes `/mcp` with an MCP initialize request (discovery documents alone do not count), which is what the `/docs/mcp` proxy route and the site `/mcp` rewrites are for. diff --git a/apps/docs/scripts/lint-agent-ready.ts b/apps/docs/scripts/lint-agent-ready.ts index a77fc4e970..3b87e865f6 100644 --- a/apps/docs/scripts/lint-agent-ready.ts +++ b/apps/docs/scripts/lint-agent-ready.ts @@ -146,6 +146,8 @@ if (unmatched.length > CATCHALL_WARN) { // fast enough to cover the full set. const directiveFailures: string[] = []; const missingDescription: string[] = []; +const headingMarkerFailures: string[] = []; +const detailsLeaks: string[] = []; for (const page of indexPages) { let text: string; try { @@ -168,6 +170,27 @@ for (const page of indexPages) { if (description && !text.includes(description)) { missingDescription.push(page.url); } + + // Heading markers: the processed markdown emits headings as bare + // "Text [#anchor]" lines; getLLMText restores the `#` markers from the toc. + // If a toc anchor appears in the output, the line carrying it must be a real + // markdown heading — otherwise agents see prose and the afdocs parity check + // strips list-like heading text ("## 1. Set up …") on the markdown side. + for (const item of page.data.toc ?? []) { + if (typeof item.url !== "string" || !item.url.startsWith("#")) continue; + const anchorRef = `[${item.url}]`; + const anchorLine = lines.find((line) => line.includes(anchorRef)); + if (anchorLine !== undefined && !/^#{1,6} /.test(anchorLine)) { + headingMarkerFailures.push(`${page.url} (${item.url})`); + } + } + + //
blocks must be converted to plain markdown (formatDetails in + // llm-markdown.ts); a leaked
means its body is still 2-space + // indented, which breaks code fences for markdown consumers. + if (text.includes(" 0) { @@ -192,39 +215,88 @@ if (missingDescription.length > 0) { pass("Description in markdown", "all frontmatter descriptions present in markdown"); } +if (headingMarkerFailures.length > 0) { + fail( + "Heading markers restored", + `${headingMarkerFailures.length} toc heading(s) rendered without markdown markers (restoreHeadingMarkers in llm-markdown.ts regressed):\n ${headingMarkerFailures + .slice(0, 10) + .join("\n ")}`, + ); +} else { + pass("Heading markers restored", "all toc anchors in markdown output sit on real headings"); +} + +if (detailsLeaks.length > 0) { + fail( + "No
leakage", + `${detailsLeaks.length} page(s) leak raw
into markdown (formatDetails in llm-markdown.ts regressed):\n ${detailsLeaks + .slice(0, 10) + .join("\n ")}`, + ); +} else { + pass("No
leakage", "all
blocks converted to plain 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