Skip to content

Commit 524b760

Browse files
ankur-archclaude
andcommitted
docs/site: address CodeRabbit review on agent-readiness PR
- Protect inline code spans (not just fenced blocks) when absolutizing in-body markdown links - Pin the guard's byte-budget measurements to the production base URL - Guard now asserts the directive blockquote positionally on all 621 pages, checks descriptions across all pages, and adds a source-level guard for the hidden HTML directive - Field-level MCP discovery validation for both the docs and site payloads - Skill workflows now mirror the quickstart's ESM/TypeScript setup steps verbatim, and require explicit user approval before provisioning a hosted database with npx create-db Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1dffbaa commit 524b760

5 files changed

Lines changed: 257 additions & 52 deletions

File tree

apps/docs/scripts/lint-agent-ready.ts

Lines changed: 159 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ const { withDocsBasePath } = await import("@/lib/urls");
2525
const { agentSkillMarkdown } = await import("@/lib/agent-skill");
2626
const { mcpDiscoveryDocument } = await import("@/lib/mcp-discovery");
2727

28-
// Measure against the production base URL so byte budgets are stable regardless
29-
// of the local NEXT_PUBLIC_PRISMA_URL, and match the numbers reviewers see live.
30-
const baseUrl = process.env.NEXT_PUBLIC_PRISMA_URL ?? "https://www.prisma.io";
28+
// Hardcode the production base URL. Byte budgets must be stable and match what
29+
// runs in production, so this must NOT read NEXT_PUBLIC_PRISMA_URL — otherwise the
30+
// budgets would vary by environment and the numbers would not match production.
31+
const baseUrl = "https://www.prisma.io";
32+
33+
// Directory of this script (apps/docs/scripts); used for source-level guards.
34+
const scriptDir = dirname(fileURLToPath(import.meta.url));
3135

3236
// Budgets. Agents commonly truncate large text feeds at ~100k chars; we hold a
3337
// 50k safety budget for any single file, with earlier warnings so a section can
@@ -133,38 +137,93 @@ if (unmatched.length > CATCHALL_WARN) {
133137
pass("Catch-all creep", `${unmatched.length} unmatched pages`);
134138
}
135139

136-
// ── Check 5: markdown directive + description on sample pages ─────────────────
137-
for (const slug of ["orm", "postgres", "guides"]) {
138-
const section = llms.llmsSections.find((s) => s.slug === slug);
139-
const samplePage = section ? llms.filterPagesForLLMsSection(indexPages, section)[0] : undefined;
140-
if (!samplePage) {
141-
warn(`Directive (${slug})`, `no page found for section "${slug}" to sample`);
142-
continue;
143-
}
144-
140+
// ── Check 5: markdown directive placement + description across ALL pages ──────
141+
// The per-page markdown must carry the llms.txt directive as a blockquote
142+
// IMMEDIATELY after the H1, so agents fetching any `.md` page get pointed at the
143+
// index. Assert positionally (H1 line, blank line, directive line) across every
144+
// index page — not a sample. getLLMText runs on preprocessed content, so this is
145+
// fast enough to cover the full set.
146+
const directiveFailures: string[] = [];
147+
const missingDescription: string[] = [];
148+
for (const page of indexPages) {
145149
let text: string;
146150
try {
147151
// getLLMText calls page.data.getText("processed"); verified to work under the
148152
// fumadocs loader used by this script (same loader as lint-links.ts).
149-
text = await getLLMText(samplePage);
153+
text = await getLLMText(page);
150154
} catch (error) {
151-
fail(`Directive (${slug})`, `getLLMText threw for ${samplePage.url}: ${String(error)}`);
155+
directiveFailures.push(`${page.url} (getLLMText threw: ${String(error)})`);
152156
continue;
153157
}
154158

155-
if (!text.includes(DIRECTIVE_MARKER)) {
156-
fail(`Directive (${slug})`, `${samplePage.url} markdown is missing the llms.txt directive`);
157-
} else {
158-
pass(`Directive (${slug})`, samplePage.url);
159+
const lines = text.split("\n");
160+
const h1Index = lines.findIndex((line) => line.startsWith("# "));
161+
// Format is `# Title\n\n> directive…`, so the directive sits two lines below H1.
162+
if (h1Index === -1 || !(lines[h1Index + 2] ?? "").startsWith(DIRECTIVE_MARKER)) {
163+
directiveFailures.push(page.url);
159164
}
160165

161-
const description = samplePage.data.description?.trim();
166+
const description = page.data.description?.trim();
162167
if (description && !text.includes(description)) {
163-
warn(
164-
`Description (${slug})`,
165-
`${samplePage.url} has a frontmatter description not in its markdown`,
168+
missingDescription.push(page.url);
169+
}
170+
}
171+
172+
if (directiveFailures.length > 0) {
173+
fail(
174+
"Directive placement",
175+
`${directiveFailures.length} of ${indexPages.length} page(s) missing the llms.txt directive immediately after the H1:\n ${directiveFailures
176+
.slice(0, 10)
177+
.join("\n ")}`,
178+
);
179+
} else {
180+
pass("Directive placement", `all ${indexPages.length} pages carry the directive after the H1`);
181+
}
182+
183+
if (missingDescription.length > 0) {
184+
warn(
185+
"Description in markdown",
186+
`${missingDescription.length} page(s) have a frontmatter description not present in their markdown:\n ${missingDescription
187+
.slice(0, 10)
188+
.join("\n ")}`,
189+
);
190+
} else {
191+
pass("Description in markdown", "all frontmatter descriptions present in markdown");
192+
}
193+
194+
// ── Check 5b: HTML surface source guard ──────────────────────────────────────
195+
// The rendered HTML page carries the same directive via a hidden element.
196+
// Rendering React in this script is not worth it; instead guard at the source
197+
// level that the docs page component emits a hidden element referencing llms.txt
198+
// BEFORE it renders <DocsPage. This is a source-level guard, not a render test.
199+
const docsPagePath = join(
200+
scriptDir,
201+
"..",
202+
"src",
203+
"app",
204+
"(docs)",
205+
"(default)",
206+
"[[...slug]]",
207+
"page.tsx",
208+
);
209+
try {
210+
const docsPageSource = readFileSync(docsPagePath, "utf8");
211+
const docsPageRenderIndex = docsPageSource.indexOf("<DocsPage");
212+
const llmsRefIndex = docsPageSource.indexOf("llms.txt");
213+
if (docsPageRenderIndex === -1) {
214+
fail("HTML directive source guard", `<DocsPage not found in ${docsPagePath}`);
215+
} else if (llmsRefIndex === -1) {
216+
fail("HTML directive source guard", `page.tsx does not reference llms.txt`);
217+
} else if (llmsRefIndex >= docsPageRenderIndex) {
218+
fail(
219+
"HTML directive source guard",
220+
"page.tsx references llms.txt only after <DocsPage; the hidden directive must precede it",
166221
);
222+
} else {
223+
pass("HTML directive source guard", "hidden llms.txt directive precedes <DocsPage");
167224
}
225+
} catch (error) {
226+
fail("HTML directive source guard", `could not read ${docsPagePath}: ${String(error)}`);
168227
}
169228

170229
// ── Check 6: common queries resolve to existing pages ────────────────────────
@@ -215,7 +274,6 @@ if (docsSkillMissing.length > 0) {
215274
// whose `@/lib/url` import cannot resolve under the docs tsconfig. Read the raw
216275
// source and check the frontmatter keys directly (they sit at column 0 in the
217276
// template literal body).
218-
const scriptDir = dirname(fileURLToPath(import.meta.url));
219277
const siteSkillPath = join(scriptDir, "..", "..", "site", "src", "lib", "agent-skills.ts");
220278
try {
221279
const siteSkillSource = readFileSync(siteSkillPath, "utf8");
@@ -229,14 +287,85 @@ try {
229287
fail("Site skill frontmatter", `could not read ${siteSkillPath}: ${String(error)}`);
230288
}
231289

232-
// ── Check 9: MCP discovery document ──────────────────────────────────────────
233-
const mcpUrlOk = mcpDiscoveryDocument.url === MCP_URL;
234-
const mcpServersOk =
235-
Array.isArray(mcpDiscoveryDocument.servers) && mcpDiscoveryDocument.servers.length > 0;
236-
if (!mcpUrlOk || !mcpServersOk) {
237-
fail("MCP discovery", !mcpUrlOk ? `url is not ${MCP_URL}` : "servers array is missing or empty");
290+
// ── Check 9: MCP discovery documents (docs + site) ───────────────────────────
291+
// Field-level validation of both discovery payloads so a wrong URL, transport,
292+
// missing version, or empty authentication is caught — not just "an object
293+
// exists". The site payload is built from a template in agent-skills.ts whose
294+
// `@/lib/url` import cannot resolve under the docs tsconfig, so its static fields
295+
// are validated from raw source (same approach as check 8).
296+
const mcpErrors: string[] = [];
297+
298+
// Widen the `as const` literal types to plain strings so the runtime comparisons
299+
// below are meaningful checks (not always-false literal-vs-literal comparisons).
300+
const docsMcp = mcpDiscoveryDocument as {
301+
url: string;
302+
transport: string;
303+
version: string;
304+
servers: readonly { name: string; url: string; transport: string; authentication: string }[];
305+
};
306+
if (docsMcp.url !== MCP_URL) {
307+
mcpErrors.push(`docs: top-level url is "${docsMcp.url}", expected "${MCP_URL}"`);
308+
}
309+
if (docsMcp.transport !== "http") {
310+
mcpErrors.push(`docs: transport is "${docsMcp.transport}", expected "http"`);
311+
}
312+
if (typeof docsMcp.version !== "string" || docsMcp.version.trim() === "") {
313+
mcpErrors.push("docs: version is missing or empty");
314+
}
315+
if (!Array.isArray(docsMcp.servers) || docsMcp.servers.length === 0) {
316+
mcpErrors.push("docs: servers array is missing or empty");
238317
} else {
239-
pass("MCP discovery", `url ${MCP_URL}, ${mcpDiscoveryDocument.servers.length} server(s)`);
318+
docsMcp.servers.forEach((server, i) => {
319+
if (server.url !== MCP_URL) {
320+
mcpErrors.push(`docs: servers[${i}].url is "${server.url}", expected "${MCP_URL}"`);
321+
}
322+
if (server.transport !== "http") {
323+
mcpErrors.push(`docs: servers[${i}].transport is "${server.transport}", expected "http"`);
324+
}
325+
if (typeof server.authentication !== "string" || server.authentication.trim() === "") {
326+
mcpErrors.push(`docs: servers[${i}].authentication is missing or empty`);
327+
}
328+
});
329+
}
330+
331+
try {
332+
const siteSource = readFileSync(siteSkillPath, "utf8");
333+
const urlMatch = siteSource.match(/const MCP_SERVER_URL\s*=\s*"([^"]+)"/);
334+
if (!urlMatch) {
335+
mcpErrors.push("site: could not find the MCP_SERVER_URL constant");
336+
} else if (urlMatch[1] !== MCP_URL) {
337+
mcpErrors.push(`site: MCP_SERVER_URL is "${urlMatch[1]}", expected "${MCP_URL}"`);
338+
}
339+
340+
// Validate the static fields of the buildMcpDiscovery() payload from source.
341+
const discoveryMatch = siteSource.match(
342+
/export function buildMcpDiscovery\([^)]*\)\s*\{([\s\S]*?)\n\}/,
343+
);
344+
const discoveryBody = discoveryMatch?.[1];
345+
if (!discoveryBody) {
346+
mcpErrors.push("site: could not find the buildMcpDiscovery() function");
347+
} else {
348+
if (!/\btransport:\s*"http"/.test(discoveryBody)) {
349+
mcpErrors.push('site: buildMcpDiscovery transport is not "http"');
350+
}
351+
if (!/\burl:\s*MCP_SERVER_URL\b/.test(discoveryBody)) {
352+
mcpErrors.push("site: buildMcpDiscovery url is not MCP_SERVER_URL");
353+
}
354+
if (!/\bauthentication:\s*"[^"]+"/.test(discoveryBody)) {
355+
mcpErrors.push("site: buildMcpDiscovery server authentication is missing or empty");
356+
}
357+
}
358+
} catch (error) {
359+
mcpErrors.push(`site: could not read ${siteSkillPath}: ${String(error)}`);
360+
}
361+
362+
if (mcpErrors.length > 0) {
363+
fail("MCP discovery", `field validation failed:\n ${mcpErrors.join("\n ")}`);
364+
} else {
365+
pass(
366+
"MCP discovery",
367+
`docs + site valid: url "${MCP_URL}", transport http, ${docsMcp.servers.length} server(s)`,
368+
);
240369
}
241370

242371
// ── Report ───────────────────────────────────────────────────────────────────

apps/docs/src/lib/agent-skill.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,47 @@ These are the core commands for a new TypeScript project (copied from the Prisma
3939
\`\`\`bash
4040
npm init -y
4141
npm install typescript tsx @types/node --save-dev
42+
npx tsc --init
4243
npm install prisma @types/pg --save-dev
4344
npm install @prisma/client @prisma/adapter-pg pg dotenv
4445
\`\`\`
4546
46-
2. **Scaffold Prisma ORM.** This creates \`prisma/schema.prisma\`, a \`.env\` with \`DATABASE_URL\`, and \`prisma.config.ts\`:
47+
2. **Configure ESM support.** Update \`tsconfig.json\`:
48+
49+
\`\`\`json
50+
{
51+
"compilerOptions": {
52+
"module": "ESNext",
53+
"moduleResolution": "bundler",
54+
"target": "ES2023",
55+
"strict": true,
56+
"esModuleInterop": true,
57+
"ignoreDeprecations": "6.0"
58+
}
59+
}
60+
\`\`\`
61+
62+
Then enable ESM in \`package.json\`:
63+
64+
\`\`\`json
65+
{
66+
"type": "module"
67+
}
68+
\`\`\`
69+
70+
3. **Scaffold Prisma ORM.** This creates \`prisma/schema.prisma\`, a \`.env\` with \`DATABASE_URL\`, and \`prisma.config.ts\`:
4771
4872
\`\`\`bash
4973
npx prisma init --output ../generated/prisma
5074
\`\`\`
5175
52-
3. **Provision a Prisma Postgres database** and replace \`DATABASE_URL\` in \`.env\` with the \`postgres://...\` connection string from the CLI output:
76+
4. **Provision a Prisma Postgres database.** Ask the user for approval before provisioning a hosted Prisma Postgres database — \`npx create-db\` creates a cloud resource. After approval, run it and replace \`DATABASE_URL\` in \`.env\` with the \`postgres://...\` connection string from the CLI output:
5377
5478
\`\`\`bash
5579
npx create-db
5680
\`\`\`
5781
58-
4. **Define your data model** in \`prisma/schema.prisma\`, for example:
82+
5. **Define your data model** in \`prisma/schema.prisma\`, for example:
5983
6084
\`\`\`prisma
6185
generator client {
@@ -84,14 +108,14 @@ These are the core commands for a new TypeScript project (copied from the Prisma
84108
}
85109
\`\`\`
86110
87-
5. **Create and apply a migration**, then **generate Prisma Client**:
111+
6. **Create and apply a migration**, then **generate Prisma Client**:
88112
89113
\`\`\`bash
90114
npx prisma migrate dev --name init
91115
npx prisma generate
92116
\`\`\`
93117
94-
6. **Instantiate Prisma Client** with the driver adapter and query your database:
118+
7. **Instantiate Prisma Client** with the driver adapter and query your database:
95119
96120
\`\`\`typescript
97121
import "dotenv/config";
@@ -108,7 +132,7 @@ These are the core commands for a new TypeScript project (copied from the Prisma
108132
const users = await prisma.user.findMany({ include: { posts: true } });
109133
\`\`\`
110134
111-
7. **Explore your data** visually with Prisma Studio:
135+
8. **Explore your data** visually with Prisma Studio:
112136
113137
\`\`\`bash
114138
npx prisma studio

apps/docs/src/lib/get-llm-text.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { source } from "@/lib/source";
2-
import { normalizeProcessedMarkdown, protectFencedCodeBlocks } from "@/lib/llm-markdown";
2+
import {
3+
normalizeProcessedMarkdown,
4+
protectFencedCodeBlocks,
5+
protectInlineCode,
6+
} from "@/lib/llm-markdown";
37
import { getPageTitleText } from "@/lib/page-title";
48
import { getBaseUrl, withDocsBasePath } from "@/lib/urls";
59
import type { InferPageType } from "fumadocs-core/source";
@@ -199,17 +203,21 @@ function resolveInBodyHref(target: string, page: DocsPage, baseUrl: string) {
199203
}
200204

201205
function absolutizeInBodyLinks(markdown: string, page: DocsPage, baseUrl: string) {
202-
// Protect fenced code blocks so example code containing markdown link syntax is
203-
// left untouched. Inline code (single backticks) cannot contain the `](...)` link
204-
// syntax we rewrite, so it needs no protection.
205-
const protectedCode = protectFencedCodeBlocks(markdown);
206-
207-
const rewritten = protectedCode.markdown.replace(/\]\((\/[^)\s]*)\)/g, (full, target: string) => {
208-
if (target.startsWith("//")) return full;
209-
return `](${resolveInBodyHref(target, page, baseUrl)})`;
210-
});
206+
// Protect fenced code blocks, then inline code spans, so example code AND inline
207+
// code containing markdown link syntax (e.g. `[label](/path)`) are left untouched
208+
// by the rewrite below. Order matters: fences first, then inline spans.
209+
const protectedFences = protectFencedCodeBlocks(markdown);
210+
const protectedInline = protectInlineCode(protectedFences.markdown);
211+
212+
const rewritten = protectedInline.markdown.replace(
213+
/\]\((\/[^)\s]*)\)/g,
214+
(full, target: string) => {
215+
if (target.startsWith("//")) return full;
216+
return `](${resolveInBodyHref(target, page, baseUrl)})`;
217+
},
218+
);
211219

212-
return protectedCode.restore(rewritten);
220+
return protectedFences.restore(protectedInline.restore(rewritten));
213221
}
214222

215223
export async function getLLMText(page: DocsPage) {

apps/docs/src/lib/llm-markdown.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,32 @@ export function protectFencedCodeBlocks(markdown: string) {
396396
};
397397
}
398398

399+
/**
400+
* Protects inline code spans (single or multiple backticks on a single line) so
401+
* their literal contents — including markdown link syntax like `[label](/path)` —
402+
* are left untouched by downstream text rewrites, then restored afterwards.
403+
* Protect fenced code blocks FIRST, then inline spans, so the backticks that open
404+
* and close a fence are never mistaken for an inline span.
405+
*/
406+
export function protectInlineCode(markdown: string) {
407+
const spans: string[] = [];
408+
const protectedMarkdown = markdown.replace(/(`+)[^\n]+?\1/g, (match) => {
409+
const token = `__LLM_INLINE_CODE_${spans.length}__`;
410+
spans.push(match);
411+
return token;
412+
});
413+
414+
return {
415+
markdown: protectedMarkdown,
416+
restore(value: string) {
417+
return spans.reduce(
418+
(text, span, index) => text.replace(`__LLM_INLINE_CODE_${index}__`, span),
419+
value,
420+
);
421+
},
422+
};
423+
}
424+
399425
export function normalizeProcessedMarkdown(markdown: string) {
400426
const componentMarkdown = markdown
401427
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")

0 commit comments

Comments
 (0)