From 8d55220196f978461a554e2196693def1f481625 Mon Sep 17 00:00:00 2001 From: ttmx Date: Tue, 21 Jul 2026 15:44:56 +0100 Subject: [PATCH 1/3] [Docs Site] Add AI Search indexing CI --- .github/workflows/publish-production.yml | 124 ++++++++++++ .gitignore | 3 + bin/ai-search-doc-events.ts | 3 + bin/ai-search-doc-events/args.ts | 102 ++++++++++ .../content-processors/changelog-post.ts | 68 +++++++ .../content-processors/data.ts | 47 +++++ .../content-processors/generated-landing.ts | 164 +++++++++++++++ .../content-processors/mdast.ts | 116 +++++++++++ .../content-processors/model-page.ts | 166 +++++++++++++++ .../content-processors/rendered-html.ts | 114 +++++++++++ .../content-processors/ruleset-field.ts | 44 ++++ .../content-processors/source-markdown.ts | 14 ++ .../content-processors/third-party-license.ts | 38 ++++ .../content-processors/utils.ts | 43 ++++ .../content-processors/video.ts | 65 ++++++ .../content-transformers.node.test.ts | 39 ++++ .../content-transformers.ts | 75 +++++++ bin/ai-search-doc-events/diff.node.test.ts | 113 +++++++++++ bin/ai-search-doc-events/diff.ts | 149 ++++++++++++++ bin/ai-search-doc-events/manifest.ts | 96 +++++++++ bin/ai-search-doc-events/run.ts | 78 ++++++++ bin/ai-search-doc-events/send.ts | 189 ++++++++++++++++++ bin/ai-search-doc-events/shared.ts | 140 +++++++++++++ bin/ai-search-doc-events/types.ts | 97 +++++++++ package.json | 6 + pnpm-lock.yaml | 19 +- 26 files changed, 2110 insertions(+), 2 deletions(-) create mode 100644 bin/ai-search-doc-events.ts create mode 100644 bin/ai-search-doc-events/args.ts create mode 100644 bin/ai-search-doc-events/content-processors/changelog-post.ts create mode 100644 bin/ai-search-doc-events/content-processors/data.ts create mode 100644 bin/ai-search-doc-events/content-processors/generated-landing.ts create mode 100644 bin/ai-search-doc-events/content-processors/mdast.ts create mode 100644 bin/ai-search-doc-events/content-processors/model-page.ts create mode 100644 bin/ai-search-doc-events/content-processors/rendered-html.ts create mode 100644 bin/ai-search-doc-events/content-processors/ruleset-field.ts create mode 100644 bin/ai-search-doc-events/content-processors/source-markdown.ts create mode 100644 bin/ai-search-doc-events/content-processors/third-party-license.ts create mode 100644 bin/ai-search-doc-events/content-processors/utils.ts create mode 100644 bin/ai-search-doc-events/content-processors/video.ts create mode 100644 bin/ai-search-doc-events/content-transformers.node.test.ts create mode 100644 bin/ai-search-doc-events/content-transformers.ts create mode 100644 bin/ai-search-doc-events/diff.node.test.ts create mode 100644 bin/ai-search-doc-events/diff.ts create mode 100644 bin/ai-search-doc-events/manifest.ts create mode 100644 bin/ai-search-doc-events/run.ts create mode 100644 bin/ai-search-doc-events/send.ts create mode 100644 bin/ai-search-doc-events/shared.ts create mode 100644 bin/ai-search-doc-events/types.ts diff --git a/.github/workflows/publish-production.yml b/.github/workflows/publish-production.yml index eae4d2bd7a6..6d662eb7fe6 100644 --- a/.github/workflows/publish-production.yml +++ b/.github/workflows/publish-production.yml @@ -3,6 +3,12 @@ on: push: branches: - production + workflow_dispatch: + inputs: + force_full_reindex: + description: "Rebuild the AI Search index from scratch (send every page, ignore the previous manifest)." + type: boolean + default: false jobs: publish: @@ -78,3 +84,121 @@ jobs: curl -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d "$JSON_PAYLOAD" + + reindex: + name: AI Search reindex + if: github.repository == 'cloudflare/cloudflare-docs' && github.ref == 'refs/heads/production' + needs: publish + runs-on: ubuntu-22.04 + permissions: + contents: read + # gh CLI needs actions:read to list/download artifacts from prior runs. + actions: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # Match the source extractors to the exact commit that produced the + # downloaded dist artifact, even if production advances meanwhile. + ref: ${{ github.sha }} + - name: Set up pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + version: 11 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + id: setup-node + with: + node-version: 24.x + cache: pnpm + - name: Restore node_modules (cache hit) + id: node-modules-cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('package.json') }} + - name: Install node_modules (cache miss) + run: pnpm install --frozen-lockfile + if: steps.node-modules-cache.outputs.cache-hit != 'true' + + # Reuse the build artifact this run's publish job uploaded (no rebuild). + - name: Download built site + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh run download "${{ github.run_id }}" --name site-html --dir dist + + # Best-effort: pull the manifest from the last successful run so the diff + # only sends changed pages. Missing/expired artifact => baseline. + - name: Restore previous manifest + id: previous-manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p .ai-search + prev_run=$(gh run list --workflow publish-production.yml --branch production \ + --status success --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null || true) + if [ -n "$prev_run" ] && gh run download "$prev_run" --name ai-search-manifest --dir .ai-search 2>/dev/null; then + echo "Restored previous manifest from run $prev_run." + echo "restored=true" >> "$GITHUB_OUTPUT" + else + echo "No previous manifest artifact. Refusing to advance an empty baseline." + echo "restored=false" >> "$GITHUB_OUTPUT" + if [ "${{ inputs.force_full_reindex }}" != "true" ]; then + echo "Restore a prior manifest or manually run against a known-empty/reset instance with force_full_reindex=true." >&2 + exit 1 + fi + fi + + - name: Reindex AI Search + env: + # Bearer token for the indexer worker's /api/ai-search/reindex endpoint + # (matches bin/ai-search-doc-events send-token-env default). + AI_SEARCH_REINDEX_TOKEN: ${{ secrets.AI_SEARCH_REINDEX_TOKEN }} + # The indexer sits behind Cloudflare Access, so the reindex call needs + # an Access service token (send.ts sends these as CF-Access-Client-*). + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + # Absolute URL of the indexer worker's reindex endpoint. + REINDEX_URL: ${{ vars.AI_SEARCH_REINDEX_URL }} + run: | + : "${REINDEX_URL:?AI_SEARCH_REINDEX_URL repository variable is required}" + : "${AI_SEARCH_REINDEX_TOKEN:?AI_SEARCH_REINDEX_TOKEN secret is required}" + : "${CF_ACCESS_CLIENT_ID:?CF_ACCESS_CLIENT_ID secret is required}" + : "${CF_ACCESS_CLIENT_SECRET:?CF_ACCESS_CLIENT_SECRET secret is required}" + + # Read + rewrite the same file so the updated manifest is ready to + # upload as this run's artifact (previous==manifest is safe: run.ts + # reads the previous manifest before overwriting it). + # force_full_reindex (workflow_dispatch toggle) ignores the previous + # manifest and sends every page. Empty on push events => incremental. + EXTRA="" + if [ "${{ inputs.force_full_reindex }}" = "true" ]; then + EXTRA="--force-full-reindex" + fi + pnpm exec tsx bin/ai-search-doc-events.ts \ + --previous .ai-search/page-hashes.json \ + --manifest .ai-search/page-hashes.json \ + --batch-size 25 \ + --send-url "$REINDEX_URL" \ + $EXTRA + + - name: Save updated manifest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: ai-search-manifest + path: .ai-search/page-hashes.json + retention-days: 7 + overwrite: true + + - name: Notify Google Chat on failure + if: failure() + env: + WEBHOOK_URL: ${{ secrets.CED_TEAM_ALERTS_CHANNEL_WEBHOOK }} + ACTOR: ${{ github.actor }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: | + MESSAGE="⚠️ *AI Search reindex* failed (site still deployed).\nActor: $ACTOR\n" + JSON_PAYLOAD=$(jq -n --arg text "$MESSAGE" '{text: $text}') + curl -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" diff --git a/.gitignore b/.gitignore index cb932586106..f3a92652bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ distllms/ # Astro build cache .astro-cache/ +# Local AI Search indexing state +.ai-search/ + # skills/ is fetched from middlecache via bin/fetch-skills.ts skills/ !.agents/skills/ diff --git a/bin/ai-search-doc-events.ts b/bin/ai-search-doc-events.ts new file mode 100644 index 00000000000..e4c36f6d62e --- /dev/null +++ b/bin/ai-search-doc-events.ts @@ -0,0 +1,3 @@ +import { run } from "./ai-search-doc-events/run"; + +await run(); diff --git a/bin/ai-search-doc-events/args.ts b/bin/ai-search-doc-events/args.ts new file mode 100644 index 00000000000..54c4bb476fa --- /dev/null +++ b/bin/ai-search-doc-events/args.ts @@ -0,0 +1,102 @@ +import { join } from "node:path"; +import { parseArgs as nodeParseArgs } from "node:util"; +import type { Args } from "./types"; + +export function parseArgs(): Args { + const { values } = nodeParseArgs({ + args: process.argv.slice(2).filter((arg) => arg !== "--"), + options: { + dist: { type: "string", default: "dist" }, + "source-docs-dir": { type: "string", default: "src/content/docs" }, + "state-dir": { type: "string", default: ".ai-search" }, + previous: { type: "string" }, + manifest: { type: "string" }, + events: { type: "string" }, + "include-path-prefix": { type: "string", multiple: true, default: [] }, + "send-url": { type: "string" }, + "send-token-env": { type: "string" }, + "batch-size": { type: "string", default: "100" }, + commit: { type: "boolean", default: false }, + "force-full-reindex": { type: "boolean", default: false }, + concurrency: { type: "string", default: "1" }, + "max-retries": { type: "string", default: "5" }, + "resume-file": { type: "string" }, + help: { type: "boolean", default: false }, + }, + }); + + if (values.help) { + printHelp(); + process.exit(0); + } + + const batchSize = Number.parseInt(values["batch-size"], 10); + if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 100) { + throw new Error("--batch-size must be an integer from 1 to 100"); + } + + const concurrency = Number.parseInt(values.concurrency, 10); + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error("--concurrency must be a positive integer"); + } + + const maxRetries = Number.parseInt(values["max-retries"], 10); + if (!Number.isInteger(maxRetries) || maxRetries < 0) { + throw new Error("--max-retries must be a non-negative integer"); + } + + const stateDir = values["state-dir"]; + + return { + dist: values.dist, + sourceDocsDir: values["source-docs-dir"], + stateDir, + previous: values.previous ?? join(stateDir, "page-hashes.json"), + manifest: values.manifest ?? join(stateDir, "latest-page-hashes.json"), + events: values.events ?? join(stateDir, "docs-search-events.jsonl"), + includePathPrefixes: values["include-path-prefix"], + sendUrl: values["send-url"], + sendTokenEnv: values["send-token-env"], + batchSize, + commit: values.commit, + forceFullReindex: values["force-full-reindex"], + concurrency, + maxRetries, + resumeFile: values["resume-file"], + }; +} + +function printHelp() { + console.log(`Usage: pnpm exec tsx bin/ai-search-doc-events.ts [options] + +Build a hash manifest for rendered docs pages, diff it against local state, and optionally POST that diff. + +Options: + --dist Astro build directory. Default: dist + --source-docs-dir Source docs Markdown/MDX directory. Default: src/content/docs + --state-dir Local state directory. Default: .ai-search + --previous Previous manifest JSON. Default: /page-hashes.json + --manifest New manifest JSON. Default: /latest-page-hashes.json + --events JSONL change events. Default: /docs-search-events.jsonl + --include-path-prefix + Only include built pages whose docs path starts with this prefix. + Repeat to include multiple prefixes. + --send-url POST the diff payload to this endpoint. The endpoint + enqueues each event for background indexing and + returns 202 Accepted; a 2xx means the batch was queued. + --send-token-env Read a bearer token from this environment variable for --send-url. + Default: AI_SEARCH_REINDEX_TOKEN when that variable is set. + For Cloudflare Access, set CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET + (service token) or CF_ACCESS_TOKEN (JWT from cloudflared access token). + --batch-size Number of events per POST when using --send-url. Default: 100 + --concurrency Number of batches to POST in parallel. Default: 1 + --max-retries Batch POST retry attempts on transient HTTP/network failures. Default: 5 + --resume-file Append-only JSONL of enqueued page paths; skipped on restart + so an interrupted run does not re-enqueue them. + --force-full-reindex Ignore any previous manifest and send every page as docs.page.changed + (full re-index). Use to rebuild the index from scratch. Without it, a + run with no previous manifest baselines (sends nothing). + --commit Save the latest manifest as the new previous manifest after a successful send/diff. + --help Show this help. +`); +} diff --git a/bin/ai-search-doc-events/content-processors/changelog-post.ts b/bin/ai-search-doc-events/content-processors/changelog-post.ts new file mode 100644 index 00000000000..f30ed51120a --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/changelog-post.ts @@ -0,0 +1,68 @@ +import { readFile } from "node:fs/promises"; +import { relative, sep } from "node:path"; +import fg from "fast-glob"; +import matter from "gray-matter"; +import { parse as parseYaml } from "yaml"; +import type { ContentTransformer, RawSection } from "../types"; +import { asArray, asRecord, asString, compactLine } from "./data"; +import { documentText } from "./mdast"; +import { makeSections } from "./utils"; + +async function directoryEntryName(id: string) { + const source = await readFile( + `src/content/directory/${id}.yaml`, + "utf8", + ).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }); + if (!source) return undefined; + const data = asRecord(parseYaml(source)); + return asString(data?.name) ?? asString(asRecord(data?.entry)?.title); +} + +async function changelogPostSectionsForPath( + path: string, + pageTitle: string, +): Promise { + const slug = path.match(/^\/changelog\/post\/([^/]+)\/$/)?.[1]; + if (!slug) throw new Error(`Invalid changelog post path: ${path}`); + + const candidates = await fg(`**/${slug}.{md,mdx}`, { + cwd: "src/content/changelog", + absolute: true, + }); + const sourceFile = candidates.sort()[0]; + if (!sourceFile) return undefined; + + const source = await readFile(sourceFile, "utf8"); + const { data: frontmatter } = matter(source); + const productId = relative("src/content/changelog", sourceFile).split(sep)[0]; + const productName = + (await directoryEntryName(productId)) ?? + asArray(frontmatter.products).map(String).join(", ") ?? + productId; + const title = asString(frontmatter.title) ?? pageTitle; + const description = asString(frontmatter.description); + const date = + frontmatter.date instanceof Date + ? frontmatter.date.toISOString().slice(0, 10) + : (asString(frontmatter.date) ?? undefined); + const bodyText = documentText(source, sourceFile.endsWith(".mdx")); + const text = [ + compactLine("Title", title), + compactLine("Date", date), + compactLine("Product", productName), + description, + bodyText, + ] + .filter(Boolean) + .join("\n\n"); + + return makeSections([{ anchor: "", heading: title, text }]); +} + +export const changelogPostProcessor: ContentTransformer = { + name: "changelog-post", + transform: ({ path, title }) => changelogPostSectionsForPath(path, title), +}; diff --git a/bin/ai-search-doc-events/content-processors/data.ts b/bin/ai-search-doc-events/content-processors/data.ts new file mode 100644 index 00000000000..82af3dcca30 --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/data.ts @@ -0,0 +1,47 @@ +import { readFile } from "node:fs/promises"; +import { normalizeText, truncateText } from "../shared"; + +export async function readJsonFile(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as unknown; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +export function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function asString(value: unknown) { + return typeof value === "string" ? value : undefined; +} + +export function asArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +export function compactLine(label: string, value: unknown) { + if (value === undefined || value === null || value === "") return undefined; + if (Array.isArray(value) && value.length === 0) return undefined; + return `${label}: ${Array.isArray(value) ? value.join(", ") : String(value)}`; +} + +export function excerpt(value: unknown, maxLength = 800): string | undefined { + if (value === undefined || value === null) return undefined; + let text: string; + if (typeof value === "string") text = value; + else if (Array.isArray(value)) { + text = value + .map((item) => (typeof item === "string" ? item : undefined)) + .filter(Boolean) + .join(""); + } else { + text = JSON.stringify(value); + } + const normalized = normalizeText(text.replace(/<[^>]+>/g, "")); + return normalized ? truncateText(normalized, maxLength) : undefined; +} diff --git a/bin/ai-search-doc-events/content-processors/generated-landing.ts b/bin/ai-search-doc-events/content-processors/generated-landing.ts new file mode 100644 index 00000000000..addf20d61c2 --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/generated-landing.ts @@ -0,0 +1,164 @@ +import { readFile } from "node:fs/promises"; +import fg from "fast-glob"; +import { parse as parseYaml } from "yaml"; +import type { ContentTransformer, RawSection } from "../types"; +import { slugifyHeading } from "../shared"; +import { asArray, asRecord, asString, readJsonFile } from "./data"; +import { makeChunkedSections, makeSections } from "./utils"; + +async function directoryLandingSections(): Promise { + const files = await fg("*.{yaml,yml,json}", { + cwd: "src/content/directory", + absolute: true, + }); + const lines = await Promise.all( + files.sort().map(async (file) => { + const raw = await readFile(file, "utf8"); + const id = file.match(/([^/]+)\.(?:ya?ml|json)$/)?.[1] ?? ""; + const data = + asRecord(file.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw)) ?? + {}; + const entry = asRecord(data.entry) ?? {}; + const title = asString(entry.title); + const url = asString(entry.url); + const group = asString(entry.group); + const description = + asString(entry.description) ?? + asString(asRecord(data.meta)?.description); + return [title ?? asString(data.name) ?? id, url, group, description] + .filter(Boolean) + .join(" — "); + }), + ); + + return makeChunkedSections("Docs directory", lines, "directory", 60); +} + +async function glossaryLandingSections(): Promise { + const files = await fg("*.{yaml,yml,json}", { + cwd: "src/content/glossary", + absolute: true, + }); + const lines: string[] = []; + for (const file of files.sort()) { + const raw = await readFile(file, "utf8"); + const data = asRecord(parseYaml(raw)) ?? {}; + const product = asString(data.productName); + for (const item of asArray(data.entries)) { + const entry = asRecord(item) ?? {}; + const term = asString(entry.term); + const definition = asString(entry.general_definition); + if (term) { + lines.push([term, product, definition].filter(Boolean).join(" — ")); + } + } + } + return makeChunkedSections("Glossary", lines, "glossary", 80); +} + +function collectPlanLines(value: unknown, path: string[] = []): string[] { + const record = asRecord(value); + if (!record) return []; + const title = asString(record.title); + const link = asString(record.link); + const availability = asRecord(asRecord(record.properties)?.availability); + const summary = asString(availability?.summary); + const lines = + title && path.length > 0 + ? [[title, link, summary].filter(Boolean).join(" — ")] + : []; + return [ + ...lines, + ...Object.entries(record).flatMap(([key, nested]) => + key === "properties" ? [] : collectPlanLines(nested, [...path, key]), + ), + ]; +} + +async function plansLandingSections(): Promise { + const data = (await readJsonFile("src/content/plans/index.json")) ?? {}; + const sections = Object.entries(asRecord(data) ?? {}).map( + ([key, category]) => { + const record = asRecord(category) ?? {}; + const title = asString(record.title) ?? key; + const lines = collectPlanLines(category); + return { + anchor: slugifyHeading(title), + heading: title, + text: [title, ...lines.map((line) => `- ${line}`)].join("\n"), + }; + }, + ); + return makeSections(sections); +} + +async function resourcesLandingSections(): Promise { + const streamFiles = await fg("**/index.{yaml,yml,json}", { + cwd: "src/content/stream", + absolute: true, + }); + const videoLines = await Promise.all( + streamFiles.sort().map(async (file) => { + const raw = await readFile(file, "utf8"); + const data = + asRecord(file.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw)) ?? + {}; + return [ + asString(data.title), + `/videos/${asString(data.url)}/`, + asString(data.description), + ] + .filter(Boolean) + .join(" — "); + }), + ); + return makeChunkedSections("Resources", videoLines, "resources", 40); +} + +async function agentSetupSections(): Promise { + const prompt = await readFile( + "src/content/agent-setup/prompt.md", + "utf8", + ).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }); + const agentsSource = await readFile( + "src/nimbus/components/agent-setup/agents.ts", + "utf8", + ).catch(() => ""); + const agentLines = [ + ...agentsSource.matchAll( + /name:\s*"([^"]+)"[\s\S]*?vendor:\s*"([^"]+)"[\s\S]*?description:\s*"([^"]+)"/g, + ), + ].map( + ([, name, vendor, description]) => `${name} by ${vendor}: ${description}`, + ); + return makeSections([ + { + anchor: "overview", + heading: "Agent setup", + text: [ + "Cloudflare provides Skills and MCP servers so coding agents can build on the Cloudflare platform.", + ...agentLines, + ].join("\n"), + }, + { anchor: "prompt", heading: "Setup prompt", text: prompt }, + ]); +} + +async function generatedLandingSectionsForPath( + path: string, +): Promise { + if (path === "/directory/") return directoryLandingSections(); + if (path === "/glossary/") return glossaryLandingSections(); + if (path === "/plans/") return plansLandingSections(); + if (path === "/resources/") return resourcesLandingSections(); + if (path === "/agent-setup/") return agentSetupSections(); + return undefined; +} + +export const generatedLandingProcessor: ContentTransformer = { + name: "generated-landing", + transform: ({ path }) => generatedLandingSectionsForPath(path), +}; diff --git a/bin/ai-search-doc-events/content-processors/mdast.ts b/bin/ai-search-doc-events/content-processors/mdast.ts new file mode 100644 index 00000000000..01530744911 --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/mdast.ts @@ -0,0 +1,116 @@ +import matter from "gray-matter"; +import type { Nodes, Root, RootContent } from "mdast"; +import { toString } from "mdast-util-to-string"; +import remarkGfm from "remark-gfm"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import type { RawSection } from "../types"; +import { + MAX_SECTION_TEXT_LENGTH, + normalizeText, + sha256, + slugifyHeading, + truncateText, +} from "../shared"; + +const markdownProcessor = unified().use(remarkParse).use(remarkGfm); +const mdxProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkMdx); + +// MDX import/export statements and `{/* … */}` expressions carry no search text. +const SCAFFOLDING_NODE_TYPES = new Set(["mdxjsEsm", "mdxFlowExpression"]); + +export function parseDoc(content: string, mdx: boolean): Root { + return (mdx ? mdxProcessor : markdownProcessor).parse(content); +} + +export function nodeText(node: Nodes): string { + return normalizeText(toString(node)); +} + +function isContentNode(node: RootContent) { + return !SCAFFOLDING_NODE_TYPES.has(node.type); +} + +/** + * Split a Markdown/MDX document into search sections, one per h1–h3 heading + * (plus a leading section for any content before the first heading). The parsed + * frontmatter is prepended to every section so page metadata stays searchable. + */ +export function extractSections( + source: string, + pageTitle: string, + mdx: boolean, +): RawSection[] { + // Passing options bypasses gray-matter's cache, which drops the + // non-enumerable raw `matter` field when identical documents are parsed. + const { content, matter: frontmatter = "" } = matter(source, {}); + const nodes = parseDoc(content, mdx).children.filter(isContentNode); + + type Group = { + heading: string; + anchor: string; + nodes: RootContent[]; + }; + const groups: Group[] = [{ heading: pageTitle, anchor: "", nodes: [] }]; + + for (const node of nodes) { + if (node.type === "heading" && node.depth <= 3) { + const heading = nodeText(node); + groups.push({ + heading, + anchor: slugifyHeading(heading), + nodes: [node], + }); + } else { + groups[groups.length - 1].nodes.push(node); + } + } + + const prefix = frontmatter.trim(); + const makeSection = ( + anchor: string, + heading: string, + body: string, + ): RawSection => { + const text = truncateText( + [prefix, body].filter(Boolean).join("\n\n"), + MAX_SECTION_TEXT_LENGTH, + ); + return { anchor, heading, text, hash: sha256(text) }; + }; + + const sections: RawSection[] = []; + for (const group of groups) { + const body = group.nodes + .map((node) => nodeText(node)) + .filter(Boolean) + .join("\n\n"); + if (!normalizeText(body)) continue; + sections.push(makeSection(group.anchor, group.heading, body)); + } + + // Component-only pages (no headings or prose) still get one section so they + // remain findable by title/description from frontmatter. + if (sections.length === 0 && prefix) { + return [makeSection("", pageTitle, "")]; + } + + return sections; +} + +/** Extract the plain text of a whole document, optionally dropping code blocks. */ +export function documentText( + source: string, + mdx: boolean, + { dropCode = false } = {}, +): string { + const { content } = matter(source); + const nodes = parseDoc(content, mdx).children.filter( + (node) => isContentNode(node) && !(dropCode && node.type === "code"), + ); + return nodes + .map((node) => nodeText(node)) + .filter(Boolean) + .join("\n\n"); +} diff --git a/bin/ai-search-doc-events/content-processors/model-page.ts b/bin/ai-search-doc-events/content-processors/model-page.ts new file mode 100644 index 00000000000..416bb4ded50 --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/model-page.ts @@ -0,0 +1,166 @@ +import { join } from "node:path"; +import type { ContentTransformer, RawSection } from "../types"; +import { MAX_SECTION_TEXT_LENGTH, sha256, truncateText } from "../shared"; +import { + asArray, + asRecord, + asString, + compactLine, + excerpt, + readJsonFile, +} from "./data"; + +function collectSchemaFields( + schema: unknown, + prefix = "", + limit = 40, +): string[] { + const fields: string[] = []; + const visit = (value: unknown, path: string) => { + if (fields.length >= limit) return; + const record = asRecord(value); + if (!record) return; + + const properties = asRecord(record.properties); + if (properties) { + for (const [name, prop] of Object.entries(properties)) { + if (fields.length >= limit) break; + const propRecord = asRecord(prop) ?? {}; + const fieldPath = path ? `${path}.${name}` : name; + const type = asString(propRecord.type); + const description = asString(propRecord.description); + fields.push( + [fieldPath, type ? `(${type})` : undefined, description] + .filter(Boolean) + .join(" "), + ); + visit(prop, fieldPath); + } + } + + for (const key of ["oneOf", "anyOf", "allOf"] as const) { + for (const item of asArray(record[key])) visit(item, path); + } + if (record.items) visit(record.items, path ? `${path}[]` : "[]"); + }; + + visit(schema, prefix); + return fields; +} + +function codeSnippetSummary(snippets: unknown, limit = 2) { + return asArray(snippets) + .slice(0, limit) + .map((snippet) => { + const record = asRecord(snippet) ?? {}; + return [ + asString(record.label) ?? asString(record.language) ?? "Code", + excerpt(record.code, 1_200), + ] + .filter(Boolean) + .join("\n"); + }) + .filter(Boolean); +} + +async function modelSectionsForPath( + path: string, + pageTitle: string, +): Promise { + const aiModelMatch = path.match(/^\/ai\/models\/(.+)\/$/); + const workersAiMatch = path.match(/^\/workers-ai\/models\/([^/]+)\/$/); + const modelPath = aiModelMatch?.[1] ?? workersAiMatch?.[1]; + if (!modelPath) throw new Error(`Invalid model page path: ${path}`); + const catalogPath = join( + "src/content/catalog-models", + `${modelPath.split("/").join("-")}.json`, + ); + const workersAiPath = join( + "src/content/workers-ai-models", + `${modelPath.split("/").at(-1)}.json`, + ); + const raw = + (await readJsonFile(catalogPath)) ?? (await readJsonFile(workersAiPath)); + const model = asRecord(raw); + if (!model) return undefined; + + const modelId = asString(model.model_id) ?? asString(model.name) ?? modelPath; + const task = + asString(model.task) ?? asString(asRecord(model.task)?.name) ?? undefined; + const overview = [ + compactLine("Model", modelId), + compactLine("Display name", model.name), + compactLine("Provider", model.provider_id), + compactLine("Task", task), + compactLine("Tags", model.tags), + compactLine("Context length", model.context_length), + compactLine("Max output tokens", model.max_output_tokens), + compactLine("Request formats", model.request_formats), + compactLine("Zero data retention", model.zdr), + compactLine("External info", model.external_info), + compactLine("Terms", model.terms), + asString(model.description), + ] + .filter(Boolean) + .join("\n"); + + const schemaRecord = asRecord(model.schema); + const schemaFields = [ + ...collectSchemaFields(schemaRecord?.input, "input", 30), + ...collectSchemaFields(schemaRecord?.output, "output", 20), + ]; + const schemaText = schemaFields.length + ? [ + "Schema field summary", + ...schemaFields.map((field) => `- ${field}`), + ].join("\n") + : undefined; + + const exampleLines = asArray(model.examples) + .slice(0, 8) + .flatMap((example, index) => { + const record = asRecord(example) ?? {}; + const input = excerpt(record.input, 500); + const outputRecord = asRecord(record.output); + const output = excerpt(outputRecord?.text ?? outputRecord?.response, 700); + return [ + `Example ${index + 1}: ${asString(record.name) ?? "Unnamed example"}`, + asString(record.description), + input ? `Input: ${input}` : undefined, + output ? `Output excerpt: ${output}` : undefined, + ...codeSnippetSummary(record.code_snippets, 1).map( + (snippet) => `Snippet:\n${snippet}`, + ), + ] + .filter(Boolean) + .join("\n"); + }); + const examplesText = exampleLines.length + ? ["Examples", ...exampleLines].join("\n\n") + : undefined; + + const sections = [ + { anchor: "overview", heading: pageTitle, text: overview }, + { anchor: "schema", heading: "Schema summary", text: schemaText }, + { anchor: "examples", heading: "Examples", text: examplesText }, + ] + .filter( + (section): section is { anchor: string; heading: string; text: string } => + Boolean(section.text), + ) + .map((section) => { + const text = truncateText(section.text, MAX_SECTION_TEXT_LENGTH); + return { + ...section, + text, + hash: sha256(text), + }; + }); + + return sections.length ? sections : undefined; +} + +export const modelPageProcessor: ContentTransformer = { + name: "model-page", + transform: ({ path, title }) => modelSectionsForPath(path, title), +}; diff --git a/bin/ai-search-doc-events/content-processors/rendered-html.ts b/bin/ai-search-doc-events/content-processors/rendered-html.ts new file mode 100644 index 00000000000..4258ca98bb4 --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/rendered-html.ts @@ -0,0 +1,114 @@ +import { HTMLElement } from "node-html-parser"; +import type { ContentTransformer, RawSection } from "../types"; +import { + MAX_SECTION_TEXT_LENGTH, + normalizeText, + sha256, + truncateText, +} from "../shared"; + +export const MAX_CODE_TEXT_LENGTH = 200; + +function extractSections(main: HTMLElement, pageTitle: string): RawSection[] { + const sections: RawSection[] = []; + const headings = main.querySelectorAll("h1, h2, h3"); + + for (const heading of headings) { + const anchor = heading.getAttribute("id") ?? ""; + const headingText = normalizeText(heading.text); + const parts: string[] = [headingText]; + const startBlock = + heading.parentNode instanceof HTMLElement && + heading.parentNode.classList.contains("heading-wrapper") + ? heading.parentNode + : heading; + let cursor = startBlock.nextElementSibling; + + while (cursor) { + if (/^H[123]$/.test(cursor.tagName)) break; + const nestedHeading = cursor.querySelector("h1, h2, h3"); + if (nestedHeading) break; + const text = normalizeText(cursor.text); + if (text) parts.push(text); + cursor = cursor.nextElementSibling; + } + + const content = truncateText(parts.join("\n\n"), MAX_SECTION_TEXT_LENGTH); + if (content) { + sections.push({ + anchor, + heading: headingText, + text: content, + hash: sha256(content), + }); + } + } + + const fullText = truncateText( + normalizeText(main.text), + MAX_SECTION_TEXT_LENGTH, + ); + const sectionTextLength = sections.reduce( + (total, section) => total + section.text.length, + 0, + ); + if (fullText && (sections.length === 0 || sectionTextLength < 100)) { + return [ + { + anchor: sections[0]?.anchor ?? "", + heading: sections[0]?.heading || pageTitle, + text: fullText, + hash: sha256(fullText), + }, + ]; + } + + return sections; +} + +export const renderedHtmlProcessor: ContentTransformer = { + name: "rendered-html", + transform: ({ root, title }) => { + removeNonContent(root); + + const main = + root.querySelector(".sl-markdown-content") ?? + root.querySelector("[data-pagefind-body]") ?? + root.querySelector("main") ?? + root.querySelector("body"); + if (!main) return []; + + removeNonContent(main); + return extractSections(main, title); + }, +}; + +export function removeNonContent(root: HTMLElement) { + for (const selector of [ + "script", + "style", + "svg", + "nav", + "header", + "footer", + "button", + '[aria-hidden="true"]', + "astro-breadcrumbs", + ".c-breadcrumbs", + ".breadcrumbs", + ".pagination-links", + ".right-sidebar", + ".right-sidebar-container", + ".sl-container > .right-sidebar", + ".example-raw-response", + ]) { + for (const node of root.querySelectorAll(selector)) node.remove(); + } + + for (const node of root.querySelectorAll("pre, code")) { + const text = normalizeText(node.text); + if (text.length > MAX_CODE_TEXT_LENGTH) { + node.set_content(truncateText(text, MAX_CODE_TEXT_LENGTH)); + } + } +} diff --git a/bin/ai-search-doc-events/content-processors/ruleset-field.ts b/bin/ai-search-doc-events/content-processors/ruleset-field.ts new file mode 100644 index 00000000000..11e9e4acbb2 --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/ruleset-field.ts @@ -0,0 +1,44 @@ +import { readFile } from "node:fs/promises"; +import { parse as parseYaml } from "yaml"; +import type { ContentTransformer, RawSection } from "../types"; +import { asArray, asRecord, asString, compactLine } from "./data"; +import { makeSections } from "./utils"; + +async function rulesetFieldSectionsForPath( + path: string, +): Promise { + const fieldName = path.match( + /^\/ruleset-engine\/rules-language\/fields\/reference\/([^/]+)\/$/, + )?.[1]; + if (!fieldName) throw new Error(`Invalid ruleset field path: ${path}`); + + const source = await readFile("src/content/fields/index.yaml", "utf8"); + const entries = asArray(asRecord(parseYaml(source))?.entries); + const entry = asRecord( + entries.find((item) => asRecord(item)?.name === fieldName), + ); + if (!entry) return undefined; + + const exampleValue = asString(entry.example_value); + const exampleBlock = asString(entry.example_block); + const text = [ + compactLine("Field", fieldName), + compactLine("Data type", entry.data_type), + compactLine("Categories", entry.categories), + compactLine("Keywords", entry.keywords), + compactLine("Plan information", entry.plan_info_label), + asString(entry.summary), + asString(entry.description), + exampleValue ? `Example value:\n${exampleValue}` : undefined, + exampleBlock ? `Example usage:\n${exampleBlock}` : undefined, + ] + .filter(Boolean) + .join("\n\n"); + + return makeSections([{ anchor: "", heading: fieldName, text }]); +} + +export const rulesetFieldProcessor: ContentTransformer = { + name: "ruleset-field", + transform: ({ path }) => rulesetFieldSectionsForPath(path), +}; diff --git a/bin/ai-search-doc-events/content-processors/source-markdown.ts b/bin/ai-search-doc-events/content-processors/source-markdown.ts new file mode 100644 index 00000000000..b358e745bae --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/source-markdown.ts @@ -0,0 +1,14 @@ +import type { ContentTransformer } from "../types"; +import { extractSections } from "./mdast"; + +export const sourceMarkdownProcessor: ContentTransformer = { + name: "source-markdown", + transform: ({ sourceMarkdown, sourceMarkdownPath, title }) => + sourceMarkdown + ? extractSections( + sourceMarkdown, + title, + sourceMarkdownPath?.endsWith(".mdx") ?? false, + ) + : undefined, +}; diff --git a/bin/ai-search-doc-events/content-processors/third-party-license.ts b/bin/ai-search-doc-events/content-processors/third-party-license.ts new file mode 100644 index 00000000000..9c93ccab48e --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/third-party-license.ts @@ -0,0 +1,38 @@ +import type { ContentTransformer, RawSection } from "../types"; +import { MAX_SECTION_TEXT_LENGTH, sha256, truncateText } from "../shared"; +import { documentText } from "./mdast"; + +function thirdPartyLicenseSections( + pageTitle: string, + description?: string, + markdown?: string, + isMdx = false, +): RawSection[] { + const licenseListing = markdown + ? documentText(markdown, isMdx, { dropCode: true }) + : undefined; + const text = truncateText( + [pageTitle, description, licenseListing].filter(Boolean).join("\n\n"), + MAX_SECTION_TEXT_LENGTH, + ); + + return [ + { + anchor: "", + heading: pageTitle, + text, + hash: sha256(text), + }, + ]; +} + +export const thirdPartyLicenseProcessor: ContentTransformer = { + name: "third-party-license", + transform: ({ title, description, sourceMarkdown, sourceMarkdownPath }) => + thirdPartyLicenseSections( + title, + description, + sourceMarkdown, + sourceMarkdownPath?.endsWith(".mdx") ?? false, + ), +}; diff --git a/bin/ai-search-doc-events/content-processors/utils.ts b/bin/ai-search-doc-events/content-processors/utils.ts new file mode 100644 index 00000000000..ae2dd7447a5 --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/utils.ts @@ -0,0 +1,43 @@ +import type { RawSection } from "../types"; +import { + MAX_SECTION_TEXT_LENGTH, + normalizeText, + sha256, + truncateText, +} from "../shared"; + +export function makeSections( + sections: Array<{ anchor: string; heading: string; text?: string }>, +): RawSection[] { + return sections + .filter( + (section): section is { anchor: string; heading: string; text: string } => + Boolean(section.text && normalizeText(section.text)), + ) + .map((section) => { + const text = truncateText( + normalizeText(section.text), + MAX_SECTION_TEXT_LENGTH, + ); + return { ...section, text, hash: sha256(text) }; + }); +} + +export function makeChunkedSections( + heading: string, + lines: string[], + anchorPrefix: string, + chunkSize: number, +): RawSection[] { + const sections = []; + for (let index = 0; index < lines.length; index += chunkSize) { + const chunk = lines.slice(index, index + chunkSize); + sections.push({ + anchor: + index === 0 ? anchorPrefix : `${anchorPrefix}-${index / chunkSize + 1}`, + heading: index === 0 ? heading : `${heading} ${index / chunkSize + 1}`, + text: [heading, ...chunk.map((line) => `- ${line}`)].join("\n"), + }); + } + return makeSections(sections); +} diff --git a/bin/ai-search-doc-events/content-processors/video.ts b/bin/ai-search-doc-events/content-processors/video.ts new file mode 100644 index 00000000000..71909e7769e --- /dev/null +++ b/bin/ai-search-doc-events/content-processors/video.ts @@ -0,0 +1,65 @@ +import { readFile } from "node:fs/promises"; +import fg from "fast-glob"; +import { parse as parseYaml } from "yaml"; +import type { ContentTransformer, RawSection } from "../types"; +import { normalizeText } from "../shared"; +import { asRecord, asString, compactLine } from "./data"; +import { makeSections } from "./utils"; + +function cleanVideoTranscript(transcript: string) { + return transcript + .replace(/WEBVTT/g, "") + .replace(/\d{2}:\d{2}:\d{2}\.\d+\s+-->\s+\d{2}:\d{2}:\d{2}\.\d+/g, "") + .replace(/^\s*\d+\s*$/gm, "") + .replace(/<\/c>|<.+?>/g, "") + .replace(/ /g, " ") + .split(/\n{2,}/) + .map((part) => normalizeText(part.replace(/\n/g, " "))) + .filter((part, index, parts) => part && part !== parts[index - 1]) + .join("\n"); +} + +async function videoSectionsForPath( + path: string, +): Promise { + const slug = path.match(/^\/videos\/([^/]+)\/$/)?.[1]; + if (!slug) throw new Error(`Invalid video path: ${path}`); + + const candidates = await fg(`**/${slug}/index.{yaml,yml,json}`, { + cwd: "src/content/stream", + absolute: true, + }); + const sourceFile = candidates.sort()[0]; + if (!sourceFile) return undefined; + + const raw = await readFile(sourceFile, "utf8"); + const data = + (asRecord( + sourceFile.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw), + ) as Record | undefined) ?? {}; + const chapters = Object.entries(asRecord(data.chapters) ?? {}).map( + ([heading, time]) => `${String(time)} ${heading}`, + ); + const transcript = asString(data.transcript); + const title = asString(data.title) ?? slug; + const text = [ + compactLine("Title", title), + compactLine("Video ID", data.id), + compactLine("Products", data.products), + compactLine("Tags", data.tags), + asString(data.description), + chapters.length + ? ["Chapters", ...chapters.map((chapter) => `- ${chapter}`)].join("\n") + : undefined, + transcript ? `Transcript:\n${cleanVideoTranscript(transcript)}` : undefined, + ] + .filter(Boolean) + .join("\n\n"); + + return makeSections([{ anchor: "", heading: title, text }]); +} + +export const videoProcessor: ContentTransformer = { + name: "video", + transform: ({ path }) => videoSectionsForPath(path), +}; diff --git a/bin/ai-search-doc-events/content-transformers.node.test.ts b/bin/ai-search-doc-events/content-transformers.node.test.ts new file mode 100644 index 00000000000..adde7ac7bd4 --- /dev/null +++ b/bin/ai-search-doc-events/content-transformers.node.test.ts @@ -0,0 +1,39 @@ +import { parse } from "node-html-parser"; +import { describe, expect, it } from "vitest"; +import { transformContent } from "./content-transformers"; + +describe("AI Search content transformers", () => { + it("extracts the Nimbus home page from rendered HTML", async () => { + const sections = await transformContent({ + path: "/", + title: "Cloudflare developer documentation", + root: parse(` + + +
+

Build on Cloudflare

+

Nimbus home page content.

+
+ + + `), + }); + + expect(sections).toHaveLength(1); + expect(sections[0]).toMatchObject({ + anchor: "build-on-cloudflare", + heading: "Build on Cloudflare", + }); + expect(sections[0].text).toContain("Nimbus home page content."); + }); + + it("extracts agent metadata from the Nimbus catalog", async () => { + const sections = await transformContent({ + path: "/agent-setup/", + title: "Agent setup", + root: parse("
"), + }); + + expect(sections[0].text).toContain("Claude Code by Anthropic"); + }); +}); diff --git a/bin/ai-search-doc-events/content-transformers.ts b/bin/ai-search-doc-events/content-transformers.ts new file mode 100644 index 00000000000..b544900f8cc --- /dev/null +++ b/bin/ai-search-doc-events/content-transformers.ts @@ -0,0 +1,75 @@ +import type { + ContentTransformer, + ContentTransformerContext, + RawSection, +} from "./types"; +import { changelogPostProcessor } from "./content-processors/changelog-post"; +import { generatedLandingProcessor } from "./content-processors/generated-landing"; +import { modelPageProcessor } from "./content-processors/model-page"; +import { renderedHtmlProcessor } from "./content-processors/rendered-html"; +import { rulesetFieldProcessor } from "./content-processors/ruleset-field"; +import { sourceMarkdownProcessor } from "./content-processors/source-markdown"; +import { thirdPartyLicenseProcessor } from "./content-processors/third-party-license"; +import { videoProcessor } from "./content-processors/video"; + +type Matcher = + | string + | RegExp + | ((context: ContentTransformerContext) => boolean); + +type ProcessorRoute = { + processor: ContentTransformer; + match: Matcher | Matcher[]; +}; + +// Ordered list: the first route whose matcher accepts the page wins. +const routes: ProcessorRoute[] = [ + { processor: thirdPartyLicenseProcessor, match: "/legal/3rdparty/" }, + { + processor: modelPageProcessor, + match: [/^\/ai\/models\/.+\/$/, /^\/workers-ai\/models\/[^/]+\/$/], + }, + { processor: changelogPostProcessor, match: /^\/changelog\/post\/[^/]+\/$/ }, + { + processor: rulesetFieldProcessor, + match: /^\/ruleset-engine\/rules-language\/fields\/reference\/[^/]+\/$/, + }, + { processor: videoProcessor, match: /^\/videos\/[^/]+\/$/ }, + { + processor: generatedLandingProcessor, + match: [ + "/directory/", + "/glossary/", + "/plans/", + "/resources/", + "/agent-setup/", + ], + }, + { + processor: sourceMarkdownProcessor, + match: (context) => Boolean(context.sourceMarkdown), + }, + { processor: renderedHtmlProcessor, match: () => true }, +]; + +function matcherMatches(matcher: Matcher, context: ContentTransformerContext) { + if (typeof matcher === "function") return matcher(context); + if (typeof matcher === "string") return matcher === context.path; + return matcher.test(context.path); +} + +function routeMatches( + route: ProcessorRoute, + context: ContentTransformerContext, +) { + const matchers = Array.isArray(route.match) ? route.match : [route.match]; + return matchers.some((matcher) => matcherMatches(matcher, context)); +} + +export async function transformContent( + context: ContentTransformerContext, +): Promise { + const route = routes.find((route) => routeMatches(route, context)); + if (!route) return []; + return (await route.processor.transform(context)) ?? []; +} diff --git a/bin/ai-search-doc-events/diff.node.test.ts b/bin/ai-search-doc-events/diff.node.test.ts new file mode 100644 index 00000000000..1acb7e974cf --- /dev/null +++ b/bin/ai-search-doc-events/diff.node.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { diffManifests, fullReindexEvents, payloadFor } from "./diff"; +import type { Manifest, PageHash } from "./types"; + +function page(overrides: Partial = {}): PageHash { + return { + path: "/workers/", + key: "docs/workers/index.md", + title: "Workers", + description: "Build applications.", + product: "Workers", + hash: "page-hash", + sections: [ + { + anchor: "example", + heading: "Example", + text: "Unchanged section text", + hash: "section-hash", + key: "docs/workers/index.example.md", + }, + ], + ...overrides, + }; +} + +function manifest(entry?: PageHash): Manifest { + return { + version: 1, + generatedAt: "2026-07-15T12:00:00.000Z", + pages: entry ? { [entry.path]: entry } : {}, + }; +} + +const indexSections = (entry: PageHash) => + entry.sections.map(({ anchor, heading, text, key }) => ({ + anchor, + heading, + text, + key, + })); + +describe("AI Search manifest diff", () => { + it("refreshes all sections when indexed page metadata changes", () => { + const previous = page(); + const current = page({ + description: "A new description.", + hash: "new-page-hash", + }); + + const [event] = diffManifests(manifest(previous), manifest(current)); + if (event.type !== "docs.page.changed") + throw new Error("expected changed event"); + expect(event.changedSections).toEqual(indexSections(current)); + expect(event.page).toEqual({ + title: "Workers", + description: "A new description.", + product: "Workers", + }); + }); + + it("includes every prior section key when deleting a page", () => { + const previous = page(); + const [event] = diffManifests(manifest(previous), manifest()); + + expect(event.type).toBe("docs.page.deleted"); + expect(event.removedSectionKeys).toEqual(["docs/workers/index.example.md"]); + }); + + it("keeps removed section deletions while upserting every current page", () => { + const section = page().sections[0]; + const previous = page({ + sections: [ + section, + { + ...section, + anchor: "removed", + key: "docs/workers/index.removed.md", + }, + ], + }); + const current = page({ hash: "new-page-hash" }); + const [event] = fullReindexEvents(manifest(previous), manifest(current)); + + if (event.type !== "docs.page.changed") + throw new Error("expected changed event"); + expect(event.changedSections).toEqual(indexSections(current)); + expect(event.removedSectionKeys).toEqual(["docs/workers/index.removed.md"]); + }); + + it("omits manifest-only hashes, complete sections, and summary from the request", () => { + const current = page(); + const events = fullReindexEvents(null, manifest(current)); + const payload = payloadFor(manifest(current), events); + + expect(payload).toEqual({ + version: 1, + generatedAt: "2026-07-15T12:00:00.000Z", + events: [ + { + type: "docs.page.changed", + path: "/workers/", + key: "docs/workers/index.md", + page: { + title: "Workers", + description: "Build applications.", + product: "Workers", + }, + changedSections: indexSections(current), + }, + ], + }); + }); +}); diff --git a/bin/ai-search-doc-events/diff.ts b/bin/ai-search-doc-events/diff.ts new file mode 100644 index 00000000000..4539144dd40 --- /dev/null +++ b/bin/ai-search-doc-events/diff.ts @@ -0,0 +1,149 @@ +import { readFile } from "node:fs/promises"; +import type { + DiffPayload, + IndexPage, + IndexSection, + Manifest, + PageChangeEvent, + PageHash, + Section, + Summary, +} from "./types"; + +export async function readManifest(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as Manifest; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function indexPage(page: PageHash): IndexPage { + const { title, description, product } = page; + return { title, description, product }; +} + +function indexSection(section: Section): IndexSection { + const { anchor, heading, text, key } = section; + return { anchor, heading, text, key }; +} + +type ChangedPageEvent = Extract; + +export function initialEvents(current: Manifest): ChangedPageEvent[] { + return Object.values(current.pages).map((page): ChangedPageEvent => { + return { + type: "docs.page.changed", + path: page.path, + key: page.key, + page: indexPage(page), + changedSections: page.sections.map(indexSection), + }; + }); +} + +export function fullReindexEvents( + previous: Manifest | null, + current: Manifest, +): PageChangeEvent[] { + const upserts = initialEvents(current); + if (!previous) return upserts; + + const incremental = diffManifests(previous, current); + const changesByPath = new Map( + incremental + .filter((event) => event.type === "docs.page.changed") + .map((event) => [event.path, event]), + ); + const deletions = incremental.filter( + (event) => event.type === "docs.page.deleted", + ); + + return [ + ...deletions, + ...upserts.map((event) => ({ + ...event, + // Full upserts replace every current item, but sections removed since the + // previous manifest still need explicit deletion. + removedSectionKeys: changesByPath.get(event.path)?.removedSectionKeys, + })), + ]; +} + +export function diffManifests(previous: Manifest, current: Manifest) { + const events: PageChangeEvent[] = []; + + for (const page of Object.values(current.pages)) { + const oldPage = previous.pages[page.path]; + if (oldPage?.hash === page.hash) continue; + + const oldSectionHashes = new Map( + oldPage?.sections.map((section) => [section.key, section.hash]) ?? [], + ); + const metadataChanged = + oldPage !== undefined && + JSON.stringify(indexPage(oldPage)) !== JSON.stringify(indexPage(page)); + // Section items duplicate page-level title, description, and product, so + // refresh every section when that shared metadata changes. + const changedSections = page.sections + .filter( + (section) => + metadataChanged || oldSectionHashes.get(section.key) !== section.hash, + ) + .map(indexSection); + const newSectionKeys = new Set(page.sections.map((section) => section.key)); + const removedSectionKeys = (oldPage?.sections ?? []) + .map((section) => section.key) + .filter((key) => !newSectionKeys.has(key)); + + events.push({ + type: "docs.page.changed", + path: page.path, + key: page.key, + page: indexPage(page), + changedSections, + removedSectionKeys, + }); + } + + for (const oldPage of Object.values(previous.pages)) { + if (current.pages[oldPage.path]) continue; + events.push({ + type: "docs.page.deleted", + path: oldPage.path, + key: oldPage.key, + // Give the worker explicit item keys so every deletion is independently + // retryable and protected by its per-item generation guard. + removedSectionKeys: oldPage.sections.map((section) => section.key), + }); + } + + return events; +} + +export function summarize( + current: Manifest, + events: PageChangeEvent[], + baseline: boolean, +): Omit { + return { + pages: Object.keys(current.pages).length, + changed: events.filter((event) => event.type === "docs.page.changed") + .length, + deleted: events.filter((event) => event.type === "docs.page.deleted") + .length, + baseline, + }; +} + +export function payloadFor( + current: Manifest, + events: PageChangeEvent[], +): DiffPayload { + return { + version: 1, + generatedAt: current.generatedAt, + events, + }; +} diff --git a/bin/ai-search-doc-events/manifest.ts b/bin/ai-search-doc-events/manifest.ts new file mode 100644 index 00000000000..566bd65257d --- /dev/null +++ b/bin/ai-search-doc-events/manifest.ts @@ -0,0 +1,96 @@ +import { readFile } from "node:fs/promises"; +import { parse } from "node-html-parser"; +import fg from "fast-glob"; +import { transformContent } from "./content-transformers"; +import type { Args, Manifest, PageHash } from "./types"; +import { + addSectionRecordFields, + docsPathToItemKey, + htmlPathToDocsPath, + meta, + normalizeText, + sha256, + shouldIndexHtmlPath, + sourceMarkdownForPath, +} from "./shared"; + +async function pageFromHtml( + dist: string, + htmlFile: string, + sourceDocsDir: string, +) { + const path = htmlPathToDocsPath(dist, htmlFile); + if (!shouldIndexHtmlPath(path)) return null; + + const html = await readFile(htmlFile, "utf8"); + const root = parse(html); + + const robots = meta(root, "robots"); + const refresh = root.querySelector('meta[http-equiv="refresh"]'); + if (robots?.includes("noindex") || refresh) return null; + + const title = normalizeText( + root.querySelector("title")?.text.split("|")[0] ?? + root.querySelector("h1")?.text ?? + path, + ); + const description = meta(root, "description"); + const source = await sourceMarkdownForPath(sourceDocsDir, path); + const rawSections = await transformContent({ + path, + title, + description, + sourceMarkdown: source?.content, + sourceMarkdownPath: source?.file, + root, + }); + const text = rawSections.map((section) => section.text).join("\n\n"); + if (!text) return null; + + const product = meta(root, "pcx_product"); + // Fold the indexed page fields into the page hash so metadata-only edits + // still produce a diff event and refresh the whole-page item. + const hash = sha256( + [title, description ?? "", product ?? "", text].join("\n"), + ); + const page: PageHash = { + path, + key: docsPathToItemKey(path), + title, + description, + product, + hash, + sections: addSectionRecordFields(path, rawSections), + }; + + return page; +} + +export async function buildManifest( + args: Pick, +): Promise { + const htmlFiles = await fg("**/*.html", { + cwd: args.dist, + absolute: true, + ignore: ["404.html", "**/404/index.html"], + }); + + const pages: Record = {}; + for (const htmlFile of htmlFiles.sort()) { + const page = await pageFromHtml(args.dist, htmlFile, args.sourceDocsDir); + if (!page) continue; + if ( + args.includePathPrefixes.length > 0 && + !args.includePathPrefixes.some((prefix) => page.path.startsWith(prefix)) + ) { + continue; + } + pages[page.path] = page; + } + + return { + version: 1, + generatedAt: new Date().toISOString(), + pages, + }; +} diff --git a/bin/ai-search-doc-events/run.ts b/bin/ai-search-doc-events/run.ts new file mode 100644 index 00000000000..c30b1757ca5 --- /dev/null +++ b/bin/ai-search-doc-events/run.ts @@ -0,0 +1,78 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { parseArgs } from "./args"; +import { + diffManifests, + fullReindexEvents, + payloadFor, + readManifest, + summarize, +} from "./diff"; +import { buildManifest } from "./manifest"; +import { sendPayload } from "./send"; + +async function writeFileWithDir(path: string, contents: string) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); +} + +function writeJson(path: string, data: unknown) { + return writeFileWithDir(path, `${JSON.stringify(data, null, "\t")}\n`); +} + +export async function run() { + const args = parseArgs(); + // A forced run upserts every current page while still using a previous + // manifest, when available, to delete pages/sections that disappeared. + // Otherwise diff normally; with no previous manifest, baseline (send nothing). + const previous = await readManifest(args.previous); + const current = await buildManifest(args); + const baseline = previous === null; + const events = args.forceFullReindex + ? fullReindexEvents(previous, current) + : previous + ? diffManifests(previous, current) + : []; + const payload = payloadFor(current, events); + const summary = summarize(current, events, baseline); + + await writeFileWithDir( + args.events, + events.map((event) => JSON.stringify(event)).join("\n"), + ); + + const sent = args.sendUrl + ? events.length === 0 || (await sendPayload(args, payload)) + : false; + if (args.sendUrl && !sent) { + throw new Error( + "Reindex payload delivery failed; manifest was not advanced", + ); + } + + // Do not advance either manifest until every requested batch has been + // accepted. This prevents a transient enqueue failure from permanently + // hiding unchanged pages from the next diff. + await writeJson(args.manifest, current); + let committed = false; + + if (args.commit) { + await writeJson(args.previous, current); + committed = true; + } + + console.log( + JSON.stringify( + { + ...summary, + sent, + committed, + previous: args.previous, + manifest: args.manifest, + events: args.events, + }, + null, + 2, + ), + ); +} diff --git a/bin/ai-search-doc-events/send.ts b/bin/ai-search-doc-events/send.ts new file mode 100644 index 00000000000..96410eb0167 --- /dev/null +++ b/bin/ai-search-doc-events/send.ts @@ -0,0 +1,189 @@ +import { appendFile, mkdir, readFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { Args, DiffPayload, PageChangeEvent } from "./types"; + +// HTTP responses worth retrying the whole batch POST for: transient network / +// backend errors that clear on retry. Matched against the status code (or the +// thrown error message for network failures). +const TRANSIENT_STATUS = new Set([429, 500, 502, 503, 504]); + +function buildHeaders(args: Args): Headers { + const tokenEnv = args.sendTokenEnv ?? "AI_SEARCH_REINDEX_TOKEN"; + const token = process.env[tokenEnv]; + const accessClientId = process.env.CF_ACCESS_CLIENT_ID; + const accessClientSecret = process.env.CF_ACCESS_CLIENT_SECRET; + const accessToken = process.env.CF_ACCESS_TOKEN; + + const headers = new Headers({ "Content-Type": "application/json" }); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (accessClientId && accessClientSecret) { + headers.set("CF-Access-Client-Id", accessClientId); + headers.set("CF-Access-Client-Secret", accessClientSecret); + } + if (accessToken) headers.set("cf-access-token", accessToken); + return headers; +} + +function payloadForBatch( + payload: DiffPayload, + events: PageChangeEvent[], +): DiffPayload { + return { ...payload, events }; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function readResumeCompleted(resumeFile?: string): Promise> { + if (!resumeFile) return new Set(); + try { + const contents = await readFile(resumeFile, "utf8"); + const done = new Set(); + for (const line of contents.split("\n")) { + const trimmed = line.trim(); + if (trimmed) done.add(JSON.parse(trimmed).path as string); + } + return done; + } catch { + return new Set(); + } +} + +/** Run `worker` over `items` with at most `concurrency` in flight. */ +async function runPool( + items: T[], + concurrency: number, + worker: (item: T) => Promise, +): Promise { + let cursor = 0; + const runners = Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + while (cursor < items.length) { + const index = cursor++; + await worker(items[index]); + } + }, + ); + await Promise.all(runners); +} + +/** + * POST the diff payload to the reindex endpoint. + * + * The endpoint is an enqueue-only front door: it validates the payload, + * pushes every event onto a Cloudflare Queue, and returns 202 Accepted without + * waiting for the AI Search uploads. A queue consumer indexes the pages in the + * background, with retries + a dead-letter queue handled natively by Queues. + * + * So this client only needs to guarantee delivery of the batch POST itself: + * a 2xx (typically 202) means every event in the batch was enqueued, which we + * treat as done. Transient HTTP/network failures retry the whole batch with + * exponential backoff; with `--resume-file` we record enqueued pages so an + * interrupted run resumes without re-enqueuing. + */ +export async function sendPayload(args: Args, payload: DiffPayload) { + if (!args.sendUrl) return false; + + const headers = buildHeaders(args); + + let events = payload.events; + const completed = await readResumeCompleted(args.resumeFile); + if (completed.size > 0) { + events = events.filter((event) => !completed.has(event.path)); + console.log( + `resume: skipping ${completed.size} already-enqueued pages, ${events.length} remaining`, + ); + } + if (events.length === 0) return true; + + if (args.resumeFile) { + await mkdir(dirname(args.resumeFile), { recursive: true }); + } + + const url = args.sendUrl; + const batches: PageChangeEvent[][] = []; + for (let i = 0; i < events.length; i += args.batchSize) { + batches.push(events.slice(i, i + args.batchSize)); + } + + const post = (batchEvents: PageChangeEvent[]) => + fetch(url, { + method: "POST", + headers, + body: JSON.stringify(payloadForBatch(payload, batchEvents)), + // Never follow redirects: a 3xx to an auth/login page (Cloudflare + // Access) would otherwise resolve to 200 and look like success. + redirect: "manual", + }); + + let ok = 0; + let failedPages = 0; + let done = 0; + let batchesDone = 0; + const total = events.length; + const totalBatches = batches.length; + const startedAt = Date.now(); + + const pct = (n: number) => + total === 0 ? "100" : ((n / total) * 100).toFixed(1); + + console.log( + `reindex enqueue: ${total} pages in ${totalBatches} batches ` + + `(batch-size ${args.batchSize}, concurrency ${args.concurrency}) → ${url}`, + ); + + const markCompleted = async (batchEvents: PageChangeEvent[]) => { + if (!args.resumeFile) return; + const lines = batchEvents + .map((event) => `${JSON.stringify({ path: event.path })}\n`) + .join(""); + await appendFile(args.resumeFile, lines); + }; + + await runPool(batches, args.concurrency, async (batchEvents) => { + for (let attempt = 0; attempt <= args.maxRetries; attempt++) { + let status = 0; + try { + const response = await post(batchEvents); + status = response.status; + if (response.ok) { + await markCompleted(batchEvents); + ok += batchEvents.length; + done += batchEvents.length; + break; + } + throw new Error(`HTTP ${status}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const retriable = status === 0 || TRANSIENT_STATUS.has(status); + if (attempt < args.maxRetries && retriable) { + await sleep(Math.min(2 ** attempt * 1000, 30_000)); + continue; + } + for (const event of batchEvents) { + console.error(`reindex enqueue failed (${event.path}): ${message}`); + failedPages++; + done++; + } + break; + } + } + + batchesDone++; + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(0); + const rate = done > 0 ? done / ((Date.now() - startedAt) / 1000) : 0; + const remaining = rate > 0 ? Math.round((total - done) / rate) : 0; + console.log( + `reindex enqueue: batch ${batchesDone}/${totalBatches} | ` + + `${done}/${total} pages (${pct(done)}%) | ` + + `ok=${ok} failed=${failedPages} | ` + + `${elapsed}s elapsed, ~${remaining}s left`, + ); + }); + + console.log( + `reindex enqueue complete: ${ok}/${total} enqueued, ${failedPages} failed ` + + `in ${((Date.now() - startedAt) / 1000).toFixed(0)}s`, + ); + return failedPages === 0; +} diff --git a/bin/ai-search-doc-events/shared.ts b/bin/ai-search-doc-events/shared.ts new file mode 100644 index 00000000000..90371915996 --- /dev/null +++ b/bin/ai-search-doc-events/shared.ts @@ -0,0 +1,140 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { HTMLElement } from "node-html-parser"; +import type { RawSection, Section } from "./types"; + +export function sha256(input: string) { + return createHash("sha256").update(input).digest("hex"); +} + +export function normalizeText(input: string) { + return input.replace(/\s+/g, " ").trim(); +} + +export const MAX_SECTION_TEXT_LENGTH = 60_000; + +export function truncateText(input: string, maxLength: number) { + if (input.length <= maxLength) return input; + return `${input.slice(0, maxLength).trim()}\n\n[Content truncated]`; +} + +export function meta(root: HTMLElement, name: string) { + return ( + root.querySelector(`meta[name="${name}"]`)?.getAttribute("content") ?? + root.querySelector(`meta[property="${name}"]`)?.getAttribute("content") ?? + undefined + ); +} + +export function htmlPathToDocsPath(dist: string, htmlFile: string) { + const rel = relative(dist, htmlFile).split(sep).join("/"); + if (rel === "index.html") return "/"; + if (rel.endsWith("/index.html")) + return `/${rel.slice(0, -"index.html".length)}`; + if (rel.endsWith(".html")) return `/${rel.slice(0, -".html".length)}/`; + return `/${rel}`; +} + +export function docsPathToMarkdownPath(pathname: string) { + if (pathname.endsWith("/")) return `${pathname}index.md`; + return `${pathname}/index.md`; +} + +export function docsPathToItemKey(pathname: string) { + const normalized = pathname === "/" ? "/index/" : pathname; + return `docs${docsPathToMarkdownPath(normalized)}`; +} + +export function sectionItemKey( + baseKey: string, + section: Pick, + index: number, +) { + const anchor = + section.anchor || slugifyHeading(section.heading) || `section-${index + 1}`; + const prefix = baseKey.endsWith(".md") + ? baseKey.slice(0, -".md".length) + : baseKey; + return `${prefix}.${anchor}.md`; +} + +export function uniqueAnchor(anchor: string, usedAnchors: Map) { + if (!anchor) return anchor; + + const count = usedAnchors.get(anchor) ?? 0; + usedAnchors.set(anchor, count + 1); + return count === 0 ? anchor : `${anchor}-${count}`; +} + +export function addSectionRecordFields( + path: string, + sections: RawSection[], +): Section[] { + const baseKey = docsPathToItemKey(path); + const usedAnchors = new Map(); + + return sections.map((section, index) => { + const anchor = uniqueAnchor(section.anchor, usedAnchors); + const sectionWithAnchor = { ...section, anchor }; + return { + ...sectionWithAnchor, + key: sectionItemKey(baseKey, sectionWithAnchor, index), + }; + }); +} + +export function slugifyHeading(value: string) { + return value + .toLowerCase() + .replace(/`([^`]+)`/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .replace(/\s+/g, "-"); +} + +export function shouldIndexHtmlPath(path: string) { + const pathWithoutTrailingSlash = path.replace(/\/$/, ""); + + if (path.startsWith("/changelog/") && !path.startsWith("/changelog/post/")) { + return false; + } + + if (/\.(?:json|xml|rss|txt)$/i.test(pathWithoutTrailingSlash)) { + return false; + } + + if (/^\/changelog\/(?:rss|feed|atom)(?:\/|$)/i.test(path)) { + return false; + } + + if (/(?:^|\/)(?:rss|feed|atom)(?:\/|$)/i.test(path)) { + return false; + } + + return true; +} + +export function sourceCandidatesForPath(sourceDocsDir: string, path: string) { + const rel = path.replace(/^\//, "").replace(/\/$/, ""); + const bases = rel ? [rel, `${rel}/index`] : ["index"]; + return bases.flatMap((base) => [ + join(sourceDocsDir, `${base}.mdx`), + join(sourceDocsDir, `${base}.md`), + ]); +} + +export async function sourceMarkdownForPath( + sourceDocsDir: string, + path: string, +): Promise<{ file: string; content: string } | undefined> { + for (const candidate of sourceCandidatesForPath(sourceDocsDir, path)) { + try { + return { file: candidate, content: await readFile(candidate, "utf8") }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return undefined; +} diff --git a/bin/ai-search-doc-events/types.ts b/bin/ai-search-doc-events/types.ts new file mode 100644 index 00000000000..ea1f08d0ac4 --- /dev/null +++ b/bin/ai-search-doc-events/types.ts @@ -0,0 +1,97 @@ +import type { HTMLElement } from "node-html-parser"; + +export type RawSection = { + anchor: string; + heading: string; + text: string; + hash: string; +}; + +export type Section = RawSection & { + key: string; +}; + +export type PageHash = { + path: string; + key: string; + title: string; + description?: string; + product?: string; + hash: string; + sections: Section[]; +}; + +export type Manifest = { + version: 1; + generatedAt: string; + pages: Record; +}; + +export type IndexSection = Pick; +export type IndexPage = Pick; + +export type PageChangeEvent = + | { + type: "docs.page.changed"; + path: string; + key: string; + page: IndexPage; + changedSections: IndexSection[]; + removedSectionKeys?: string[]; + } + | { + type: "docs.page.deleted"; + path: string; + key: string; + removedSectionKeys: string[]; + }; + +export type Summary = { + pages: number; + changed: number; + deleted: number; + baseline: boolean; + sent: boolean; + committed: boolean; +}; + +export type DiffPayload = { + version: 1; + generatedAt: string; + events: PageChangeEvent[]; +}; + +export type Args = { + dist: string; + sourceDocsDir: string; + stateDir: string; + previous: string; + manifest: string; + events: string; + includePathPrefixes: string[]; + sendUrl?: string; + sendTokenEnv?: string; + batchSize: number; + commit: boolean; + forceFullReindex: boolean; + // Reliable-send options (see send.ts). + concurrency: number; + maxRetries: number; + resumeFile?: string; +}; + +export type ContentTransformerContext = { + path: string; + title: string; + description?: string; + sourceMarkdown?: string; + sourceMarkdownPath?: string; + root: HTMLElement; +}; + +export type ContentTransformer = { + name: string; + transform: ( + context: ContentTransformerContext, + ) => RawSection[] | undefined | Promise; +}; diff --git a/package.json b/package.json index e90ef00d033..3de004ebaae 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "postinstall": "patch-package", "preview": "astro preview", "script:optimize-svgs": "tsx scripts/optimize-svgs.ts", + "ai-search:events": "tsx bin/ai-search-doc-events.ts", "start": "astro dev", "sync": "astro sync", "test": "vitest", @@ -112,6 +113,7 @@ "hast-util-to-string": "3.0.1", "hast-util-to-text": "4.0.2", "hastscript": "9.0.1", + "gray-matter": "4.0.3", "he": "1.2.0", "husky": "9.1.7", "jsonc-parser": "3.3.1", @@ -123,6 +125,7 @@ "mdast-util-from-markdown": "2.0.3", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-expression": "2.0.1", + "mdast-util-to-string": "4.0.0", "medium-zoom": "1.1.0", "mermaid": "11.15.0", "micromark-extension-mdxjs": "3.0.0", @@ -151,6 +154,8 @@ "rehype-title-figure": "0.1.2", "remark": "15.0.1", "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", + "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "satteri": "0.6.3", "sharp": "0.35.1", @@ -180,6 +185,7 @@ "vite-tsconfig-paths": "6.1.1", "vitest": "4.1.9", "wrangler": "4.107.0", + "yaml": "2.9.0", "zod": "4.4.3" }, "lint-staged": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b0e9ddae7a..1cadc20a0f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: globals: specifier: 17.6.0 version: 17.6.0 + gray-matter: + specifier: 4.0.3 + version: 4.0.3 happy-dom: specifier: 20.10.3 version: 20.10.3 @@ -260,6 +263,9 @@ importers: mdast-util-mdx-expression: specifier: 2.0.1 version: 2.0.1 + mdast-util-to-string: + specifier: 4.0.0 + version: 4.0.0 medium-zoom: specifier: 1.1.0 version: 1.1.0 @@ -344,6 +350,12 @@ importers: remark-gfm: specifier: 4.0.1 version: 4.0.1 + remark-mdx: + specifier: 3.1.1 + version: 3.1.1 + remark-parse: + specifier: 11.0.0 + version: 11.0.0 remark-stringify: specifier: 11.0.0 version: 11.0.0 @@ -431,6 +443,9 @@ importers: wrangler: specifier: 4.107.0 version: 4.107.0(@cloudflare/workers-types@4.20260615.1) + yaml: + specifier: 2.9.0 + version: 2.9.0 zod: specifier: 4.4.3 version: 4.4.3 @@ -849,7 +864,7 @@ packages: optional: true '@cloudflare/vitest-pool-workers@0.16.15': - resolution: {integrity: sha512-R0kZhIm4uSxOeTWPHY9xYIFPGRBEHPzl/n9BbHZSY/gk0n16uDU7T1JZe372oTF+diXG1uVBWqiiRc7Hxstdow==} + resolution: {integrity: sha512-R0kZhIm4uSxOeTWPHY9xYIFPGRBEHPzl/n9BbHZSY/gk0n16uDU7T1JZe372oTF+diXG1uVBWqiiRc7Hxstdow==, tarball: https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.16.15.tgz} peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 @@ -916,7 +931,7 @@ packages: os: [win32] '@cloudflare/workers-types@4.20260615.1': - resolution: {integrity: sha512-fGOiTwoLj/8bU8mj3VAfa1EULx4ceZhDwnjvY+afDBlSXI9pvY7PE9t62rGEhJjbAOGd7i5WUDun0eZCWBDrzg==} + resolution: {integrity: sha512-fGOiTwoLj/8bU8mj3VAfa1EULx4ceZhDwnjvY+afDBlSXI9pvY7PE9t62rGEhJjbAOGd7i5WUDun0eZCWBDrzg==, tarball: https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260615.1.tgz} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} From 27e69b1d5316b04e209184a67e948420bf262c3e Mon Sep 17 00:00:00 2001 From: ttmx Date: Thu, 23 Jul 2026 13:19:09 +0100 Subject: [PATCH 2/3] [Docs Site] Align AI Search CI authentication --- .github/workflows/publish-production.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/publish-production.yml b/.github/workflows/publish-production.yml index 6d662eb7fe6..1d370f9ab73 100644 --- a/.github/workflows/publish-production.yml +++ b/.github/workflows/publish-production.yml @@ -12,7 +12,7 @@ on: jobs: publish: - if: github.repository == 'cloudflare/cloudflare-docs' + if: github.repository == 'cloudflare/cloudflare-docs' && github.ref == 'refs/heads/production' name: Production runs-on: ubuntu-22.04 permissions: @@ -150,9 +150,6 @@ jobs: - name: Reindex AI Search env: - # Bearer token for the indexer worker's /api/ai-search/reindex endpoint - # (matches bin/ai-search-doc-events send-token-env default). - AI_SEARCH_REINDEX_TOKEN: ${{ secrets.AI_SEARCH_REINDEX_TOKEN }} # The indexer sits behind Cloudflare Access, so the reindex call needs # an Access service token (send.ts sends these as CF-Access-Client-*). CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} @@ -161,7 +158,6 @@ jobs: REINDEX_URL: ${{ vars.AI_SEARCH_REINDEX_URL }} run: | : "${REINDEX_URL:?AI_SEARCH_REINDEX_URL repository variable is required}" - : "${AI_SEARCH_REINDEX_TOKEN:?AI_SEARCH_REINDEX_TOKEN secret is required}" : "${CF_ACCESS_CLIENT_ID:?CF_ACCESS_CLIENT_ID secret is required}" : "${CF_ACCESS_CLIENT_SECRET:?CF_ACCESS_CLIENT_SECRET secret is required}" From 56ae2181113a48df7ddef95e8109a8a17175fa68 Mon Sep 17 00:00:00 2001 From: ttmx Date: Fri, 24 Jul 2026 12:56:19 +0100 Subject: [PATCH 3/3] [Docs Site] Decouple AI Search reindexing --- .github/workflows/ai-search-reindex.yml | 142 +++++++++++++++++++++++ .github/workflows/publish-production.yml | 122 +------------------ 2 files changed, 143 insertions(+), 121 deletions(-) create mode 100644 .github/workflows/ai-search-reindex.yml diff --git a/.github/workflows/ai-search-reindex.yml b/.github/workflows/ai-search-reindex.yml new file mode 100644 index 00000000000..cac2392c910 --- /dev/null +++ b/.github/workflows/ai-search-reindex.yml @@ -0,0 +1,142 @@ +name: AI Search Reindex + +on: + workflow_run: + workflows: ["Publish"] + types: [completed] + workflow_dispatch: + inputs: + force_full_reindex: + description: "Rebuild the AI Search index from scratch (send every page, ignore the previous manifest)." + type: boolean + default: false + +permissions: + actions: read + contents: read + +jobs: + reindex: + name: AI Search reindex + if: >- + github.repository == 'cloudflare/cloudflare-docs' && + (github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-22.04 + steps: + - name: Resolve published site + id: published + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_RUN_ID: ${{ github.event.workflow_run.id }} + PUBLISHED_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + if [ -z "$PUBLISH_RUN_ID" ] || [ -z "$PUBLISHED_SHA" ]; then + PUBLISH_RUN=$(gh run list --workflow publish-production.yml --branch production \ + --event push --status success --limit 1 --json databaseId,headSha --jq '.[0]') + PUBLISH_RUN_ID=$(jq -r '.databaseId // empty' <<< "$PUBLISH_RUN") + PUBLISHED_SHA=$(jq -r '.headSha // empty' <<< "$PUBLISH_RUN") + fi + : "${PUBLISH_RUN_ID:?No successful production publish found}" + : "${PUBLISHED_SHA:?No successful production publish found}" + echo "run_id=$PUBLISH_RUN_ID" >> "$GITHUB_OUTPUT" + echo "sha=$PUBLISHED_SHA" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # Match the indexer code to the commit that produced the site artifact. + ref: ${{ steps.published.outputs.sha }} + - name: Set up pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + version: 11 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + id: setup-node + with: + node-version: 24.x + cache: pnpm + - name: Restore node_modules (cache hit) + id: node-modules-cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('package.json') }} + - name: Install node_modules (cache miss) + run: pnpm install --frozen-lockfile + if: steps.node-modules-cache.outputs.cache-hit != 'true' + + - name: Download published site + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: site-html + path: dist + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.published.outputs.run_id }} + + # Successful runs can lack an artifact when the reindex job was skipped, + # so try recent runs until one contains a manifest. + - name: Restore previous manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p .ai-search + restored=false + for previous_run in $(gh run list --workflow ai-search-reindex.yml --branch production \ + --status success --limit 10 --json databaseId --jq '.[].databaseId' 2>/dev/null); do + if gh run download "$previous_run" --name ai-search-manifest --dir .ai-search 2>/dev/null; then + echo "Restored previous manifest from run $previous_run." + restored=true + break + fi + done + if [ "$restored" != "true" ] && [ "${{ inputs.force_full_reindex }}" != "true" ]; then + echo "No previous manifest artifact. Refusing to advance an empty baseline." >&2 + echo "Restore a prior manifest or manually run against a known-empty/reset instance with force_full_reindex=true." >&2 + exit 1 + fi + + - name: Reindex AI Search + env: + # The indexer sits behind Cloudflare Access, so the reindex call needs + # an Access service token (send.ts sends these as CF-Access-Client-*). + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + # Absolute URL of the indexer worker's reindex endpoint. + REINDEX_URL: ${{ vars.AI_SEARCH_REINDEX_URL }} + run: | + : "${REINDEX_URL:?AI_SEARCH_REINDEX_URL repository variable is required}" + : "${CF_ACCESS_CLIENT_ID:?CF_ACCESS_CLIENT_ID secret is required}" + : "${CF_ACCESS_CLIENT_SECRET:?CF_ACCESS_CLIENT_SECRET secret is required}" + + EXTRA="" + if [ "${{ inputs.force_full_reindex }}" = "true" ]; then + EXTRA="--force-full-reindex" + fi + pnpm exec tsx bin/ai-search-doc-events.ts \ + --previous .ai-search/page-hashes.json \ + --manifest .ai-search/page-hashes.json \ + --batch-size 25 \ + --send-url "$REINDEX_URL" \ + $EXTRA + + - name: Save updated manifest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: ai-search-manifest + path: .ai-search/page-hashes.json + retention-days: 7 + overwrite: true + + - name: Notify Google Chat on failure + if: failure() + env: + WEBHOOK_URL: ${{ secrets.CED_TEAM_ALERTS_CHANNEL_WEBHOOK }} + ACTOR: ${{ github.actor }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: | + MESSAGE="⚠️ *AI Search reindex* failed (site still deployed).\nActor: $ACTOR\n" + JSON_PAYLOAD=$(jq -n --arg text "$MESSAGE" '{text: $text}') + curl -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" diff --git a/.github/workflows/publish-production.yml b/.github/workflows/publish-production.yml index 1d370f9ab73..eae4d2bd7a6 100644 --- a/.github/workflows/publish-production.yml +++ b/.github/workflows/publish-production.yml @@ -3,16 +3,10 @@ on: push: branches: - production - workflow_dispatch: - inputs: - force_full_reindex: - description: "Rebuild the AI Search index from scratch (send every page, ignore the previous manifest)." - type: boolean - default: false jobs: publish: - if: github.repository == 'cloudflare/cloudflare-docs' && github.ref == 'refs/heads/production' + if: github.repository == 'cloudflare/cloudflare-docs' name: Production runs-on: ubuntu-22.04 permissions: @@ -84,117 +78,3 @@ jobs: curl -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d "$JSON_PAYLOAD" - - reindex: - name: AI Search reindex - if: github.repository == 'cloudflare/cloudflare-docs' && github.ref == 'refs/heads/production' - needs: publish - runs-on: ubuntu-22.04 - permissions: - contents: read - # gh CLI needs actions:read to list/download artifacts from prior runs. - actions: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - # Match the source extractors to the exact commit that produced the - # downloaded dist artifact, even if production advances meanwhile. - ref: ${{ github.sha }} - - name: Set up pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - with: - version: 11 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - id: setup-node - with: - node-version: 24.x - cache: pnpm - - name: Restore node_modules (cache hit) - id: node-modules-cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 - with: - path: node_modules - key: node-modules-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('package.json') }} - - name: Install node_modules (cache miss) - run: pnpm install --frozen-lockfile - if: steps.node-modules-cache.outputs.cache-hit != 'true' - - # Reuse the build artifact this run's publish job uploaded (no rebuild). - - name: Download built site - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh run download "${{ github.run_id }}" --name site-html --dir dist - - # Best-effort: pull the manifest from the last successful run so the diff - # only sends changed pages. Missing/expired artifact => baseline. - - name: Restore previous manifest - id: previous-manifest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - mkdir -p .ai-search - prev_run=$(gh run list --workflow publish-production.yml --branch production \ - --status success --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null || true) - if [ -n "$prev_run" ] && gh run download "$prev_run" --name ai-search-manifest --dir .ai-search 2>/dev/null; then - echo "Restored previous manifest from run $prev_run." - echo "restored=true" >> "$GITHUB_OUTPUT" - else - echo "No previous manifest artifact. Refusing to advance an empty baseline." - echo "restored=false" >> "$GITHUB_OUTPUT" - if [ "${{ inputs.force_full_reindex }}" != "true" ]; then - echo "Restore a prior manifest or manually run against a known-empty/reset instance with force_full_reindex=true." >&2 - exit 1 - fi - fi - - - name: Reindex AI Search - env: - # The indexer sits behind Cloudflare Access, so the reindex call needs - # an Access service token (send.ts sends these as CF-Access-Client-*). - CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} - CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} - # Absolute URL of the indexer worker's reindex endpoint. - REINDEX_URL: ${{ vars.AI_SEARCH_REINDEX_URL }} - run: | - : "${REINDEX_URL:?AI_SEARCH_REINDEX_URL repository variable is required}" - : "${CF_ACCESS_CLIENT_ID:?CF_ACCESS_CLIENT_ID secret is required}" - : "${CF_ACCESS_CLIENT_SECRET:?CF_ACCESS_CLIENT_SECRET secret is required}" - - # Read + rewrite the same file so the updated manifest is ready to - # upload as this run's artifact (previous==manifest is safe: run.ts - # reads the previous manifest before overwriting it). - # force_full_reindex (workflow_dispatch toggle) ignores the previous - # manifest and sends every page. Empty on push events => incremental. - EXTRA="" - if [ "${{ inputs.force_full_reindex }}" = "true" ]; then - EXTRA="--force-full-reindex" - fi - pnpm exec tsx bin/ai-search-doc-events.ts \ - --previous .ai-search/page-hashes.json \ - --manifest .ai-search/page-hashes.json \ - --batch-size 25 \ - --send-url "$REINDEX_URL" \ - $EXTRA - - - name: Save updated manifest - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: ai-search-manifest - path: .ai-search/page-hashes.json - retention-days: 7 - overwrite: true - - - name: Notify Google Chat on failure - if: failure() - env: - WEBHOOK_URL: ${{ secrets.CED_TEAM_ALERTS_CHANNEL_WEBHOOK }} - ACTOR: ${{ github.actor }} - REPO: ${{ github.repository }} - RUN_ID: ${{ github.run_id }} - run: | - MESSAGE="⚠️ *AI Search reindex* failed (site still deployed).\nActor: $ACTOR\n" - JSON_PAYLOAD=$(jq -n --arg text "$MESSAGE" '{text: $text}') - curl -X POST "$WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "$JSON_PAYLOAD"