/* RFC-0052 robots.txt generation. Pure function buildRobotsTxt produces the robots exclusion standard file from a structured RobotsPolicy config. Do not read content files or validate input — the caller is responsible. Do not manage default policy fallback — caller decides skip vs fallback. RFC-0784: Add contentSignal field and Content-Signal directive output. RFC-1161: derive named AI-training bot blocks in robots.txt from Content-Signal AI_TRAINING_CRAWLER_REGISTRY (14 train tokens) + derived User-agent group in buildRobotsTxt when contentSignal declares ai-train=no, deduped against crawlerBlocklist/customRules. ROBOTS-AITRAIN-01 (missing derived group) and ROBOTS-AITRAIN-02 (policy self-contradiction) in robots.validate; rule registry entries; robots.validate registered in build-prepare after robots.generate (first pipeline placement). Fix upstream dead config: robots block now declared in systemManifestSchema (manifest.robots was stripped by strict parse). RFC-1161: fo-review — unexport AI_TRAINING_GROUP_MARKER (no external consumer) RFC-1175: retire the non-standard Schemamap directive — Lighthouse flags it as an invalid robots.txt line (discovery stays covered by the head link). Adds ROBOTS_DIRECTIVE_ALLOWLIST so robots.validate can flag unknown directives. RFC-1175: close Lighthouse gaps — directive-allowlist robots (Schemamap retired, ROBOTS-SURFACE-01), capability-gated video (preload=none, data-bg-video, saveData/reduced-motion), single fetchpriority=high per page (IMG-DELIVERY-05), inlined shared texture token, 24px QR touch targets; RFC-1177 security-headers draft RFC-0052 */ export interface RobotsRule { userAgent: string; disallow: string[]; allow: string[]; } export interface RobotsPolicy { defaultPolicy?: "allow" | "disallow"; disallowedPaths?: string[]; allowedPaths?: string[]; crawlerBlocklist?: string[]; crawlerAllowlist?: string[]; sitemap?: string; customRules?: RobotsRule[]; /** RFC-0784: Content-Signal directive — AI usage preferences (ai-train, search, ai-input). */ contentSignal?: string[]; } /** RFC-1175: directive names the shared robots.txt surface is allowed to emit — * RFC 9309 group/non-group fields plus the crawler-recognized extensions that * Lighthouse's robots parser accepts. Case-insensitive matching: robots.txt * directive names are case-insensitive per RFC 9309 §2.2.2. `robots.validate` * warns (ROBOTS-SURFACE-01) on any `Name:` line outside this list. */ export const ROBOTS_DIRECTIVE_ALLOWLIST: readonly string[] = [ "user-agent", "disallow", "allow", "sitemap", // RFC-0784: Google-documented (Sept 2025) AI-usage preference directive. "content-signal", // Widely supported crawler extensions (Google/Yandex-era compatibility). "crawl-delay", "host", "clean-param", ]; /** Product-token categories for AI crawlers (RFC-1161). */ export type AiCrawlerCategory = "train" | "search" | "input"; export interface AiCrawlerRegistryEntry { /** Exact robots.txt User-agent product token. */ userAgent: string; category: AiCrawlerCategory; } /** Workshop-owned registry of known AI crawler product tokens (RFC-1161). * Only "train" entries participate in the derived Disallow group. * Registry changes are platform commits; sites inherit via regeneration. */ export const AI_TRAINING_CRAWLER_REGISTRY: readonly AiCrawlerRegistryEntry[] = [ { userAgent: "GPTBot", category: "train" }, { userAgent: "Bytespider", category: "train" }, { userAgent: "CCBot", category: "train" }, { userAgent: "Google-Extended", category: "train" }, { userAgent: "Applebot-Extended", category: "train" }, { userAgent: "ClaudeBot", category: "train" }, { userAgent: "anthropic-ai", category: "train" }, { userAgent: "cohere-ai", category: "train" }, { userAgent: "Meta-ExternalAgent", category: "train" }, { userAgent: "AI2Bot", category: "train" }, { userAgent: "Diffbot", category: "train" }, { userAgent: "Webzio-Extended", category: "train" }, { userAgent: "Timpibot", category: "train" }, { userAgent: "VelenPublicWebCrawler", category: "train" }, ]; /** Marker comment line identifying the derived AI-training group (RFC-1161). * Module-private: the canonical rendered group is exposed via * aiTrainingCrawlerGroupLines — no external consumer reads the marker alone. */ const AI_TRAINING_GROUP_MARKER = "# AI-training crawlers — derived from Content-Signal ai-train=no (RFC-1161)"; /** RFC-1161: true when the policy's Content-Signal declares ai-train=no. * Entries are matched whitespace-tolerant at the edges and case-insensitively * (Content-Signal tokens are canonically lowercase, but hand-edited manifests * must not defeat the derivation). One-directional: ai-train=yes or absence * emits nothing. */ export function declaresAiTrainNo(policy: RobotsPolicy): boolean { return (policy.contentSignal ?? []).some((entry) => entry.trim().toLowerCase() === "ai-train=no"); } /** RFC-1161: train-category registry tokens covered by the derived group for * this policy. Tokens already covered by crawlerBlocklist or a * customRules[].userAgent entry are excluded — the site's explicit per-bot * configuration wins, and duplicate User-agent groups for the same product * token are never emitted (RFC 9309 leaves multi-group merging to crawlers). */ export function deriveAiTrainingUserAgents(policy: RobotsPolicy): string[] { if (!declaresAiTrainNo(policy)) { return []; } const explicitTokens = new Set([ ...(policy.crawlerBlocklist ?? []), ...(policy.customRules ?? []).map((rule) => rule.userAgent), ]); return AI_TRAINING_CRAWLER_REGISTRY.filter( (entry) => entry.category === "train" && !explicitTokens.has(entry.userAgent), ).map((entry) => entry.userAgent); } /** RFC-1161: canonical rendered lines of the derived AI-training crawler group. * Shared by the generator and robots.validate (AITRAIN-01) so the presence * check can never drift from what the generator emits. */ export function aiTrainingCrawlerGroupLines(userAgents: readonly string[]): string[] { return [ AI_TRAINING_GROUP_MARKER, ...userAgents.map((userAgent) => `User-agent: ${userAgent}`), "Disallow: /", ]; } export function buildRobotsTxt(policy: RobotsPolicy): string { const lines: string[] = []; lines.push("# robots.txt"); lines.push(`# Generated by robots.generate (RFC-0052)`); lines.push(""); // RFC-0784: Content-Signal directive — placed after header comment, before User-agent block. if (policy.contentSignal && policy.contentSignal.length > 0) { lines.push(`Content-Signal: ${policy.contentSignal.join(", ")}`); lines.push(""); } const defaultPolicy = policy.defaultPolicy ?? "allow"; const defaultDisallow = policy.disallowedPaths ?? []; const defaultAllow = policy.allowedPaths ?? []; const blocklist = policy.crawlerBlocklist ?? []; const allowlist = policy.crawlerAllowlist ?? []; const customRules = policy.customRules ?? []; // Default User-agent group lines.push("# Default crawl policy"); lines.push("User-agent: *"); if (defaultPolicy === "disallow") { if (defaultDisallow.length === 0) { lines.push("Disallow: /"); } else { for (const path of defaultDisallow) { lines.push(`Disallow: ${path}`); } } } else if (defaultDisallow.length > 0 || defaultAllow.length > 0) { // Default allow with specific overrides for (const path of defaultAllow) { lines.push(`Allow: ${path}`); } for (const path of defaultDisallow) { lines.push(`Disallow: ${path}`); } } // Default allow with no overrides — omit redundant "Allow: /" (PUBTXT-09); // the auxiliary discovery files below are explicitly allowed. // Auxiliary discovery files — explicitly allowed for all crawlers lines.push(""); lines.push("# Auxiliary discovery files — explicitly allowed for all crawlers"); lines.push("Allow: /robots.txt"); lines.push("Allow: /llms.txt"); lines.push("Allow: /llms-full.txt"); lines.push("Allow: /sitemap.xml"); lines.push("Allow: /sitemap-content.xml"); // RFC-1161: derived AI-training crawler group — emitted when the policy // declares ai-train=no. Placed after the complete default group (the // auxiliary Allow lines above belong to User-agent: *) and before the // allowlist/blocklist groups. const aiTrainingTokens = deriveAiTrainingUserAgents(policy); if (aiTrainingTokens.length > 0) { lines.push(""); lines.push(...aiTrainingCrawlerGroupLines(aiTrainingTokens)); } // Crawler allowlist (friendly AI / search crawlers) if (allowlist.length > 0) { lines.push(""); lines.push("# Allow known friendly crawlers explicitly"); for (const agent of allowlist) { lines.push(`User-agent: ${agent}`); } lines.push("Allow: /"); } // Crawler blocklist if (blocklist.length > 0) { lines.push(""); lines.push("# Block known low-value crawlers"); for (const agent of blocklist) { lines.push(`User-agent: ${agent}`); } lines.push("Disallow: /"); } // Custom per-user-agent rules for (const rule of customRules) { lines.push(""); lines.push(`User-agent: ${rule.userAgent}`); for (const path of rule.disallow) { lines.push(`Disallow: ${path}`); } for (const path of rule.allow) { lines.push(`Allow: ${path}`); } } // Sitemap reference const sitemap = policy.sitemap ?? "/sitemap.xml"; lines.push(""); lines.push(`Sitemap: ${sitemap}`); // RFC-1175: no Schemamap: directive — Lighthouse's robots parser flags it as // an unknown directive ("robots.txt is not valid"). Schemamap discovery is // already covered by in every page head (RFC-1164). return lines.join("\n") + "\n"; }