From 23ec5d429013b58cba0df7aa461b12612e26c7a4 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 17 Aug 2026 14:10:14 -0700 Subject: [PATCH 01/15] Durable AI first phase --- bin/generate-og-gallery.js | 8 +- bin/sync-ai-cookbook.js | 17 +++- bin/validate-og-images.js | 6 +- docs/ai/index.mdx | 94 +++++++++++++++++++ docusaurus.config.js | 16 ++-- plugins/cookbook-index/index.js | 8 +- readme/INFORMATION-ARCHITECTURE.md | 11 +++ sidebars.js | 9 ++ .../DocItem/CookbookCategoryIndex.tsx | 4 +- .../Cookbook/DocItem/CookbookDocItem.tsx | 2 +- .../{ai-cookbook.tsx => ai/cookbook.tsx} | 2 +- tests/playwright/cookbook-home.spec.ts | 6 +- vercel.json | 18 +++- 13 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 docs/ai/index.mdx rename src/pages/{ai-cookbook.tsx => ai/cookbook.tsx} (94%) diff --git a/bin/generate-og-gallery.js b/bin/generate-og-gallery.js index d156e386cb..cf3666cf46 100644 --- a/bin/generate-og-gallery.js +++ b/bin/generate-og-gallery.js @@ -20,7 +20,7 @@ const OUT_FILE = path.join(BUILD_DIR, '__og-gallery.html'); // docusaurus.config.js — every docs plugin instance it renders cards for. const DOC_TARGETS = [ { dir: DOCS_DIR, routeBasePath: '/' }, - { dir: AI_COOKBOOK_DIR, routeBasePath: 'ai-cookbook', footerText: 'AI COOKBOOK' }, + { dir: AI_COOKBOOK_DIR, routeBasePath: 'ai/cookbook', footerText: 'AI COOKBOOK' }, ]; // Section grouping/labeling is purely a gallery-review concern now — the @@ -108,13 +108,13 @@ async function main() { } } - // /ai-cookbook (src/pages/ai-cookbook.tsx) is a plain page, not an MDX doc, + // /ai/cookbook (src/pages/ai/cookbook.tsx) is a plain page, not an MDX doc, // so it's invisible to the DOC_TARGETS walk above — added manually so the // gallery still shows every card the site actually ships. - const cookbookHomeHtmlPath = path.join(BUILD_DIR, 'ai-cookbook', 'index.html'); + const cookbookHomeHtmlPath = path.join(BUILD_DIR, 'ai', 'cookbook', 'index.html'); if (fs.existsSync(cookbookHomeHtmlPath)) { cards.push({ - urlPath: '/ai-cookbook', + urlPath: '/ai/cookbook', section: 'AI Cookbook', title: 'AI Cookbook (landing page)', isOverride: true, diff --git a/bin/sync-ai-cookbook.js b/bin/sync-ai-cookbook.js index 4e8a39310a..371232b9a0 100644 --- a/bin/sync-ai-cookbook.js +++ b/bin/sync-ai-cookbook.js @@ -442,9 +442,17 @@ function rewriteLinks(body, readmePath, slugLookup, assetMap) { }); } +function rewriteCookbookLinkPrefix(body) { + // Recipe READMEs in the external ai-cookbook repo sometimes hardcode absolute + // links to other recipes using this site's current route (e.g. + // /ai-cookbook/some-recipe). Rewrite that prefix so those links keep + // resolving after the /ai/cookbook move, independent of any slug rename. + return body.split('/ai-cookbook/').join('/ai/cookbook/'); +} + function applyCookbookSlugAliases(body) { // Resolve aliased/renamed cookbook slugs in links to their current slugs. - // Handles both relative (./slug.mdx) and absolute (/ai-cookbook/slug) formats. + // Handles both relative (./slug.mdx) and absolute (/ai/cookbook/slug) formats. if (SLUG_ALIASES.size === 0) { return body; } @@ -457,8 +465,8 @@ function applyCookbookSlugAliases(body) { return `${prefix}${newSlug}${ext || ''}${suffix || ''}`; }); - // Match absolute links: /ai-cookbook/old-slug (with optional query/hash) - const absolutePattern = new RegExp(`(/ai-cookbook/)${oldSlug}([?#][^)\\s"']*)?(?=[)\\s"'])`, 'g'); + // Match absolute links: /ai/cookbook/old-slug (with optional query/hash) + const absolutePattern = new RegExp(`(/ai/cookbook/)${oldSlug}([?#][^)\\s"']*)?(?=[)\\s"'])`, 'g'); result = result.replace(absolutePattern, (match, prefix, suffix) => { return `${prefix}${newSlug}${suffix || ''}`; }); @@ -582,7 +590,8 @@ async function transformReadme(readmePath, slugLookup) { const markdownImages = convertHtmlImagesToMarkdown(rewrittenBody); const docusaurusAdmonitions = convertGitHubAdmonitions(markdownImages); const mdxCompatibleBody = fixUnclosedHtmlTags(docusaurusAdmonitions); - const aliasResolvedBody = applyCookbookSlugAliases(mdxCompatibleBody); + const prefixRewrittenBody = rewriteCookbookLinkPrefix(mdxCompatibleBody); + const aliasResolvedBody = applyCookbookSlugAliases(prefixRewrittenBody); const finalContent = `${frontMatterBlock}\n\n${aliasResolvedBody.length > 0 ? `${aliasResolvedBody}\n` : ''}`; return { diff --git a/bin/validate-og-images.js b/bin/validate-og-images.js index b0e115a4ab..df3306490c 100644 --- a/bin/validate-og-images.js +++ b/bin/validate-og-images.js @@ -40,7 +40,7 @@ const AI_COOKBOOK_DIR = path.join(process.cwd(), 'ai-cookbook'); // this validator checks the same pages the plugin generates cards for. const DOC_TARGETS = [ { dir: DOCS_DIR, routeBasePath: '/' }, - { dir: AI_COOKBOOK_DIR, routeBasePath: 'ai-cookbook', footerText: 'AI COOKBOOK' }, + { dir: AI_COOKBOOK_DIR, routeBasePath: 'ai/cookbook', footerText: 'AI COOKBOOK' }, ]; function walkHtmlFiles(dir) { @@ -140,12 +140,12 @@ async function main() { } } - // /ai-cookbook (src/pages/ai-cookbook.tsx) is a plain page, not an MDX doc, + // /ai/cookbook (src/pages/ai/cookbook.tsx) is a plain page, not an MDX doc, // so it never went through the DOC_TARGETS loop above — but it does declare // its own og:image (see plugins/cookbook-index's postBuild), so it's // checked here as a manual override rather than folded into "other pages // must match the site default" below. - const cookbookHomeHtmlPath = path.join(BUILD_DIR, 'ai-cookbook', 'index.html'); + const cookbookHomeHtmlPath = path.join(BUILD_DIR, 'ai', 'cookbook', 'index.html'); if (fs.existsSync(cookbookHomeHtmlPath)) { docHtmlPaths.add(cookbookHomeHtmlPath); docPagesChecked++; diff --git a/docs/ai/index.mdx b/docs/ai/index.mdx new file mode 100644 index 0000000000..a71e9714cf --- /dev/null +++ b/docs/ai/index.mdx @@ -0,0 +1,94 @@ +--- +id: index +title: Durable AI +sidebar_label: Overview +description: Build AI applications and agents on Temporal, with links to the AI Cookbook, SDK integrations, and relevant design patterns. +slug: /ai +--- + +import PatternCards from '@site/src/components/PatternCards'; + +Temporal gives AI applications and agents Durable Execution: a Workflow resumes automatically after a crash, a +network timeout, or a multi-day wait for a human to approve a step. Use it to keep long-running agent loops, LLM tool +calls, and multi-step AI pipelines running reliably, without hand-rolling retry logic, checkpointing, or state +machines. + +Looking to use an AI coding assistant to write Temporal code instead? See [Develop with AI](/with-ai). + +## Start here + + + +## Use cases + +Temporal shows up in four recurring shapes of AI system: + +**Agents.** Long-running, stateful agent loops that call LLMs and tools, wait on humans, and pick up exactly where +they left off after a failure. Start with the [AI Cookbook](/ai/cookbook) and the +[Approval](/design-patterns/approval) and [Entity Workflow](/design-patterns/entity-workflow) patterns. + +**Processing pipelines.** Multi-step data and document pipelines, such as extraction, embedding, or batch inference, +that need to fan out, retry failed steps in isolation, and resume without reprocessing completed work. See the +[batch processing patterns](/design-patterns#batch-processing-patterns). + +**Internal agent platforms.** Teams building a shared runtime for many agents reuse Temporal's Worker and Task Queue +primitives instead of building their own scheduler. See the +[worker configuration patterns](/design-patterns#worker-configuration-patterns) for routing and isolating agent +workloads. + +**Model training.** Long-running training and fine-tuning jobs coordinated across GPU resources, with checkpointing +and recovery handled by Temporal's Event History instead of custom orchestration code. + +## Design patterns for AI agents + + + +Browse the full [Design Patterns catalog](/design-patterns) for more, or jump straight into the +[AI Cookbook](/ai/cookbook) for runnable code. diff --git a/docusaurus.config.js b/docusaurus.config.js index d9a30e605f..55cdb7063a 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -113,9 +113,9 @@ module.exports = async function createConfigAsync() { right: 'left', }, { - label: 'AI Cookbook', - to: '/ai-cookbook', - activeBasePath: 'ai-cookbook', + label: 'Durable AI', + to: '/ai', + activeBasePath: 'ai', position: 'left', }, // hide this for now, making this a soft-launch @@ -375,7 +375,7 @@ module.exports = async function createConfigAsync() { { id: 'ai-cookbook', path: 'ai-cookbook', - routeBasePath: 'ai-cookbook', // published at /ai-cookbook/* ✅ + routeBasePath: 'ai/cookbook', // published at /ai/cookbook/* ✅ sidebarPath: false, // no left nav for these pages ✅ // optional polish: showLastUpdateAuthor: true, @@ -395,7 +395,7 @@ module.exports = async function createConfigAsync() { require.resolve('./plugins/cookbook-index'), { docsDir: 'ai-cookbook', // change if your folder differs - routeBasePath: 'ai-cookbook', // change if you use a different base + routeBasePath: 'ai/cookbook', // change if you use a different base }, ], [ @@ -403,7 +403,7 @@ module.exports = async function createConfigAsync() { { targets: [ { docsDir: 'docs', routeBasePath: '/' }, - { docsDir: 'ai-cookbook', routeBasePath: 'ai-cookbook' }, + { docsDir: 'ai-cookbook', routeBasePath: 'ai/cookbook' }, ], llmsTxt: { siteUrl: 'https://docs.temporal.io', @@ -447,7 +447,7 @@ module.exports = async function createConfigAsync() { { path: 'best-practices', title: 'Best Practices', description: 'Recommended patterns for Temporal' }, { path: 'design-patterns', title: 'Design Patterns', description: 'Reusable Workflow and Activity patterns for common orchestration problems.' }, { path: 'guides', title: 'Guides', description: 'End-to-end walkthroughs that solve a specific problem with Temporal.' }, - { path: 'ai-cookbook', title: 'AI Cookbook', description: 'Runnable examples for building AI and agent applications with Temporal.' }, + { path: 'ai/cookbook', title: 'AI Cookbook', description: 'Runnable examples for building AI and agent applications with Temporal.' }, { path: 'demos', title: 'Interactive Demos', description: 'Browser-based interactive demos. These pages are visual tools rather than prose documentation.' }, ], }, @@ -458,7 +458,7 @@ module.exports = async function createConfigAsync() { { targets: [ { docsDir: 'docs', routeBasePath: '/' }, - { docsDir: 'ai-cookbook', routeBasePath: 'ai-cookbook', footerText: 'AI COOKBOOK' }, + { docsDir: 'ai-cookbook', routeBasePath: 'ai/cookbook', footerText: 'AI COOKBOOK' }, ], }, ], diff --git a/plugins/cookbook-index/index.js b/plugins/cookbook-index/index.js index aa3fc9f2e7..722161aed9 100644 --- a/plugins/cookbook-index/index.js +++ b/plugins/cookbook-index/index.js @@ -102,7 +102,7 @@ console.log('[cookbook-index] init with docsDir:', options.docsDir); setGlobalData({ items: content.items }); }, - // The /ai-cookbook landing page (src/pages/ai-cookbook.tsx) is a plain + // The /ai/cookbook landing page (src/pages/ai/cookbook.tsx) is a plain // React page, not an MDX doc, so it's invisible to plugins/markdown-pages // (which only walks docsDir trees). It links to a markdown alternate // () same as every recipe @@ -127,8 +127,10 @@ console.log('[cookbook-index] init with docsDir:', options.docsDir); '', ]; - fs.writeFileSync(path.join(outDir, 'ai-cookbook.md'), lines.join('\n')); - console.log(`[cookbook-index] Generated ai-cookbook.md index (${sorted.length} recipe(s))`); + const aiCookbookMdDir = path.join(outDir, 'ai'); + fs.mkdirSync(aiCookbookMdDir, { recursive: true }); + fs.writeFileSync(path.join(aiCookbookMdDir, 'cookbook.md'), lines.join('\n')); + console.log(`[cookbook-index] Generated ai/cookbook.md index (${sorted.length} recipe(s))`); // Same reasoning as the .md file above: this page is invisible to // plugins/og-image's docsDir walk, so nothing else renders it a card. diff --git a/readme/INFORMATION-ARCHITECTURE.md b/readme/INFORMATION-ARCHITECTURE.md index a4f946f347..02af70c320 100644 --- a/readme/INFORMATION-ARCHITECTURE.md +++ b/readme/INFORMATION-ARCHITECTURE.md @@ -90,6 +90,17 @@ This document describes the purpose, audience, and content type for each top-lev - **Content type:** Reference - **Description:** Alphabetical listing of Temporal-specific terms. Each entry should be a concise definition with a link to the page that covers the concept in depth. +## Durable AI + +- **Audience:** Developers and architects building AI applications and agents on Temporal. +- **Content type:** Explanation with curated links. +- **Description:** Landing page (`/ai`) for building AI systems with Temporal — agents, processing pipelines, + internal agent platforms, and model training. Links out to the AI Cookbook (`/ai/cookbook`), relevant Design + Patterns, and SDK agent-framework integrations rather than duplicating their content. + + Distinct from "Develop with AI" below: this section is about using Temporal to build AI products, not about using + AI tooling to write Temporal code. Don't merge the two. + ## Develop with AI - **Audience:** Developers using AI coding assistants who want Temporal-aware tooling. diff --git a/sidebars.js b/sidebars.js index cbcb3f767e..c98e3ce49c 100644 --- a/sidebars.js +++ b/sidebars.js @@ -1786,6 +1786,15 @@ module.exports = { }, ], }, + { + type: 'category', + label: 'Durable AI', + collapsed: true, + link: { type: 'doc', id: 'ai/index' }, + items: [ + { type: 'link', label: 'AI Cookbook', href: '/ai/cookbook' }, + ], + }, { type: 'category', label: 'References', diff --git a/src/components/Cookbook/DocItem/CookbookCategoryIndex.tsx b/src/components/Cookbook/DocItem/CookbookCategoryIndex.tsx index 908ad72964..8412ad2a4e 100644 --- a/src/components/Cookbook/DocItem/CookbookCategoryIndex.tsx +++ b/src/components/Cookbook/DocItem/CookbookCategoryIndex.tsx @@ -5,9 +5,9 @@ import Original from '@theme-original/DocCategoryGeneratedIndexPage'; export default function CookbookCategoryIndex(props: Props) { const {categoryGeneratedIndex} = props; - const isRoot = categoryGeneratedIndex.permalink.replace(/\/+$/, '').endsWith('/ai-cookbook'); + const isRoot = categoryGeneratedIndex.permalink.replace(/\/+$/, '').endsWith('/ai/cookbook'); - // Root /ai-cookbook uses your custom React page (no MDX, no DocCardList) + // Root /ai/cookbook uses your custom React page (no MDX, no DocCardList) if (isRoot) return ; // Everything else (including /docs) stays default diff --git a/src/components/Cookbook/DocItem/CookbookDocItem.tsx b/src/components/Cookbook/DocItem/CookbookDocItem.tsx index ddac7daa6f..4ecfc6b686 100644 --- a/src/components/Cookbook/DocItem/CookbookDocItem.tsx +++ b/src/components/Cookbook/DocItem/CookbookDocItem.tsx @@ -194,7 +194,7 @@ function InnerCookbookDocItem({ content, tags }: CookbookDocItemProps) {
  • - + AI Cookbook
  • diff --git a/src/pages/ai-cookbook.tsx b/src/pages/ai/cookbook.tsx similarity index 94% rename from src/pages/ai-cookbook.tsx rename to src/pages/ai/cookbook.tsx index fa0eeffa85..27cff38903 100644 --- a/src/pages/ai-cookbook.tsx +++ b/src/pages/ai/cookbook.tsx @@ -17,7 +17,7 @@ export default function CookbookLanding() { React page, not an MDX doc, so it's outside the automatic per-doc pipeline and needs these tags set explicitly. */} - + diff --git a/tests/playwright/cookbook-home.spec.ts b/tests/playwright/cookbook-home.spec.ts index 8e18f47792..7d56ac8190 100644 --- a/tests/playwright/cookbook-home.spec.ts +++ b/tests/playwright/cookbook-home.spec.ts @@ -15,7 +15,7 @@ const collectTileData = async (locator: Locator) => { test.describe('Cookbook home', () => { test('renders cookbook tiles with expected metadata and layout on desktop', async ({ page }) => { - await page.goto('/ai-cookbook'); + await page.goto('/ai/cookbook'); await expect(page.getByTestId('cookbook-hero')).toBeVisible(); @@ -27,7 +27,7 @@ test.describe('Cookbook home', () => { for (const { title, href } of tileData) { expect(title).not.toEqual(''); - expect(href).toMatch(/\/ai-cookbook\//); + expect(href).toMatch(/\/ai\/cookbook\//); } const gridMetrics = await tiles.evaluateAll((elements) => { @@ -59,7 +59,7 @@ test.describe('Cookbook home', () => { test('stacks tiles into a single column on mobile viewports', async ({ page }) => { await page.setViewportSize({ width: 600, height: 900 }); - await page.goto('/ai-cookbook'); + await page.goto('/ai/cookbook'); const tiles = page.locator('.tile'); await expect(tiles).not.toHaveCount(0); diff --git a/vercel.json b/vercel.json index 33279f7763..92f16dcdc7 100644 --- a/vercel.json +++ b/vercel.json @@ -188,22 +188,32 @@ }, { "source": "/ai-cookbook/basic-python", - "destination": "/ai-cookbook/hello-world-openai-responses-python", + "destination": "/ai/cookbook/hello-world-openai-responses-python", "permanent": true }, { "source": "/ai-cookbook/deep-research-python", - "destination": "/ai-cookbook/basic-openai-python", + "destination": "/ai/cookbook/basic-openai-python", "permanent": true }, { "source": "/ai-cookbook/durable-agent-with-tools", - "destination": "/ai-cookbook/openai-agents-sdk-python", + "destination": "/ai/cookbook/openai-agents-sdk-python", "permanent": true }, { "source": "/ai-cookbook/tool-calling-python", - "destination": "/ai-cookbook/tool-call-openai-python", + "destination": "/ai/cookbook/tool-call-openai-python", + "permanent": true + }, + { + "source": "/ai-cookbook", + "destination": "/ai/cookbook", + "permanent": true + }, + { + "source": "/ai-cookbook/:path*", + "destination": "/ai/cookbook/:path*", "permanent": true }, { From e85ef30e1d3d7f6e994b37652850fe957e8a6796 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 17 Aug 2026 14:22:36 -0700 Subject: [PATCH 02/15] Make the integration grid & guide grid persist their filters in the URL --- docs/ai/index.mdx | 4 +- src/components/GuidesGrid/index.tsx | 9 +-- src/components/IntegrationsGrid/index.tsx | 9 +-- src/components/hooks/useQueryStringFilters.ts | 71 +++++++++++++++++++ 4 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 src/components/hooks/useQueryStringFilters.ts diff --git a/docs/ai/index.mdx b/docs/ai/index.mdx index a71e9714cf..b0b6922af2 100644 --- a/docs/ai/index.mdx +++ b/docs/ai/index.mdx @@ -24,9 +24,9 @@ Looking to use an AI coding assistant to write Temporal code instead? See [Devel description: "Runnable, step-by-step recipes for building AI systems and agents with Temporal: tool calling, MCP, structured output, human-in-the-loop, and more.", }, { - href: "/integrations", + href: "/integrations?tags=Agent+framework", title: "SDK integrations", - description: "Framework integrations for OpenAI Agents SDK, LangGraph, Google ADK, Vercel AI SDK, Strands Agents, Spring AI, and more. Filter by the \"Agent framework\" tag.", + description: "Framework integrations for OpenAI Agents SDK, LangGraph, Google ADK, Vercel AI SDK, Strands Agents, Spring AI, and more.", }, { href: "/design-patterns", diff --git a/src/components/GuidesGrid/index.tsx b/src/components/GuidesGrid/index.tsx index 5abe51710e..f738a29f2a 100644 --- a/src/components/GuidesGrid/index.tsx +++ b/src/components/GuidesGrid/index.tsx @@ -3,6 +3,7 @@ import Link from "@docusaurus/Link"; import clsx from "clsx"; import guides, { type SDK, type Guide } from "./guides-data"; import SdkSvg from "../elements/SdkSvgs/SdkSvg"; +import { useQueryStringFilters } from "../hooks/useQueryStringFilters"; import styles from "./GuidesGrid.module.css"; const ALL_SDKS: SDK[] = ["Python", "TypeScript", "Go"]; @@ -24,11 +25,7 @@ const FILTER_GROUPS = [ { label: "SDK", key: "sdks" as const, options: ALL_SDK_FILTERS as string[] }, { label: "Tag", key: "tags" as const, options: ALL_TAGS }, ]; - -type FilterState = { - sdks: SdkFilter[]; - tags: string[]; -}; +const FILTER_KEYS = ["sdks", "tags"] as const; function isExternal(href: string): boolean { return href.startsWith("http://") || href.startsWith("https://"); @@ -110,7 +107,7 @@ export default function GuidesGrid({ defaultSdks = [], }: GuidesGridProps) { const [query, setQuery] = useState(""); - const [filters, setFilters] = useState({ + const [filters, setFilters] = useQueryStringFilters(FILTER_KEYS, { sdks: defaultSdks, tags: [], }); diff --git a/src/components/IntegrationsGrid/index.tsx b/src/components/IntegrationsGrid/index.tsx index 5c9018307e..cbfd2a751b 100644 --- a/src/components/IntegrationsGrid/index.tsx +++ b/src/components/IntegrationsGrid/index.tsx @@ -8,6 +8,7 @@ import Link from "@docusaurus/Link"; import clsx from "clsx"; import integrations, { type SDK, type Integration } from "./integrations-data"; import SdkSvg from "../elements/SdkSvgs/SdkSvg"; +import { useQueryStringFilters } from "../hooks/useQueryStringFilters"; import styles from "./IntegrationsGrid.module.css"; const ALL_SDKS: SDK[] = ["Go", "Java", "Python", "Ruby", "TypeScript"]; @@ -31,11 +32,7 @@ const FILTER_GROUPS = [ { label: "SDK", key: "sdks" as const, options: ALL_SDK_FILTERS as string[] }, { label: "Tag", key: "tags" as const, options: ALL_TAGS }, ]; - -type FilterState = { - sdks: SdkFilter[]; - tags: string[]; -}; +const FILTER_KEYS = ["sdks", "tags"] as const; function isExternal(href: string): boolean { return href.startsWith("http://") || href.startsWith("https://"); @@ -119,7 +116,7 @@ export default function IntegrationsGrid({ defaultSdks = [], }: IntegrationsGridProps) { const [query, setQuery] = useState(""); - const [filters, setFilters] = useState({ + const [filters, setFilters] = useQueryStringFilters(FILTER_KEYS, { sdks: defaultSdks, tags: [], }); diff --git a/src/components/hooks/useQueryStringFilters.ts b/src/components/hooks/useQueryStringFilters.ts new file mode 100644 index 0000000000..f32020bdbd --- /dev/null +++ b/src/components/hooks/useQueryStringFilters.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react'; +import { useHistory } from '@docusaurus/router'; +import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; + +type FilterState = Record; +type FilterUpdater = (prev: FilterState) => FilterState; + +function parseListParam(search: string, key: string): string[] | null { + const raw = new URLSearchParams(search).get(key); + if (!raw) return null; + return raw.split(',').map((v) => v.trim()).filter(Boolean); +} + +/** + * Filter state backed by comma-separated query string params (one param per + * key, e.g. ?tags=Agent+framework,MCP&sdks=Python), so a filtered view like + * IntegrationsGrid or GuidesGrid can be deep-linked with a filter + * pre-selected, and the current filters stay reflected in a shareable URL. + * + * The URL is adopted once on mount (covering the deep-link case) and from + * then on state drives the URL, not the other way around — filter changes use + * history.replace so clicking through pills doesn't spam the back button. + */ +export function useQueryStringFilters( + keys: readonly K[], + defaults: FilterState, +): [FilterState, (updater: FilterUpdater) => void] { + const history = useHistory(); + const [state, setState] = useState>(defaults); + + useIsomorphicLayoutEffect(() => { + setState((prev) => { + let changed = false; + const next = { ...prev }; + for (const key of keys) { + const fromUrl = parseListParam(history.location.search, key); + if (fromUrl) { + next[key] = fromUrl; + changed = true; + } + } + return changed ? next : prev; + }); + // Adopt the URL's filters once, right after hydration. Deliberately + // mount-only: afterward the URL mirrors state via history.replace below, + // it doesn't drive it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const update = useCallback( + (updater: FilterUpdater) => { + setState((prev) => { + const next = updater(prev); + const searchParams = new URLSearchParams(history.location.search); + for (const key of keys) { + const values = next[key]; + if (values.length > 0) { + searchParams.set(key, values.join(',')); + } else { + searchParams.delete(key); + } + } + history.replace({ ...history.location, search: searchParams.toString() }); + return next; + }); + }, + [history, keys], + ); + + return [state, update]; +} From 15ec29e9bc644f578e1d3d54c7ef9d5510ab5918 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 17 Aug 2026 15:24:43 -0700 Subject: [PATCH 03/15] Refactor AI Cookbook components and enhance integration with new CookbookPreview and useCookbookItems hooks - Updated .gitignore to properly ignore the ai-cookbook directory. - Introduced CookbookPreview component to display a preview of AI Cookbook recipes. - Created useCookbookItems hook for consistent recipe data retrieval across components. - Refactored CookbookHome to utilize the new hook and GridCard for rendering. - Enhanced IntegrationsGrid to support defaultTags filtering. - Added new high-priority and no-priority recipe fixtures for testing. - Updated styles for Cookbook components to ensure consistent layout and design. --- .gitignore | 3 +- docs/ai/index.mdx | 32 ++- fixtures/ai-cookbook/high-priority.mdx | 7 + fixtures/ai-cookbook/index.mdx | 7 + fixtures/ai-cookbook/no-priority-a.mdx | 6 + fixtures/ai-cookbook/no-priority-b.mdx | 6 + .../component-handlers/cookbook-preview.mjs | 111 ++++++++++ scripts/component-handlers/integrations.mjs | 29 ++- scripts/mdx-to-md.mjs | 28 ++- .../Cookbook/Home/CookbookHome.module.css | 23 +- src/components/Cookbook/Home/CookbookHome.tsx | 195 ++--------------- .../Preview/CookbookPreview.module.css | 5 + .../Cookbook/Preview/CookbookPreview.tsx | 38 ++++ src/components/Cookbook/index.js | 1 + src/components/Cookbook/useCookbookItems.ts | 206 ++++++++++++++++++ .../GuidesGrid/GuidesGrid.module.css | 98 +-------- src/components/GuidesGrid/index.tsx | 134 +++++------- .../IntegrationsGrid.module.css | 98 +-------- src/components/IntegrationsGrid/index.tsx | 150 +++++-------- .../elements/GridCard/GridCard.module.css | 103 +++++++++ src/components/elements/GridCard/GridCard.tsx | 73 +++++++ .../elements/SdkSvgs/sdkBlockNames.ts | 14 ++ src/components/elements/index.js | 1 + src/components/hooks/useQueryStringFilters.ts | 49 +++-- tests/playwright/cookbook-home.spec.ts | 21 +- tests/test-mdx-to-md.mjs | 84 ++++++- 26 files changed, 900 insertions(+), 622 deletions(-) create mode 100644 fixtures/ai-cookbook/high-priority.mdx create mode 100644 fixtures/ai-cookbook/index.mdx create mode 100644 fixtures/ai-cookbook/no-priority-a.mdx create mode 100644 fixtures/ai-cookbook/no-priority-b.mdx create mode 100644 scripts/component-handlers/cookbook-preview.mjs create mode 100644 src/components/Cookbook/Preview/CookbookPreview.module.css create mode 100644 src/components/Cookbook/Preview/CookbookPreview.tsx create mode 100644 src/components/Cookbook/useCookbookItems.ts create mode 100644 src/components/elements/GridCard/GridCard.module.css create mode 100644 src/components/elements/GridCard/GridCard.tsx create mode 100644 src/components/elements/SdkSvgs/sdkBlockNames.ts diff --git a/.gitignore b/.gitignore index d3d08d49a3..6341657974 100644 --- a/.gitignore +++ b/.gitignore @@ -55,5 +55,6 @@ test-results/* # screenshots screenshots # Ignore cookbook recipes since they are synced from the remote repo -ai-cookbook +# (anchored to the root — fixtures/ai-cookbook/ is a real, tracked test fixture) +/ai-cookbook scripts/mermaid-compare/* diff --git a/docs/ai/index.mdx b/docs/ai/index.mdx index b0b6922af2..522fe21906 100644 --- a/docs/ai/index.mdx +++ b/docs/ai/index.mdx @@ -7,6 +7,8 @@ slug: /ai --- import PatternCards from '@site/src/components/PatternCards'; +import IntegrationsGrid from '@site/src/components/IntegrationsGrid'; +import { CookbookPreview } from '@site/src/components'; Temporal gives AI applications and agents Durable Execution: a Workflow resumes automatically after a crash, a network timeout, or a multi-day wait for a human to approve a step. Use it to keep long-running agent loops, LLM tool @@ -15,25 +17,19 @@ machines. Looking to use an AI coding assistant to write Temporal code instead? See [Develop with AI](/with-ai). -## Start here +## AI Cookbook - +Runnable, step-by-step recipes for building AI systems and agents with Temporal: tool calling, MCP, structured +output, human-in-the-loop, and more. + + + +## Agent framework integrations + +Temporal integrations for the SDKs and frameworks teams use to build agents. This view is pre-filtered to agent +frameworks — browse [every integration](/integrations) for the full catalog. + + ## Use cases diff --git a/fixtures/ai-cookbook/high-priority.mdx b/fixtures/ai-cookbook/high-priority.mdx new file mode 100644 index 0000000000..6cbea9e1a5 --- /dev/null +++ b/fixtures/ai-cookbook/high-priority.mdx @@ -0,0 +1,7 @@ +--- +title: High priority recipe +description: This one should sort first. +priority: 900 +--- + +Fixture content. diff --git a/fixtures/ai-cookbook/index.mdx b/fixtures/ai-cookbook/index.mdx new file mode 100644 index 0000000000..bbf0a770e9 --- /dev/null +++ b/fixtures/ai-cookbook/index.mdx @@ -0,0 +1,7 @@ +--- +id: cookbook +title: AI Cookbook +description: Should be excluded from readCookbookRecipes as the index page. +--- + +Fixture content. diff --git a/fixtures/ai-cookbook/no-priority-a.mdx b/fixtures/ai-cookbook/no-priority-a.mdx new file mode 100644 index 0000000000..4cf7d95afa --- /dev/null +++ b/fixtures/ai-cookbook/no-priority-a.mdx @@ -0,0 +1,6 @@ +--- +title: A recipe +description: Also no priority. +--- + +Fixture content. diff --git a/fixtures/ai-cookbook/no-priority-b.mdx b/fixtures/ai-cookbook/no-priority-b.mdx new file mode 100644 index 0000000000..558939be1a --- /dev/null +++ b/fixtures/ai-cookbook/no-priority-b.mdx @@ -0,0 +1,6 @@ +--- +title: B recipe +description: No priority set. +--- + +Fixture content. diff --git a/scripts/component-handlers/cookbook-preview.mjs b/scripts/component-handlers/cookbook-preview.mjs new file mode 100644 index 0000000000..9bf67b3102 --- /dev/null +++ b/scripts/component-handlers/cookbook-preview.mjs @@ -0,0 +1,111 @@ +/** + * component-handlers/cookbook-preview.mjs + * + * Handler for . + * Component: src/components/Cookbook/Preview/CookbookPreview.tsx. + * + * CookbookPreview renders, in the browser, a strip of the top-N AI Cookbook + * recipes (via useCookbookItems, sourced from plugins/cookbook-index's + * plugin data) plus a "Browse all recipes" link. The LLM markdown pipeline + * runs outside a live Docusaurus plugin instance, so instead of reaching + * into that plugin's data we read the same ai-cookbook/*.mdx front matter + * directly and mirror the sort plugins/cookbook-index/index.js's postBuild + * uses for its own generated index: priority (front matter, descending), + * then title alphabetically. Keep both in sync if either changes. + * + * Degrades gracefully: if the recipes directory can't be resolved (e.g. no + * projectRoot in a unit test) it returns a short placeholder and pushes a + * warning. + */ + +import { readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { join, relative } from "path"; +import matter from "gray-matter"; + +const RECIPES_REL = "ai-cookbook"; + +function walk(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + if (statSync(full).isDirectory()) return walk(full); + return /\.(md|mdx)$/i.test(name) ? [full] : []; + }); +} + +/** + * Read + sort every AI Cookbook recipe the way the site's own cookbook-index + * plugin does for its own generated index (see plugins/cookbook-index/index.js). + * @param {string} projectRoot + */ +export function readCookbookRecipes(projectRoot) { + const docsDir = join(projectRoot, RECIPES_REL); + const items = walk(docsDir) + .map((file) => { + const { data } = matter(readFileSync(file, "utf8")); + const rel = relative(docsDir, file).replace(/\\/g, "/").replace(/\.(md|mdx)$/i, ""); + if (data.id === "cookbook" || /(^|\/)index$/i.test(rel)) return null; + + const slug = (data.slug || data.id || rel).replace(/^\/+/, ""); + const title = data.title || slug; + const description = data.description || ""; + const rawPriority = data.priority; + const priority = + typeof rawPriority === "number" + ? rawPriority + : Number.isFinite(Number(rawPriority)) + ? Number(rawPriority) + : undefined; + + return { title, description, permalink: `/ai/cookbook/${slug}`, priority }; + }) + .filter(Boolean); + + return items.sort((a, b) => { + const pa = typeof a.priority === "number" ? a.priority : -Infinity; + const pb = typeof b.priority === "number" ? b.priority : -Infinity; + if (pa !== pb) return pb - pa; + return a.title.localeCompare(b.title); + }); +} + +/** + * Render a list of resolved recipes as a Markdown list. + * Each entry: - [title](permalink) — description + */ +export function cookbookRecipesToMarkdownList(items) { + return items + .map((it) => `- [${it.title}](${it.permalink})${it.description ? ` — ${it.description}` : ""}`) + .join("\n"); +} + +/** + * Resolve a to a Markdown list matching the + * component's default view (the top `limit` recipes), plus the "Browse all + * recipes" link the live component also renders below the strip. + * + * @param {number} limit + * @param {object} options + * @param {string} [options.projectRoot] + * @param {string[]} [options.warnings] + * @param {string} [options.sourceFile] + * @returns {string} + */ +export function cookbookPreviewToMarkdown(limit, options = {}) { + const { projectRoot, warnings, sourceFile = "" } = options; + + if (!projectRoot) { + return ""; + } + + try { + const items = readCookbookRecipes(projectRoot).slice(0, limit); + if (items.length === 0) { + return ""; + } + return `${cookbookRecipesToMarkdownList(items)}\n\n[Browse all recipes](/ai/cookbook)`; + } catch (err) { + if (warnings) warnings.push(`[${sourceFile}] CookbookPreview parse error — ${err.message}`); + return ""; + } +} diff --git a/scripts/component-handlers/integrations.mjs b/scripts/component-handlers/integrations.mjs index e36e111994..c16e598944 100644 --- a/scripts/component-handlers/integrations.mjs +++ b/scripts/component-handlers/integrations.mjs @@ -1,16 +1,19 @@ /** * component-handlers/integrations.mjs * - * Handler for . + * Handler for . * Component: src/components/IntegrationsGrid/index.tsx (carries a matching comment). * * IntegrationsGrid renders, in the browser, an interactive filterable grid of * integrations sourced from src/components/IntegrationsGrid/integrations-data.json. * For the LLM markdown pipeline we resolve that JSON at build time and emit a - * Markdown list reflecting the grid's *default* view: when `defaultSdks` is set, - * only integrations for those SDKs are shown (mirroring the component's initial - * SDK filter); otherwise all integrations are listed. Results are sorted by name, - * matching the component. + * Markdown list reflecting the grid's *default* view: when `defaultSdks` and/or + * `defaultTags` are set, only matching integrations are shown (mirroring the + * component's initial filters — an item must match one of `defaultSdks` if given, + * AND one of `defaultTags` if given); otherwise all integrations are listed. + * Results are sorted by name, matching the component. `hideSdkFilter`/ + * `hideTagFilter` only affect which pills render in the browser, not the + * filtered set, so they're irrelevant here. * * Degrades gracefully: if the data file can't be resolved (e.g. no projectRoot * in a unit test) it returns a short placeholder and pushes a warning. @@ -40,26 +43,32 @@ export function integrationsToMarkdownList(integrations) { * Filter + sort integrations the way the grid does for its default view. * @param {Array} all - all integration objects * @param {string[]} defaultSdks - SDKs pre-selected by the `defaultSdks` prop + * @param {string[]} defaultTags - tags pre-selected by the `defaultTags` prop */ -export function selectIntegrations(all, defaultSdks = []) { +export function selectIntegrations(all, defaultSdks = [], defaultTags = []) { let result = all; if (defaultSdks.length > 0) { - result = all.filter((it) => it.sdk && defaultSdks.includes(it.sdk)); + result = result.filter((it) => it.sdk && defaultSdks.includes(it.sdk)); + } + if (defaultTags.length > 0) { + result = result.filter((it) => (it.tags || []).some((t) => defaultTags.includes(t))); } return [...result].sort((a, b) => a.name.localeCompare(b.name)); } /** - * Resolve an to a Markdown list. + * Resolve an to a + * Markdown list. * * @param {string[]} defaultSdks + * @param {string[]} defaultTags * @param {object} options * @param {string} [options.projectRoot] * @param {string[]} [options.warnings] * @param {string} [options.sourceFile] * @returns {string} */ -export function integrationsGridToMarkdown(defaultSdks = [], options = {}) { +export function integrationsGridToMarkdown(defaultSdks = [], defaultTags = [], options = {}) { const { projectRoot, warnings, sourceFile = "" } = options; if (!projectRoot) { @@ -74,7 +83,7 @@ export function integrationsGridToMarkdown(defaultSdks = [], options = {}) { try { const all = JSON.parse(readFileSync(fullPath, "utf8")); - const selected = selectIntegrations(all, defaultSdks); + const selected = selectIntegrations(all, defaultSdks, defaultTags); if (selected.length === 0) { return ""; } diff --git a/scripts/mdx-to-md.mjs b/scripts/mdx-to-md.mjs index 45a89293a7..18b61f0e59 100644 --- a/scripts/mdx-to-md.mjs +++ b/scripts/mdx-to-md.mjs @@ -35,6 +35,7 @@ import { jsonTableToMarkdown } from "./component-handlers/data-tables.mjs"; import { integrationsGridToMarkdown } from "./component-handlers/integrations.mjs"; +import { cookbookPreviewToMarkdown } from "./component-handlers/cookbook-preview.mjs"; import { heroCardToMarkdown, heroHeadlineToMarkdown } from "./component-handlers/hero.mjs"; import { parseCardItems, cardsToMarkdown } from "./component-handlers/cards.mjs"; import { sdkOverviewCardsToMarkdown } from "./component-handlers/sdk-overview-cards.mjs"; @@ -70,6 +71,7 @@ export const COMPONENT_REGISTRY = { SetupStep: "setup-step", JsonTable: "json-table", IntegrationsGrid: "integrations-grid", + CookbookPreview: "cookbook-preview", SdkOverviewCards: "sdk-overview-cards", ViewSourceCodeNotice: "view-source-code-notice", @@ -211,6 +213,15 @@ export function extractProp(tagStr, propName) { return null; } +/** + * Extract a bare numeric JSX prop, e.g. extractNumberProp('', 'limit') => 4. + * Returns undefined if the prop isn't present or isn't a plain number literal. + */ +export function extractNumberProp(tagStr, propName) { + const m = tagStr.match(new RegExp(`${propName}=\\{(-?\\d+(?:\\.\\d+)?)\\}`)); + return m ? Number(m[1]) : undefined; +} + /** * Parse a JSX values/items array prop used by Tabs: * values={[{label: 'Go', value: 'go'}, ...]} @@ -1231,7 +1242,22 @@ export function transformMdx(mdxContent, options = {}) { tag += " " + lines[i].trim(); } const defaultSdks = parseStringArrayProp(tag, "defaultSdks"); - const md = integrationsGridToMarkdown(defaultSdks, { projectRoot, warnings, sourceFile }); + const defaultTags = parseStringArrayProp(tag, "defaultTags"); + const md = integrationsGridToMarkdown(defaultSdks, defaultTags, { projectRoot, warnings, sourceFile }); + outputLines.push(md); + outputLines.push(""); + continue; + } + + // --- CookbookPreview (self-closing) → resolved Markdown list --- + if (state === State.NORMAL && /^\s*/.test(tag) && i + 1 < lines.length) { + i++; + tag += " " + lines[i].trim(); + } + const limit = extractNumberProp(tag, "limit") ?? 4; + const md = cookbookPreviewToMarkdown(limit, { projectRoot, warnings, sourceFile }); outputLines.push(md); outputLines.push(""); continue; diff --git a/src/components/Cookbook/Home/CookbookHome.module.css b/src/components/Cookbook/Home/CookbookHome.module.css index 4c9720a953..114c888d15 100644 --- a/src/components/Cookbook/Home/CookbookHome.module.css +++ b/src/components/Cookbook/Home/CookbookHome.module.css @@ -38,14 +38,6 @@ width: 100%; display: flex; justify-content: center; - --cookbook-tile-border-color: rgba(15, 23, 42, 0.08); - --cookbook-tile-shadow: 0 0 1rem 0.5rem rgba(139, 143, 150, 0.2); -} - - -:global([data-theme='dark']) .page { - --cookbook-tile-border-color: rgba(226, 232, 240, 0.5); - --cookbook-tile-shadow: 0 0 1rem 0.5rem rgba(142, 145, 153, 0.78); } .inner { @@ -85,18 +77,11 @@ color: var(--ifm-color-emphasis-700); } -/* Tiles grid: force 3-up from md+ */ +/* Same card-grid layout as IntegrationsGrid/CookbookPreview, so recipe cards + look and behave identically everywhere they appear. */ .grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--ifm-spacing-lg, var(--cbk-space-lg, 24px)); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; width: 100%; } - -.cell { -} - -/* Mobile: stack */ -@media (max-width: 768px) { - .grid { grid-template-columns: 1fr; } -} diff --git a/src/components/Cookbook/Home/CookbookHome.tsx b/src/components/Cookbook/Home/CookbookHome.tsx index a7d5f2136f..9de42934a4 100644 --- a/src/components/Cookbook/Home/CookbookHome.tsx +++ b/src/components/Cookbook/Home/CookbookHome.tsx @@ -1,186 +1,14 @@ import React from 'react'; -import { useAllDocsData } from '@docusaurus/plugin-content-docs/client'; import styles from './CookbookHome.module.css'; -import useGlobalData, { usePluginData } from '@docusaurus/useGlobalData'; import clsx from 'clsx'; -import Tile from '../../elements/Tile/Tile'; +import GridCard from '../../elements/GridCard/GridCard'; +import SdkSvg from '../../elements/SdkSvgs/SdkSvg'; +import { SDK_BLOCK_NAMES } from '../../elements/SdkSvgs/sdkBlockNames'; +import { useCookbookItems } from '../useCookbookItems'; import { AI_COOKBOOK_BLURB } from '@site/src/constants/aiCookbookBlurb'; -type CookbookItem = { - id: string; - title: string; - description: string; - tags: string[]; - permalink: string; - source?: string; - priority?: number; -}; - -type DocMeta = { - id: string; - unversionedId?: string; - title?: string; - description?: string; - frontMatter?: { - title?: string; - description?: string; - tags?: any[]; - last_updated?: unknown; - last_updated_at?: unknown; - }; - tags?: { label: string }[]; - permalink?: string; - lastUpdatedAt?: number | string | null; -}; - -function resolveDocMeta(item: CookbookItem, docsById: Map) { - return ( - docsById.get(item.id) ?? - docsById.get(`cookbook:${item.id}`) ?? - docsById.get(item.id.replace(/^cookbook:/, '')) ?? - null - ); -} - -function DocTile({ item, docsById }: { item: CookbookItem; docsById: Map }) { - const { id, title: pluginTitle, description: pluginDescription, tags: pluginTags, permalink: pluginPermalink } = item; - - const docMeta = resolveDocMeta(item, docsById); - - const title = docMeta?.title ?? docMeta?.frontMatter?.title ?? pluginTitle; - const description = docMeta?.description ?? docMeta?.frontMatter?.description ?? pluginDescription; - - if (!title || !description) { - throw new Error( - `Cookbook doc "${id}" missing required field(s):` + - `${!title ? ' title' : ''}` + - `${!description ? ' description' : ''}` - ); - } - - const tagsFromMeta = docMeta?.tags?.map((t: any) => t.label); - const tagsFromFrontMatter = Array.isArray(docMeta?.frontMatter?.tags) - ? docMeta.frontMatter.tags.map((t: any) => (typeof t === 'string' ? t : t?.label)).filter(Boolean) - : undefined; - const resolvedTags = (tagsFromMeta ?? tagsFromFrontMatter ?? pluginTags) as string[]; - - const href = docMeta?.permalink ?? pluginPermalink ?? '#'; - - return ; -} - export default function CookbookHome() { - const global = useGlobalData(); - console.log('[CookbookHome] plugins:', Object.keys(global?.plugins ?? {})); // should include 'cookbook-index' - - const dataAny = usePluginData('cookbook-index') as any; - const allDocsData = useAllDocsData(); - const cookbookDocs = - allDocsData?.cookbook?.versions?.find((version: any) => version?.isLast) ?? allDocsData?.cookbook?.versions?.[0]; - - const docsById = React.useMemo(() => { - const map = new Map(); - const docs: DocMeta[] = cookbookDocs?.docs ?? []; - docs.forEach((doc) => { - map.set(doc.id, doc); - map.set(`cookbook:${doc.id}`, doc); - if (doc.unversionedId) { - map.set(doc.unversionedId, doc); - map.set(`cookbook:${doc.unversionedId}`, doc); - } - }); - return map; - }, [cookbookDocs]); - - const raw = (dataAny?.items ?? []) as (CookbookItem | null | undefined)[]; - raw.forEach((x, i) => { - if (!x || typeof (x as any).title !== 'string') { - console.warn('[CookbookHome] invalid item at index', i, x); - } - }); - - const items: CookbookItem[] = raw.filter( - (x): x is CookbookItem => !!x && typeof x === 'object' && typeof (x as any).title === 'string' - ); - - if (items.length === 0) { - throw new Error('CookbookHome: no items found by cookbook-index plugin (check server logs for [cookbook-index]).'); - } - - const normalizeTimestamp = React.useCallback((value: unknown): number | undefined => { - const normalizeNumber = (input: number): number | undefined => { - if (!Number.isFinite(input)) { - return undefined; - } - return input < 1e11 ? input * 1000 : input; - }; - - if (typeof value === 'number') { - return normalizeNumber(value); - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const numeric = Number(trimmed); - if (!Number.isNaN(numeric)) { - return normalizeNumber(numeric); - } - const parsed = Date.parse(trimmed); - return Number.isNaN(parsed) ? undefined : normalizeNumber(parsed); - } - if (value instanceof Date) { - const time = value.getTime(); - return Number.isNaN(time) ? undefined : normalizeNumber(time); - } - return undefined; - }, []); - - const getLastUpdatedTimestamp = React.useCallback( - (item: CookbookItem) => { - const meta = resolveDocMeta(item, docsById); - const frontMatterTimestampCandidates = [meta?.frontMatter?.last_updated, meta?.frontMatter?.last_updated_at]; - for (const candidate of frontMatterTimestampCandidates) { - const normalized = normalizeTimestamp(candidate); - if (typeof normalized === 'number') { - return normalized; - } - } - - const normalizedMeta = normalizeTimestamp(meta?.lastUpdatedAt ?? null); - if (typeof normalizedMeta === 'number') { - return normalizedMeta; - } - return 0; - }, - [docsById, normalizeTimestamp] - ); - - const sortedItems = React.useMemo(() => { - return [...items].sort((a, b) => { - const priorityA = typeof a.priority === 'number' && Number.isFinite(a.priority) ? a.priority : null; - const priorityB = typeof b.priority === 'number' && Number.isFinite(b.priority) ? b.priority : null; - - if (priorityA !== null && priorityB !== null) { - if (priorityA !== priorityB) { - return priorityB - priorityA; - } - } else if (priorityA !== null) { - return -1; - } else if (priorityB !== null) { - return 1; - } - - const updatedA = getLastUpdatedTimestamp(a); - const updatedB = getLastUpdatedTimestamp(b); - - if (updatedA === updatedB) { - return 0; - } - return updatedB - updatedA; - }); - }, [getLastUpdatedTimestamp, items]); + const items = useCookbookItems(); return (
    @@ -190,10 +18,15 @@ export default function CookbookHome() {

    {AI_COOKBOOK_BLURB}

    - {sortedItems.map((it) => ( -
    - -
    + {items.map((item) => ( + : undefined} + /> ))}
    diff --git a/src/components/Cookbook/Preview/CookbookPreview.module.css b/src/components/Cookbook/Preview/CookbookPreview.module.css new file mode 100644 index 0000000000..4248c38154 --- /dev/null +++ b/src/components/Cookbook/Preview/CookbookPreview.module.css @@ -0,0 +1,5 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} diff --git a/src/components/Cookbook/Preview/CookbookPreview.tsx b/src/components/Cookbook/Preview/CookbookPreview.tsx new file mode 100644 index 0000000000..7787759e08 --- /dev/null +++ b/src/components/Cookbook/Preview/CookbookPreview.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; +import GridCard from '../../elements/GridCard/GridCard'; +import SdkSvg from '../../elements/SdkSvgs/SdkSvg'; +import { SDK_BLOCK_NAMES } from '../../elements/SdkSvgs/sdkBlockNames'; +import { useCookbookItems } from '../useCookbookItems'; +import styles from './CookbookPreview.module.css'; + +type CookbookPreviewProps = { + /** Number of recipes to show, taken in the same priority/recency order as the full Cookbook. */ + limit?: number; +}; + +export default function CookbookPreview({ limit = 4 }: CookbookPreviewProps) { + const items = useCookbookItems().slice(0, limit); + + return ( +
    +
    + {items.map((item) => ( + : undefined} + /> + ))} +
    +

    + + Browse all recipes → + +

    +
    + ); +} diff --git a/src/components/Cookbook/index.js b/src/components/Cookbook/index.js index 84784abd61..b4c12a738f 100644 --- a/src/components/Cookbook/index.js +++ b/src/components/Cookbook/index.js @@ -1,3 +1,4 @@ export { default as CookbookHome } from './Home/CookbookHome' +export { default as CookbookPreview } from './Preview/CookbookPreview' export * from './DocItem/CookbookCategoryIndex' export * from './DocItem/CookbookDocItem' \ No newline at end of file diff --git a/src/components/Cookbook/useCookbookItems.ts b/src/components/Cookbook/useCookbookItems.ts new file mode 100644 index 0000000000..2ce86ad075 --- /dev/null +++ b/src/components/Cookbook/useCookbookItems.ts @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { useAllDocsData } from '@docusaurus/plugin-content-docs/client'; +import useGlobalData, { usePluginData } from '@docusaurus/useGlobalData'; +import { type SDK } from '../elements/SdkSvgs/sdkBlockNames'; + +// Recipes tag their language lowercase (e.g. `python`, `typescript`) rather +// than carrying a structured `sdk` field — this is the only place that +// distinction is inferred, so IntegrationsGrid/GuidesGrid-style SDK icons can +// show up on Cookbook cards too. +const TAG_TO_SDK: Record = { + go: 'Go', + java: 'Java', + python: 'Python', + ruby: 'Ruby', + typescript: 'TypeScript', +}; + +type CookbookIndexItem = { + id: string; + title: string; + description: string; + tags: string[]; + permalink: string; + source?: string; + priority?: number; +}; + +type DocMeta = { + id: string; + unversionedId?: string; + title?: string; + description?: string; + frontMatter?: { + title?: string; + description?: string; + tags?: any[]; + last_updated?: unknown; + last_updated_at?: unknown; + }; + tags?: { label: string }[]; + permalink?: string; + lastUpdatedAt?: number | string | null; +}; + +export type ResolvedCookbookItem = { + id: string; + title: string; + description: string; + tags: string[]; + href: string; + sdk?: SDK; +}; + +function resolveDocMeta(item: CookbookIndexItem, docsById: Map) { + return ( + docsById.get(item.id) ?? + docsById.get(`cookbook:${item.id}`) ?? + docsById.get(item.id.replace(/^cookbook:/, '')) ?? + null + ); +} + +function normalizeTimestamp(value: unknown): number | undefined { + const normalizeNumber = (input: number): number | undefined => { + if (!Number.isFinite(input)) { + return undefined; + } + return input < 1e11 ? input * 1000 : input; + }; + + if (typeof value === 'number') { + return normalizeNumber(value); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const numeric = Number(trimmed); + if (!Number.isNaN(numeric)) { + return normalizeNumber(numeric); + } + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : normalizeNumber(parsed); + } + if (value instanceof Date) { + const time = value.getTime(); + return Number.isNaN(time) ? undefined : normalizeNumber(time); + } + return undefined; +} + +function getLastUpdatedTimestamp(item: CookbookIndexItem, docsById: Map): number { + const meta = resolveDocMeta(item, docsById); + const frontMatterTimestampCandidates = [meta?.frontMatter?.last_updated, meta?.frontMatter?.last_updated_at]; + for (const candidate of frontMatterTimestampCandidates) { + const normalized = normalizeTimestamp(candidate); + if (typeof normalized === 'number') { + return normalized; + } + } + + const normalizedMeta = normalizeTimestamp(meta?.lastUpdatedAt ?? null); + return typeof normalizedMeta === 'number' ? normalizedMeta : 0; +} + +/** + * Every AI Cookbook recipe, resolved (title/description/tags/href) and sorted + * the same way CookbookHome renders them: by `priority` (front matter, higher + * first), falling back to most-recently-updated. Shared by CookbookHome (the + * full grid) and CookbookPreview (a top-N strip) so both stay in sync without + * duplicating the doc-metadata resolution and sort logic. + */ +export function useCookbookItems(): ResolvedCookbookItem[] { + const global = useGlobalData(); + console.log('[useCookbookItems] plugins:', Object.keys(global?.plugins ?? {})); // should include 'cookbook-index' + + const dataAny = usePluginData('cookbook-index') as any; + const allDocsData = useAllDocsData(); + const cookbookDocs = + allDocsData?.cookbook?.versions?.find((version: any) => version?.isLast) ?? allDocsData?.cookbook?.versions?.[0]; + + const docsById = React.useMemo(() => { + const map = new Map(); + const docs: DocMeta[] = cookbookDocs?.docs ?? []; + docs.forEach((doc) => { + map.set(doc.id, doc); + map.set(`cookbook:${doc.id}`, doc); + if (doc.unversionedId) { + map.set(doc.unversionedId, doc); + map.set(`cookbook:${doc.unversionedId}`, doc); + } + }); + return map; + }, [cookbookDocs]); + + const raw = (dataAny?.items ?? []) as (CookbookIndexItem | null | undefined)[]; + raw.forEach((x, i) => { + if (!x || typeof (x as any).title !== 'string') { + console.warn('[useCookbookItems] invalid item at index', i, x); + } + }); + + const items: CookbookIndexItem[] = raw.filter( + (x): x is CookbookIndexItem => !!x && typeof x === 'object' && typeof (x as any).title === 'string' + ); + + if (items.length === 0) { + throw new Error('useCookbookItems: no items found by cookbook-index plugin (check server logs for [cookbook-index]).'); + } + + const sortedItems = React.useMemo(() => { + return [...items].sort((a, b) => { + const priorityA = typeof a.priority === 'number' && Number.isFinite(a.priority) ? a.priority : null; + const priorityB = typeof b.priority === 'number' && Number.isFinite(b.priority) ? b.priority : null; + + if (priorityA !== null && priorityB !== null) { + if (priorityA !== priorityB) { + return priorityB - priorityA; + } + } else if (priorityA !== null) { + return -1; + } else if (priorityB !== null) { + return 1; + } + + const updatedA = getLastUpdatedTimestamp(a, docsById); + const updatedB = getLastUpdatedTimestamp(b, docsById); + return updatedB - updatedA; + }); + }, [items, docsById]); + + return React.useMemo( + () => + sortedItems.map((item) => { + const docMeta = resolveDocMeta(item, docsById); + + const title = docMeta?.title ?? docMeta?.frontMatter?.title ?? item.title; + const description = docMeta?.description ?? docMeta?.frontMatter?.description ?? item.description; + + if (!title || !description) { + throw new Error( + `useCookbookItems: cookbook doc "${item.id}" missing required field(s):` + + `${!title ? ' title' : ''}` + + `${!description ? ' description' : ''}` + ); + } + + const tagsFromMeta = docMeta?.tags?.map((t: any) => t.label); + const tagsFromFrontMatter = Array.isArray(docMeta?.frontMatter?.tags) + ? docMeta.frontMatter.tags.map((t: any) => (typeof t === 'string' ? t : t?.label)).filter(Boolean) + : undefined; + const allTags = (tagsFromMeta ?? tagsFromFrontMatter ?? item.tags) as string[]; + + // Pull the language out as an icon (matching IntegrationsGrid/ + // GuidesGrid) instead of also leaving it as a redundant badge. + const sdk = allTags.map((t) => TAG_TO_SDK[t.toLowerCase()]).find(Boolean); + const tags = sdk ? allTags.filter((t) => TAG_TO_SDK[t.toLowerCase()] !== sdk) : allTags; + + const href = docMeta?.permalink ?? item.permalink ?? '#'; + + return { id: item.id, title, description, tags, href, sdk }; + }), + [sortedItems, docsById] + ); +} diff --git a/src/components/GuidesGrid/GuidesGrid.module.css b/src/components/GuidesGrid/GuidesGrid.module.css index 2ef6497988..c9494aa30e 100644 --- a/src/components/GuidesGrid/GuidesGrid.module.css +++ b/src/components/GuidesGrid/GuidesGrid.module.css @@ -16,11 +16,6 @@ --ig-focus-shadow: 0 0 0 3px rgba(68, 76, 231, 0.15); --ig-pill-text: #475569; --ig-pill-border: rgba(15, 23, 42, 0.25); - --ig-card-bg: #fff; - --ig-card-border: rgba(15, 23, 42, 0.15); - --ig-card-hover: 0 4px 24px rgba(68, 76, 231, 0.18), 0 0 0 1px rgba(68, 76, 231, 0.12); - --ig-badge-bg: #f1f5f9; - --ig-badge-text: #475569; --ig-muted: #64748b; } @@ -33,11 +28,6 @@ --ig-focus-shadow: 0 0 0 3px rgba(124, 106, 239, 0.25); --ig-pill-text: #cbd5e1; --ig-pill-border: rgba(148, 163, 184, 0.35); - --ig-card-bg: #1a1a1a; - --ig-card-border: rgba(148, 163, 184, 0.4); - --ig-card-hover: 0 0 24px rgba(130, 90, 255, 0.25), 0 0 0 1px rgba(130, 90, 255, 0.2); - --ig-badge-bg: rgba(148, 163, 184, 0.15); - --ig-badge-text: #cbd5e1; --ig-muted: #64748b; } @@ -137,92 +127,8 @@ gap: 1rem; } -/* Card */ - -.card { - display: flex; - flex-direction: column; - padding: 1.25rem; - background: var(--ig-card-bg); - border: 1px solid var(--ig-card-border); - border-radius: 0; - text-decoration: none; - color: inherit; - transition: box-shadow 0.2s ease; -} - -.card:hover { - border-image: linear-gradient(255deg, #444ce7 0%, #b664ff 100%) 1; - box-shadow: var(--ig-card-hover); - text-decoration: none; -} - -.card:focus-visible { - outline: 2px solid var(--ig-focus-color); - outline-offset: 2px; -} - -.cardHeader { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.cardName { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--ifm-color-emphasis-900); - display: inline-flex; - align-items: center; - gap: 0.375rem; -} - -.sdkIcons { - display: flex; - align-items: center; - gap: 0.375rem; - margin-left: auto; -} - -.sdkIcons svg { - width: 25px; - height: 25px; - pointer-events: none; -} - -.externalIcon { - opacity: 0.6; - flex-shrink: 0; -} - -.cardDescription { - font-size: 0.875rem; - line-height: 1.5; - color: var(--ifm-color-emphasis-700); - margin: 0 0 0.75rem 0; - flex: 1; -} - -.cardMeta { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; - margin-top: auto; -} - -.badge { - display: inline-block; - padding: 0.2rem 0.5rem; - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - border-radius: 0; - background: var(--ig-badge-bg); - color: var(--ig-badge-text); -} +/* Card styles now live in src/components/elements/GridCard, shared with + IntegrationsGrid and CookbookPreview so grids look identical. */ /* Empty state */ diff --git a/src/components/GuidesGrid/index.tsx b/src/components/GuidesGrid/index.tsx index f738a29f2a..7a536cc189 100644 --- a/src/components/GuidesGrid/index.tsx +++ b/src/components/GuidesGrid/index.tsx @@ -1,8 +1,9 @@ import { useState, useMemo } from "react"; -import Link from "@docusaurus/Link"; import clsx from "clsx"; import guides, { type SDK, type Guide } from "./guides-data"; import SdkSvg from "../elements/SdkSvgs/SdkSvg"; +import { SDK_BLOCK_NAMES } from "../elements/SdkSvgs/sdkBlockNames"; +import GridCard from "../elements/GridCard/GridCard"; import { useQueryStringFilters } from "../hooks/useQueryStringFilters"; import styles from "./GuidesGrid.module.css"; @@ -11,12 +12,6 @@ const LANGUAGE_AGNOSTIC = "Language-agnostic"; type SdkFilter = SDK | typeof LANGUAGE_AGNOSTIC; const ALL_SDK_FILTERS: SdkFilter[] = [...ALL_SDKS, LANGUAGE_AGNOSTIC]; -const SDK_BLOCK_NAMES: Record = { - Python: "pythonBlock", - TypeScript: "typeScriptBlock", - Go: "goLangBlock", -}; - const ALL_TAGS = Array.from( new Set(guides.flatMap((i) => i.tags)), ).sort(); @@ -25,11 +20,6 @@ const FILTER_GROUPS = [ { label: "SDK", key: "sdks" as const, options: ALL_SDK_FILTERS as string[] }, { label: "Tag", key: "tags" as const, options: ALL_TAGS }, ]; -const FILTER_KEYS = ["sdks", "tags"] as const; - -function isExternal(href: string): boolean { - return href.startsWith("http://") || href.startsWith("https://"); -} function SearchIcon() { return ( @@ -45,53 +35,15 @@ function SearchIcon() { ); } -function ExternalLinkIcon() { - return ( - - - - ); -} - function GuideCard({ item }: { item: Guide }) { - const external = isExternal(item.href); return ( - -
    -

    - {item.name} - {external && } -

    - {item.sdk && ( -
    - -
    - )} -
    -

    {item.description}

    -
    - {item.tags.map((tag) => ( - {tag} - ))} -
    - + : undefined} + /> ); } @@ -101,15 +53,31 @@ function toggleIn(arr: T[], value: T): T[] { type GuidesGridProps = { defaultSdks?: SDK[]; + defaultTags?: string[]; + /** Hide the SDK pill group and pin the filter to defaultSdks. */ + hideSdkFilter?: boolean; + /** Hide the Tag pill group and pin the filter to defaultTags. */ + hideTagFilter?: boolean; }; export default function GuidesGrid({ defaultSdks = [], + defaultTags = [], + hideSdkFilter = false, + hideTagFilter = false, }: GuidesGridProps) { + const visibleFilterGroups = FILTER_GROUPS.filter( + ({ key }) => !(key === "sdks" && hideSdkFilter) && !(key === "tags" && hideTagFilter), + ); + // A hidden group has no pill UI to change it, so it's never worth syncing + // to the URL — that's what keeps a locked-down embed from showing a param + // the reader can't actually change. + const syncedFilterKeys = visibleFilterGroups.map(({ key }) => key); + const [query, setQuery] = useState(""); - const [filters, setFilters] = useQueryStringFilters(FILTER_KEYS, { + const [filters, setFilters] = useQueryStringFilters(syncedFilterKeys, { sdks: defaultSdks, - tags: [], + tags: defaultTags, }); const filtered = useMemo(() => { @@ -152,29 +120,31 @@ export default function GuidesGrid({ /> -
    - {FILTER_GROUPS.map(({ label, key, options }) => ( -
    - {label} - {options.map((value) => ( - - ))} -
    - ))} -
    + {visibleFilterGroups.length > 0 && ( +
    + {visibleFilterGroups.map(({ label, key, options }) => ( +
    + {label} + {options.map((value) => ( + + ))} +
    + ))} +
    + )} {filtered.length > 0 ? (
    diff --git a/src/components/IntegrationsGrid/IntegrationsGrid.module.css b/src/components/IntegrationsGrid/IntegrationsGrid.module.css index 9c36cabb6c..37423dce66 100644 --- a/src/components/IntegrationsGrid/IntegrationsGrid.module.css +++ b/src/components/IntegrationsGrid/IntegrationsGrid.module.css @@ -16,11 +16,6 @@ --ig-focus-shadow: 0 0 0 3px rgba(68, 76, 231, 0.15); --ig-pill-text: #475569; --ig-pill-border: rgba(15, 23, 42, 0.25); - --ig-card-bg: #fff; - --ig-card-border: rgba(15, 23, 42, 0.15); - --ig-card-hover: 0 4px 24px rgba(68, 76, 231, 0.18), 0 0 0 1px rgba(68, 76, 231, 0.12); - --ig-badge-bg: #f1f5f9; - --ig-badge-text: #475569; --ig-muted: #64748b; } @@ -33,11 +28,6 @@ --ig-focus-shadow: 0 0 0 3px rgba(124, 106, 239, 0.25); --ig-pill-text: #cbd5e1; --ig-pill-border: rgba(148, 163, 184, 0.35); - --ig-card-bg: #1a1a1a; - --ig-card-border: rgba(148, 163, 184, 0.4); - --ig-card-hover: 0 0 24px rgba(130, 90, 255, 0.25), 0 0 0 1px rgba(130, 90, 255, 0.2); - --ig-badge-bg: rgba(148, 163, 184, 0.15); - --ig-badge-text: #cbd5e1; --ig-muted: #64748b; } @@ -137,92 +127,8 @@ gap: 1rem; } -/* Card */ - -.card { - display: flex; - flex-direction: column; - padding: 1.25rem; - background: var(--ig-card-bg); - border: 1px solid var(--ig-card-border); - border-radius: 0; - text-decoration: none; - color: inherit; - transition: box-shadow 0.2s ease; -} - -.card:hover { - border-image: linear-gradient(255deg, #444ce7 0%, #b664ff 100%) 1; - box-shadow: var(--ig-card-hover); - text-decoration: none; -} - -.card:focus-visible { - outline: 2px solid var(--ig-focus-color); - outline-offset: 2px; -} - -.cardHeader { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.cardName { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--ifm-color-emphasis-900); - display: inline-flex; - align-items: center; - gap: 0.375rem; -} - -.sdkIcons { - display: flex; - align-items: center; - gap: 0.375rem; - margin-left: auto; -} - -.sdkIcons svg { - width: 22px; - height: 22px; - pointer-events: none; -} - -.externalIcon { - opacity: 0.6; - flex-shrink: 0; -} - -.cardDescription { - font-size: 0.875rem; - line-height: 1.5; - color: var(--ifm-color-emphasis-700); - margin: 0 0 0.75rem 0; - flex: 1; -} - -.cardMeta { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; - margin-top: auto; -} - -.badge { - display: inline-block; - padding: 0.2rem 0.5rem; - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - border-radius: 0; - background: var(--ig-badge-bg); - color: var(--ig-badge-text); -} +/* Card styles now live in src/components/elements/GridCard, shared with + CookbookPreview so the two grids look identical. */ /* Empty state */ diff --git a/src/components/IntegrationsGrid/index.tsx b/src/components/IntegrationsGrid/index.tsx index cbfd2a751b..2cd0fcb274 100644 --- a/src/components/IntegrationsGrid/index.tsx +++ b/src/components/IntegrationsGrid/index.tsx @@ -1,13 +1,16 @@ // ⚠️ LLM MARKDOWN PIPELINE: the generated .md output renders this grid via // scripts/component-handlers/integrations.mjs, which reads the same -// integrations-data.json and mirrors the `defaultSdks` filtering below. If you -// change the data source or filter logic here, update that handler too. +// integrations-data.json and mirrors the `defaultSdks`/`defaultTags` filtering +// below (hideSdkFilter/hideTagFilter only affect which pills render, not the +// underlying filtered set, so the handler doesn't need to know about them). +// If you change the data source or filter logic here, update that handler too. // See readme/MARKDOWN_PIPELINE.md. import { useState, useMemo } from "react"; -import Link from "@docusaurus/Link"; import clsx from "clsx"; import integrations, { type SDK, type Integration } from "./integrations-data"; import SdkSvg from "../elements/SdkSvgs/SdkSvg"; +import { SDK_BLOCK_NAMES } from "../elements/SdkSvgs/sdkBlockNames"; +import GridCard from "../elements/GridCard/GridCard"; import { useQueryStringFilters } from "../hooks/useQueryStringFilters"; import styles from "./IntegrationsGrid.module.css"; @@ -16,14 +19,6 @@ const LANGUAGE_AGNOSTIC = "Language-agnostic"; type SdkFilter = SDK | typeof LANGUAGE_AGNOSTIC; const ALL_SDK_FILTERS: SdkFilter[] = [...ALL_SDKS, LANGUAGE_AGNOSTIC]; -const SDK_BLOCK_NAMES: Record = { - Go: "goLangBlock", - Java: "javaBlock", - Python: "pythonBlock", - Ruby: "rubyBlock", - TypeScript: "typeScriptBlock", -}; - const ALL_TAGS = Array.from( new Set(integrations.flatMap((i) => i.tags)), ).sort(); @@ -32,11 +27,6 @@ const FILTER_GROUPS = [ { label: "SDK", key: "sdks" as const, options: ALL_SDK_FILTERS as string[] }, { label: "Tag", key: "tags" as const, options: ALL_TAGS }, ]; -const FILTER_KEYS = ["sdks", "tags"] as const; - -function isExternal(href: string): boolean { - return href.startsWith("http://") || href.startsWith("https://"); -} function SearchIcon() { return ( @@ -52,55 +42,16 @@ function SearchIcon() { ); } -function ExternalLinkIcon() { - return ( - - - - ); -} - function IntegrationCard({ item }: { item: Integration }) { - const external = isExternal(item.href); return ( - -
    -

    - {item.name} - {external && } -

    - {item.sdk && ( -
    - -
    - )} -
    -

    {item.description}

    -
    - {item.tags.map((tag) => ( - {tag} - ))} -
    - + : undefined} + analyticsId={`integrations-card-${item.name}`} + /> ); } @@ -110,15 +61,32 @@ function toggleIn(arr: T[], value: T): T[] { type IntegrationsGridProps = { defaultSdks?: SDK[]; + defaultTags?: string[]; + /** Hide the SDK pill group and pin the filter to defaultSdks. */ + hideSdkFilter?: boolean; + /** Hide the Tag pill group and pin the filter to defaultTags. */ + hideTagFilter?: boolean; }; export default function IntegrationsGrid({ defaultSdks = [], + defaultTags = [], + hideSdkFilter = false, + hideTagFilter = false, }: IntegrationsGridProps) { + const visibleFilterGroups = FILTER_GROUPS.filter( + ({ key }) => !(key === "sdks" && hideSdkFilter) && !(key === "tags" && hideTagFilter), + ); + // A hidden group has no pill UI to change it, so it's never worth syncing + // to the URL — that's what keeps a locked-down embed (e.g. an "Agent + // framework" grid on another page) from showing a `tags=` param the reader + // can't actually change. + const syncedFilterKeys = visibleFilterGroups.map(({ key }) => key); + const [query, setQuery] = useState(""); - const [filters, setFilters] = useQueryStringFilters(FILTER_KEYS, { + const [filters, setFilters] = useQueryStringFilters(syncedFilterKeys, { sdks: defaultSdks, - tags: [], + tags: defaultTags, }); const filtered = useMemo(() => { @@ -163,31 +131,33 @@ export default function IntegrationsGrid({ />
    -
    - {FILTER_GROUPS.map(({ label, key, options }) => ( -
    - {label} - {options.map((value) => ( - - ))} -
    - ))} -
    + {visibleFilterGroups.length > 0 && ( +
    + {visibleFilterGroups.map(({ label, key, options }) => ( +
    + {label} + {options.map((value) => ( + + ))} +
    + ))} +
    + )} {filtered.length > 0 ? (
    diff --git a/src/components/elements/GridCard/GridCard.module.css b/src/components/elements/GridCard/GridCard.module.css new file mode 100644 index 0000000000..b85c662d89 --- /dev/null +++ b/src/components/elements/GridCard/GridCard.module.css @@ -0,0 +1,103 @@ +:global([data-theme="light"]) .card { + --gc-card-bg: #fff; + --gc-card-border: rgba(15, 23, 42, 0.15); + --gc-card-hover: 0 4px 24px rgba(68, 76, 231, 0.18), 0 0 0 1px rgba(68, 76, 231, 0.12); + --gc-badge-bg: #f1f5f9; + --gc-badge-text: #475569; + --gc-focus-color: #444ce7; +} + +:global([data-theme="dark"]) .card { + --gc-card-bg: #1a1a1a; + --gc-card-border: rgba(148, 163, 184, 0.4); + --gc-card-hover: 0 0 24px rgba(130, 90, 255, 0.25), 0 0 0 1px rgba(130, 90, 255, 0.2); + --gc-badge-bg: rgba(148, 163, 184, 0.15); + --gc-badge-text: #cbd5e1; + --gc-focus-color: #7c6aef; +} + +.card { + display: flex; + flex-direction: column; + height: 100%; + padding: 1.25rem; + background: var(--gc-card-bg); + border: 1px solid var(--gc-card-border); + border-radius: 0; + text-decoration: none; + color: inherit; + transition: box-shadow 0.2s ease; +} + +.card:hover { + border-image: linear-gradient(255deg, #444ce7 0%, #b664ff 100%) 1; + box-shadow: var(--gc-card-hover); + text-decoration: none; +} + +.card:focus-visible { + outline: 2px solid var(--gc-focus-color); + outline-offset: 2px; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.cardName { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--ifm-color-emphasis-900); + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.icons { + display: flex; + align-items: center; + gap: 0.375rem; + margin-left: auto; +} + +.icons svg { + width: 22px; + height: 22px; + pointer-events: none; +} + +.externalIcon { + opacity: 0.6; + flex-shrink: 0; +} + +.cardDescription { + font-size: 0.875rem; + line-height: 1.5; + color: var(--ifm-color-emphasis-700); + margin: 0 0 0.75rem 0; + flex: 1; +} + +.cardMeta { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: auto; +} + +.badge { + display: inline-block; + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + border-radius: 0; + background: var(--gc-badge-bg); + color: var(--gc-badge-text); +} diff --git a/src/components/elements/GridCard/GridCard.tsx b/src/components/elements/GridCard/GridCard.tsx new file mode 100644 index 0000000000..a34f3b1418 --- /dev/null +++ b/src/components/elements/GridCard/GridCard.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; +import clsx from 'clsx'; +import styles from './GridCard.module.css'; + +function isExternal(href: string): boolean { + return href.startsWith('http://') || href.startsWith('https://'); +} + +function ExternalLinkIcon() { + return ( + + + + ); +} + +export type GridCardProps = { + title: string; + description: string; + href: string; + tags?: string[]; + /** Rendered top-right of the header, e.g. an SDK logo. */ + icon?: React.ReactNode; + analyticsId?: string; +}; + +/** + * The card look shared by IntegrationsGrid and CookbookPreview — kept as one + * component so the two stay visually identical instead of drifting apart. + */ +export default function GridCard({ title, description, href, tags = [], icon, analyticsId }: GridCardProps) { + const external = isExternal(href); + return ( + +
    +

    + {title} + {external && } +

    + {icon &&
    {icon}
    } +
    +

    {description}

    + {tags.length > 0 && ( +
    + {tags.map((tag) => ( + + {tag} + + ))} +
    + )} + + ); +} diff --git a/src/components/elements/SdkSvgs/sdkBlockNames.ts b/src/components/elements/SdkSvgs/sdkBlockNames.ts new file mode 100644 index 0000000000..a3c556fbe3 --- /dev/null +++ b/src/components/elements/SdkSvgs/sdkBlockNames.ts @@ -0,0 +1,14 @@ +export type SDK = 'Go' | 'Java' | 'Python' | 'Ruby' | 'TypeScript'; + +/** + * Maps an SDK label to the SdkSvg "block" icon name — shared by + * IntegrationsGrid, GuidesGrid, and the AI Cookbook cards so the same SDK + * always gets the same icon everywhere. + */ +export const SDK_BLOCK_NAMES: Record = { + Go: 'goLangBlock', + Java: 'javaBlock', + Python: 'pythonBlock', + Ruby: 'rubyBlock', + TypeScript: 'typeScriptBlock', +}; diff --git a/src/components/elements/index.js b/src/components/elements/index.js index 5ca4d299f8..e8cccda638 100644 --- a/src/components/elements/index.js +++ b/src/components/elements/index.js @@ -5,6 +5,7 @@ export * from './ReleaseNoteHeader' export * from './Sdk' export * from './Tables' export * from './Tile' +export { default as GridCard } from './GridCard/GridCard' export * from './ViewSourceCodeNotice/ViewSourceCodeNotice' export { default as AnnotatedCode } from './AnnotatedCode' export { default as PriorityFairnessSimulator } from './PriorityFairnessSimulator' diff --git a/src/components/hooks/useQueryStringFilters.ts b/src/components/hooks/useQueryStringFilters.ts index f32020bdbd..30f4d658e1 100644 --- a/src/components/hooks/useQueryStringFilters.ts +++ b/src/components/hooks/useQueryStringFilters.ts @@ -2,8 +2,8 @@ import { useCallback, useState } from 'react'; import { useHistory } from '@docusaurus/router'; import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; -type FilterState = Record; -type FilterUpdater = (prev: FilterState) => FilterState; +type FilterState = Record; +type FilterUpdater = (prev: S) => S; function parseListParam(search: string, key: string): string[] | null { const raw = new URLSearchParams(search).get(key); @@ -13,29 +13,34 @@ function parseListParam(search: string, key: string): string[] | null { /** * Filter state backed by comma-separated query string params (one param per - * key, e.g. ?tags=Agent+framework,MCP&sdks=Python), so a filtered view like - * IntegrationsGrid or GuidesGrid can be deep-linked with a filter + * synced key, e.g. ?tags=Agent+framework,MCP&sdks=Python), so a filtered view + * like IntegrationsGrid or GuidesGrid can be deep-linked with a filter * pre-selected, and the current filters stay reflected in a shareable URL. * + * `syncedKeys` may be a subset of `defaults`' keys — any key left out is never + * read from or written to the URL, which is how a locked/hidden filter (e.g. + * a grid pinned to one tag with its Tag pills hidden) stays out of the URL + * entirely instead of showing up as a param the reader can't actually change. + * * The URL is adopted once on mount (covering the deep-link case) and from * then on state drives the URL, not the other way around — filter changes use * history.replace so clicking through pills doesn't spam the back button. */ -export function useQueryStringFilters( - keys: readonly K[], - defaults: FilterState, -): [FilterState, (updater: FilterUpdater) => void] { +export function useQueryStringFilters( + syncedKeys: readonly (keyof S)[], + defaults: S, +): [S, (updater: FilterUpdater) => void] { const history = useHistory(); - const [state, setState] = useState>(defaults); + const [state, setState] = useState(defaults); useIsomorphicLayoutEffect(() => { setState((prev) => { let changed = false; const next = { ...prev }; - for (const key of keys) { - const fromUrl = parseListParam(history.location.search, key); + for (const key of syncedKeys) { + const fromUrl = parseListParam(history.location.search, key as string); if (fromUrl) { - next[key] = fromUrl; + next[key] = fromUrl as S[keyof S]; changed = true; } } @@ -48,23 +53,29 @@ export function useQueryStringFilters( }, []); const update = useCallback( - (updater: FilterUpdater) => { + (updater: FilterUpdater) => { setState((prev) => { const next = updater(prev); const searchParams = new URLSearchParams(history.location.search); - for (const key of keys) { - const values = next[key]; + for (const key of syncedKeys) { + const values = next[key] as string[]; if (values.length > 0) { - searchParams.set(key, values.join(',')); + searchParams.set(key as string, values.join(',')); } else { - searchParams.delete(key); + searchParams.delete(key as string); } } - history.replace({ ...history.location, search: searchParams.toString() }); + // URLSearchParams percent-encodes commas (sdks=Java%2CPython); commas + // aren't actually reserved in a query value, so unescape them back for + // a URL a person can read and hand-edit (sdks=Java,Python). Decoding + // is unaffected either way — URLSearchParams.get() treats a literal + // comma and %2C identically. + const search = searchParams.toString().replace(/%2C/g, ','); + history.replace({ ...history.location, search }); return next; }); }, - [history, keys], + [history, syncedKeys], ); return [state, update]; diff --git a/tests/playwright/cookbook-home.spec.ts b/tests/playwright/cookbook-home.spec.ts index 7d56ac8190..9f4a1c8236 100644 --- a/tests/playwright/cookbook-home.spec.ts +++ b/tests/playwright/cookbook-home.spec.ts @@ -19,7 +19,7 @@ test.describe('Cookbook home', () => { await expect(page.getByTestId('cookbook-hero')).toBeVisible(); - const tiles = page.locator('.tile'); + const tiles = page.locator('.grid-card'); await expect(tiles).not.toHaveCount(0); const tileData = await collectTileData(tiles); @@ -34,7 +34,7 @@ test.describe('Cookbook home', () => { if (elements.length === 0) return null; const sample = elements[0]; - const grid = sample.parentElement?.parentElement; + const grid = sample.parentElement; if (!grid) return null; const gridRect = grid.getBoundingClientRect(); @@ -49,25 +49,30 @@ test.describe('Cookbook home', () => { }); expect(gridMetrics).not.toBeNull(); - expect(gridMetrics?.columnCount).toBe(3); + // The grid is a responsive auto-fill (not a fixed column count), so assert + // it actually laid out multiple columns rather than pinning an exact + // count tied to one viewport width. + expect(gridMetrics?.columnCount ?? 0).toBeGreaterThan(1); for (const fraction of gridMetrics?.fractions ?? []) { - expect(fraction).toBeGreaterThan(0.2); - expect(fraction).toBeLessThan(0.38); + expect(fraction).toBeGreaterThan(0.1); + expect(fraction).toBeLessThan(0.6); } }); test('stacks tiles into a single column on mobile viewports', async ({ page }) => { - await page.setViewportSize({ width: 600, height: 900 }); + // A true phone width (600px fits two 260px-min auto-fill columns, same as + // the Integrations grid at that width — not a regression, just not "mobile"). + await page.setViewportSize({ width: 375, height: 812 }); await page.goto('/ai/cookbook'); - const tiles = page.locator('.tile'); + const tiles = page.locator('.grid-card'); await expect(tiles).not.toHaveCount(0); const widthFractions = await tiles.evaluateAll((elements) => elements.map((element) => { const tileRect = element.getBoundingClientRect(); - const grid = element.parentElement?.parentElement; + const grid = element.parentElement; const gridRect = grid?.getBoundingClientRect(); const fraction = gridRect && gridRect.width > 0 ? tileRect.width / gridRect.width : 0; return Number(fraction.toFixed(2)); diff --git a/tests/test-mdx-to-md.mjs b/tests/test-mdx-to-md.mjs index dae4fff612..cb819261ff 100644 --- a/tests/test-mdx-to-md.mjs +++ b/tests/test-mdx-to-md.mjs @@ -14,6 +14,7 @@ import { transformMdx, parseFrontmatter, extractProp, + extractNumberProp, parseTabValues, parseReadList, parseSdkGuideLinks, @@ -26,6 +27,11 @@ import { selectIntegrations, integrationsToMarkdownList, } from "../scripts/component-handlers/integrations.mjs"; +import { + readCookbookRecipes, + cookbookRecipesToMarkdownList, + cookbookPreviewToMarkdown, +} from "../scripts/component-handlers/cookbook-preview.mjs"; import { parseCardItems, cardsToMarkdown } from "../scripts/component-handlers/cards.mjs"; import { sdkOverviewCardsToMarkdown } from "../scripts/component-handlers/sdk-overview-cards.mjs"; @@ -914,6 +920,18 @@ test("selectIntegrations filters to the given SDK(s)", () => { assert(!out.some((i) => i.name === "Datadog"), "agnostic entry should be excluded"); }); +test("selectIntegrations filters to the given tag(s)", () => { + const out = selectIntegrations(SAMPLE_INTEGRATIONS, [], ["Agent framework"]); + assert(out.length === 2, "expected 2 Agent framework integrations"); + assert(out[0].name === "LangGraph" && out[1].name === "Spring AI", "expected LangGraph then Spring AI (alphabetical)"); +}); + +test("selectIntegrations combines defaultSdks and defaultTags (AND across groups)", () => { + const out = selectIntegrations(SAMPLE_INTEGRATIONS, ["Java"], ["Agent framework"]); + assert(out.length === 1, "expected only the Java + Agent framework integration"); + assert(out[0].name === "Spring AI", "expected Spring AI"); +}); + test("integrationsToMarkdownList renders link + description + meta", () => { const md = integrationsToMarkdownList([SAMPLE_INTEGRATIONS[0]]); assertContains(md, "- [Spring Boot](/a)"); @@ -929,6 +947,14 @@ test("transformMdx resolves to a real list (with projectRoot) assertContains(markdown, "- [Spring AI](/develop/java/integrations/spring-ai)"); }); +test("transformMdx resolves to a real, tag-filtered list", () => { + const input = ``; + const { markdown } = transformMdx(input, { projectRoot: PROJECT_ROOT }); + assertNotContains(markdown, " { const input = ``; const { markdown } = transformMdx(input); @@ -936,6 +962,62 @@ test("transformMdx IntegrationsGrid without projectRoot degrades to a comment", assertContains(markdown, "IntegrationsGrid (not resolved)"); }); +// --------------------------------------------------------------------------- +// Unit tests: cookbook-preview handler +// Uses fixtures/ai-cookbook/ (not the real, gitignored, build-time-synced +// ai-cookbook/ directory) so these tests don't depend on that sync having run. +// --------------------------------------------------------------------------- +console.log("\n📦 component-handlers/cookbook-preview"); + +test("readCookbookRecipes sorts by priority (desc), then title, and excludes the index page", () => { + const items = readCookbookRecipes(FIXTURES_DIR); + assert(items.length === 3, `expected 3 recipes, got ${items.length}`); + assert(items[0].title === "High priority recipe", "expected the prioritized recipe first"); + assert(items[1].title === "A recipe" && items[2].title === "B recipe", "expected no-priority recipes alphabetically after"); + assert(!items.some((it) => it.title === "AI Cookbook"), "index page should be excluded"); +}); + +test("readCookbookRecipes builds /ai/cookbook permalinks from the file slug", () => { + const items = readCookbookRecipes(FIXTURES_DIR); + const highPriority = items.find((it) => it.title === "High priority recipe"); + assert(highPriority.permalink === "/ai/cookbook/high-priority", `got ${highPriority.permalink}`); +}); + +test("cookbookRecipesToMarkdownList renders link + description", () => { + const md = cookbookRecipesToMarkdownList([ + { title: "Hello world", description: "Do a thing.", permalink: "/ai/cookbook/hello-world" }, + ]); + assertContains(md, "- [Hello world](/ai/cookbook/hello-world) — Do a thing."); +}); + +test("cookbookPreviewToMarkdown respects limit and links to the full Cookbook", () => { + const md = cookbookPreviewToMarkdown(2, { projectRoot: FIXTURES_DIR }); + assertContains(md, "- [High priority recipe]"); + assertContains(md, "- [A recipe]"); + assertNotContains(md, "- [B recipe]"); + assertContains(md, "[Browse all recipes](/ai/cookbook)"); +}); + +test("transformMdx resolves to a real, sorted list", () => { + const input = ``; + const { markdown } = transformMdx(input, { projectRoot: FIXTURES_DIR, sourceFile: "test" }); + assertNotContains(markdown, " { + const input = ``; + const { markdown } = transformMdx(input); + assertNotContains(markdown, " { + assertEqual(extractNumberProp(``, "limit"), 4); + assertEqual(extractNumberProp(``, "limit"), undefined); +}); + // --------------------------------------------------------------------------- // Unit tests: SdkOverviewCards handler // --------------------------------------------------------------------------- @@ -1157,7 +1239,7 @@ test("all registry strategies are valid strings", () => { "related-read-container", "related-read-item", "captioned-image", "photo-carousel", "code-snippet", "sdk-tabs", "tooltip-term", "release-note-header", "call-to-action", "setup-steps", "setup-step", - "json-table", "integrations-grid", "sdk-overview-cards", "hero-card", "hero-headline", "view-source-code-notice", "cards", "strip-tag", "strip-block", "details", "summary", + "json-table", "integrations-grid", "cookbook-preview", "sdk-overview-cards", "hero-card", "hero-headline", "view-source-code-notice", "cards", "strip-tag", "strip-block", "details", "summary", "sdk-guide-links", ]; for (const [comp, strategy] of Object.entries(COMPONENT_REGISTRY)) { From 8cc47d77c70cbb94e718dc00d5a55bd623617a66 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 17 Aug 2026 15:30:35 -0700 Subject: [PATCH 04/15] Update vercel.json to correct AI Cookbook destination paths for routing consistency --- src/components/Cookbook/useCookbookItems.ts | 1 - vercel.json | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Cookbook/useCookbookItems.ts b/src/components/Cookbook/useCookbookItems.ts index 2ce86ad075..f93aa98190 100644 --- a/src/components/Cookbook/useCookbookItems.ts +++ b/src/components/Cookbook/useCookbookItems.ts @@ -113,7 +113,6 @@ function getLastUpdatedTimestamp(item: CookbookIndexItem, docsById: Map Date: Mon, 17 Aug 2026 15:42:03 -0700 Subject: [PATCH 05/15] Enhance CookbookPreview component to display remaining recipes count - Refactored CookbookPreview to calculate and display the number of remaining recipes. - Added a new GridCard to link to the full AI Cookbook when there are additional recipes available. - Removed unused Link import for cleaner code. --- .../Cookbook/Preview/CookbookPreview.tsx | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/components/Cookbook/Preview/CookbookPreview.tsx b/src/components/Cookbook/Preview/CookbookPreview.tsx index 7787759e08..ccb6a8b91a 100644 --- a/src/components/Cookbook/Preview/CookbookPreview.tsx +++ b/src/components/Cookbook/Preview/CookbookPreview.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import Link from '@docusaurus/Link'; import GridCard from '../../elements/GridCard/GridCard'; import SdkSvg from '../../elements/SdkSvgs/SdkSvg'; import { SDK_BLOCK_NAMES } from '../../elements/SdkSvgs/sdkBlockNames'; @@ -12,27 +11,29 @@ type CookbookPreviewProps = { }; export default function CookbookPreview({ limit = 4 }: CookbookPreviewProps) { - const items = useCookbookItems().slice(0, limit); + const allItems = useCookbookItems(); + const items = allItems.slice(0, limit); + const remaining = allItems.length - items.length; return ( -
    -
    - {items.map((item) => ( - : undefined} - /> - ))} -
    -

    - - Browse all recipes → - -

    +
    + {items.map((item) => ( + : undefined} + /> + ))} + {remaining > 0 && ( + + )}
    ); } From 6124c6aa4963af23b0190d8930ffcc6c9540f46c Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Tue, 18 Aug 2026 08:13:50 -0700 Subject: [PATCH 06/15] add option to hide sdk options that have no matching content --- docs/ai/index.mdx | 2 +- src/components/GuidesGrid/index.tsx | 62 ++++++++++++++------- src/components/IntegrationsGrid/index.tsx | 67 +++++++++++++++-------- 3 files changed, 86 insertions(+), 45 deletions(-) diff --git a/docs/ai/index.mdx b/docs/ai/index.mdx index 522fe21906..2dd964bcd6 100644 --- a/docs/ai/index.mdx +++ b/docs/ai/index.mdx @@ -29,7 +29,7 @@ output, human-in-the-loop, and more. Temporal integrations for the SDKs and frameworks teams use to build agents. This view is pre-filtered to agent frameworks — browse [every integration](/integrations) for the full catalog. - + ## Use cases diff --git a/src/components/GuidesGrid/index.tsx b/src/components/GuidesGrid/index.tsx index 7a536cc189..a6729b2c4d 100644 --- a/src/components/GuidesGrid/index.tsx +++ b/src/components/GuidesGrid/index.tsx @@ -51,6 +51,26 @@ function toggleIn(arr: T[], value: T): T[] { return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; } +function matchesSearch(item: Guide, q: string): boolean { + if (!q) return true; + const searchable = `${item.name} ${item.description} ${item.tags.join(" ")}`.toLowerCase(); + return searchable.includes(q); +} + +function matchesSdks(item: Guide, sdkFilters: string[]): boolean { + if (sdkFilters.length === 0) return true; + const wantsAgnostic = sdkFilters.includes(LANGUAGE_AGNOSTIC); + const sdkOnly = sdkFilters.filter((s): s is SDK => s !== LANGUAGE_AGNOSTIC); + const matchesSdk = Boolean(item.sdk && sdkOnly.includes(item.sdk)); + const matchesAgnostic = wantsAgnostic && !item.sdk; + return matchesSdk || matchesAgnostic; +} + +function matchesTags(item: Guide, tagFilters: string[]): boolean { + if (tagFilters.length === 0) return true; + return tagFilters.some((t) => item.tags.includes(t)); +} + type GuidesGridProps = { defaultSdks?: SDK[]; defaultTags?: string[]; @@ -58,6 +78,8 @@ type GuidesGridProps = { hideSdkFilter?: boolean; /** Hide the Tag pill group and pin the filter to defaultTags. */ hideTagFilter?: boolean; + /** Hide pills (in either visible group) that would match zero guides given the current filters. */ + hideEmptyOptions?: boolean; }; export default function GuidesGrid({ @@ -65,6 +87,7 @@ export default function GuidesGrid({ defaultTags = [], hideSdkFilter = false, hideTagFilter = false, + hideEmptyOptions = false, }: GuidesGridProps) { const visibleFilterGroups = FILTER_GROUPS.filter( ({ key }) => !(key === "sdks" && hideSdkFilter) && !(key === "tags" && hideTagFilter), @@ -79,30 +102,27 @@ export default function GuidesGrid({ sdks: defaultSdks, tags: defaultTags, }); + const q = query.toLowerCase().trim(); const filtered = useMemo(() => { - const q = query.toLowerCase().trim(); return guides - .filter((item) => { - if (q) { - const searchable = - `${item.name} ${item.description} ${item.tags.join(" ")}`.toLowerCase(); - if (!searchable.includes(q)) return false; - } - if (filters.sdks.length > 0) { - const wantsAgnostic = filters.sdks.includes(LANGUAGE_AGNOSTIC); - const sdkFilters = filters.sdks.filter((s): s is SDK => s !== LANGUAGE_AGNOSTIC); - const matchesSdk = item.sdk && sdkFilters.includes(item.sdk); - const matchesAgnostic = wantsAgnostic && !item.sdk; - if (!matchesSdk && !matchesAgnostic) return false; - } - if (filters.tags.length > 0) { - if (!filters.tags.some((t) => item.tags.includes(t))) return false; - } - return true; - }) + .filter((item) => matchesSearch(item, q) && matchesSdks(item, filters.sdks) && matchesTags(item, filters.tags)) .sort((a, b) => a.name.localeCompare(b.name)); - }, [query, filters]); + }, [q, filters]); + + // With hideEmptyOptions, a pill for a value that would match nothing (given + // search + the *other* group's current selection) doesn't render — except + // one already selected, which stays visible so it can still be cleared even + // if that combination happens to match zero guides. + const isOptionVisible = (key: "sdks" | "tags", value: string): boolean => { + if (!hideEmptyOptions || filters[key].includes(value)) return true; + return guides.some((item) => { + if (!matchesSearch(item, q)) return false; + return key === "sdks" + ? matchesSdks(item, [value]) && matchesTags(item, filters.tags) + : matchesTags(item, [value]) && matchesSdks(item, filters.sdks); + }); + }; return (
    @@ -125,7 +145,7 @@ export default function GuidesGrid({ {visibleFilterGroups.map(({ label, key, options }) => (
    {label} - {options.map((value) => ( + {options.filter((value) => isOptionVisible(key, value)).map((value) => (